From 0110a3ea940e725c6f2293e22e94e8a128de4651 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 06:46:44 +0000 Subject: [PATCH 1/2] =?UTF-8?q?[1/5]=20SPM:=20TimeWatcherCore=E3=83=A2?= =?UTF-8?q?=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalPackage/Package.swiftを作成しSPMローカルパッケージを定義 - TimeWatcherCoreモジュールを追加: - Domain: TimerStatus, TimerActionType, WidgetUrlKey, TimerLiveActivityState - Interface: LiveActivityManaging, TimerControlable, NullLiveActivityManager - Model: TimeWatch - Utility: AppConstants, DateDependency, Calendar/Date拡張 TimerLiveActivityStateを導入しActivityKitへの依存をCore層から排除する https://claude.ai/code/session_01QuAAadYpLRWRXW25J7R3vF --- LocalPackage/Package.swift | 43 +++++ .../Domain/TimerActionType.swift | 46 ++++++ .../Domain/TimerLiveActivityState.swift | 25 +++ .../TimeWatcherCore/Domain/TimerStatus.swift | 56 +++++++ .../TimeWatcherCore/Domain/WidgetUrlKey.swift | 38 +++++ .../Interface/LiveActivityManaging.swift | 18 +++ .../Interface/NullLiveActivityManager.swift | 11 ++ .../Interface/TimerControlable.swift | 25 +++ .../TimeWatcherCore/Model/TimeWatch.swift | 148 ++++++++++++++++++ .../Utility/AppConstants.swift | 19 +++ .../Utility/DateDependency.swift | 18 +++ .../Extension/Calendar+Extension.swift | 17 ++ .../Utility/Extension/Date+Extension.swift | 17 ++ 13 files changed, 481 insertions(+) create mode 100644 LocalPackage/Package.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Domain/TimerActionType.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Domain/TimerLiveActivityState.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Domain/TimerStatus.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Domain/WidgetUrlKey.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Interface/LiveActivityManaging.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Interface/NullLiveActivityManager.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Interface/TimerControlable.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Model/TimeWatch.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Utility/AppConstants.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Utility/DateDependency.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Utility/Extension/Calendar+Extension.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Utility/Extension/Date+Extension.swift 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) + } +} From 7a0f298b2900eca9ab3562a35f95da4cfa60d6a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 06:47:21 +0000 Subject: [PATCH 2/2] =?UTF-8?q?[2/5]=20SPM:=20TimeWatcherFeature=E3=83=BBT?= =?UTF-8?q?estSupport=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimeWatcherCoreにTimeInterval拡張・Loggerを追加 - TimeWatcherFeatureモジュールを追加: - MainTimerView, TimerClockAnimationView - OpenUrlViewModel - FrameButtonStyle, ResourceAdapt (CustomColor/CustomImage typealiases) - TimeWatcherTestSupportモジュールを追加: - LiveActivityManagerMock (TimerLiveActivityStateベース) - TimeWatcherCoreTestsの雛形を追加 https://claude.ai/code/session_01QuAAadYpLRWRXW25J7R3vF --- .../Extension/TimeInterval+Extension.swift | 48 +++++++ .../TimeWatcherCore/Utility/Logger.swift | 33 +++++ .../MainTimer/MainTimerView.swift | 123 ++++++++++++++++++ .../ViewParts/TimerClockAnimationView.swift | 76 +++++++++++ .../RootView/OpenUrlViewModel.swift | 24 ++++ .../Utility/FrameButtonStyle.swift | 35 +++++ .../Utility/ResourceAdapt.swift | 21 +++ .../LiveActivityManagerMock.swift | 35 +++++ .../TimeWatcherCoreTests.swift | 4 + 9 files changed, 399 insertions(+) create mode 100644 LocalPackage/Sources/TimeWatcherCore/Utility/Extension/TimeInterval+Extension.swift create mode 100644 LocalPackage/Sources/TimeWatcherCore/Utility/Logger.swift create mode 100644 LocalPackage/Sources/TimeWatcherFeature/MainTimer/MainTimerView.swift create mode 100644 LocalPackage/Sources/TimeWatcherFeature/MainTimer/ViewParts/TimerClockAnimationView.swift create mode 100644 LocalPackage/Sources/TimeWatcherFeature/RootView/OpenUrlViewModel.swift create mode 100644 LocalPackage/Sources/TimeWatcherFeature/Utility/FrameButtonStyle.swift create mode 100644 LocalPackage/Sources/TimeWatcherFeature/Utility/ResourceAdapt.swift create mode 100644 LocalPackage/Sources/TimeWatcherTestSupport/LiveActivityManagerMock.swift create mode 100644 LocalPackage/Tests/TimeWatcherCoreTests/TimeWatcherCoreTests.swift diff --git a/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/TimeInterval+Extension.swift b/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/TimeInterval+Extension.swift new file mode 100644 index 0000000..847ee54 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Utility/Extension/TimeInterval+Extension.swift @@ -0,0 +1,48 @@ +import Foundation + +extension TimeInterval { + + public var hour: Int { + + return abs(Int(self / 3600)) + } + + public var minute: Int { + + return Int(self / 60) % 60 + } + + public var seconds: Int { + + return Int(self) % 60 + } + + public var milliSec: Int { + + let timeLapse = self * 1000 + let roundTimeLapse = timeLapse.rounded() + return Int(roundTimeLapse) % 1000 + } + + /// HH:mm:ss.SSS形式の経過時間の文字列を返す + public var timeLapseFullString: String { + + guard AppConstants.maxDisplayTimeLapse > self else { + + return AppConstants.maxDisplayTimeString + } + + return String(format: "%02d:%02d:%02d.%03d", self.hour, self.minute, self.seconds, self.milliSec) + } + + /// HH:mm:ss形式の経過時間文字列を返す + public var timeLapseShortString: String { + + guard AppConstants.maxDisplayTimeLapse > self else { + + return AppConstants.maxDisplayShortTimeString + } + + return String(format: "%02d:%02d:%02d", self.hour, self.minute, self.seconds) + } +} diff --git a/LocalPackage/Sources/TimeWatcherCore/Utility/Logger.swift b/LocalPackage/Sources/TimeWatcherCore/Utility/Logger.swift new file mode 100644 index 0000000..a55842b --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherCore/Utility/Logger.swift @@ -0,0 +1,33 @@ +import Foundation +import OSLog + +public let logger = CustomLogger() + +public struct CustomLogger { + + #if DEBUG + private let logger = Logger(subsystem: "taichi.satou.TimeWatcher", category: "DEBUG CONFIG") + #else + private let logger = Logger(subsystem: "taichi.satou.TimeWatcher", category: "PRD CONFIG") + #endif + + public func debug(_ message: String, file: String = #fileID, function: String = #function, line: Int = #line) { + + logger.debug("🟩 [DEBUG] [\(file):\(function) \(line)]: \(message)") + } + + public func info(_ message: String, file: String = #fileID, function: String = #function, line: Int = #line) { + + logger.info("🟪 [INFO] [\(file):\(function) \(line)]: \(message)") + } + + public func warning(_ message: String, file: String = #fileID, function: String = #function, line: Int = #line) { + + logger.warning("🟨 [WARNING] [\(file):\(function) \(line)]: \(message)") + } + + public func error(_ message: String, file: String = #fileID, function: String = #function, line: Int = #line) { + + logger.error("🟥 [ERROR] [\(file):\(function) \(line)]: \(message)") + } +} diff --git a/LocalPackage/Sources/TimeWatcherFeature/MainTimer/MainTimerView.swift b/LocalPackage/Sources/TimeWatcherFeature/MainTimer/MainTimerView.swift new file mode 100644 index 0000000..816f9cd --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherFeature/MainTimer/MainTimerView.swift @@ -0,0 +1,123 @@ +import SwiftUI +import TimeWatcherCore +import TimeWatcherExternalResouce + +public struct MainTimerView: View { + + @StateObject var viewModel: MainTimerViewModel + @EnvironmentObject var openUrlViewModel: OpenUrlViewModel + + // MARK: - layout property + + private let actionButtonTopPadding: CGFloat = 50 + private let emergencyTextTopPadding: CGFloat = 20 + private let emergencyTextHorizontalPadding: CGFloat = 30 + private let actionButtonSpacing: CGFloat = 100 + private let viewBottomPadding: CGFloat = 15 + private let timerTextBottomPadding: CGFloat = 20 + + private let actionButtonSize: CGFloat = 80 + + private let displayTimeFontSize: CGFloat = 40 + private let emergencyTextFontSize: CGFloat = 18 + private let actionButtonTextFontSize: CGFloat = 18 + + private let actionButtonShadowRadius: CGFloat = 5 + private let actionButtonShadowY: CGFloat = 4 + + public init(viewModel: MainTimerViewModel) { + + self._viewModel = StateObject(wrappedValue: viewModel) + } + + // MARK: - view body property + + public var body: some View { + NavigationStack { + ZStack { + Color(CustomColor.primaryBackgroundColor) + .ignoresSafeArea() + VStack(spacing: .zero) { + createTimerDisplayView() + .frame(maxWidth: .infinity, + maxHeight: .infinity) + Spacer() + .frame(height: timerTextBottomPadding) + Divider() + Spacer() + .frame(height: actionButtonTopPadding) + createTimerActionView() + Spacer(minLength: viewBottomPadding) + } + } + } + .onAppear { + viewModel.onAppear() + } + .onReceive(openUrlViewModel.$widgetUrlKey.compactMap { $0 }) { widgetUrl in + viewModel.onOpenLiveActivityUrl(widgetUrl) + } + } +} + +// MARK: - private method + +private extension MainTimerView { + + func createTimerDisplayView() -> some View { + ZStack { + TimerClockAnimationView(progress: viewModel.timeProgressPerMinute, + size: .infinity) + .padding() + VStack(spacing: .zero) { + Text(viewModel.currentTimeString) + .font(.system(size: displayTimeFontSize, + weight: .bold)) + .foregroundStyle(Color(asset: CustomColor.timerTextColor)) + if viewModel.isOverMaxTime { + Spacer() + .frame(height: emergencyTextTopPadding) + Text("最大表経過時間を超過しているため、これ以上は計測できません。") + .font(.system(size: emergencyTextFontSize)) + .foregroundStyle(Color(CustomColor.emergencyColor)) + .padding(.horizontal, emergencyTextHorizontalPadding) + } + } + } + } + + func createTimerActionView() -> some View { + HStack(spacing: actionButtonSpacing) { + ForEach(viewModel.timerStatus.useableActions, id: \.self) { + createTimerActionButton($0) + } + } + .animation(.spring, value: viewModel.timerStatus) + } + + func createTimerActionButton(_ type: TimerActionType) -> some View { + Button { + viewModel.tappedTimerActionButton(type) + } label: { + Text(type.buttonTitle) + .font(.system(size: actionButtonTextFontSize, + weight: .bold)) + .frame(width: actionButtonSize, + height: actionButtonSize) + } + .frameButtonStyle(radius: actionButtonSize / 2) + .shadow(radius: actionButtonShadowRadius, + y: actionButtonShadowY) + .accessibilityIdentifier("ActionButton_\(type.buttonTitle)") + } +} + +#Preview("通常時") { + MainTimerView(viewModel: MainTimerViewModel()) + .environmentObject(OpenUrlViewModel()) +} + +#Preview("最大表示可能経過時間超過時") { + MainTimerView(viewModel: MainTimerViewModel(isOverMaxTime: true)) + .environmentObject(OpenUrlViewModel()) +} diff --git a/LocalPackage/Sources/TimeWatcherFeature/MainTimer/ViewParts/TimerClockAnimationView.swift b/LocalPackage/Sources/TimeWatcherFeature/MainTimer/ViewParts/TimerClockAnimationView.swift new file mode 100644 index 0000000..77460ec --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherFeature/MainTimer/ViewParts/TimerClockAnimationView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import TimeWatcherExternalResouce + +public struct TimerClockAnimationView: View { + + // MARK: - layout static property + + private let initialPointDegrees: CGFloat = -90 + private let unProgressAreaOpacity: CGFloat = 0.2 + private let progressRotationAngle: CGFloat = 720 + + // MARK: - input parameter + + private let progress: Double + @State private var postProgress: Double = .zero + @State private var progressAngle: CGFloat = .zero + + private let size: CGFloat + private let lineWidth: CGFloat + private let progressColor: Color + + // MARK: - computedProperty + + private var startPoint: Double { + + return progressPoint >= 0.5 ? 0.5 : .zero + } + + private var progressPoint: Double { + + return progress >= 1 ? progress.truncatingRemainder(dividingBy: 1) : progress + } + + public init(progress: Double, + size: CGFloat, + lineWidth: CGFloat = 10, + progressColor: Color = Color(CustomColor.timerActionBackgroundColor)) { + + self.progress = progress + self.size = size + self.lineWidth = lineWidth + self.progressColor = progressColor + } + + // MARK: - view body property + + public var body: some View { + ZStack { + Circle() + .trim(from: .zero, to: 1) + .stroke(lineWidth: lineWidth) + .rotation(.degrees(initialPointDegrees)) + .foregroundStyle(progressColor.opacity(unProgressAreaOpacity)) + .frame(maxWidth: size, maxHeight: size) + Circle() + .trim(from: startPoint, to: progressPoint) + .stroke(lineWidth: lineWidth) + .rotation(.degrees(initialPointDegrees)) + .foregroundStyle(progressColor) + .frame(maxWidth: size, maxHeight: size) + .animation(.spring, value: progress) + } + .rotationEffect(.degrees(progressAngle)) + .onChange(of: progress) { _, newValue in + if floor(postProgress) != floor(newValue) { + withAnimation(.spring) { + progressAngle += progressRotationAngle + } + } + else { + progressAngle = .zero + } + postProgress = newValue + } + } +} diff --git a/LocalPackage/Sources/TimeWatcherFeature/RootView/OpenUrlViewModel.swift b/LocalPackage/Sources/TimeWatcherFeature/RootView/OpenUrlViewModel.swift new file mode 100644 index 0000000..23896a0 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherFeature/RootView/OpenUrlViewModel.swift @@ -0,0 +1,24 @@ +import Foundation +import TimeWatcherCore + +@MainActor +public class OpenUrlViewModel: ObservableObject { + + /// LiveActivityから開かれた際のURL + @Published public var widgetUrlKey: WidgetUrlKey? + + public init() {} + + /// 開かれたURLの設定 + public func setUrl(_ url: URL) { + + guard let widgetURL = WidgetUrlKey.allCases.first(where: { $0.url == url }) else { + + logger.debug("Not found widget url: \(url).") + return + } + + logger.info("Did open widget url: \(widgetURL).") + widgetUrlKey = widgetURL + } +} diff --git a/LocalPackage/Sources/TimeWatcherFeature/Utility/FrameButtonStyle.swift b/LocalPackage/Sources/TimeWatcherFeature/Utility/FrameButtonStyle.swift new file mode 100644 index 0000000..2517335 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherFeature/Utility/FrameButtonStyle.swift @@ -0,0 +1,35 @@ +import SwiftUI +import TimeWatcherExternalResouce + +public struct FrameButtonStyle: ButtonStyle { + + let foregroundColor: Color + let backgroundColor: Color + let pressedBackgroundColor: Color + let frameWidth: CGFloat + let radius: CGFloat + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(foregroundColor) + .background(configuration.isPressed ? pressedBackgroundColor : backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .overlay { + RoundedRectangle(cornerRadius: radius).stroke(lineWidth: frameWidth) + } + } +} + +extension Button { + + public func frameButtonStyle(foregroundColor: Color = Color(CustomColor.timerActionForegroundColor), + backgroundColor: Color = Color(CustomColor.timerActionBackgroundColor), + pressedBackgroundColor: Color = Color(CustomColor.pressedBackgroundColor), + frameWidth: CGFloat = .zero, + radius: CGFloat = 8) -> some View { + return self.buttonStyle(FrameButtonStyle(foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + pressedBackgroundColor: pressedBackgroundColor, + frameWidth: frameWidth, radius: radius)) + } +} diff --git a/LocalPackage/Sources/TimeWatcherFeature/Utility/ResourceAdapt.swift b/LocalPackage/Sources/TimeWatcherFeature/Utility/ResourceAdapt.swift new file mode 100644 index 0000000..cb607e5 --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherFeature/Utility/ResourceAdapt.swift @@ -0,0 +1,21 @@ +import TimeWatcherExternalResouce +import SwiftUI + +public typealias CustomColor = Asset.Color +public typealias CustomImage = Asset.Image + +extension Color { + + public init(_ custom: ColorAsset) { + + self.init(asset: custom) + } +} + +extension Image { + + public init(_ custom: ImageAsset) { + + self.init(asset: custom) + } +} diff --git a/LocalPackage/Sources/TimeWatcherTestSupport/LiveActivityManagerMock.swift b/LocalPackage/Sources/TimeWatcherTestSupport/LiveActivityManagerMock.swift new file mode 100644 index 0000000..46cb94c --- /dev/null +++ b/LocalPackage/Sources/TimeWatcherTestSupport/LiveActivityManagerMock.swift @@ -0,0 +1,35 @@ +import TimeWatcherCore + +public actor LiveActivityManagerMock: LiveActivityManaging { + + private var startProc: (TimerLiveActivityState) throws -> Void + private var updateProc: (TimerLiveActivityState) throws -> Void + private var stopProc: () throws -> Void + + public init(startProc: @escaping (TimerLiveActivityState) throws -> Void, + updateProc: @escaping (TimerLiveActivityState) throws -> Void, + stopProc: @escaping () throws -> Void) { + + self.startProc = startProc + self.updateProc = updateProc + self.stopProc = stopProc + } + + public func start(state: TimerLiveActivityState) async throws { + + logger.debug("[In] state=\(state.timeLapseString)") + try startProc(state) + } + + public func update(state: TimerLiveActivityState) async throws { + + logger.debug("[In] state=\(state.timeLapseString)") + try updateProc(state) + } + + public func stop() async throws { + + logger.debug("[In]") + try stopProc() + } +} diff --git a/LocalPackage/Tests/TimeWatcherCoreTests/TimeWatcherCoreTests.swift b/LocalPackage/Tests/TimeWatcherCoreTests/TimeWatcherCoreTests.swift new file mode 100644 index 0000000..cf87d2b --- /dev/null +++ b/LocalPackage/Tests/TimeWatcherCoreTests/TimeWatcherCoreTests.swift @@ -0,0 +1,4 @@ +import XCTest +@testable import TimeWatcherCore + +final class TimeWatcherCoreTests: XCTestCase {}