diff --git a/LocalPackage/Package.swift b/LocalPackage/Package.swift new file mode 100644 index 0000000..a960e9c --- /dev/null +++ b/LocalPackage/Package.swift @@ -0,0 +1,43 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "TimeWatcherLocalPackage", + platforms: [ + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library(name: "TimeWatcherCore", targets: ["TimeWatcherCore"]), + .library(name: "TimeWatcherFeature", targets: ["TimeWatcherFeature"]), + .library(name: "TimeWatcherTestSupport", targets: ["TimeWatcherTestSupport"]), + ], + dependencies: [ + .package(path: "../TimeWatcherExternalResouce"), + ], + targets: [ + .target( + name: "TimeWatcherCore", + path: "Sources/TimeWatcherCore" + ), + .target( + name: "TimeWatcherFeature", + dependencies: [ + "TimeWatcherCore", + .product(name: "TimeWatcherExternalResouce", package: "TimeWatcherExternalResouce"), + ], + path: "Sources/TimeWatcherFeature" + ), + .target( + name: "TimeWatcherTestSupport", + dependencies: ["TimeWatcherCore"], + path: "Sources/TimeWatcherTestSupport" + ), + .testTarget( + name: "TimeWatcherCoreTests", + dependencies: ["TimeWatcherCore", "TimeWatcherTestSupport"], + path: "Tests/TimeWatcherCoreTests" + ), + ] +) diff --git a/LocalPackage/Sources/TimeWatcherCore/Domain/TimerActionType.swift b/LocalPackage/Sources/TimeWatcherCore/Domain/TimerActionType.swift new file mode 100644 index 0000000..53919cb --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Domain/TimerActionType.swift @@ -0,0 +1,46 @@ +/// タイマー操作のボタンの動作を定義 +public enum TimerActionType { + + /// 開始のアクション + case start + + /// 停止のアクション + case stop + + /// リセットのアクション + case reset +} + +// MARK: - 外部公開用のプロパティ定義 +extension TimerActionType { + + public var buttonTitle: String { + + switch self { + + case .start: + "Start" + + case .stop: + "Stop" + + case .reset: + "Reset" + } + } + + public var buttonIconName: String { + + switch self { + + case .start: + "play.fill" + + case .stop: + "pause.fill" + + case .reset: + "xmark" + } + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Domain/TimerLiveActivityState.swift b/LocalPackage/Sources/TimeWatcherCore/Domain/TimerLiveActivityState.swift new file mode 100644 index 0000000..d46c5c5 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Domain/TimerLiveActivityState.swift @@ -0,0 +1,25 @@ +import Foundation + +/// LiveActivityに渡すタイマー状態(ActivityKit非依存) +public struct TimerLiveActivityState { + + /// 経過時間(秒) + public var timeLapse: TimeInterval + /// 現在日時 + public var currentDate: Date + /// 表示用経過時間文字列 + public var timeLapseString: String + /// タイマーの状態 + public var timerStatus: TimerStatus + + public init(timeLapse: TimeInterval, + currentDate: Date, + timeLapseString: String, + timerStatus: TimerStatus) { + + self.timeLapse = timeLapse + self.currentDate = currentDate + self.timeLapseString = timeLapseString + self.timerStatus = timerStatus + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Domain/TimerStatus.swift b/LocalPackage/Sources/TimeWatcherCore/Domain/TimerStatus.swift new file mode 100644 index 0000000..d7f8110 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Domain/TimerStatus.swift @@ -0,0 +1,56 @@ +/// タイマーの動作状態 +public enum TimerStatus: Codable { + + /// 停止中(タイマーリセット済み) + case initial + /// 停止中(タイマーリセット前) + case stop + /// 開始中 + case start +} + +// MARK: - 外部公開用のプロパティ +extension TimerStatus { + + /// 使用可能なタイマーアクション + public var useableActions: [TimerActionType] { + + return switch self { + + case .initial: + [.start] + + case .stop: + [.reset, .start] + + case .start: + [.reset, .stop] + } + } + + /// 経過時間計測中かどうか + public var isPlaying: Bool { + + return switch self { + + case .initial, .stop: + false + + case .start: + true + } + } + + /// 状態を表すアイコン名 + public var icon: String { + + return switch self { + + case .initial, .stop: + "pause.fill" + + case .start: + "play.fill" + } + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Domain/WidgetUrlKey.swift b/LocalPackage/Sources/TimeWatcherCore/Domain/WidgetUrlKey.swift new file mode 100644 index 0000000..cf4fb4f --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Domain/WidgetUrlKey.swift @@ -0,0 +1,38 @@ +import Foundation + +/// WidgetURLで使用するURLのKey +public enum WidgetUrlKey: CaseIterable { + + /// タイマーリセットのリンク + case timerResetLink + /// その他デフォルトのリンク + case defaultLink +} + +extension WidgetUrlKey { + + private static let scheme = "https://" + + private static var defaultURL: URL { + + return URL(string: "\(scheme)\(AppConstants.mainBundleId)")! + } + + /// Keyに紐づくWidgetURL + public var url: URL { + + return URL(string: "\(Self.scheme)\(AppConstants.mainBundleId)\(self.path)") ?? Self.defaultURL + } + + public var path: String { + + switch self { + + case .timerResetLink: + return "/reset" + + case .defaultLink: + return "" + } + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Interface/LiveActivityManaging.swift b/LocalPackage/Sources/TimeWatcherCore/Interface/LiveActivityManaging.swift new file mode 100644 index 0000000..a3871fe --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Interface/LiveActivityManaging.swift @@ -0,0 +1,18 @@ +/// LiveActivityを管理するプロトコル(ActivityKit非依存) +public protocol LiveActivityManaging: Sendable { + + /// LiveActivityを開始する + func start(state: TimerLiveActivityState) async throws + + /// LiveActivityの情報の更新 + func update(state: TimerLiveActivityState) async throws + + /// LiveActivityの終了 + func stop() async throws +} + +/// LiveActivity操作時のエラー +public enum LiveActivityRequestError: Error, Equatable { + + case notFoundActivity +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Interface/NullLiveActivityManager.swift b/LocalPackage/Sources/TimeWatcherCore/Interface/NullLiveActivityManager.swift new file mode 100644 index 0000000..a8b58c3 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Interface/NullLiveActivityManager.swift @@ -0,0 +1,11 @@ +/// 何もしない LiveActivityManaging 実装(Preview・テストのデフォルト用) +public actor NullLiveActivityManager: LiveActivityManaging { + + public init() {} + + public func start(state: TimerLiveActivityState) async throws {} + + public func update(state: TimerLiveActivityState) async throws {} + + public func stop() async throws {} +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Interface/TimerControlable.swift b/LocalPackage/Sources/TimeWatcherCore/Interface/TimerControlable.swift new file mode 100644 index 0000000..ef02a6d --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Interface/TimerControlable.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Widget Intent がタイマーを操作するためのプロトコル +@MainActor +public protocol TimerControlable: Sendable { + + var timeWatch: TimeWatch { get } + var liveActivityManager: LiveActivityManaging { get } + var dateDependency: DateDependency { get } +} + +@MainActor +extension TimerControlable { + + public func updateLiveActivity(status: TimerStatus) async throws { + + let timeLapse = timeWatch.getCurrentTimeLapse() + try await liveActivityManager.update(state: TimerLiveActivityState( + timeLapse: timeLapse, + currentDate: dateDependency.generateNow(), + timeLapseString: timeLapse.timeLapseShortString, + timerStatus: status + )) + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Model/TimeWatch.swift b/LocalPackage/Sources/TimeWatcherCore/Model/TimeWatch.swift new file mode 100644 index 0000000..bd8b78c --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Model/TimeWatch.swift @@ -0,0 +1,148 @@ +import Combine +import Foundation + +@MainActor +public class TimeWatch { + + public static let shared = TimeWatch() + + /// タイマーの起動状態監視用のPublisher + private let timerStatusPublisher = CurrentValueSubject(.initial) + + /// 現在の時間 + private var currentTime: DateDependency + /// タイマーを開始した時の時間 + private var startTime: Date? + /// タイマー一時停止した時の総時間 + private var addedTime: TimeInterval? + /// 検知した時間経過を外部に伝えるクロージャ + private var timeLapseHandler: ((TimeInterval) -> Void)? + /// タイマー監視のバブリッシャー + private let timerPublisher: AnyPublisher + /// タイマー監視用のキャンセラブル + private var timerCancellable: AnyCancellable? + /// 現在の総経過時間 + private var currentTimeLapse: TimeInterval = .zero + + /// 時間計測の間隔 + private static let timeWatchInterval = 0.001 + + /// - Parameters: + /// - publisher: 時間経過監視のPublisher + /// - currentTime: 現在の時間 + /// - Attention: publisherの引数はテスト時など時間経過監視の処理を制御する用途で設定するので、 + /// プロダクトコードでは設定しないようにする. + /// currentTimeについてもテスト用に現在の時間を制御したい場合に設定する + public init(publisher: AnyPublisher? = nil, + currentTime: DateDependency = DateDependency()) { + + logger.info("[In] testPublisher: \(String(describing: publisher))") + + if let publisher { + + self.timerPublisher = publisher + } + else { + + self.timerPublisher = Timer.publish(every: Self.timeWatchInterval, + on: .main, + in: .common).autoconnect().eraseToAnyPublisher() + } + + self.currentTime = currentTime + } + + deinit { + + logger.info("[In]") + } + + /// タイマー状態監視用のPublisherを返す + public func createTimerStatusPublisher() -> AnyPublisher { + + return timerStatusPublisher.eraseToAnyPublisher() + } + + /// timeWatchIntervalの間隔ごとに経過時間を通知するクロージャを設定する + public func setTimerHandler(timeLapseHandler: @escaping (TimeInterval) -> Void) { + + self.timeLapseHandler = timeLapseHandler + } + + /// タイムウォッチを開始する + public func startTimer() { + + // 状態更新 + timerStatusPublisher.send(.start) + + self.startTime = currentTime.generateNow() + + logger.debug("startTime: \(String(describing: startTime?.toStringDate()))") + + // タイマー起動 + observeTime(timerPublisher) + } + + /// タイムウォッチを終了する + public func stopTimer() { + + // 状態更新 + timerStatusPublisher.send(.stop) + + // 現在のタイマーを停止する + timerCancellable?.cancel() + + guard let startTime else { + + assertionFailure("startTime is nil") + return + } + + // 現在の計測時間の総計を更新 + let totalTimeLapse = currentTime.generateNow().timeIntervalSince(startTime) + addedTime = addedTime == nil ? totalTimeLapse : addedTime! + totalTimeLapse + + logger.debug("addedTime : \(String(describing: addedTime))") + } + + /// タイムウォッチを終了して、経過時間を0に戻す + public func resetTimer() { + + // 状態更新 + timerStatusPublisher.send(.initial) + + // 現在のタイマーを停止する + timerCancellable?.cancel() + + // 計測時間の総計をリセットする + addedTime = nil + + // 外部にリセットした計測時間を送信する + timeLapseHandler?(.zero) + } + + /// 現在の経過時間総計を返す + public func getCurrentTimeLapse() -> TimeInterval { + + return currentTimeLapse + } +} + +// MARK: - private method + +private extension TimeWatch { + + func observeTime(_ timePublisher: AnyPublisher) { + + timerCancellable = timePublisher.receive(on: DispatchQueue.main).sink { [weak self] date in + + guard let self = self, + let startTime = self.startTime else { return } + + // 現在の総経過時間の更新 + currentTimeLapse = date.timeIntervalSince(startTime) + (addedTime ?? .zero) + // 経過時間を外部に連携 + self.timeLapseHandler?(currentTimeLapse) + } + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Utility/AppConstants.swift b/LocalPackage/Sources/TimeWatcherCore/Utility/AppConstants.swift new file mode 100644 index 0000000..6311c92 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Utility/AppConstants.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct AppConstants { + + // MARK: - アプリ共通 + + public static let mainBundleId = "taichi.satou.TimeWatcher" + + // MARK: - ウォッチ関連のプロパティ + + // 最大表示時間 + public static let maxDisplayTimeString = "99:59:59.999" + // 最大表示時間 + public static let maxDisplayShortTimeString = "99:59:59" + // 表示最大可能経過時間 + public static var maxDisplayTime = 100 + // 表示最大可能経過時間 + public static var maxDisplayTimeLapse: TimeInterval = 360000 +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Utility/DateDependency.swift b/LocalPackage/Sources/TimeWatcherCore/Utility/DateDependency.swift new file mode 100644 index 0000000..7aab9b3 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Utility/DateDependency.swift @@ -0,0 +1,18 @@ +import Foundation + +public final class DateDependency: @unchecked Sendable { + + public var now: Date? + private let isTest: Bool + + public init(now: Date? = nil, isTest: Bool = false) { + + self.now = now + self.isTest = isTest + } + + public func generateNow() -> Date { + + return isTest ? now ?? Date.now : Date.now + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/Calendar+Extension.swift b/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/Calendar+Extension.swift new file mode 100644 index 0000000..19d9e56 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/Calendar+Extension.swift @@ -0,0 +1,17 @@ +import Foundation + +extension Calendar { + + public func date(byAdding components: [Calendar.Component: Int], to: Date) -> Date { + + return components.reduce(into: to) { + + $0 = self.date(byAdding: $1.key, value: $1.value, to: $0) ?? $0 + } + } + + public func date(byAdding miliSec: Int, to: Date) -> Date { + + return Date(timeIntervalSince1970: (to.timeIntervalSince1970MiliSec + TimeInterval(miliSec)) / 1000) + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/Date+Extension.swift b/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/Date+Extension.swift new file mode 100644 index 0000000..2afcf97 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/Date+Extension.swift @@ -0,0 +1,17 @@ +import Foundation + +extension Date { + + public var timeIntervalSince1970MiliSec: TimeInterval { + + return self.timeIntervalSince1970 * 1000 + } + + /// yyyy/mm/dd hh:mm:ss.sss形式で文字列変換 + public func toStringDate() -> String { + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss.SSS" + return formatter.string(from: self) + } +}