├── .github └── workflows │ └── test.yml ├── .gitignore ├── Examples └── TimerTest │ ├── TimerTest.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved │ └── TimerTest │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── MultipleStopwatchView.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ ├── StopwatchView.swift │ ├── TimePicker.swift │ ├── TimerTestApp.swift │ └── TimerView.swift ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── PersistableTimer │ ├── PersistableTimer.swift │ ├── PrivacyInfo.xcprivacy │ └── exported.swift ├── PersistableTimerCore │ ├── DataSource.swift │ ├── RestoreTimerContainer.swift │ └── RestoreTimerData.swift └── PersistableTimerText │ ├── PersistableTimerText.swift │ └── exported.swift └── Tests └── PersistableTimerCoreTests └── PersistableTimerCoreTests.swift /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main" ] 6 | paths-ignore: 7 | - README.md 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: format-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build: 16 | name: Test 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: [macos-14] 21 | runs-on: ${{ matrix.os }} 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Select Xcode 16.2 27 | run: sudo xcode-select -s /Applications/Xcode_16.2.app 28 | 29 | - name: Test 30 | run: swift test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 4225D7C02C5D4C3D00F932BC /* PersistableTimerText in Frameworks */ = {isa = PBXBuildFile; productRef = 4225D7BF2C5D4C3D00F932BC /* PersistableTimerText */; }; 11 | 4252439E2C66137400981D2F /* MultipleStopwatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4252439D2C66137400981D2F /* MultipleStopwatchView.swift */; }; 12 | 425243A12C661BCB00981D2F /* UserDefaultsEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 425243A02C661BCB00981D2F /* UserDefaultsEditor */; }; 13 | 42A8519F2B6F95C700B94CA1 /* TimerTestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A8519E2B6F95C700B94CA1 /* TimerTestApp.swift */; }; 14 | 42A851A12B6F95C700B94CA1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A851A02B6F95C700B94CA1 /* ContentView.swift */; }; 15 | 42A851A32B6F95C900B94CA1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 42A851A22B6F95C900B94CA1 /* Assets.xcassets */; }; 16 | 42A851A62B6F95C900B94CA1 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 42A851A52B6F95C900B94CA1 /* Preview Assets.xcassets */; }; 17 | 42A851AF2B6F960A00B94CA1 /* PersistableTimer in Frameworks */ = {isa = PBXBuildFile; productRef = 42A851AE2B6F960A00B94CA1 /* PersistableTimer */; }; 18 | 42A851B12B6FAAAC00B94CA1 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A851B02B6FAAAC00B94CA1 /* TimerView.swift */; }; 19 | 42A851B32B6FAAB900B94CA1 /* StopwatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A851B22B6FAAB900B94CA1 /* StopwatchView.swift */; }; 20 | 42A851B52B6FB0CE00B94CA1 /* TimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A851B42B6FB0CE00B94CA1 /* TimePicker.swift */; }; 21 | /* End PBXBuildFile section */ 22 | 23 | /* Begin PBXFileReference section */ 24 | 4252439D2C66137400981D2F /* MultipleStopwatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleStopwatchView.swift; sourceTree = ""; }; 25 | 42A8519B2B6F95C700B94CA1 /* TimerTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TimerTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; 26 | 42A8519E2B6F95C700B94CA1 /* TimerTestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerTestApp.swift; sourceTree = ""; }; 27 | 42A851A02B6F95C700B94CA1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 28 | 42A851A22B6F95C900B94CA1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 29 | 42A851A52B6F95C900B94CA1 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 30 | 42A851AC2B6F95E400B94CA1 /* swift-persistable-timer */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "swift-persistable-timer"; path = ../..; sourceTree = ""; }; 31 | 42A851B02B6FAAAC00B94CA1 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = ""; }; 32 | 42A851B22B6FAAB900B94CA1 /* StopwatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopwatchView.swift; sourceTree = ""; }; 33 | 42A851B42B6FB0CE00B94CA1 /* TimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimePicker.swift; sourceTree = ""; }; 34 | /* End PBXFileReference section */ 35 | 36 | /* Begin PBXFrameworksBuildPhase section */ 37 | 42A851982B6F95C700B94CA1 /* Frameworks */ = { 38 | isa = PBXFrameworksBuildPhase; 39 | buildActionMask = 2147483647; 40 | files = ( 41 | 4225D7C02C5D4C3D00F932BC /* PersistableTimerText in Frameworks */, 42 | 42A851AF2B6F960A00B94CA1 /* PersistableTimer in Frameworks */, 43 | 425243A12C661BCB00981D2F /* UserDefaultsEditor in Frameworks */, 44 | ); 45 | runOnlyForDeploymentPostprocessing = 0; 46 | }; 47 | /* End PBXFrameworksBuildPhase section */ 48 | 49 | /* Begin PBXGroup section */ 50 | 42A851922B6F95C600B94CA1 = { 51 | isa = PBXGroup; 52 | children = ( 53 | 42A851AC2B6F95E400B94CA1 /* swift-persistable-timer */, 54 | 42A8519D2B6F95C700B94CA1 /* TimerTest */, 55 | 42A851AD2B6F960A00B94CA1 /* Frameworks */, 56 | ); 57 | sourceTree = ""; 58 | }; 59 | 42A8519C2B6F95C700B94CA1 /* Products */ = { 60 | isa = PBXGroup; 61 | children = ( 62 | 42A8519B2B6F95C700B94CA1 /* TimerTest.app */, 63 | ); 64 | name = Products; 65 | path = ..; 66 | sourceTree = ""; 67 | }; 68 | 42A8519D2B6F95C700B94CA1 /* TimerTest */ = { 69 | isa = PBXGroup; 70 | children = ( 71 | 42A8519E2B6F95C700B94CA1 /* TimerTestApp.swift */, 72 | 42A851B22B6FAAB900B94CA1 /* StopwatchView.swift */, 73 | 42A851B02B6FAAAC00B94CA1 /* TimerView.swift */, 74 | 4252439D2C66137400981D2F /* MultipleStopwatchView.swift */, 75 | 42A851A02B6F95C700B94CA1 /* ContentView.swift */, 76 | 42A8519C2B6F95C700B94CA1 /* Products */, 77 | 42A851A22B6F95C900B94CA1 /* Assets.xcassets */, 78 | 42A851A42B6F95C900B94CA1 /* Preview Content */, 79 | 42A851B42B6FB0CE00B94CA1 /* TimePicker.swift */, 80 | ); 81 | path = TimerTest; 82 | sourceTree = ""; 83 | }; 84 | 42A851A42B6F95C900B94CA1 /* Preview Content */ = { 85 | isa = PBXGroup; 86 | children = ( 87 | 42A851A52B6F95C900B94CA1 /* Preview Assets.xcassets */, 88 | ); 89 | path = "Preview Content"; 90 | sourceTree = ""; 91 | }; 92 | 42A851AD2B6F960A00B94CA1 /* Frameworks */ = { 93 | isa = PBXGroup; 94 | children = ( 95 | ); 96 | name = Frameworks; 97 | sourceTree = ""; 98 | }; 99 | /* End PBXGroup section */ 100 | 101 | /* Begin PBXNativeTarget section */ 102 | 42A8519A2B6F95C700B94CA1 /* TimerTest */ = { 103 | isa = PBXNativeTarget; 104 | buildConfigurationList = 42A851A92B6F95C900B94CA1 /* Build configuration list for PBXNativeTarget "TimerTest" */; 105 | buildPhases = ( 106 | 42A851972B6F95C700B94CA1 /* Sources */, 107 | 42A851982B6F95C700B94CA1 /* Frameworks */, 108 | 42A851992B6F95C700B94CA1 /* Resources */, 109 | ); 110 | buildRules = ( 111 | ); 112 | dependencies = ( 113 | ); 114 | name = TimerTest; 115 | packageProductDependencies = ( 116 | 42A851AE2B6F960A00B94CA1 /* PersistableTimer */, 117 | 4225D7BF2C5D4C3D00F932BC /* PersistableTimerText */, 118 | 425243A02C661BCB00981D2F /* UserDefaultsEditor */, 119 | ); 120 | productName = TimerTest; 121 | productReference = 42A8519B2B6F95C700B94CA1 /* TimerTest.app */; 122 | productType = "com.apple.product-type.application"; 123 | }; 124 | /* End PBXNativeTarget section */ 125 | 126 | /* Begin PBXProject section */ 127 | 42A851932B6F95C600B94CA1 /* Project object */ = { 128 | isa = PBXProject; 129 | attributes = { 130 | BuildIndependentTargetsInParallel = 1; 131 | LastSwiftUpdateCheck = 1520; 132 | LastUpgradeCheck = 1520; 133 | TargetAttributes = { 134 | 42A8519A2B6F95C700B94CA1 = { 135 | CreatedOnToolsVersion = 15.2; 136 | }; 137 | }; 138 | }; 139 | buildConfigurationList = 42A851962B6F95C600B94CA1 /* Build configuration list for PBXProject "TimerTest" */; 140 | compatibilityVersion = "Xcode 14.0"; 141 | developmentRegion = en; 142 | hasScannedForEncodings = 0; 143 | knownRegions = ( 144 | en, 145 | Base, 146 | ); 147 | mainGroup = 42A851922B6F95C600B94CA1; 148 | packageReferences = ( 149 | 4252439F2C661BCB00981D2F /* XCRemoteSwiftPackageReference "UserDefaultsEditor" */, 150 | ); 151 | productRefGroup = 42A8519C2B6F95C700B94CA1 /* Products */; 152 | projectDirPath = ""; 153 | projectRoot = ""; 154 | targets = ( 155 | 42A8519A2B6F95C700B94CA1 /* TimerTest */, 156 | ); 157 | }; 158 | /* End PBXProject section */ 159 | 160 | /* Begin PBXResourcesBuildPhase section */ 161 | 42A851992B6F95C700B94CA1 /* Resources */ = { 162 | isa = PBXResourcesBuildPhase; 163 | buildActionMask = 2147483647; 164 | files = ( 165 | 42A851A62B6F95C900B94CA1 /* Preview Assets.xcassets in Resources */, 166 | 42A851A32B6F95C900B94CA1 /* Assets.xcassets in Resources */, 167 | ); 168 | runOnlyForDeploymentPostprocessing = 0; 169 | }; 170 | /* End PBXResourcesBuildPhase section */ 171 | 172 | /* Begin PBXSourcesBuildPhase section */ 173 | 42A851972B6F95C700B94CA1 /* Sources */ = { 174 | isa = PBXSourcesBuildPhase; 175 | buildActionMask = 2147483647; 176 | files = ( 177 | 42A851B12B6FAAAC00B94CA1 /* TimerView.swift in Sources */, 178 | 42A851A12B6F95C700B94CA1 /* ContentView.swift in Sources */, 179 | 4252439E2C66137400981D2F /* MultipleStopwatchView.swift in Sources */, 180 | 42A851B32B6FAAB900B94CA1 /* StopwatchView.swift in Sources */, 181 | 42A8519F2B6F95C700B94CA1 /* TimerTestApp.swift in Sources */, 182 | 42A851B52B6FB0CE00B94CA1 /* TimePicker.swift in Sources */, 183 | ); 184 | runOnlyForDeploymentPostprocessing = 0; 185 | }; 186 | /* End PBXSourcesBuildPhase section */ 187 | 188 | /* Begin XCBuildConfiguration section */ 189 | 42A851A72B6F95C900B94CA1 /* Debug */ = { 190 | isa = XCBuildConfiguration; 191 | buildSettings = { 192 | ALWAYS_SEARCH_USER_PATHS = NO; 193 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 194 | CLANG_ANALYZER_NONNULL = YES; 195 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 196 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 197 | CLANG_ENABLE_MODULES = YES; 198 | CLANG_ENABLE_OBJC_ARC = YES; 199 | CLANG_ENABLE_OBJC_WEAK = YES; 200 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 201 | CLANG_WARN_BOOL_CONVERSION = YES; 202 | CLANG_WARN_COMMA = YES; 203 | CLANG_WARN_CONSTANT_CONVERSION = YES; 204 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 205 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 206 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 207 | CLANG_WARN_EMPTY_BODY = YES; 208 | CLANG_WARN_ENUM_CONVERSION = YES; 209 | CLANG_WARN_INFINITE_RECURSION = YES; 210 | CLANG_WARN_INT_CONVERSION = YES; 211 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 212 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 213 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 215 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 216 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 217 | CLANG_WARN_STRICT_PROTOTYPES = YES; 218 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 219 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 220 | CLANG_WARN_UNREACHABLE_CODE = YES; 221 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 222 | COPY_PHASE_STRIP = NO; 223 | DEBUG_INFORMATION_FORMAT = dwarf; 224 | ENABLE_STRICT_OBJC_MSGSEND = YES; 225 | ENABLE_TESTABILITY = YES; 226 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 227 | GCC_C_LANGUAGE_STANDARD = gnu17; 228 | GCC_DYNAMIC_NO_PIC = NO; 229 | GCC_NO_COMMON_BLOCKS = YES; 230 | GCC_OPTIMIZATION_LEVEL = 0; 231 | GCC_PREPROCESSOR_DEFINITIONS = ( 232 | "DEBUG=1", 233 | "$(inherited)", 234 | ); 235 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 236 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 237 | GCC_WARN_UNDECLARED_SELECTOR = YES; 238 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 239 | GCC_WARN_UNUSED_FUNCTION = YES; 240 | GCC_WARN_UNUSED_VARIABLE = YES; 241 | IPHONEOS_DEPLOYMENT_TARGET = 17.2; 242 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 243 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 244 | MTL_FAST_MATH = YES; 245 | ONLY_ACTIVE_ARCH = YES; 246 | SDKROOT = iphoneos; 247 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 248 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 249 | }; 250 | name = Debug; 251 | }; 252 | 42A851A82B6F95C900B94CA1 /* Release */ = { 253 | isa = XCBuildConfiguration; 254 | buildSettings = { 255 | ALWAYS_SEARCH_USER_PATHS = NO; 256 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 257 | CLANG_ANALYZER_NONNULL = YES; 258 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 259 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 260 | CLANG_ENABLE_MODULES = YES; 261 | CLANG_ENABLE_OBJC_ARC = YES; 262 | CLANG_ENABLE_OBJC_WEAK = YES; 263 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 264 | CLANG_WARN_BOOL_CONVERSION = YES; 265 | CLANG_WARN_COMMA = YES; 266 | CLANG_WARN_CONSTANT_CONVERSION = YES; 267 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 268 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 269 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 270 | CLANG_WARN_EMPTY_BODY = YES; 271 | CLANG_WARN_ENUM_CONVERSION = YES; 272 | CLANG_WARN_INFINITE_RECURSION = YES; 273 | CLANG_WARN_INT_CONVERSION = YES; 274 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 275 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 276 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 277 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 278 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 279 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 280 | CLANG_WARN_STRICT_PROTOTYPES = YES; 281 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 282 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 283 | CLANG_WARN_UNREACHABLE_CODE = YES; 284 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 285 | COPY_PHASE_STRIP = NO; 286 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 287 | ENABLE_NS_ASSERTIONS = NO; 288 | ENABLE_STRICT_OBJC_MSGSEND = YES; 289 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 290 | GCC_C_LANGUAGE_STANDARD = gnu17; 291 | GCC_NO_COMMON_BLOCKS = YES; 292 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 293 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 294 | GCC_WARN_UNDECLARED_SELECTOR = YES; 295 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 296 | GCC_WARN_UNUSED_FUNCTION = YES; 297 | GCC_WARN_UNUSED_VARIABLE = YES; 298 | IPHONEOS_DEPLOYMENT_TARGET = 17.2; 299 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 300 | MTL_ENABLE_DEBUG_INFO = NO; 301 | MTL_FAST_MATH = YES; 302 | SDKROOT = iphoneos; 303 | SWIFT_COMPILATION_MODE = wholemodule; 304 | VALIDATE_PRODUCT = YES; 305 | }; 306 | name = Release; 307 | }; 308 | 42A851AA2B6F95C900B94CA1 /* Debug */ = { 309 | isa = XCBuildConfiguration; 310 | buildSettings = { 311 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 312 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 313 | CODE_SIGN_STYLE = Automatic; 314 | CURRENT_PROJECT_VERSION = 1; 315 | DEVELOPMENT_ASSET_PATHS = "\"TimerTest/Preview Content\""; 316 | DEVELOPMENT_TEAM = G8RH83B4LT; 317 | ENABLE_PREVIEWS = YES; 318 | GENERATE_INFOPLIST_FILE = YES; 319 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 320 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 321 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 322 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 323 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 324 | LD_RUNPATH_SEARCH_PATHS = ( 325 | "$(inherited)", 326 | "@executable_path/Frameworks", 327 | ); 328 | MARKETING_VERSION = 1.0; 329 | PRODUCT_BUNDLE_IDENTIFIER = com.ios.TimerTest; 330 | PRODUCT_NAME = "$(TARGET_NAME)"; 331 | SWIFT_EMIT_LOC_STRINGS = YES; 332 | SWIFT_VERSION = 5.0; 333 | TARGETED_DEVICE_FAMILY = "1,2"; 334 | }; 335 | name = Debug; 336 | }; 337 | 42A851AB2B6F95C900B94CA1 /* Release */ = { 338 | isa = XCBuildConfiguration; 339 | buildSettings = { 340 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 341 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 342 | CODE_SIGN_STYLE = Automatic; 343 | CURRENT_PROJECT_VERSION = 1; 344 | DEVELOPMENT_ASSET_PATHS = "\"TimerTest/Preview Content\""; 345 | DEVELOPMENT_TEAM = G8RH83B4LT; 346 | ENABLE_PREVIEWS = YES; 347 | GENERATE_INFOPLIST_FILE = YES; 348 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 349 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 350 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 351 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 352 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 353 | LD_RUNPATH_SEARCH_PATHS = ( 354 | "$(inherited)", 355 | "@executable_path/Frameworks", 356 | ); 357 | MARKETING_VERSION = 1.0; 358 | PRODUCT_BUNDLE_IDENTIFIER = com.ios.TimerTest; 359 | PRODUCT_NAME = "$(TARGET_NAME)"; 360 | SWIFT_EMIT_LOC_STRINGS = YES; 361 | SWIFT_VERSION = 5.0; 362 | TARGETED_DEVICE_FAMILY = "1,2"; 363 | }; 364 | name = Release; 365 | }; 366 | /* End XCBuildConfiguration section */ 367 | 368 | /* Begin XCConfigurationList section */ 369 | 42A851962B6F95C600B94CA1 /* Build configuration list for PBXProject "TimerTest" */ = { 370 | isa = XCConfigurationList; 371 | buildConfigurations = ( 372 | 42A851A72B6F95C900B94CA1 /* Debug */, 373 | 42A851A82B6F95C900B94CA1 /* Release */, 374 | ); 375 | defaultConfigurationIsVisible = 0; 376 | defaultConfigurationName = Release; 377 | }; 378 | 42A851A92B6F95C900B94CA1 /* Build configuration list for PBXNativeTarget "TimerTest" */ = { 379 | isa = XCConfigurationList; 380 | buildConfigurations = ( 381 | 42A851AA2B6F95C900B94CA1 /* Debug */, 382 | 42A851AB2B6F95C900B94CA1 /* Release */, 383 | ); 384 | defaultConfigurationIsVisible = 0; 385 | defaultConfigurationName = Release; 386 | }; 387 | /* End XCConfigurationList section */ 388 | 389 | /* Begin XCRemoteSwiftPackageReference section */ 390 | 4252439F2C661BCB00981D2F /* XCRemoteSwiftPackageReference "UserDefaultsEditor" */ = { 391 | isa = XCRemoteSwiftPackageReference; 392 | repositoryURL = "https://github.com/Ryu0118/UserDefaultsEditor"; 393 | requirement = { 394 | kind = upToNextMajorVersion; 395 | minimumVersion = 0.4.0; 396 | }; 397 | }; 398 | /* End XCRemoteSwiftPackageReference section */ 399 | 400 | /* Begin XCSwiftPackageProductDependency section */ 401 | 4225D7BF2C5D4C3D00F932BC /* PersistableTimerText */ = { 402 | isa = XCSwiftPackageProductDependency; 403 | productName = PersistableTimerText; 404 | }; 405 | 425243A02C661BCB00981D2F /* UserDefaultsEditor */ = { 406 | isa = XCSwiftPackageProductDependency; 407 | package = 4252439F2C661BCB00981D2F /* XCRemoteSwiftPackageReference "UserDefaultsEditor" */; 408 | productName = UserDefaultsEditor; 409 | }; 410 | 42A851AE2B6F960A00B94CA1 /* PersistableTimer */ = { 411 | isa = XCSwiftPackageProductDependency; 412 | productName = PersistableTimer; 413 | }; 414 | /* End XCSwiftPackageProductDependency section */ 415 | }; 416 | rootObject = 42A851932B6F95C600B94CA1 /* Project object */; 417 | } 418 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "54b5944423804ba96689c1095d3c165711f7de78f9a25eb4b7f02125ca922c1b", 3 | "pins" : [ 4 | { 5 | "identity" : "editvalueview", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/p-x9/EditValueView.git", 8 | "state" : { 9 | "revision" : "454d77987aea7a3673dc2b7ce8ab15efa154987f", 10 | "version" : "0.7.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-async-algorithms", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-async-algorithms.git", 17 | "state" : { 18 | "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", 19 | "version" : "1.0.1" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-collections", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-collections.git", 26 | "state" : { 27 | "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", 28 | "version" : "1.1.2" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-concurrency-extras", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", 35 | "state" : { 36 | "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", 37 | "version" : "1.3.1" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-magic-mirror", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/p-x9/swift-magic-mirror.git", 44 | "state" : { 45 | "revision" : "390e248dd6727e17aeb3949c12bb83e6eac876d1", 46 | "version" : "0.2.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swiftui-reflection-view", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/p-x9/swiftui-reflection-view.git", 53 | "state" : { 54 | "revision" : "d4cef50ab1a3ea729df02807ad1c1698a5d646da", 55 | "version" : "0.8.1" 56 | } 57 | }, 58 | { 59 | "identity" : "swiftuicolor", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/p-x9/SwiftUIColor.git", 62 | "state" : { 63 | "revision" : "126ae1f4fdd8cde2b49359707f175b12104176f9", 64 | "version" : "0.5.0" 65 | } 66 | }, 67 | { 68 | "identity" : "userdefaultseditor", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/Ryu0118/UserDefaultsEditor", 71 | "state" : { 72 | "revision" : "b06a32bdd5dbb27fda6e095af913743cd2c14a3d", 73 | "version" : "0.4.0" 74 | } 75 | } 76 | ], 77 | "version" : 3 78 | } 79 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import PersistableTimer 3 | import Observation 4 | 5 | @Observable 6 | final class ContentModel { 7 | var isTimerPresented = false 8 | var isStopwatchPresented = false 9 | var isMultipleStopwatchPresented = false 10 | let persistableTimer: PersistableTimer 11 | 12 | init(persistableTimer: PersistableTimer) { 13 | self.persistableTimer = persistableTimer 14 | } 15 | 16 | func onAppear() { 17 | if let timerData = try? persistableTimer.getTimerData() { 18 | switch timerData.type { 19 | case .stopwatch: 20 | isStopwatchPresented = true 21 | case .timer: 22 | isTimerPresented = true 23 | } 24 | } 25 | } 26 | } 27 | 28 | struct ContentView: View { 29 | @Bindable var contentModel: ContentModel 30 | 31 | var body: some View { 32 | List { 33 | Text("Timer") 34 | .onTapGesture { 35 | contentModel.isTimerPresented = true 36 | } 37 | Text("Stopwatch") 38 | .onTapGesture { 39 | contentModel.isStopwatchPresented = true 40 | } 41 | Text("Multiple Stopwatch") 42 | .onTapGesture { 43 | contentModel.isMultipleStopwatchPresented = true 44 | } 45 | } 46 | .sheet(isPresented: $contentModel.isTimerPresented) { 47 | TimerView( 48 | timerModel: TimerModel( 49 | persistableTimer: contentModel.persistableTimer 50 | ) 51 | ) 52 | } 53 | .sheet(isPresented: $contentModel.isStopwatchPresented) { 54 | StopwatchView( 55 | stopwatchModel: StopwatchModel( 56 | persistableTimer: contentModel.persistableTimer 57 | ) 58 | ) 59 | } 60 | .sheet(isPresented: $contentModel.isMultipleStopwatchPresented) { 61 | MultipleStopwatchView( 62 | stopwatchModel: MultipleStopwatchModel() 63 | ) 64 | } 65 | .onAppear { 66 | contentModel.onAppear() 67 | } 68 | } 69 | } 70 | 71 | #Preview { 72 | ContentView( 73 | contentModel: ContentModel( 74 | persistableTimer: PersistableTimer(dataSourceType: .inMemory) 75 | ) 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/MultipleStopwatchView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Observation 3 | import PersistableTimer 4 | import PersistableTimerText 5 | import UserDefaultsEditor 6 | 7 | @Observable 8 | final class MultipleStopwatchModel { 9 | var timer1: TimerContainer 10 | var timer2: TimerContainer 11 | var timer3: TimerContainer 12 | 13 | var isUDEditorPresented = false 14 | 15 | init() { 16 | self.timer1 = TimerContainer( 17 | persistableTimer: PersistableTimer( 18 | id: "1", 19 | dataSourceType: .userDefaults(.standard), 20 | shouldEmitTimeStream: false 21 | ) 22 | ) 23 | self.timer2 = TimerContainer( 24 | persistableTimer: PersistableTimer( 25 | id: "2", 26 | dataSourceType: .userDefaults(.standard), 27 | shouldEmitTimeStream: false 28 | ) 29 | ) 30 | self.timer3 = TimerContainer( 31 | persistableTimer: PersistableTimer( 32 | id: "3", 33 | dataSourceType: .userDefaults(.standard), 34 | shouldEmitTimeStream: false 35 | ) 36 | ) 37 | } 38 | 39 | func synchronize() async { 40 | await timer1.synchronize() 41 | await timer2.synchronize() 42 | await timer3.synchronize() 43 | } 44 | 45 | func finish() async { 46 | await timer1.finish() 47 | await timer2.finish() 48 | await timer3.finish() 49 | } 50 | 51 | @Observable 52 | final class TimerContainer { 53 | let persistableTimer: PersistableTimer 54 | var timerState: TimerState? 55 | 56 | init(persistableTimer: PersistableTimer) { 57 | self.persistableTimer = persistableTimer 58 | } 59 | 60 | var buttonTitle: String { 61 | switch timerState?.status { 62 | case .running: 63 | "Stop" 64 | case .paused: 65 | "Resume" 66 | case .finished: 67 | "Finished" 68 | case nil: 69 | "Start" 70 | } 71 | } 72 | 73 | func buttonTapped() async { 74 | do { 75 | let container = switch timerState?.status { 76 | case .running: 77 | try await persistableTimer.pause() 78 | case .paused: 79 | try await persistableTimer.resume() 80 | case .finished, nil: 81 | try await persistableTimer.start(type: .stopwatch) 82 | } 83 | self.timerState = container.elapsedTimeAndStatus() 84 | } catch { 85 | print(error) 86 | } 87 | } 88 | 89 | func synchronize() async { 90 | timerState = try? persistableTimer.getTimerData()?.elapsedTimeAndStatus() 91 | } 92 | 93 | func finish() async { 94 | timerState = try? await persistableTimer.finish().elapsedTimeAndStatus() 95 | } 96 | } 97 | } 98 | 99 | struct MultipleStopwatchView: View { 100 | @Bindable var stopwatchModel: MultipleStopwatchModel 101 | @State var id = UUID() 102 | 103 | public var body: some View { 104 | VStack(spacing: 20) { 105 | VStack { 106 | timerView(timer: \.timer1) 107 | timerView(timer: \.timer2) 108 | timerView(timer: \.timer3) 109 | } 110 | .id(id) 111 | Button("Present UserDefaultsEditor") { 112 | stopwatchModel.isUDEditorPresented = true 113 | } 114 | Button("Update View forcefully") { 115 | id = UUID() 116 | } 117 | } 118 | .task { 119 | await stopwatchModel.synchronize() 120 | } 121 | .task(id: id) { 122 | await stopwatchModel.synchronize() 123 | } 124 | .onDisappear { 125 | Task { 126 | await stopwatchModel.finish() 127 | } 128 | } 129 | .sheet(isPresented: $stopwatchModel.isUDEditorPresented) { 130 | UserDefaultsEditor(userDefaults: .standard, presentationStyle: .modal) 131 | } 132 | } 133 | 134 | private func timerView(timer: KeyPath) -> some View { 135 | VStack { 136 | Text(timerState: stopwatchModel[keyPath: timer].timerState) 137 | .font(.title) 138 | 139 | Button { 140 | Task { 141 | await stopwatchModel[keyPath: timer].buttonTapped() 142 | await stopwatchModel.synchronize() 143 | } 144 | } label: { 145 | Text(stopwatchModel[keyPath: timer].buttonTitle) 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/StopwatchView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Observation 3 | import PersistableTimer 4 | import PersistableTimerText 5 | 6 | @Observable 7 | final class StopwatchModel { 8 | private let persistableTimer: PersistableTimer 9 | 10 | var timerState: TimerState? 11 | 12 | var buttonTitle: String { 13 | switch timerState?.status { 14 | case .running: 15 | "Stop" 16 | case .paused: 17 | "Resume" 18 | case .finished: 19 | "Finished" 20 | case nil: 21 | "Start" 22 | } 23 | } 24 | 25 | init(persistableTimer: PersistableTimer) { 26 | self.persistableTimer = persistableTimer 27 | } 28 | 29 | func buttonTapped() async { 30 | do { 31 | let container = switch timerState?.status { 32 | case .running: 33 | try await persistableTimer.pause() 34 | case .paused: 35 | try await persistableTimer.resume() 36 | case .finished, nil: 37 | try await persistableTimer.start(type: .stopwatch) 38 | } 39 | self.timerState = container.elapsedTimeAndStatus() 40 | } catch { 41 | print(error) 42 | } 43 | } 44 | 45 | /// Calls addElapsedTime(5) to increase the stopwatch's elapsed time by 5 seconds. 46 | func addExtraElapsedTime() async { 47 | do { 48 | let container = try await persistableTimer.addElapsedTime(5) 49 | self.timerState = container.elapsedTimeAndStatus() 50 | } catch { 51 | print("Error adding elapsed time: \(error)") 52 | } 53 | } 54 | 55 | func synchronize() async { 56 | timerState = try? persistableTimer.getTimerData()?.elapsedTimeAndStatus() 57 | } 58 | 59 | func finish() async { 60 | timerState = try? await persistableTimer.finish().elapsedTimeAndStatus() 61 | } 62 | } 63 | 64 | struct StopwatchView: View { 65 | let stopwatchModel: StopwatchModel 66 | 67 | public var body: some View { 68 | VStack(spacing: 20) { 69 | Text(timerState: stopwatchModel.timerState) 70 | .font(.title) 71 | 72 | Button { 73 | Task { 74 | await stopwatchModel.buttonTapped() 75 | } 76 | } label: { 77 | Text(stopwatchModel.buttonTitle) 78 | } 79 | Button("Add 5 sec") { 80 | Task { 81 | await stopwatchModel.addExtraElapsedTime() 82 | } 83 | } 84 | } 85 | .task { 86 | await stopwatchModel.synchronize() 87 | } 88 | .onDisappear { 89 | Task { 90 | await stopwatchModel.finish() 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/TimePicker.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct TimePicker: View { 4 | public let hours: Int 5 | public let minutes: Int 6 | public let seconds: Int 7 | 8 | @Binding var selectedHours: Int 9 | @Binding var selectedMinutes: Int 10 | @Binding var selectedSeconds: Int 11 | 12 | public init( 13 | hours: Int = 24, 14 | minutes: Int = 59, 15 | seconds: Int = 59, 16 | selectedHours: Binding, 17 | selectedMinutes: Binding, 18 | selectedSeconds: Binding 19 | ) { 20 | self.hours = hours 21 | self.minutes = minutes 22 | self.seconds = seconds 23 | _selectedHours = selectedHours 24 | _selectedMinutes = selectedMinutes 25 | _selectedSeconds = selectedSeconds 26 | } 27 | 28 | public var body: some View { 29 | HStack { 30 | Picker("", selection: $selectedHours) { 31 | ForEach(0 ... hours, id: \.self) { hour in 32 | Text("\(hour)hours") 33 | .tag(hour) 34 | } 35 | } 36 | Picker("", selection: $selectedMinutes) { 37 | ForEach(0 ... minutes, id: \.self) { minute in 38 | Text("\(minute)min") 39 | .tag(minute) 40 | } 41 | } 42 | Picker("", selection: $selectedSeconds) { 43 | ForEach(0 ... seconds, id: \.self) { minute in 44 | Text("\(minute)sec") 45 | .tag(minute) 46 | } 47 | } 48 | } 49 | .pickerStyle(.wheel) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/TimerTestApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import PersistableTimer 3 | 4 | @main 5 | struct TimerTestApp: App { 6 | var body: some Scene { 7 | WindowGroup { 8 | ContentView( 9 | contentModel: ContentModel( 10 | persistableTimer: PersistableTimer( 11 | dataSourceType: .userDefaults(.standard) 12 | ) 13 | ) 14 | ) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Examples/TimerTest/TimerTest/TimerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Observation 3 | import PersistableTimer 4 | import PersistableTimerText 5 | 6 | @Observable 7 | final class TimerModel { 8 | private let persistableTimer: PersistableTimer 9 | var timerState: TimerState? 10 | 11 | var selectedHours: Int = 0 12 | var selectedMinutes: Int = 0 13 | var selectedSeconds: Int = 0 14 | 15 | var duration: TimeInterval { 16 | TimeInterval((selectedHours * 60 * 60) + (selectedMinutes * 60) + selectedSeconds) 17 | } 18 | 19 | var buttonTitle: String { 20 | switch timerState?.status { 21 | case .running: 22 | "Stop" 23 | case .paused: 24 | "Resume" 25 | case .finished: 26 | "Finished" 27 | case nil: 28 | "Start" 29 | } 30 | } 31 | 32 | init(persistableTimer: PersistableTimer) { 33 | self.persistableTimer = persistableTimer 34 | } 35 | 36 | func buttonTapped() async { 37 | do { 38 | let container = switch timerState?.status { 39 | case .running: 40 | try await persistableTimer.pause() 41 | case .paused: 42 | try await persistableTimer.resume() 43 | case .finished, nil: 44 | try await persistableTimer.start( 45 | type: .timer( 46 | duration: duration 47 | ) 48 | ) 49 | } 50 | self.timerState = container.elapsedTimeAndStatus() 51 | } catch { 52 | print(error) 53 | } 54 | } 55 | 56 | /// Calls addRemainingTime(5) to extend the timer's remaining duration by 5 seconds. 57 | func addExtraTime() async { 58 | do { 59 | let container = try await persistableTimer.addRemainingTime(5) 60 | self.timerState = container.elapsedTimeAndStatus() 61 | } catch { 62 | print("Error adding remaining time: \(error)") 63 | } 64 | } 65 | 66 | func synchronize() async { 67 | timerState = try? persistableTimer.getTimerData()?.elapsedTimeAndStatus() 68 | } 69 | 70 | func finish() async { 71 | timerState = try? await persistableTimer.finish().elapsedTimeAndStatus() 72 | } 73 | } 74 | 75 | struct TimerView: View { 76 | @Bindable var timerModel: TimerModel 77 | 78 | var body: some View { 79 | VStack(spacing: 20) { 80 | if let timerState = timerModel.timerState { 81 | Text(timerState: timerState) 82 | .font(.title) 83 | } else { 84 | TimePicker( 85 | selectedHours: $timerModel.selectedHours, 86 | selectedMinutes: $timerModel.selectedMinutes, 87 | selectedSeconds: $timerModel.selectedSeconds 88 | ) 89 | } 90 | Button { 91 | Task { 92 | await timerModel.buttonTapped() 93 | } 94 | } label: { 95 | Text(timerModel.buttonTitle) 96 | } 97 | // 「Add 5 sec」ボタンは、タイマータイプ(.timer)の場合のみ表示 98 | if let timerState = timerModel.timerState, case .timer = timerState.type { 99 | Button("Add 5 sec") { 100 | Task { 101 | await timerModel.addExtraTime() 102 | } 103 | } 104 | } 105 | } 106 | .task { 107 | await timerModel.synchronize() 108 | } 109 | .onDisappear { 110 | Task { 111 | await timerModel.finish() 112 | } 113 | } 114 | } 115 | } 116 | 117 | #Preview { 118 | TimerView( 119 | timerModel: TimerModel( 120 | persistableTimer: PersistableTimer( 121 | dataSourceType: .inMemory 122 | ) 123 | ) 124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ryu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "0395560d25e31e5d0c5ea880878f181e6595013c7c017b9915364edf3bf79c85", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-async-algorithms", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-async-algorithms.git", 8 | "state" : { 9 | "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", 10 | "version" : "1.0.1" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-collections", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-collections.git", 17 | "state" : { 18 | "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", 19 | "version" : "1.1.2" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-concurrency-extras", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", 26 | "state" : { 27 | "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", 28 | "version" : "1.3.1" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-persistable-timer", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .macCatalyst(.v13), 12 | .tvOS(.v13), 13 | .watchOS(.v6), 14 | .visionOS(.v1) 15 | ], 16 | products: [ 17 | // Products define the executables and libraries a package produces, making them visible to other packages. 18 | .library( 19 | name: "PersistableTimerCore", 20 | targets: ["PersistableTimerCore"] 21 | ), 22 | .library( 23 | name: "PersistableTimer", 24 | targets: ["PersistableTimer"] 25 | ), 26 | .library( 27 | name: "PersistableTimerText", 28 | targets: ["PersistableTimerText"] 29 | ) 30 | ], 31 | dependencies: [ 32 | .package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.0.0"), 33 | .package(url: "https://github.com/pointfreeco/swift-concurrency-extras.git", exact: "1.3.1") 34 | ], 35 | targets: [ 36 | // Targets are the basic building blocks of a package, defining a module or a test suite. 37 | // Targets can depend on other targets in this package and products from dependencies. 38 | .target( 39 | name: "PersistableTimerCore", 40 | dependencies: [ 41 | .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras") 42 | ] 43 | ), 44 | .target( 45 | name: "PersistableTimer", 46 | dependencies: [ 47 | "PersistableTimerCore", 48 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms") 49 | ], 50 | resources: [.copy("PrivacyInfo.xcprivacy")] 51 | ), 52 | .target( 53 | name: "PersistableTimerText", 54 | dependencies: ["PersistableTimerCore"] 55 | ), 56 | .testTarget( 57 | name: "PersistableTimerCoreTests", 58 | dependencies: [ 59 | "PersistableTimerCore", 60 | ] 61 | ), 62 | ] 63 | ) 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PersistableTimer 2 | 3 | PersistableTimer is a Swift library that provides persistent timers and stopwatches with seamless state restoration — even across app restarts. It supports both countdown timers and stopwatches, with flexible data sources such as UserDefaults (for production) and in-memory storage (for testing or previews). 4 | 5 | ## Features 6 | 7 | - **Persistent State:** Restore timer state automatically after app termination or restart. 8 | - **Dual Modes:** Choose between a running stopwatch and a countdown timer. 9 | - **Real-time Updates:** Subscribe to continuous timer updates via an asynchronous stream. 10 | - **Dynamic Time Adjustment:** Add extra time to a countdown or extra elapsed time to a stopwatch. 11 | - **SwiftUI Integration:** Easily display timer states using extensions from `PersistableTimerText`. 12 | 13 | ## Example Application 14 | 15 | See the [Example App](https://github.com/Ryu0118/swift-persistable-timer/tree/main/Examples/TimerTest) for a complete SwiftUI implementation. 16 | 17 | ## Installation 18 | 19 | Add the package dependency in your `Package.swift`: 20 | 21 | ```swift 22 | dependencies: [ 23 | .package(url: "https://github.com/Ryu0118/swift-persistable-timer.git", from: "0.7.0") 24 | ], 25 | ``` 26 | 27 | Then add the desired products (`PersistableTimer`, `PersistableTimerCore`, or `PersistableTimerText`) to your target dependencies. 28 | 29 | ## Usage 30 | 31 | ### Initialization 32 | 33 | Instantiate `PersistableTimer` with your preferred data source and configuration: 34 | 35 | ```swift 36 | import PersistableTimer 37 | 38 | // For testing or previews: 39 | let timer = PersistableTimer(dataSourceType: .inMemory) 40 | 41 | // For production (using UserDefaults): 42 | let timer = PersistableTimer(dataSourceType: .userDefaults(.standard)) 43 | 44 | // With a custom update interval: 45 | let timer = PersistableTimer(dataSourceType: .userDefaults(.standard), updateInterval: 0.5) 46 | ``` 47 | 48 | ### Starting a Timer 49 | 50 | Start a stopwatch or a countdown timer. You can also force-start a new timer even if one is already running: 51 | 52 | ```swift 53 | // Start a stopwatch 54 | try await timer.start(type: .stopwatch) 55 | 56 | // Start a countdown timer with a duration of 100 seconds 57 | try await timer.start(type: .timer(duration: 100)) 58 | 59 | // Force start a new timer even if one is already active: 60 | try await timer.start(type: .timer(duration: 100), forceStart: true) 61 | ``` 62 | 63 | ### Pausing, Resuming, and Finishing 64 | 65 | Control the timer state as needed: 66 | 67 | ```swift 68 | // Pause the timer 69 | try await timer.pause() 70 | 71 | // Resume a paused timer 72 | try await timer.resume() 73 | 74 | // Finish the timer (optionally reset the elapsed time) 75 | try await timer.finish(isResetTime: false) 76 | ``` 77 | 78 | ### Dynamic Time Adjustments 79 | 80 | Adjust the timer on the fly: 81 | 82 | ```swift 83 | // For countdown timers: add extra time to the remaining duration. 84 | try await timer.addRemainingTime(5) // Adds 5 seconds 85 | 86 | // For stopwatches: add extra elapsed time (i.e., effectively moving the start date earlier). 87 | try await timer.addElapsedTime(5) // Adds 5 seconds to the elapsed time 88 | ``` 89 | 90 | ### Restoring Timer State 91 | 92 | Restore the timer's previous state after an app restart: 93 | 94 | ```swift 95 | try timer.restore() 96 | ``` 97 | 98 | ### Receiving Timer Updates 99 | 100 | Subscribe to the asynchronous time stream to update your UI in real time: 101 | 102 | ```swift 103 | for await timeState in timer.timeStream { 104 | // Update your UI with the current timer state. 105 | print("Elapsed time: \(timeState.elapsedTime)") 106 | } 107 | ``` 108 | 109 | ### SwiftUI Integration with PersistableTimerText 110 | 111 | Display the timer state easily in your SwiftUI views using the provided Text initializer: 112 | 113 | ```swift 114 | import SwiftUI 115 | import PersistableTimerText 116 | 117 | struct TimerView: View { 118 | @State private var timerState: TimerState? 119 | 120 | var body: some View { 121 | Text(timerState: timerState) 122 | .font(.title) 123 | .onAppear { 124 | // Update `timerState` with your PersistableTimer's current state. 125 | } 126 | } 127 | } 128 | ``` 129 | 130 | ## License 131 | 132 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 133 | -------------------------------------------------------------------------------- /Sources/PersistableTimer/PersistableTimer.swift: -------------------------------------------------------------------------------- 1 | import AsyncAlgorithms 2 | import Foundation 3 | import PersistableTimerCore 4 | import ConcurrencyExtras 5 | 6 | /// A class for managing a persistable timer, capable of restoring state after application termination. 7 | public final class PersistableTimer: Sendable { 8 | /// An async stream of timer states, providing continuous updates. 9 | public var timeStream: AsyncStream { 10 | if !shouldEmitTimeStream { 11 | assertionFailure("Attempted to access timeStream while shouldEmitTimeStream is set to false.") 12 | } 13 | return stream.stream 14 | } 15 | 16 | private let restoreTimerData: LockIsolated = .init(nil) 17 | private let timerType: LockIsolated = .init(nil) 18 | private let stream: LockIsolated<( 19 | stream: AsyncStream, 20 | continuation: AsyncStream.Continuation 21 | )> = .init(AsyncStream.makeStream()) 22 | 23 | private let container: RestoreTimerContainer 24 | nonisolated(unsafe) private let now: () -> Date 25 | 26 | /// The interval at which the timer updates its elapsed time. 27 | let updateInterval: TimeInterval 28 | let useFoundationTimer: Bool 29 | let shouldEmitTimeStream: Bool 30 | let id: String? 31 | 32 | /// Initializes a new PersistableTimer. 33 | /// 34 | /// - Parameters: 35 | /// - dataSourceType: The type of data source to use, either in-memory or UserDefaults. 36 | /// - updateInterval: The interval at which the timer updates, defaults to 1 second. 37 | /// - now: A closure providing the current date and time, defaults to `Date()`. 38 | public init( 39 | id: String? = nil, 40 | dataSourceType: DataSourceType, 41 | shouldEmitTimeStream: Bool = true, 42 | updateInterval: TimeInterval = 1, 43 | useFoundationTimer: Bool = false, 44 | now: @escaping () -> Date = { Date() } 45 | ) { 46 | let dataSource: any DataSource = 47 | switch dataSourceType { 48 | case .inMemory: 49 | InMemoryDataSource() 50 | case .userDefaults(let userDefaults): 51 | UserDefaultsClient(userDefaults: userDefaults) 52 | } 53 | container = RestoreTimerContainer(dataSource: dataSource) 54 | self.id = id 55 | self.now = now 56 | self.updateInterval = updateInterval 57 | self.useFoundationTimer = useFoundationTimer 58 | self.shouldEmitTimeStream = shouldEmitTimeStream 59 | } 60 | 61 | deinit { 62 | timerType.value?.cancel() 63 | } 64 | 65 | /// Retrieves the persisted timer data if available. 66 | /// 67 | /// - Throws: Any errors encountered while fetching the timer data. 68 | /// - Returns: The `RestoreTimerData` if available. 69 | public func getTimerData() throws -> RestoreTimerData? { 70 | try container.getTimerData(id: id) 71 | } 72 | 73 | /// Checks if a timer is currently running. 74 | /// 75 | /// - Returns: A Boolean value indicating whether a timer is running. 76 | public func isTimerRunning() -> Bool { 77 | container.isTimerRunning(id: id) 78 | } 79 | 80 | /// Restores the timer from the last known state and starts the timer if it was running. 81 | /// 82 | /// - Throws: Any errors encountered while restoring the timer. 83 | /// - Returns: The restored `RestoreTimerData`. 84 | @discardableResult 85 | public func restore() throws -> RestoreTimerData { 86 | let now = now() 87 | let restoreTimerData = try container.getTimerData(id: id) 88 | let timerState = restoreTimerData.elapsedTimeAndStatus(now: now) 89 | 90 | self.stream.continuation.yieldIfNeeded(timerState, enable: shouldEmitTimeStream) 91 | if timerState.status == .running { 92 | startTimerIfNeeded() 93 | } 94 | 95 | return restoreTimerData 96 | } 97 | 98 | /// Starts the timer with the specified type, optionally forcing a start even if a timer is already running. 99 | /// 100 | /// - Parameters: 101 | /// - type: The type of timer, either stopwatch or countdown. 102 | /// - forceStart: A Boolean value to force start the timer, ignoring if another timer is already running. 103 | /// - Throws: Any errors encountered while starting the timer. 104 | @discardableResult 105 | public func start(type: RestoreType, forceStart: Bool = false) async throws -> RestoreTimerData { 106 | let now = now() 107 | let restoreTimerData = try await container.start( 108 | id: id, 109 | now: now, 110 | type: type, 111 | forceStart: forceStart 112 | ) 113 | self.restoreTimerData.setValue(restoreTimerData) 114 | 115 | stream.continuation.yieldIfNeeded(restoreTimerData.elapsedTimeAndStatus(now: now), enable: shouldEmitTimeStream) 116 | startTimerIfNeeded() 117 | 118 | return restoreTimerData 119 | } 120 | 121 | /// Resumes a paused timer. 122 | /// 123 | /// - Throws: Any errors encountered while resuming the timer. 124 | @discardableResult 125 | public func resume() async throws -> RestoreTimerData { 126 | let now = now() 127 | let restoreTimerData = try await container.resume(id: id, now: now) 128 | self.restoreTimerData.setValue(restoreTimerData) 129 | 130 | stream.continuation.yieldIfNeeded(restoreTimerData.elapsedTimeAndStatus(now: now), enable: shouldEmitTimeStream) 131 | startTimerIfNeeded() 132 | 133 | return restoreTimerData 134 | } 135 | 136 | /// Pauses the currently running timer. 137 | /// 138 | /// - Throws: Any errors encountered while pausing the timer. 139 | @discardableResult 140 | public func pause() async throws -> RestoreTimerData { 141 | let now = now() 142 | let restoreTimerData = try await container.pause(id: id, now: now) 143 | self.restoreTimerData.setValue(restoreTimerData) 144 | 145 | stream.continuation.yieldIfNeeded(restoreTimerData.elapsedTimeAndStatus(now: now), enable: shouldEmitTimeStream) 146 | invalidate() 147 | 148 | return restoreTimerData 149 | } 150 | 151 | /// Finishes the timer and optionally resets the elapsed time. 152 | /// 153 | /// - Parameter isResetTime: A Boolean value indicating whether to reset the elapsed time upon finishing. 154 | /// - Throws: Any errors encountered while finishing the timer. 155 | @discardableResult 156 | public func finish(isResetTime: Bool = false) async throws -> RestoreTimerData { 157 | do { 158 | let now = now() 159 | let restoreTimerData = try await container.finish(id: id, now: now) 160 | var elapsedTimeAndStatus = restoreTimerData.elapsedTimeAndStatus(now: now) 161 | if isResetTime { 162 | elapsedTimeAndStatus.elapsedTime = 0 163 | } 164 | self.restoreTimerData.setValue(restoreTimerData) 165 | stream.continuation.yieldIfNeeded(elapsedTimeAndStatus, enable: shouldEmitTimeStream) 166 | invalidate(isFinish: true) 167 | 168 | return restoreTimerData 169 | } catch { 170 | invalidate(isFinish: true) 171 | throw error 172 | } 173 | } 174 | 175 | /// For a timer, adds extra time to the remaining duration. 176 | /// 177 | /// - Parameter extraTime: The time (in seconds) to add. 178 | /// - Throws: An error if the timer type is not .timer. 179 | /// - Returns: The updated RestoreTimerData. 180 | @discardableResult 181 | public func addRemainingTime(_ extraTime: TimeInterval) async throws -> RestoreTimerData { 182 | let now = self.now() 183 | let currentData = try container.getTimerData(id: id) 184 | guard case .timer = currentData.type else { 185 | throw PersistableTimerClientError.invalidTimerType 186 | } 187 | let updatedData = try await container.addRemainingTime(id: id, extraTime: extraTime, now: now) 188 | stream.continuation.yieldIfNeeded(updatedData.elapsedTimeAndStatus(now: now), enable: shouldEmitTimeStream) 189 | return updatedData 190 | } 191 | 192 | /// For a stopwatch, adds extra elapsed time by moving the start date earlier. 193 | /// 194 | /// - Parameter extraTime: The time (in seconds) to add. 195 | /// - Throws: An error if the timer type is not .stopwatch. 196 | /// - Returns: The updated RestoreTimerData. 197 | @discardableResult 198 | public func addElapsedTime(_ extraTime: TimeInterval) async throws -> RestoreTimerData { 199 | let now = self.now() 200 | let currentData = try container.getTimerData(id: id) 201 | guard case .stopwatch = currentData.type else { 202 | throw PersistableTimerClientError.invalidTimerType 203 | } 204 | let updatedData = try await container.addElapsedTime(id: id, extraTime: extraTime, now: now) 205 | stream.continuation.yieldIfNeeded(updatedData.elapsedTimeAndStatus(now: now), enable: shouldEmitTimeStream) 206 | return updatedData 207 | } 208 | 209 | /// Starts the timer if it's not already running. 210 | private func startTimerIfNeeded() { 211 | guard shouldEmitTimeStream else { 212 | return 213 | } 214 | invalidate(isFinish: true) 215 | if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *), !useFoundationTimer { 216 | self.timerType.setValue( 217 | .asyncTimerSequence( 218 | Task { [weak self] in 219 | let timer = AsyncTimerSequence(interval: .seconds(self?.updateInterval ?? 1), clock: .continuous) 220 | for await _ in timer { 221 | self?.updateTimerStream() 222 | } 223 | } 224 | ) 225 | ) 226 | } else { 227 | nonisolated(unsafe) let timer = Timer(fire: now(), interval: updateInterval, repeats: true) { [weak self] timer in 228 | self?.updateTimerStream() 229 | } 230 | self.timerType.setValue(.timer(timer)) 231 | RunLoop.main.add(timer, forMode: .common) 232 | } 233 | } 234 | 235 | /// Invalidates the current timer and optionally finishes the stream. 236 | /// 237 | /// - Parameter isFinish: A Boolean value indicating whether to finish the stream. 238 | private func invalidate(isFinish: Bool = false) { 239 | timerType.value?.cancel() 240 | timerType.setValue(nil) 241 | if isFinish && shouldEmitTimeStream { 242 | stream.continuation.finish() 243 | stream.setValue(AsyncStream.makeStream()) 244 | } 245 | } 246 | 247 | private func updateTimerStream() { 248 | guard let restoreTimerData = try? restoreTimerData.value ?? container.getTimerData(id: id) 249 | else { 250 | timerType.value?.cancel() 251 | return 252 | } 253 | let timerState = restoreTimerData.elapsedTimeAndStatus(now: now()) 254 | stream.continuation.yieldIfNeeded(timerState, enable: shouldEmitTimeStream) 255 | } 256 | } 257 | 258 | private extension PersistableTimer { 259 | private enum TimerType: @unchecked Sendable { 260 | case timer(Timer) 261 | case asyncTimerSequence(Task) 262 | 263 | func cancel() { 264 | switch self { 265 | case .timer(let timer): 266 | timer.invalidate() 267 | case .asyncTimerSequence(let task): 268 | task.cancel() 269 | } 270 | } 271 | } 272 | } 273 | 274 | extension AsyncStream.Continuation { 275 | @discardableResult 276 | func yieldIfNeeded(_ value: sending Element, enable: Bool) -> AsyncStream.Continuation.YieldResult? { 277 | if enable { 278 | return yield(value) 279 | } else { 280 | return nil 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /Sources/PersistableTimer/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | NSPrivacyTrackingDomains 8 | 9 | NSPrivacyCollectedDataTypes 10 | 11 | NSPrivacyAccessedAPITypes 12 | 13 | 14 | NSPrivacyAccessedAPIType 15 | NSPrivacyAccessedAPICategoryUserDefaults 16 | NSPrivacyAccessedAPITypeReasons 17 | 18 | C56D.1 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Sources/PersistableTimer/exported.swift: -------------------------------------------------------------------------------- 1 | @_exported import PersistableTimerCore 2 | -------------------------------------------------------------------------------- /Sources/PersistableTimerCore/DataSource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ConcurrencyExtras 3 | 4 | /// A protocol defining the requirements for a data source. 5 | package protocol DataSource: Sendable { 6 | func data( 7 | forKey: String, 8 | type: T.Type 9 | ) -> T? 10 | 11 | func set( 12 | _ value: T, 13 | forKey: String 14 | ) async throws 15 | 16 | func setNil( 17 | forKey: String 18 | ) async 19 | 20 | 21 | func keys() -> [String] 22 | } 23 | 24 | /// An enum representing the type of data source to be used. 25 | public enum DataSourceType { 26 | case userDefaults(UserDefaults) 27 | case inMemory 28 | } 29 | 30 | /// A client for interacting with UserDefaults as a data source. 31 | package struct UserDefaultsClient: Sendable, DataSource { 32 | nonisolated(unsafe) private let userDefaults: UserDefaults 33 | 34 | private let decoder = JSONDecoder() 35 | private let encoder = JSONEncoder() 36 | 37 | package init(userDefaults: UserDefaults) { 38 | self.userDefaults = userDefaults 39 | } 40 | 41 | package func data( 42 | forKey: String, 43 | type: T.Type 44 | ) -> T? { 45 | if let data = userDefaults.object(forKey: forKey) as? Data { 46 | do { 47 | return try decoder.decode(type, from: data) 48 | } catch { 49 | return nil 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | package func set( 56 | _ value: T, 57 | forKey: String 58 | ) async throws { 59 | let data = try encoder.encode(value) 60 | userDefaults.set(data, forKey: forKey) 61 | } 62 | 63 | package func setNil(forKey: String) async { 64 | userDefaults.set(nil, forKey: forKey) 65 | } 66 | 67 | package func keys() -> [String] { 68 | Array(userDefaults.dictionaryRepresentation().keys) 69 | } 70 | } 71 | 72 | /// A client for managing data in memory, mainly for testing purposes. 73 | package final class InMemoryDataSource: Sendable, DataSource { 74 | private let encoder = JSONEncoder() 75 | private let decoder = JSONDecoder() 76 | 77 | let dataStore: LockIsolated<[String: Data]> = .init([:]) 78 | 79 | package init() {} 80 | 81 | package func data( 82 | forKey: String, 83 | type: T.Type 84 | ) -> T? where T : Decodable { 85 | guard let data = dataStore[forKey] else { return nil } 86 | return try? decoder.decode(type, from: data) 87 | } 88 | 89 | package func set( 90 | _ value: T, 91 | forKey: String 92 | ) async throws { 93 | let data = try encoder.encode(value) 94 | dataStore.withValue { 95 | $0[forKey] = data 96 | } 97 | } 98 | 99 | package func setNil(forKey: String) async { 100 | dataStore.withValue { 101 | $0[forKey] = nil 102 | } 103 | } 104 | 105 | package func keys() -> [String] { 106 | Array(dataStore.keys) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/PersistableTimerCore/RestoreTimerContainer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A container for managing and persisting timer data. 4 | /// Supports handling multiple timers using unique identifiers. 5 | public struct RestoreTimerContainer: Sendable { 6 | /// A constant structure for defining keys used in data persistence. 7 | private enum Const { 8 | static let persistableTimerKey = "persistableTimerKey" 9 | 10 | static func persistableTimerKey(id: String?) -> String { 11 | if let id { 12 | return "\(persistableTimerKey)_\(id)" 13 | } else { 14 | return persistableTimerKey 15 | } 16 | } 17 | } 18 | 19 | /// The data source for persisting and retrieving timer data. 20 | private let dataSource: any DataSource 21 | 22 | /// Initializes a new container with a given UserDefaults instance. 23 | /// 24 | /// - Parameter userDefaults: An instance of UserDefaults to be used as the data source. 25 | public init(userDefaults: UserDefaults) { 26 | self.dataSource = UserDefaultsClient(userDefaults: userDefaults) 27 | } 28 | 29 | /// Initializes a new container with a given data source. 30 | /// 31 | /// - Parameter dataSource: An instance conforming to `DataSource` protocol. 32 | package init(dataSource: any DataSource) { 33 | self.dataSource = dataSource 34 | } 35 | 36 | /// Retrieves the persisted timer data for a given identifier. 37 | /// 38 | /// - Parameter id: An optional identifier for the timer. If `nil`, retrieves the default timer data. 39 | /// - Throws: `PersistableTimerClientError.timerHasNotStarted` if no timer data is found. 40 | /// - Returns: The retrieved `RestoreTimerData`. 41 | public func getTimerData(id: String? = nil) throws -> RestoreTimerData { 42 | guard let restoreTimerData = dataSource.data(forKey: Const.persistableTimerKey(id: id), type: RestoreTimerData.self) else { 43 | throw PersistableTimerClientError.timerHasNotStarted 44 | } 45 | return restoreTimerData 46 | } 47 | 48 | /// Checks if a timer is currently running for a given identifier. 49 | /// 50 | /// - Parameter id: An optional identifier for the timer. If `nil`, checks the default timer. 51 | /// - Returns: A Boolean value indicating whether a timer is running. 52 | public func isTimerRunning(id: String? = nil) -> Bool { 53 | dataSource.data(forKey: Const.persistableTimerKey(id: id), type: RestoreTimerData.self) != nil 54 | } 55 | 56 | /// Starts a new timer with an optional identifier. 57 | /// 58 | /// - Parameters: 59 | /// - id: An optional identifier for the timer. If `nil`, starts the default timer. 60 | /// - now: The current date and time, defaults to `Date()`. 61 | /// - type: The type of restore operation, either stopwatch or timer. 62 | /// - forceStart: A Boolean value to force start the timer, ignoring if another timer is already running. 63 | /// - Throws: `PersistableTimerClientError.timerAlreadyStarted` if a timer is already running and `forceStart` is `false`. 64 | /// - Returns: The newly created `RestoreTimerData`. 65 | @discardableResult 66 | public func start( 67 | id: String? = nil, 68 | now: Date = Date(), 69 | type: RestoreType, 70 | forceStart: Bool = false 71 | ) async throws -> RestoreTimerData { 72 | if !forceStart { 73 | guard (try? getTimerData(id: id)) == nil else { 74 | throw PersistableTimerClientError.timerAlreadyStarted 75 | } 76 | } 77 | let restoreTimerData = RestoreTimerData( 78 | startDate: now, 79 | pausePeriods: [], 80 | type: type, 81 | stopDate: nil 82 | ) 83 | try await dataSource.set(restoreTimerData, forKey: Const.persistableTimerKey(id: id)) 84 | return restoreTimerData 85 | } 86 | 87 | /// Resumes a paused timer with an optional identifier. 88 | /// 89 | /// - Parameters: 90 | /// - id: An optional identifier for the timer. If `nil`, resumes the default timer. 91 | /// - now: The current date and time, defaults to `Date()`. 92 | /// - Throws: `PersistableTimerClientError.timerHasNotPaused` if the timer is not in a paused state. 93 | /// - Returns: The updated `RestoreTimerData` after resuming. 94 | @discardableResult 95 | public func resume(id: String? = nil, now: Date = Date()) async throws -> RestoreTimerData { 96 | var restoreTimerData = try getTimerData(id: id) 97 | guard let lastPausePeriod = restoreTimerData.pausePeriods.last, 98 | lastPausePeriod.start == nil 99 | else { 100 | throw PersistableTimerClientError.timerHasNotPaused 101 | } 102 | restoreTimerData.pausePeriods[restoreTimerData.pausePeriods.endIndex - 1].start = now 103 | try await dataSource.set(restoreTimerData, forKey: Const.persistableTimerKey(id: id)) 104 | return restoreTimerData 105 | } 106 | 107 | /// Pauses a running timer with an optional identifier. 108 | /// 109 | /// - Parameters: 110 | /// - id: An optional identifier for the timer. If `nil`, pauses the default timer. 111 | /// - now: The current date and time, defaults to `Date()`. 112 | /// - Throws: `PersistableTimerClientError.timerAlreadyPaused` if the timer is already paused. 113 | /// - Returns: The updated `RestoreTimerData` after pausing. 114 | @discardableResult 115 | public func pause(id: String? = nil, now: Date = Date()) async throws -> RestoreTimerData { 116 | var restoreTimerData = try getTimerData(id: id) 117 | guard restoreTimerData.pausePeriods.allSatisfy({ $0.start != nil }) else { 118 | throw PersistableTimerClientError.timerAlreadyPaused 119 | } 120 | restoreTimerData.pausePeriods.append( 121 | PausePeriod( 122 | pause: now, 123 | start: nil 124 | ) 125 | ) 126 | try await dataSource.set(restoreTimerData, forKey: Const.persistableTimerKey(id: id)) 127 | return restoreTimerData 128 | } 129 | 130 | /// Finishes the current timer with an optional identifier. 131 | /// 132 | /// - Parameters: 133 | /// - id: An optional identifier for the timer. If `nil`, finishes the default timer. 134 | /// - now: The current date and time, defaults to `Date()`. 135 | /// - Returns: The final `RestoreTimerData`. 136 | @discardableResult 137 | public func finish(id: String? = nil, now: Date = Date()) async throws -> RestoreTimerData { 138 | var restoreTimerData = try getTimerData(id: id) 139 | restoreTimerData.stopDate = now 140 | await dataSource.setNil(forKey: Const.persistableTimerKey(id: id)) 141 | return restoreTimerData 142 | } 143 | 144 | /// Finishes all running timers. 145 | /// 146 | /// - Parameter now: The current date and time, defaults to `Date()`. 147 | /// - Returns: A dictionary containing the final `RestoreTimerData` for all finished timers, keyed by their identifiers. 148 | @discardableResult 149 | public func finishAll(now: Date = Date()) async throws -> [String?: RestoreTimerData] { 150 | let keys = dataSource.keys().filter { $0.hasPrefix(Const.persistableTimerKey) } 151 | return try await withThrowingTaskGroup( 152 | of: (String?, RestoreTimerData).self, 153 | returning: [String?: RestoreTimerData].self 154 | ) { group in 155 | for key in keys { 156 | group.addTask { 157 | if let id = key.components(separatedBy: "_").last, 158 | id != Const.persistableTimerKey 159 | { 160 | return (id, try await self.finish(id: id, now: now)) 161 | } else { 162 | return (nil, try await self.finish(now: now)) 163 | } 164 | } 165 | } 166 | 167 | return try await group.reduce(into: [String?: RestoreTimerData]()) { partialResult, data in 168 | partialResult.updateValue(data.1, forKey: data.0) 169 | } 170 | } 171 | } 172 | 173 | /// For a timer, adds extra time to the remaining duration. 174 | /// 175 | /// - Parameters: 176 | /// - id: The optional identifier for the timer. 177 | /// - extraTime: The time (in seconds) to add. 178 | /// - now: The current date (defaults to Date()). 179 | /// - Throws: An error if the timer type is not .timer. 180 | /// - Returns: The updated RestoreTimerData. 181 | @discardableResult 182 | public func addRemainingTime(id: String? = nil, extraTime: TimeInterval, now: Date = Date()) async throws -> RestoreTimerData { 183 | var restoreTimerData = try getTimerData(id: id) 184 | guard case .timer(let currentDuration) = restoreTimerData.type else { 185 | throw PersistableTimerClientError.invalidTimerType 186 | } 187 | let newDuration = currentDuration + extraTime 188 | restoreTimerData.type = .timer(duration: newDuration) 189 | try await dataSource.set(restoreTimerData, forKey: Const.persistableTimerKey(id: id)) 190 | return restoreTimerData 191 | } 192 | 193 | /// For a stopwatch, adds extra elapsed time by moving the start date earlier. 194 | /// 195 | /// - Parameters: 196 | /// - id: The optional identifier for the timer. 197 | /// - extraTime: The time (in seconds) to add. 198 | /// - now: The current date (defaults to Date()). 199 | /// - Throws: An error if the timer type is not .stopwatch. 200 | /// - Returns: The updated RestoreTimerData. 201 | @discardableResult 202 | public func addElapsedTime(id: String? = nil, extraTime: TimeInterval, now: Date = Date()) async throws -> RestoreTimerData { 203 | var restoreTimerData = try getTimerData(id: id) 204 | guard case .stopwatch = restoreTimerData.type else { 205 | throw PersistableTimerClientError.invalidTimerType 206 | } 207 | // Adjust the start date earlier by extraTime to increase the elapsed time. 208 | restoreTimerData.startDate = restoreTimerData.startDate.addingTimeInterval(-extraTime) 209 | try await dataSource.set(restoreTimerData, forKey: Const.persistableTimerKey(id: id)) 210 | return restoreTimerData 211 | } 212 | } 213 | 214 | /// Errors specific to the PersistableTimerClient. 215 | public enum PersistableTimerClientError: Error, Sendable { 216 | case timerHasNotStarted 217 | case timerHasNotPaused 218 | case timerAlreadyPaused 219 | case timerAlreadyStarted 220 | case invalidTimerType 221 | } 222 | -------------------------------------------------------------------------------- /Sources/PersistableTimerCore/RestoreTimerData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents the status of a timer. 4 | public enum TimerStatus: Sendable, Codable, Hashable { 5 | /// The timer is currently running. 6 | case running 7 | /// The timer is currently paused. 8 | case paused 9 | /// The timer has finished. 10 | case finished 11 | } 12 | 13 | /// Represents a period during which the timer is paused. 14 | public struct PausePeriod: Sendable, Codable, Hashable { 15 | /// The date and time when the timer was paused. 16 | public var pause: Date 17 | /// The date and time when the timer resumed. 18 | /// If `nil`, the timer is still paused. 19 | public var start: Date? 20 | 21 | public init(pause: Date, start: Date?) { 22 | self.pause = pause 23 | self.start = start 24 | } 25 | } 26 | 27 | /// Represents the type of timer, either a stopwatch or a countdown timer. 28 | public enum RestoreType: Codable, Hashable, Sendable { 29 | /// A stopwatch timer. 30 | case stopwatch 31 | /// A countdown timer with a specified duration (in seconds). 32 | case timer(duration: TimeInterval) 33 | } 34 | 35 | /// Represents the state of a timer, including elapsed time, status, and the last calculation timestamp. 36 | public struct TimerState: Sendable, Codable, Hashable { 37 | /// The date and time when the timer started. 38 | public let startDate: Date 39 | /// The total elapsed time of the timer in seconds, adjusted for any pause durations. 40 | public var elapsedTime: TimeInterval 41 | /// The current status of the timer (running, paused, or finished). 42 | public var status: TimerStatus 43 | /// The type of timer operation (stopwatch or timer with duration). 44 | public var type: RestoreType 45 | /// An array of periods during which the timer was paused. 46 | public var pausePeriods: [PausePeriod] 47 | /// The date and time when the elapsed time was last calculated. 48 | /// 49 | /// This property is updated each time `elapsedTimeAndStatus(now:)` is called, 50 | /// and represents the moment when the elapsed time and timer status were computed. 51 | public let lastElapsedTimeCalculatedAt: Date 52 | 53 | /// The computed time value for the timer. 54 | /// 55 | /// - For a stopwatch, this value is equal to `elapsedTime`. 56 | /// - For a countdown timer, this value is the remaining time (initial duration minus `elapsedTime`). 57 | public var time: TimeInterval { 58 | switch type { 59 | case .stopwatch: 60 | return elapsedTime 61 | case let .timer(duration): 62 | return duration - elapsedTime 63 | } 64 | } 65 | 66 | /// The display date used for UI representation of the timer. 67 | /// 68 | /// - For a stopwatch, this is calculated by subtracting `elapsedTime` from the current time. 69 | /// - For a timer, this is calculated by subtracting `elapsedTime` from the timer's duration. 70 | public var displayDate: Date { 71 | switch type { 72 | case .stopwatch: 73 | return Date(timeIntervalSinceNow: -elapsedTime) 74 | case let .timer(duration): 75 | return Date(timeIntervalSinceNow: duration - elapsedTime) 76 | } 77 | } 78 | 79 | /// The timer interval used for creating countdown or stopwatch animations. 80 | /// 81 | /// For a stopwatch, if a pause exists, it returns a range ending at the pause time. 82 | /// For a timer, it returns a range from the start date to the expected finish date. 83 | package var timerInterval: ClosedRange { 84 | switch type { 85 | case .stopwatch: 86 | if let lastPausePeriod = pausePeriods.last { 87 | if #available(iOS 18, macCatalyst 18, macOS 18, tvOS 18, visionOS 2, watchOS 11, *) { 88 | lastPausePeriod.pause.addingTimeInterval(min(0, -elapsedTime + 1)) ... lastPausePeriod.pause 89 | } else { 90 | lastPausePeriod.pause.addingTimeInterval(-elapsedTime) ... lastPausePeriod.pause 91 | } 92 | } else { 93 | startDate ... startDate 94 | } 95 | case .timer(let duration): 96 | startDate ... startDate.addingTimeInterval(duration - elapsedTime - 1) 97 | } 98 | } 99 | 100 | /// The time at which the timer is set to resume if it is currently paused. 101 | /// 102 | /// - For a stopwatch, if currently paused, returns the pause time. 103 | /// - For a timer, if currently paused, returns the expected resume time. 104 | package var pauseTime: Date? { 105 | switch type { 106 | case .stopwatch: 107 | if let pausePeriod = pausePeriods.last, pausePeriod.start == nil { 108 | pausePeriod.pause 109 | } else { 110 | nil 111 | } 112 | case .timer(let duration): 113 | if let pausePeriod = pausePeriods.last, pausePeriod.start == nil { 114 | startDate.addingTimeInterval(duration - elapsedTime) 115 | } else { 116 | nil 117 | } 118 | } 119 | } 120 | 121 | public init( 122 | startDate: Date, 123 | elapsedTime: TimeInterval, 124 | status: TimerStatus, 125 | type: RestoreType, 126 | pausePeriods: [PausePeriod], 127 | lastElapsedTimeCalculatedAt: Date 128 | ) { 129 | self.startDate = startDate 130 | self.elapsedTime = elapsedTime 131 | self.status = status 132 | self.type = type 133 | self.pausePeriods = pausePeriods 134 | self.lastElapsedTimeCalculatedAt = lastElapsedTimeCalculatedAt 135 | } 136 | } 137 | 138 | /// Represents the data required to restore a timer's state. 139 | public struct RestoreTimerData: Codable, Hashable, Sendable { 140 | /// The date and time when the timer was started. 141 | public var startDate: Date 142 | /// An array of pause periods during which the timer was paused. 143 | public var pausePeriods: [PausePeriod] 144 | /// The type of timer (stopwatch or timer with duration). 145 | public var type: RestoreType 146 | /// The date and time when the timer was stopped, if applicable. 147 | public var stopDate: Date? 148 | 149 | /// Calculates the elapsed time and determines the current status of the timer. 150 | /// 151 | /// This method accounts for any pause periods and adjusts the elapsed time accordingly. 152 | /// It also records the current time as `lastElapsedTimeCalculatedAt` in the returned `TimerState`, 153 | /// indicating when the calculation was performed. 154 | /// 155 | /// - Parameter now: The current date and time. Defaults to `Date()`. 156 | /// - Returns: A `TimerState` representing the timer's state, including the adjusted elapsed time, 157 | /// current status, and the timestamp of the calculation. 158 | public func elapsedTimeAndStatus(now: Date = Date()) -> TimerState { 159 | let endDate = stopDate ?? now 160 | var elapsedTime = endDate.timeIntervalSince(startDate) 161 | var status: TimerStatus = .running 162 | 163 | for period in pausePeriods { 164 | if let resumeTime = period.start { 165 | let pauseDuration = resumeTime.timeIntervalSince(period.pause) 166 | elapsedTime -= pauseDuration 167 | } else { 168 | let pauseDuration = endDate.timeIntervalSince(period.pause) 169 | elapsedTime -= pauseDuration 170 | status = .paused 171 | break 172 | } 173 | } 174 | 175 | if stopDate != nil { 176 | status = .finished 177 | } 178 | 179 | return TimerState( 180 | startDate: startDate, 181 | elapsedTime: max(elapsedTime, 0), 182 | status: status, 183 | type: type, 184 | pausePeriods: pausePeriods, 185 | lastElapsedTimeCalculatedAt: now 186 | ) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /Sources/PersistableTimerText/PersistableTimerText.swift: -------------------------------------------------------------------------------- 1 | import PersistableTimerCore 2 | import SwiftUI 3 | 4 | @available(iOS 16.0, macOS 13.0, *) 5 | public extension Text { 6 | init(timerState: TimerState?, countsDown: Bool = true) { 7 | if let timerState, let pauseTime = timerState.pauseTime { 8 | self.init(timerInterval: timerState.timerInterval, pauseTime: pauseTime, countsDown: countsDown) 9 | } else if let displayDate = timerState?.displayDate { 10 | self.init(displayDate, style: .timer) 11 | } else { 12 | let now = Date() 13 | self.init(timerInterval: now ... now, countsDown: countsDown) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/PersistableTimerText/exported.swift: -------------------------------------------------------------------------------- 1 | @_exported import PersistableTimerCore 2 | -------------------------------------------------------------------------------- /Tests/PersistableTimerCoreTests/PersistableTimerCoreTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | @testable import PersistableTimerCore 4 | 5 | @Suite struct PersistableTimerCoreTests { 6 | var restoreTimerContainer: RestoreTimerContainer! 7 | var mockUserDefaultsClient: InMemoryDataSource! 8 | 9 | init() { 10 | mockUserDefaultsClient = InMemoryDataSource() 11 | restoreTimerContainer = PersistableTimerCore.RestoreTimerContainer(dataSource: mockUserDefaultsClient) 12 | } 13 | 14 | @Test func startTimerSuccessfully() async throws { 15 | let expectedStartDate = Date() 16 | let result = try await restoreTimerContainer.start(now: expectedStartDate, type: .timer(duration: 10)) 17 | #expect(result.startDate.timeIntervalSince1970.floorInt == expectedStartDate.timeIntervalSince1970.floorInt) 18 | #expect(result.pausePeriods.isEmpty) 19 | #expect(result.stopDate == nil) 20 | } 21 | 22 | @Test func startTimerWithIDSuccessfully() async throws { 23 | let expectedStartDate = Date() 24 | let timerID = "unique-timer-id" 25 | let result = try await restoreTimerContainer.start(id: timerID, now: expectedStartDate, type: .timer(duration: 10)) 26 | #expect(result.startDate.timeIntervalSince1970.floorInt == expectedStartDate.timeIntervalSince1970.floorInt) 27 | #expect(result.pausePeriods.isEmpty) 28 | #expect(result.stopDate == nil) 29 | } 30 | 31 | @Test func startTimerThrowsErrorWhenAlreadyStarted() async throws { 32 | try await restoreTimerContainer.start(type: .stopwatch) 33 | await #expect { try await restoreTimerContainer.start(type: .stopwatch) } throws: { error in 34 | let persistableTimerClientError = try #require(error as? PersistableTimerClientError) 35 | return persistableTimerClientError == .timerAlreadyStarted 36 | } 37 | } 38 | 39 | @Test func startTimerForcefullyWhenAlreadyStarted() async throws { 40 | try await restoreTimerContainer.start(type: .timer(duration: 10)) 41 | let result = try await restoreTimerContainer.start(type: .timer(duration: 10), forceStart: true) 42 | #expect(result.startDate != nil) 43 | } 44 | 45 | @Test func startMultipleTimersSuccessfully() async throws { 46 | let timerID1 = "timer-1" 47 | let timerID2 = "timer-2" 48 | 49 | let result1 = try await restoreTimerContainer.start(id: timerID1, type: .stopwatch) 50 | let result2 = try await restoreTimerContainer.start(id: timerID2, type: .timer(duration: 10)) 51 | 52 | #expect(result1.startDate != nil) 53 | #expect(result2.startDate != nil) 54 | } 55 | 56 | @Test func pauseTimerSuccessfully() async throws { 57 | let startDate = Date() 58 | let pauseDate = Date() 59 | try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 60 | let result = try await restoreTimerContainer.pause(now: pauseDate) 61 | #expect(result.pausePeriods.count == 1) 62 | #expect(result.pausePeriods.first?.pause == pauseDate) 63 | #expect(result.pausePeriods.first?.start == nil) 64 | #expect(result.startDate.timeIntervalSince1970.floorInt == startDate.timeIntervalSince1970.floorInt) 65 | } 66 | 67 | @Test func pauseTimerWithIDSuccessfully() async throws { 68 | let timerID = "timer-1" 69 | let startDate = Date() 70 | let pauseDate = Date() 71 | try await restoreTimerContainer.start(id: timerID, now: startDate, type: .stopwatch) 72 | let result = try await restoreTimerContainer.pause(id: timerID, now: pauseDate) 73 | #expect(result.pausePeriods.count == 1) 74 | #expect(result.pausePeriods.first?.pause == pauseDate) 75 | #expect(result.pausePeriods.first?.start == nil) 76 | #expect(result.startDate.timeIntervalSince1970.floorInt == startDate.timeIntervalSince1970.floorInt) 77 | } 78 | 79 | @Test func pauseTimerThrowsErrorWhenAlreadyPaused() async throws { 80 | try await restoreTimerContainer.start(type: .stopwatch) 81 | try await restoreTimerContainer.pause() 82 | await #expect { try await restoreTimerContainer.pause() } throws: { error in 83 | let persistableTimerClientError = try #require(error as? PersistableTimerClientError) 84 | return persistableTimerClientError == .timerAlreadyPaused 85 | } 86 | } 87 | 88 | @Test func resumeTimerSuccessfully() async throws { 89 | try await restoreTimerContainer.start(type: .stopwatch) 90 | try await restoreTimerContainer.pause() 91 | let result = try await restoreTimerContainer.resume() 92 | #expect(result.pausePeriods.count == 1) 93 | #expect(result.pausePeriods.first?.start != nil) 94 | } 95 | 96 | @Test func resumeTimerWithIDSuccessfully() async throws { 97 | let timerID = "timer-1" 98 | try await restoreTimerContainer.start(id: timerID, type: .stopwatch) 99 | try await restoreTimerContainer.pause(id: timerID) 100 | let result = try await restoreTimerContainer.resume(id: timerID) 101 | #expect(result.pausePeriods.count == 1) 102 | #expect(result.pausePeriods.first?.start != nil) 103 | } 104 | 105 | @Test func resumeTimerThrowsErrorWhenNotPaused() async throws { 106 | try await restoreTimerContainer.start(type: .stopwatch) 107 | await #expect { try await restoreTimerContainer.resume() } throws: { error in 108 | let persistableTimerClientError = try #require(error as? PersistableTimerClientError) 109 | return persistableTimerClientError == .timerHasNotPaused 110 | } 111 | } 112 | 113 | @Test func finishTimerSuccessfullyWhenRunning() async throws { 114 | try await restoreTimerContainer.start(type: .stopwatch) 115 | let result = try await restoreTimerContainer.finish() 116 | #expect(result.stopDate != nil) 117 | } 118 | 119 | @Test func finishTimerWithIDSuccessfullyWhenRunning() async throws { 120 | let timerID = "timer-1" 121 | try await restoreTimerContainer.start(id: timerID, type: .stopwatch) 122 | let result = try await restoreTimerContainer.finish(id: timerID) 123 | #expect(result.stopDate != nil) 124 | } 125 | 126 | @Test func finishAllTimersSuccessfully() async throws { 127 | let timerID1 = "timer-1" 128 | let timerID2 = "timer-2" 129 | 130 | try await restoreTimerContainer.start(id: timerID1, type: .stopwatch) 131 | try await restoreTimerContainer.start(id: timerID2, type: .timer(duration: 10)) 132 | 133 | let results = try await restoreTimerContainer.finishAll() 134 | 135 | #expect(results[timerID1]?.stopDate != nil) 136 | #expect(results[timerID2]?.stopDate != nil) 137 | } 138 | 139 | @Test func finishTimerSuccessfullyWhenPaused() async throws { 140 | try await restoreTimerContainer.start(type: .stopwatch) 141 | try await restoreTimerContainer.pause() 142 | let result = try await restoreTimerContainer.finish() 143 | #expect(result.stopDate != nil) 144 | } 145 | 146 | @Test func finishTimerThrowsErrorWhenNotStarted() async throws { 147 | await #expect { try await restoreTimerContainer.finish() } throws: { error in 148 | let persistableTimerClientError = try #require(error as? PersistableTimerClientError) 149 | return persistableTimerClientError == .timerHasNotStarted 150 | } 151 | } 152 | 153 | @Test func getTimerDataThrowsErrorWhenNotStarted() async throws { 154 | #expect { try restoreTimerContainer.getTimerData() } throws: { error in 155 | let persistableTimerClientError = try #require(error as? PersistableTimerClientError) 156 | return persistableTimerClientError == .timerHasNotStarted 157 | } 158 | } 159 | 160 | @Test func getTimerDataReturnsCorrectDataWhenRunning() async throws { 161 | let startedTimerData = try await restoreTimerContainer.start(type: .stopwatch) 162 | let fetchedTimerData = try restoreTimerContainer.getTimerData() 163 | #expect(fetchedTimerData.startDate == startedTimerData.startDate) 164 | #expect(fetchedTimerData.pausePeriods.count == startedTimerData.pausePeriods.count) 165 | } 166 | 167 | @Test func pauseTimerThrowsErrorWhenStopped() async throws { 168 | try await restoreTimerContainer.start(type: .stopwatch) 169 | try await restoreTimerContainer.finish() 170 | await #expect { try await restoreTimerContainer.pause() } throws: { error in 171 | let persistableTimerClientError = try #require(error as? PersistableTimerClientError) 172 | return persistableTimerClientError == .timerHasNotStarted 173 | } 174 | } 175 | 176 | @Test func resumeTimerThrowsErrorWhenStopped() async throws { 177 | try await restoreTimerContainer.start(type: .stopwatch) 178 | try await restoreTimerContainer.pause() 179 | try await restoreTimerContainer.finish() 180 | await #expect { try await restoreTimerContainer.resume() } throws: { error in 181 | let persistableTimerClientError = try #require(error as? PersistableTimerClientError) 182 | return persistableTimerClientError == .timerHasNotStarted 183 | } 184 | } 185 | 186 | @Test func elapsedTimeAndStatusReturnsRunningAndCorrectTime() async throws { 187 | let startDate = Date() 188 | try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 189 | let timerData = try restoreTimerContainer.getTimerData() 190 | let result = timerData.elapsedTimeAndStatus() 191 | #expect(result.status == .running) 192 | #expect(result.elapsedTime >= 0) 193 | } 194 | 195 | @Test func elapsedTimeAndStatusReturnsPausedAndCorrectTime() async throws { 196 | let startDate = Date() 197 | try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 198 | try await restoreTimerContainer.pause() 199 | let timerData = try restoreTimerContainer.getTimerData() 200 | let result = timerData.elapsedTimeAndStatus() 201 | #expect(result.status == .paused) 202 | #expect(result.elapsedTime >= 0) 203 | } 204 | 205 | @Test func elapsedTimeAndStatusReturnsStoppedAndCorrectTime() async throws { 206 | let startDate = Date() 207 | try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 208 | try await restoreTimerContainer.finish() 209 | #expect { try restoreTimerContainer.getTimerData() } throws: { error in 210 | let persistableTimerClientError = try #require(error as? PersistableTimerClientError) 211 | return persistableTimerClientError == .timerHasNotStarted 212 | } 213 | } 214 | 215 | @Test func elapsedTimeAndStatusCalculatesCorrectElapsedTimeWhenRunning() async throws { 216 | let startDate = Date() 217 | try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 218 | 219 | let futureDate = startDate.addingTimeInterval(2) 220 | 221 | let timerData = try restoreTimerContainer.getTimerData() 222 | let result = timerData.elapsedTimeAndStatus(now: futureDate) 223 | 224 | #expect(result.status == .running) 225 | #expect(result.elapsedTime.ceilInt == 2) 226 | } 227 | 228 | @Test func elapsedTimeAndStatusCalculatesCorrectElapsedTimeWhenPaused() async throws { 229 | let startDate = Date() 230 | try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 231 | 232 | let pauseDate = startDate.addingTimeInterval(1) 233 | try await restoreTimerContainer.pause(now: pauseDate) 234 | 235 | let futureDate = startDate.addingTimeInterval(3) 236 | 237 | let timerData = try restoreTimerContainer.getTimerData() 238 | let result = timerData.elapsedTimeAndStatus(now: futureDate) 239 | 240 | #expect(result.status == .paused) 241 | #expect(result.elapsedTime.ceilInt == 1) 242 | } 243 | 244 | @Test func elapsedTimeAndStatusCalculatesCorrectElapsedTimeWhenStopped() async throws { 245 | let startDate = Date() 246 | try await restoreTimerContainer.start(now: startDate, type: .timer(duration: 10)) 247 | 248 | let stopDate = startDate.addingTimeInterval(2) 249 | let timerData = try await restoreTimerContainer.finish(now: stopDate) 250 | let futureDate = stopDate.addingTimeInterval(10) 251 | let result = timerData.elapsedTimeAndStatus(now: futureDate) 252 | 253 | #expect(result.status == .finished) 254 | #expect(result.elapsedTime.ceilInt == 2) 255 | } 256 | 257 | @Test func addRemainingTimeSuccessfully() async throws { 258 | let startDate = Date() 259 | let initialDuration: TimeInterval = 10 260 | let extraTime: TimeInterval = 5 261 | _ = try await restoreTimerContainer.start(now: startDate, type: .timer(duration: initialDuration)) 262 | let updatedTimerData = try await restoreTimerContainer.addRemainingTime(extraTime: extraTime) 263 | if case .timer(let newDuration) = updatedTimerData.type { 264 | #expect(newDuration.ceilInt == (initialDuration + extraTime).ceilInt) 265 | } else { 266 | throw PersistableTimerClientError.invalidTimerType 267 | } 268 | } 269 | 270 | @Test func addRemainingTimeThrowsErrorForNonTimer() async throws { 271 | _ = try await restoreTimerContainer.start(type: .stopwatch) 272 | await #expect { try await restoreTimerContainer.addRemainingTime(extraTime: 5) } throws: { error in 273 | let timerError = try #require(error as? PersistableTimerClientError) 274 | return timerError == .invalidTimerType 275 | } 276 | } 277 | 278 | @Test func addElapsedTimeSuccessfully() async throws { 279 | let startDate = Date() 280 | let extraTime: TimeInterval = 5 281 | _ = try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 282 | let updatedTimerData = try await restoreTimerContainer.addElapsedTime(extraTime: extraTime) 283 | let testNow = startDate.addingTimeInterval(3) 284 | let timerState = updatedTimerData.elapsedTimeAndStatus(now: testNow) 285 | // Since the startDate is moved 5 seconds earlier, elapsed time should be 3 + 5 = 8 seconds. 286 | #expect(timerState.elapsedTime.ceilInt == 8) 287 | } 288 | 289 | @Test func addElapsedTimeThrowsErrorForNonStopwatch() async throws { 290 | let startDate = Date() 291 | _ = try await restoreTimerContainer.start(now: startDate, type: .timer(duration: 10)) 292 | await #expect { try await restoreTimerContainer.addElapsedTime(extraTime: 5) } throws: { error in 293 | let timerError = try #require(error as? PersistableTimerClientError) 294 | return timerError == .invalidTimerType 295 | } 296 | } 297 | 298 | @Test func elapsedTimeAndStatusSetsLastCalculatedAtCorrectly() async throws { 299 | let startDate = Date() 300 | let calculationDate = startDate.addingTimeInterval(3) 301 | try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 302 | let timerData = try restoreTimerContainer.getTimerData() 303 | let state = timerData.elapsedTimeAndStatus(now: calculationDate) 304 | #expect(state.lastElapsedTimeCalculatedAt == calculationDate) 305 | } 306 | 307 | @Test func elapsedTimeAndStatusUpdatesLastCalculatedAtWithMultipleCalls() async throws { 308 | let startDate = Date() 309 | try await restoreTimerContainer.start(now: startDate, type: .stopwatch) 310 | let timerData = try restoreTimerContainer.getTimerData() 311 | 312 | let firstCalculationDate = startDate.addingTimeInterval(2) 313 | let state1 = timerData.elapsedTimeAndStatus(now: firstCalculationDate) 314 | 315 | let secondCalculationDate = startDate.addingTimeInterval(5) 316 | let state2 = timerData.elapsedTimeAndStatus(now: secondCalculationDate) 317 | 318 | #expect(state1.lastElapsedTimeCalculatedAt == firstCalculationDate) 319 | #expect(state2.lastElapsedTimeCalculatedAt == secondCalculationDate) 320 | } 321 | } 322 | 323 | fileprivate extension TimeInterval { 324 | var ceilInt: Int { 325 | Int(ceil(self)) 326 | } 327 | 328 | var floorInt: Int { 329 | Int(floor(self)) 330 | } 331 | } 332 | --------------------------------------------------------------------------------