├── Sources
├── PersistableTimer
│ ├── exported.swift
│ ├── PrivacyInfo.xcprivacy
│ └── PersistableTimer.swift
├── PersistableTimerText
│ ├── exported.swift
│ └── PersistableTimerText.swift
└── PersistableTimerCore
│ ├── DataSource.swift
│ ├── RestoreTimerData.swift
│ └── RestoreTimerContainer.swift
├── Examples
└── TimerTest
│ ├── TimerTest
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ └── AppIcon.appiconset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── TimerTestApp.swift
│ ├── TimePicker.swift
│ ├── ContentView.swift
│ ├── StopwatchView.swift
│ ├── TimerView.swift
│ └── MultipleStopwatchView.swift
│ └── TimerTest.xcodeproj
│ ├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
│ └── project.pbxproj
├── .gitignore
├── .github
└── workflows
│ └── test.yml
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
└── Tests
└── PersistableTimerCoreTests
└── PersistableTimerCoreTests.swift
/Sources/PersistableTimer/exported.swift:
--------------------------------------------------------------------------------
1 | @_exported import PersistableTimerCore
2 |
--------------------------------------------------------------------------------
/Sources/PersistableTimerText/exported.swift:
--------------------------------------------------------------------------------
1 | @_exported import PersistableTimerCore
2 |
--------------------------------------------------------------------------------
/Examples/TimerTest/TimerTest/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Examples/TimerTest/TimerTest/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.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.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/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.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | ### Basic Setup
32 |
33 | ```swift
34 | import PersistableTimer
35 |
36 | // For production (with persistence):
37 | let timer = PersistableTimer(dataSourceType: .userDefaults(.standard))
38 |
39 | // For testing or previews:
40 | let timer = PersistableTimer(dataSourceType: .inMemory)
41 | ```
42 |
43 | ### Stopwatch Mode
44 |
45 | ```swift
46 | // Start a stopwatch
47 | try await timer.start(type: .stopwatch)
48 |
49 | // Pause and resume
50 | try await timer.pause()
51 | try await timer.resume()
52 |
53 | // Add extra elapsed time (moves start date back by 5 seconds)
54 | try await timer.addElapsedTime(5)
55 |
56 | // Finish the stopwatch
57 | try await timer.finish()
58 | ```
59 |
60 | ### Countdown Timer Mode
61 |
62 | ```swift
63 | // Start a 100-second countdown
64 | try await timer.start(type: .timer(duration: 100))
65 |
66 | // Add extra time to the countdown
67 | try await timer.addRemainingTime(30) // Now 130 seconds total
68 |
69 | // Force restart even if already running
70 | try await timer.start(type: .timer(duration: 60), forceStart: true)
71 | ```
72 |
73 | ### Real-time Updates with SwiftUI
74 |
75 | ```swift
76 | import SwiftUI
77 | import PersistableTimerText
78 |
79 | struct TimerView: View {
80 | @State private var timerState: TimerState?
81 | let timer = PersistableTimer(dataSourceType: .userDefaults(.standard))
82 |
83 | var body: some View {
84 | VStack {
85 | // Automatic timer display
86 | Text(timerState: timerState)
87 | .font(.largeTitle)
88 |
89 | HStack {
90 | Button("Start") {
91 | Task { try? await timer.start(type: .timer(duration: 60)) }
92 | }
93 | Button("Pause") {
94 | Task { try? await timer.pause() }
95 | }
96 | Button("Resume") {
97 | Task { try? await timer.resume() }
98 | }
99 | }
100 | }
101 | .onAppear {
102 | // Restore timer state after app restart
103 | try? timer.restore()
104 | }
105 | }
106 | }
107 | ```
108 |
109 | ### Managing Multiple Timers
110 |
111 | Use unique IDs to manage multiple timers simultaneously:
112 |
113 | ```swift
114 | let workoutTimer = PersistableTimer(
115 | id: "workout",
116 | dataSourceType: .userDefaults(.standard)
117 | )
118 |
119 | let restTimer = PersistableTimer(
120 | id: "rest",
121 | dataSourceType: .userDefaults(.standard)
122 | )
123 |
124 | // Each timer maintains its own state
125 | try await workoutTimer.start(type: .timer(duration: 300))
126 | try await restTimer.start(type: .timer(duration: 60))
127 | ```
128 |
129 | ### Advanced Configuration
130 |
131 | ```swift
132 | let timer = PersistableTimer(
133 | id: "custom",
134 | dataSourceType: .userDefaults(.standard),
135 | shouldEmitTimeStream: true, // Enable real-time updates
136 | updateInterval: 0.1, // Update every 100ms (default: 1s)
137 | useFoundationTimer: false, // Use AsyncTimerSequence (default)
138 | now: { Date() } // Custom date provider
139 | )
140 | ```
141 |
142 | ### Accessing Timer State
143 |
144 | ```swift
145 | // Check if timer is running
146 | if timer.isTimerRunning() {
147 | print("Timer is active")
148 | }
149 |
150 | // Get current timer data
151 | if let data = try? timer.getTimerData() {
152 | print("Started at: \(data.startDate)")
153 | print("Type: \(data.type)")
154 | }
155 |
156 | // Subscribe to updates
157 | for await state in timer.timeStream {
158 | print("Elapsed: \(state.elapsedTime)s")
159 | print("Status: \(state.status)")
160 |
161 | switch state.type {
162 | case .stopwatch:
163 | print("Stopwatch time: \(state.time)s")
164 | case .timer(let duration):
165 | print("Remaining: \(state.time)s / \(duration)s")
166 | }
167 | }
168 | ```
169 |
170 | ### Error Handling
171 |
172 | ```swift
173 | do {
174 | try await timer.start(type: .stopwatch)
175 | } catch PersistableTimerClientError.timerAlreadyStarted {
176 | print("Timer is already running")
177 | } catch {
178 | print("Failed to start timer: \(error)")
179 | }
180 |
181 | // Common errors:
182 | // - .timerAlreadyStarted: Cannot start when already running (use forceStart: true)
183 | // - .timerAlreadyPaused: Cannot pause when already paused
184 | // - .timerHasNotPaused: Cannot resume when not paused
185 | // - .timerHasNotStarted: Cannot perform operation on non-existent timer
186 | // - .invalidTimerType: Wrong operation for timer type (e.g., addRemainingTime on stopwatch)
187 | ```
188 |
189 | ## API Reference
190 |
191 | ### PersistableTimer
192 |
193 | | Method | Description |
194 | |--------|-------------|
195 | | `start(type:forceStart:)` | Start a new timer (stopwatch or countdown) |
196 | | `pause()` | Pause the running timer |
197 | | `resume()` | Resume a paused timer |
198 | | `finish(isResetTime:)` | Finish the timer, optionally resetting elapsed time |
199 | | `restore()` | Restore timer state after app restart |
200 | | `addElapsedTime(_:)` | Add elapsed time to stopwatch (moves start date back) |
201 | | `addRemainingTime(_:)` | Add time to countdown timer |
202 | | `isTimerRunning()` | Check if timer is currently active |
203 | | `getTimerData()` | Get the current timer data |
204 |
205 | ### Core Types
206 |
207 | **TimerState** - Complete timer state with computed properties
208 | - `elapsedTime: TimeInterval` - Total elapsed time (adjusted for pauses)
209 | - `status: TimerStatus` - `.running`, `.paused`, or `.finished`
210 | - `type: RestoreType` - `.stopwatch` or `.timer(duration:)`
211 | - `time: TimeInterval` - Elapsed time for stopwatch, remaining for countdown
212 | - `displayDate: Date` - Date for UI display
213 |
214 | **RestoreType** - Timer operation mode
215 | - `.stopwatch` - Count up from zero
216 | - `.timer(duration: TimeInterval)` - Count down from duration
217 |
218 | **DataSourceType** - Storage backend
219 | - `.userDefaults(UserDefaults)` - Persistent storage
220 | - `.inMemory` - Temporary storage (testing/previews)
221 |
222 | ### SwiftUI Text Extension
223 |
224 | ```swift
225 | Text(timerState: timerState, countsDown: true)
226 | ```
227 | Automatically displays and updates timer in `MM:SS` format. Shows `--:--` when `timerState` is `nil`.
228 |
229 | ## License
230 |
231 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
232 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------