├── .gitignore
├── Images
└── TimerView_Config.png
├── Tests
├── LinuxMain.swift
└── KSTimerViewTests
│ ├── XCTestManifests.swift
│ └── KSTimerViewTests.swift
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Sources
└── KSTimerView
│ ├── HapticHelper.swift
│ ├── LocalNotificationHelper.swift
│ └── KSTimerView.swift
├── LICENSE
├── Package.swift
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/Images/TimerView_Config.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karthironald/KSTimerView/HEAD/Images/TimerView_Config.png
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import KSTimerViewTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += KSTimerViewTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Tests/KSTimerViewTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(KSTimerViewTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Tests/KSTimerViewTests/KSTimerViewTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | //@testable import KSTimerView
3 |
4 | final class KSTimerViewTests: XCTestCase {
5 | func testExample() {
6 | // This is an example of a functional test case.
7 | // Use XCTAssert and related functions to verify your tests produce the correct
8 | // results.
9 | // XCTAssertEqual(KSTimerView().text, "Hello, World!")
10 | }
11 |
12 | static var allTests = [
13 | ("testExample", testExample),
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/KSTimerView/HapticHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HapticHelper.swift
3 | // KSTimerView
4 | //
5 | // Created by Karthick Selvaraj on 13/12/20.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | class HapticHelper: NSObject {
12 |
13 | static let shared = HapticHelper()
14 | var isEnabled = true // Set this to false if you don't want haptic feedback.
15 |
16 | // MARK: - Private init method
17 |
18 | private override init() {
19 | super.init()
20 | }
21 |
22 | // MARK: - Custom methods
23 |
24 | func hapticFeedback(style: UIImpactFeedbackGenerator.FeedbackStyle = .rigid) {
25 | if isEnabled {
26 | let generator = UIImpactFeedbackGenerator(style: style)
27 | generator.impactOccurred()
28 | }
29 | }
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Karthick Selvaraj
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.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.3
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: "KSTimerView",
8 | platforms: [
9 | .iOS(.v14),
10 | ],
11 | products: [
12 | // Products define the executables and libraries a package produces, and make them visible to other packages.
13 | .library(
14 | name: "KSTimerView",
15 | targets: ["KSTimerView"]),
16 | ],
17 | dependencies: [
18 | // Dependencies declare other packages that this package depends on.
19 | // .package(url: /* package url */, from: "1.0.0"),
20 | ],
21 | targets: [
22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
23 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
24 | .target(
25 | name: "KSTimerView",
26 | dependencies: []),
27 | .testTarget(
28 | name: "KSTimerViewTests",
29 | dependencies: ["KSTimerView"]),
30 | ]
31 | )
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # KSTimerView
2 |
3 | A simple `SwiftUI` timer view with **Background**, **LocalNotification** and **Haptic** support.
4 |
5 | ## Demo Video (YouTube)
6 | [](http://www.youtube.com/watch?v=o3eHn7bowko "")
7 |
8 | ## Usage
9 |
10 | Initialise the KSTimerView with `TimeInterval` and present it using `.sheet` or `.fullScreenCover` modifier or use with `ZStack`.
11 |
12 | ```
13 | .sheet(isPresented: $shouldPresentTimerView, content: {
14 | KSTimerView(timerInterval: $timeInterval)
15 | })
16 | ```
17 |
18 | ### Customisation
19 |
20 | You can customise the **KSTimerView** using `KSTimerView.Configuration` and initialise KSTimerView with your configuration.
21 |
22 | ```
23 | let configuration = KSTimerView.Configuration(timerBgColor: .yellow, timerRingBgColor: .red, actionButtonsBgColor: .blue, foregroundColor: .white, stepperValue: 10, enableLocalNotification: true, enableHapticFeedback: true)
24 |
25 | KSTimerView(timerInterval: $timeInterval, configuration: configuration)
26 |
27 | ```
28 | 
29 |
30 | ## Background
31 | Background to foreground will be handled by default. You no need to do anything.
32 |
33 | ## LocalNotification
34 |
35 | It is disabled by default. Enable it using `enableLocalNotification` in `KSTimerView.Configuration`.
36 |
37 | ```
38 | let configuration = KSTimerView.Configuration(..., enableLocalNotification: true, ...)
39 | ```
40 |
41 | > ⚠️ Note: You need to get permission from user to use `LocalNotification`. Get it before presenting the timer view to the user.
42 |
43 | ## Haptic Feedback
44 | It is disabled by default. Enable it using `enableHapticFeedback` in `KSTimerView.Configuration`.
45 |
46 | ```
47 | let configuration = KSTimerView.Configuration(..., enableHapticFeedback: true)
48 |
49 | ```
50 |
51 | ## Integration
52 | KSTimerView supports SPM (Swift Package Manager). You can integrate it using Xcode, `File -> Swift Packages -> Add Package Dependency...`
53 |
54 | Enter, **https://github.com/karthironald/KSTimerView** in repo URL.
55 |
56 | ## Contribution
57 | 1. Open an issue, if you need any improvements or if you face any issues.
58 |
59 | Thanks! 👨🏻💻
--------------------------------------------------------------------------------
/Sources/KSTimerView/LocalNotificationHelper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocalNotificationHelper.swift
3 | // KSTimerView
4 | //
5 | // Created by Karthick Selvaraj on 13/12/20.
6 | //
7 |
8 | import Foundation
9 | import UserNotifications
10 |
11 | private let kLocalTimerNotificationIdentifier = "kLocalTimerNotificationIdentifier"
12 |
13 | class LocalNotificationHelper: NSObject {
14 |
15 | static let shared = LocalNotificationHelper()
16 | var isEnabled = true // Set this to false if you don't want local notification to be triggered when timer completed.
17 |
18 | // MARK: - Private init method
19 | private override init() {
20 | super.init()
21 | UNUserNotificationCenter.current().delegate = self
22 | }
23 |
24 | // MARK: - Custom methods
25 |
26 | private func registerForPushNotification(interval: TimeInterval) {
27 | let userNotification = UNUserNotificationCenter.current()
28 | userNotification.requestAuthorization(options: [.sound, .badge, .alert]) { (status, error) in
29 | if status && error == nil {
30 | self.scheduleNotification(with: interval)
31 | }
32 | }
33 | }
34 |
35 | /**Resets scheduled local notification*/
36 | func resetTimerNotification() {
37 | invalidateLocalNotification(with: [kLocalTimerNotificationIdentifier])
38 | }
39 |
40 | /**Adds local notification at specified interval*/
41 | func addLocalNoification(interval: TimeInterval) {
42 | if interval > 0 && isEnabled {
43 | let userNotification = UNUserNotificationCenter.current()
44 | userNotification.getNotificationSettings { (setting) in
45 | if setting.authorizationStatus == .authorized {
46 | self.scheduleNotification(with: interval)
47 | } else {
48 | self.registerForPushNotification(interval: interval)
49 | }
50 | }
51 | }
52 | }
53 |
54 | private func scheduleNotification(with interval: TimeInterval) {
55 | invalidateLocalNotification(with: [kLocalTimerNotificationIdentifier])
56 |
57 | let centre = UNUserNotificationCenter.current()
58 |
59 | let notificationContent = UNMutableNotificationContent()
60 | notificationContent.title = "Timer completed!"
61 |
62 | notificationContent.sound = UNNotificationSound.default
63 |
64 | let triggerAt = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false)
65 | let request = UNNotificationRequest(identifier: kLocalTimerNotificationIdentifier, content: notificationContent, trigger: triggerAt)
66 | centre.add(request) { (error) in
67 | if error != nil {
68 | print(error ?? "")
69 | }
70 | }
71 | }
72 |
73 | /**Invalidate local notification which we configured in `configureLocalNotification()`*/
74 | private func invalidateLocalNotification(with identifiers: [String]) {
75 | UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
76 | UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
77 | }
78 |
79 | }
80 |
81 | extension LocalNotificationHelper: UNUserNotificationCenterDelegate {
82 |
83 | func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
84 | completionHandler([.sound, .badge, .banner, .list])
85 | }
86 |
87 | }
88 |
--------------------------------------------------------------------------------
/Sources/KSTimerView/KSTimerView.swift:
--------------------------------------------------------------------------------
1 |
2 | import SwiftUI
3 |
4 | public enum KSTimerStatus {
5 | case notStarted, running, paused
6 | }
7 |
8 | public struct KSTimerView: View {
9 |
10 | // MARK: - Private Properties
11 | @State private var offset: CGFloat = 70
12 | @State private var completedTime: TimeInterval = 0
13 | @State private var shouldShowMenus = true // Need to use this for future enhancement
14 | @State private var status: KSTimerStatus = .notStarted
15 | @State private var timer: Timer.TimerPublisher = Timer.publish(every: 1, on: .main, in: .common)
16 | @State private var backgroundAt = Date()
17 |
18 | private var progress: CGFloat {
19 | CGFloat((timerInterval - completedTime) / timerInterval)
20 | }
21 |
22 | // MARK: - Public Properties and Init
23 | @Binding var timerInterval: TimeInterval
24 |
25 | var configuration = KSTimerView.Configuration(timerBgColor: .green, timerRingBgColor: .green, actionButtonsBgColor: .blue, foregroundColor: .white, stepperValue: 5)
26 |
27 |
28 | public init(timerInterval: Binding, configuration: KSTimerView.Configuration = KSTimerView.Configuration(timerBgColor: .green, timerRingBgColor: .green, actionButtonsBgColor: .blue, foregroundColor: .white, stepperValue: 5)) {
29 | self._timerInterval = timerInterval
30 | self.configuration = configuration
31 | }
32 |
33 | // MARK: - Body
34 | public var body: some View {
35 | ZStack {
36 | Color.clear
37 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { (_) in
38 | self.stopTimer()
39 | self.backgroundAt = Date()
40 | }
41 | Color.clear
42 | .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { (_) in
43 | if self.status == .running {
44 | let backgroundInterval = TimeInterval(Int(Date().timeIntervalSince(self.backgroundAt) + 1))
45 | if (self.completedTime + backgroundInterval) >= (self.timerInterval - 1) {
46 | self.resetDetails()
47 | } else {
48 | self.completedTime = self.completedTime + backgroundInterval
49 | self.startTimer()
50 | }
51 | }
52 | }
53 | if shouldShowMenus {
54 | Color.white
55 | .opacity(0.7)
56 | .edgesIgnoringSafeArea(.all)
57 | }
58 |
59 | // Main timer view button
60 | ZStack {
61 | if status == .running || status == .paused {
62 | Circle()
63 | .trim(from: 0, to: progress)
64 | .stroke(configuration.timerRingBgColor, style: StrokeStyle(lineWidth: 7, lineCap: .round))
65 | .animation((status == .running || status == .paused) ? Animation.linear(duration: 1) : nil)
66 | .rotationEffect(.degrees(-90))
67 | .frame(width: shouldShowMenus ? 220 : 70, height: shouldShowMenus ? 220 : 70)
68 | }
69 |
70 | Text("\((Int(timerInterval - completedTime) == 0) ? Int(timerInterval) : Int(timerInterval - completedTime))s")
71 | .font(.largeTitle)
72 | .bold()
73 | .foregroundColor(.white)
74 | .animation(nil)
75 | .frame(width: shouldShowMenus ? 200 : 50, height: shouldShowMenus ? 200 : 50)
76 | .background(configuration.timerBgColor)
77 | .clipShape(Circle())
78 |
79 | Button(action: {
80 | HapticHelper.shared.hapticFeedback()
81 | if self.status == .running {
82 | self.status = .paused
83 | } else if self.status == .paused || self.status == .notStarted {
84 | self.status = .running
85 | }
86 | configureTimerAndNotification()
87 | }) {
88 | Color.clear
89 | }
90 | .frame(width: shouldShowMenus ? 200 : 50, height: shouldShowMenus ? 200 : 50)
91 | .padding(10)
92 | .shadow(radius: shouldShowMenus ? 5 : 0)
93 | }
94 | .offset(y: shouldShowMenus ? -(offset) : 0)
95 | .animation(.spring())
96 |
97 | // Minus button
98 | Button(action: {
99 | HapticHelper.shared.hapticFeedback(style: .soft)
100 | if self.timerInterval > configuration.stepperValue {
101 | self.timerInterval = self.timerInterval - configuration.stepperValue
102 | LocalNotificationHelper.shared.addLocalNoification(interval: TimeInterval(self.timerInterval - self.completedTime))
103 | }
104 | }) {
105 | Text("-\(Int(configuration.stepperValue))s")
106 | .timerControlStyle(backgroundColor: configuration.actionButtonsBgColor)
107 | }
108 | .shadow(radius: shouldShowMenus ? 5 : 0)
109 | .offset(x: shouldShowMenus ? -offset : 0, y: shouldShowMenus ? (offset + 20) : 0)
110 | .animation(.spring())
111 |
112 | // Plus button
113 | Button(action: {
114 | HapticHelper.shared.hapticFeedback(style: .soft)
115 | self.timerInterval = self.timerInterval + configuration.stepperValue
116 | LocalNotificationHelper.shared.addLocalNoification(interval: TimeInterval(self.timerInterval - self.completedTime))
117 | }) {
118 | Text("+\(Int(configuration.stepperValue))s")
119 | .timerControlStyle(backgroundColor: configuration.actionButtonsBgColor)
120 | }
121 | .shadow(radius: shouldShowMenus ? 5 : 0)
122 | .offset(x: shouldShowMenus ? offset : 0, y: shouldShowMenus ? (offset + 20) : 0)
123 | .animation(.spring())
124 |
125 | // Stop button
126 | if status == .running || status == .notStarted {
127 | Button(action: {
128 | HapticHelper.shared.hapticFeedback()
129 | if status == .running {
130 | self.resetDetails()
131 | LocalNotificationHelper.shared.resetTimerNotification()
132 | } else {
133 | status = .running
134 | configureTimerAndNotification()
135 | }
136 | }) {
137 | Image(systemName: status == .running ? "stop" : "play")
138 | .timerControlStyle(backgroundColor: status == .running ? Color.red : configuration.actionButtonsBgColor)
139 | }
140 | .shadow(radius: shouldShowMenus ? 5 : 0)
141 | .offset(y: shouldShowMenus ? (offset + 20) : 0)
142 | .zIndex(10)
143 | }
144 | }
145 | .padding(.leading, shouldShowMenus ? 0 : 10)
146 | .onReceive(timer, perform: { (_) in
147 | if self.status == .running {
148 | self.completedTime += 1
149 | if self.completedTime >= self.timerInterval - 1 {
150 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
151 | self.resetDetails()
152 | }
153 | }
154 | }
155 | })
156 | }
157 |
158 | // MARK: - Custom Methods
159 | /**Configures timer and local notification*/
160 | private func configureTimerAndNotification() {
161 | if self.status == .running {
162 | self.startTimer()
163 | LocalNotificationHelper.shared.addLocalNoification(interval: TimeInterval(self.timerInterval - self.completedTime))
164 | } else if self.status == .paused {
165 | self.stopTimer()
166 | LocalNotificationHelper.shared.resetTimerNotification()
167 | }
168 | }
169 |
170 | /**Resets local properties to default*/
171 | private func resetDetails() {
172 | self.status = .notStarted
173 | self.stopTimer()
174 | self.completedTime = 0
175 | }
176 |
177 | private func stopTimer() {
178 | self.timer.connect().cancel()
179 | }
180 |
181 | private func startTimer() {
182 | self.timer = Timer.publish(every: 1, on: .main, in: .common)
183 | _ = timer.connect()
184 | }
185 |
186 | }
187 |
188 | public extension KSTimerView {
189 |
190 | struct Configuration {
191 | var timerBgColor: Color = .blue
192 | var timerRingBgColor: Color = .blue
193 | var actionButtonsBgColor: Color = .blue
194 | var foregroundColor: Color = .white
195 | var stepperValue: TimeInterval = 10
196 | var enableLocalNotification: Bool = true
197 | var enableHapticFeedback: Bool = true
198 |
199 | public init(timerBgColor: Color = .blue, timerRingBgColor: Color = .blue, actionButtonsBgColor: Color = .blue, foregroundColor: Color = .white, stepperValue: TimeInterval = 10, enableLocalNotification: Bool = true, enableHapticFeedback: Bool = true) {
200 | self.timerBgColor = timerBgColor
201 | self.timerRingBgColor = timerRingBgColor
202 | self.actionButtonsBgColor = actionButtonsBgColor
203 | self.foregroundColor = foregroundColor
204 | self.stepperValue = stepperValue
205 | LocalNotificationHelper.shared.isEnabled = enableLocalNotification
206 | HapticHelper.shared.isEnabled = enableHapticFeedback
207 | }
208 | }
209 |
210 | }
211 |
212 | // MARK: - Custom Modifier
213 | struct TimerControlStyle: ViewModifier {
214 |
215 | var backgroundColor: Color
216 |
217 | func body(content: Content) -> some View {
218 | content
219 | .font(.body)
220 | .frame(width: 60, height: 30)
221 | .background(backgroundColor)
222 | .foregroundColor(.white)
223 | .clipShape(RoundedRectangle(cornerRadius: 15))
224 | }
225 | }
226 |
227 | extension View {
228 |
229 | func timerControlStyle(backgroundColor: Color) -> some View {
230 | self.modifier(TimerControlStyle(backgroundColor: backgroundColor))
231 | }
232 |
233 | }
234 |
--------------------------------------------------------------------------------