A => .gitignore +125 -0
@@ 1,125 @@
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+## User settings
+xcuserdata/
+
+## Xcode 8 and earlier
+*.xcscmblueprint
+*.xccheckout
+
+# Xcode
+#
+# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+
+## User settings
+xcuserdata/
+
+## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
+*.xcscmblueprint
+*.xccheckout
+
+## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
+build/
+DerivedData/
+*.moved-aside
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+
+## Obj-C/Swift specific
+*.hmap
+
+## App packaging
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+## Playgrounds
+timeline.xctimeline
+playground.xcworkspace
+
+# Swift Package Manager
+#
+# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
+# Packages/
+# Package.pins
+# Package.resolved
+# *.xcodeproj
+#
+# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
+# hence it is not needed unless you have added a package configuration file to your project
+# .swiftpm
+
+.build/
+
+# CocoaPods
+#
+# We recommend against adding the Pods directory to your .gitignore. However
+# you should judge for yourself, the pros and cons are mentioned at:
+# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
+#
+Pods/
+#
+# Add this line if you want to avoid checking in source code from the Xcode workspace
+# *.xcworkspace
+
+# Carthage
+#
+# Add this line if you want to avoid checking in source code from Carthage dependencies.
+# Carthage/Checkouts
+
+Carthage/Build/
+
+# Accio dependency management
+Dependencies/
+.accio/
+
+# fastlane
+#
+# It is recommended to not store the screenshots in the git repo.
+# Instead, use fastlane to re-generate the screenshots whenever they are needed.
+# For more information about the recommended setup visit:
+# https://docs.fastlane.tools/best-practices/source-control/#source-control
+
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
+
+# Code Injection
+#
+# After new code Injection tools there's a generated folder /iOSInjectionProject
+# https://github.com/johnno1962/injectionforxcode
+
+iOSInjectionProject/<
\ No newline at end of file
A => Documents/icon.png +0 -0
A => Documents/website/AppStoreImage.png +0 -0
A => Documents/website/app-image-1.png +0 -0
A => Documents/website/app-image-2.png +0 -0
A => MPTimerDesktop/Assets.xcassets/AccentColor.colorset/Contents.json +11 -0
@@ 1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
A => MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/1024.png +0 -0
A => MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/128.png +0 -0
A => MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/16.png +0 -0
A => MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/256.png +0 -0
A => MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/32.png +0 -0
A => MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/512.png +0 -0
A => MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/64.png +0 -0
A => MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
@@ 1,68 @@
+{
+ "images" : [
+ {
+ "filename" : "16.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "32.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "32.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "64.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "128.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "256.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "256.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "512.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "512.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "filename" : "1024.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
A => MPTimerDesktop/Assets.xcassets/Contents.json +6 -0
@@ 1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
A => MPTimerDesktop/Extensions/Combine+Pairwise.swift +38 -0
@@ 1,38 @@
+//
+// Combine+Pairwise.swift
+//
+// Created by Felix Mau on 17.05.21.
+// Copyright © 2021 Felix Mau. All rights reserved.
+//
+// https://gist.github.com/fxm90/be62335d987016c84d2f8b3731197c98
+//
+import Combine
+
+extension Publisher {
+
+ typealias Pairwise<T> = (previous: T?, current: T)
+
+ /// Includes the current element as well as the previous element from the upstream publisher in a tuple where the previous element is optional.
+ /// The first time the upstream publisher emits an element, the previous element will be `nil`.
+ ///
+ /// ```
+ /// let range = (1...5)
+ /// let subscription = range.publisher
+ /// .pairwise()
+ /// .sink { print("(\($0.previous), \($0.current))", terminator: " ") }
+ /// ```
+ /// Prints: "(nil, 1) (Optional(1), 2) (Optional(2), 3) (Optional(3), 4) (Optional(4), 5)".
+ ///
+ /// - Returns: A publisher of a tuple of the previous and current elements from the upstream publisher.
+ ///
+ /// - Note: Based on <https://stackoverflow.com/a/67133582/3532505>.
+ func pairwise() -> AnyPublisher<Pairwise<Output>, Failure> {
+ // `scan()` needs an initial value, which is `nil` in our case.
+ // Therefore we have to return an optional here and use `compactMap()` below the remove the optional type.
+ scan(nil) { previousPair, currentElement -> Pairwise<Output>? in
+ Pairwise(previous: previousPair?.current, current: currentElement)
+ }
+ .compactMap { $0 }
+ .eraseToAnyPublisher()
+ }
+}
A => MPTimerDesktop/Extensions/Date+Milliseconds.swift +18 -0
@@ 1,18 @@
+//
+// Date+Milliseconds.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/03/28.
+//
+
+import Foundation
+
+extension Date {
+ var millisecondsSince1970: Int64 {
+ Int64((self.timeIntervalSince1970 * 1000.0).rounded())
+ }
+
+ init(milliseconds: Int64) {
+ self = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000)
+ }
+}
A => MPTimerDesktop/Extensions/DateInterval+formats.swift +23 -0
@@ 1,23 @@
+//
+// DateInterval+formats.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/28.
+//
+
+import Foundation
+
+
+private var dateFormatter: DateComponentsFormatter = {
+ let formatter = DateComponentsFormatter()
+ formatter.unitsStyle = .positional
+ formatter.allowedUnits = [.minute, .second]
+ formatter.zeroFormattingBehavior = .pad
+ return formatter
+}()
+
+extension DateInterval {
+ func formatHoursAndMinutes() -> String {
+ return dateFormatter.string(from: self.duration) ?? "00:00"
+ }
+}
A => MPTimerDesktop/Extensions/NSAttributedString+formats.swift +33 -0
@@ 1,33 @@
+//
+// NSAttributedString+formats.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/27.
+//
+
+import Foundation
+import SwiftUI
+
+let baseAttributes = [
+ NSAttributedString.Key.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .bold)
+]
+
+extension NSAttributedString {
+ static func formatWorking(_ str: String) -> NSAttributedString {
+ return NSAttributedString(
+ string: str,
+ attributes: [
+ NSAttributedString.Key.foregroundColor: NSColor.white
+ ].merging(baseAttributes, uniquingKeysWith: { (_, new) in new })
+ )
+ }
+
+ static func formatNotWorking(_ str: String) -> NSAttributedString {
+ return NSAttributedString(
+ string: str,
+ attributes: [
+ NSAttributedString.Key.foregroundColor: NSColor.darkGray
+ ].merging(baseAttributes, uniquingKeysWith: { (_, new) in new })
+ )
+ }
+}
A => MPTimerDesktop/Extensions/String+urlEncoding.swift +21 -0
@@ 1,21 @@
+//
+// String+urlEncoding.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/28.
+//
+
+import Foundation
+
+extension String {
+
+ // https://dev.classmethod.jp/articles/urlencode-spec-and-implementation-for-swift/
+ var urlEncoded: String {
+ // 半角英数字 + "/?-._~" のキャラクタセットを定義
+ let charset = CharacterSet.alphanumerics.union(.init(charactersIn: "?-._~"))
+ // 一度すべてのパーセントエンコードを除去(URLデコード)
+ let removed = removingPercentEncoding ?? self
+ // あらためてパーセントエンコードして返す
+ return removed.addingPercentEncoding(withAllowedCharacters: charset) ?? removed
+ }
+}
A => MPTimerDesktop/Extensions/Task+SleepInMilliseconds.swift +14 -0
@@ 1,14 @@
+//
+// Task+SleepInMilliseconds.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/05/14.
+//
+
+import Foundation
+
+extension Task where Success == Never, Failure == Never {
+ static func sleep(milliseconds duration: UInt64) async throws {
+ try await Task.sleep(nanoseconds: duration * 1_000_000)
+ }
+}
A => MPTimerDesktop/Info.plist +27 -0
@@ 1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeRole</key>
+ <string>Editor</string>
+ <key>CFBundleURLName</key>
+ <string>$(URL_SCHEME)</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>$(URL_SCHEME)</string>
+ </array>
+ </dict>
+ </array>
+ <key>LSUIElement</key>
+ <true/>
+ <key>deepLinkHost</key>
+ <string>$(DEEP_LINK_HOST)</string>
+ <key>fbDynamicLinkHost</key>
+ <string>$(FB_DYNAMIC_LINK_HOST)</string>
+ <key>urlScheme</key>
+ <string>$(URL_SCHEME)</string>
+</dict>
+</plist>
A => MPTimerDesktop/MPTimerDesktopApp.swift +155 -0
@@ 1,155 @@
+//
+// MPTimerDesktopApp.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/03/23.
+//
+
+import SwiftUI
+import Firebase
+import Combine
+
+@main
+struct MPTimerDesktopApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ WindowGroup {
+ EmptyView()
+ .handlesExternalEvents(preferring: ["tid"], allowing: ["tid"])
+ .frame(width: .zero)
+ .onOpenURL(perform: { url in
+ SharedTimerManager.shared.newTimer(fromURL: url)
+ })
+ }
+ WindowGroup("QR") {
+ QRView()
+ .frame(width: 200, height: 200)
+ }.handlesExternalEvents(matching: ["qr"])
+ Settings {
+ PreferencesView().frame(width: 320, height: 160)
+ }
+ }
+}
+
+class AppDelegate: NSObject, NSApplicationDelegate {
+
+ private var menuExtrasConfigurator: MacExtrasConfigurator?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ FirebaseService.shared.configure()
+ menuExtrasConfigurator = .init()
+ }
+
+ // https://khorbushko.github.io/article/2021/04/30/minimal-macOS-menu-bar-extra's-app-with-SwiftUI.html
+ final private class MacExtrasConfigurator: NSObject, ObservableObject {
+
+ private var statusBar: NSStatusBar
+ private var statusItem: NSStatusItem
+ private var contentHostingView: NSView
+ private var subscriptions = Set<AnyCancellable>();
+
+ override init() {
+ statusBar = NSStatusBar.system
+ statusItem = statusBar.statusItem(withLength: NSStatusItem.variableLength)
+ contentHostingView = NSHostingView(rootView: ContentView())
+ contentHostingView.frame = NSRect(x: 0, y: 0, width: 400, height: 150);
+
+ super.init()
+
+ createMenu()
+ setupSubscription()
+ setupTimer()
+ }
+
+ func createMenu() {
+ if let statusBarButton = statusItem.button {
+ let image = NSApp.applicationIconImage
+ image?.size = NSSize(width: statusBarButton.frame.height * 0.9, height: statusBarButton.frame.height * 0.9)
+ statusBarButton.image = image
+ statusBarButton.imagePosition = .imageLeft
+ statusBarButton.attributedTitle = NSAttributedString.formatNotWorking("--:--")
+
+ let mainMenu = NSMenu()
+
+ let contentItem = NSMenuItem()
+ contentItem.view = contentHostingView
+ mainMenu.addItem(contentItem)
+
+ mainMenu.addItem(NSMenuItem.separator())
+
+ let prefrencesItem = NSMenuItem()
+ prefrencesItem.title = NSLocalizedString("Preferences", comment: "Preferences")
+ prefrencesItem.target = self
+ prefrencesItem.action = #selector(Self.onPreferences(_:))
+ mainMenu.addItem(prefrencesItem)
+
+ mainMenu.addItem(NSMenuItem.separator())
+
+ let quitItem = NSMenuItem()
+ quitItem.title = NSLocalizedString("Quit", comment: "Quit")
+ quitItem.target = self
+ quitItem.action = #selector(Self.onQuit(_:))
+ mainMenu.addItem(quitItem)
+
+ statusItem.menu = mainMenu
+ }
+ }
+
+ func setupSubscription() {
+ Task { @MainActor in
+ SharedTimerManager.shared.$remainingTime.sink { remainingTime in
+ if let statusBarButton = self.statusItem.button {
+ if let time = remainingTime {
+ statusBarButton.attributedTitle = NSAttributedString.formatWorking(time)
+ } else {
+ statusBarButton.attributedTitle = NSAttributedString.formatNotWorking("--:--")
+ }
+ }
+ }.store(in: &subscriptions)
+
+ SharedTimerManager.shared.$status.sink { status in
+ let mode = SharedTimerManager.shared.mode
+ if let statusBarButton = self.statusItem.button {
+ var image: NSImage
+ switch status {
+ case .playing:
+ switch mode {
+ case .timer:
+ image = NSImage(systemSymbolName: "timer", accessibilityDescription: nil)!
+ case .rest:
+ image = NSImage(systemSymbolName: "cup.and.saucer", accessibilityDescription: nil)!
+ case .none:
+ image = NSApp.applicationIconImage
+ }
+ case .stop, .none:
+ image = NSApp.applicationIconImage
+ }
+ image.size = NSSize(width: statusBarButton.frame.height * 0.9, height: statusBarButton.frame.height * 0.9)
+ statusBarButton.image = image
+ statusBarButton.imagePosition = .imageLeft
+ }
+ }.store(in: &subscriptions)
+ }
+ }
+
+ func setupTimer() {
+ let timerManager = SharedTimerManager.shared
+ Task { @MainActor in
+ if timerManager.timer == nil {
+ timerManager.newTimer(timerManager.timerId)
+ }
+ }
+ }
+
+
+ @objc private func onQuit(_ sender: Any?) {
+ NSApplication.shared.terminate(nil)
+ }
+
+ @objc private func onPreferences(_ sender: Any?) {
+ NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ }
+}
A => MPTimerDesktop/Models/AppPreferences.swift +19 -0
@@ 1,19 @@
+//
+// Preferences.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/05/14.
+//
+
+import Foundation
+import Combine
+import SwiftUI
+
+@MainActor
+class AppPreferences: ObservableObject {
+ static private(set) var shared = AppPreferences()
+
+ private init() {}
+
+ @AppStorage("isEnableNotificationSound") var isEnableNotificationSound = false
+}
A => MPTimerDesktop/Models/SharedTimer.swift +291 -0
@@ 1,291 @@
+//
+// Timer.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/03/23.
+//
+
+import Foundation
+import Combine
+import FirebaseDatabase
+
+enum SharedTimerMode: String, Codable {
+ case rest = "REST"
+ case timer = "TIMER"
+
+ func next() -> SharedTimerMode {
+ if (self == .timer) {
+ return .rest
+ } else {
+ return .timer
+ }
+ }
+}
+
+enum SharedTimerStatus: String, Codable {
+ case playing = "PLAYING"
+ case stop = "STOP"
+}
+
+let initialTimerData = [
+ "mode": SharedTimerMode.timer.rawValue,
+ "status": SharedTimerStatus.stop.rawValue,
+ "timerDurations": [5, 10, 25, 55],
+ "restDurations": [5, 15, 30],
+ "duration": 0,
+ "finishedAt": 0
+] as [String : Any]
+
+
+actor SharedTimer: ObservableObject {
+
+ @MainActor @Published private(set) var id: String
+ @MainActor @Published private(set) var duration: Int = 0
+ @MainActor @Published private(set) var mode = SharedTimerMode.rest
+ @MainActor @Published private(set) var status = SharedTimerStatus.stop
+ @MainActor @Published private(set) var workDurations: [Int] = []
+ @MainActor @Published private(set) var restDurations: [Int] = []
+ @MainActor @Published private(set) var finishedAt = Date(milliseconds: 0)
+ @MainActor @Published private(set) var remainingTime: DateInterval?
+
+ @MainActor private var timers = Set<AnyCancellable>()
+ private var statusWatcher = Set<AnyCancellable>()
+
+ private var db: DatabaseReference
+ private var dbHandle: DatabaseHandle?
+
+ @MainActor
+ init(_ id: String, withDb db: DatabaseReference) {
+ self.db = db
+ self.id = id
+ }
+
+ // MARK: - 外部用API
+
+ /// タイマーの存在を確定させる
+ /// - リモートのタイマーのデータをタイマーインスタンスに適用する
+ /// - リモートにタイマーのデータがない場合、作成する
+ func ensureTimer() async {
+ let id = await MainActor.run { self.id }
+ let db = self.db
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
+ db.child("/timers/\(id)").getData(completion: { error, snapshot in
+ guard error == nil && !snapshot.exists() else {
+ // データを正しく取得できた場合は、リモート側のデータを持ってくる
+ Task { @MainActor in
+ self.applySnapshot(snapshot)
+
+ // リモート側のタイマーが起動状態であれば、こちらも起動する
+ if self.status == .playing {
+ self.invokeTimer()
+ }
+ }
+ continuation.resume()
+ return
+ }
+ Task {
+ try? await db.child("/timers/\(id)").updateChildValues(initialTimerData)
+ }
+ continuation.resume()
+ })
+ }
+ }
+
+ func watch() async {
+ let id = await MainActor.run { self.id }
+ self.dbHandle = db.child("/timers/\(id)").observe(.value, with: { snapshot in
+ Task { @MainActor in
+ self.applySnapshot(snapshot)
+ }
+ })
+
+ self.$status.pairwise().sink { (previous, current) in
+ Task { @MainActor in
+ if previous == .stop && current == .playing {
+ self.invokeTimer()
+ } else if previous == .playing && current == .stop {
+ self.cancelTimer()
+ }
+ }
+ }.store(in: &statusWatcher)
+ }
+
+ deinit {
+ if let dbHandle = dbHandle {
+ db.removeObserver(withHandle: dbHandle)
+ }
+ }
+
+ @MainActor
+ func start(_ duration: Int) async throws {
+ let finishedAtMillis = Date().millisecondsSince1970 + Int64(duration * 60 * 1000)
+
+ try await self.db.child("/timers/\(self.id)").updateChildValues([
+ "status": SharedTimerStatus.playing.rawValue,
+ "duration": duration,
+ "finishedAt": finishedAtMillis
+ ])
+
+ self.duration = duration
+ self.status = .playing
+ self.finishedAt = Date(milliseconds: finishedAtMillis)
+ }
+
+ @MainActor
+ func stop() async throws {
+ try await db.child("/timers/\(self.id)").updateChildValues(["status": SharedTimerStatus.stop.rawValue])
+ self.status = .stop
+ }
+
+ @MainActor
+ func skip() async throws {
+ try await self.db.child("/timers/\(self.id)").updateChildValues([
+ "status": SharedTimerStatus.stop.rawValue,
+ "mode": mode.next().rawValue,
+ "finishedAt": 0
+ ])
+
+ self.status = .stop
+ self.finishedAt = Date(milliseconds: 0)
+ }
+
+ @MainActor
+ func addDuration(_ duration: Int, to mode: SharedTimerMode) async throws {
+ var durations = mode == .timer ? workDurations : restDurations
+ if durations.contains(duration) {
+ return
+ }
+
+ durations.append(duration)
+ durations = durations.sorted(by: { $1 > $0 })
+ let key = mode == .timer ? "timerDurations" : "restDurations"
+ try await db.child("/timers/\(self.id)").updateChildValues([key: durations])
+
+ if (mode == .timer) {
+ self.workDurations = durations
+ } else {
+ self.restDurations = durations
+ }
+ }
+
+ @MainActor
+ func removeDuration(_ duration: Int, to mode: SharedTimerMode) async throws {
+ var durations = mode == .timer ? workDurations : restDurations
+ guard durations.contains(duration) else {
+ return
+ }
+
+ durations = durations.filter({ $0 != duration })
+ durations = durations.sorted(by: { $1 > $0 })
+
+ if durations.count == 0 {
+ durations = mode == .timer ? initialTimerData["timerDurations"] as! [Int] : initialTimerData["restDurations"] as! [Int]
+ }
+
+ let key = mode == .timer ? "timerDurations" : "restDurations"
+ try await db.child("/timers/\(self.id)").updateChildValues([key: durations])
+
+ if (mode == .timer) {
+ self.workDurations = durations
+ } else {
+ self.restDurations = durations
+ }
+ }
+
+ @MainActor
+ func toggleMode() async throws {
+ let nextMode = mode.next()
+ try await db.child("/timers/\(self.id)").updateChildValues(["mode": nextMode.rawValue])
+ mode = nextMode
+ }
+
+ // MARK: - 内部で使うメソッド
+
+ @MainActor
+ private func invokeTimer() {
+ let completionSubject = PassthroughSubject<Void, Never>()
+ let completionSubjectCancellable = completionSubject.first().sink(receiveValue: {
+ Task { @MainActor in
+ AppNotification.send(
+ title: NSLocalizedString("Timer finished", comment: "タイマー完了時に通知する文言(タイトル)"),
+ body: String(format: NSLocalizedString("Timer finished At", comment: "タイマー完了時に通知する文言(本文)"), self.finishedAt.formatted())
+ )
+
+ // 設定で通知時の音が有効化されていたら、鳴らす
+ if AppPreferences.shared.isEnableNotificationSound {
+ Sound.beep()
+ }
+
+ // ランダムな秒数を待って、タイマーをキャンセルする
+ // MPTimerモバイル版でも採用しているロジックだが、同時多発的にクライアントからデータの上書きが起こる可能性が有り、
+ // その同時多発状況を多少緩和するために入れている
+ try await Task.sleep(milliseconds: UInt64(Int.random(in: 0..<2000)) + UInt64(2000))
+
+ // タイマー完了後はキャンセルを行う
+ self.cancelTimer()
+
+ // タイマーモードであれば休憩モードへ、休憩モードであればタイマーモードへ状態を変更する
+ // 内部処理的にはスキップと同じなので、skipメソッドを呼び出している
+ // タイマーがまだ動作中のみ、この処理を行う(複数のタイマーで同じ処理を行う可能性があるため)
+ if self.status == .playing {
+ try? await self.skip()
+ }
+ }
+ })
+
+ Timer.publish(every: 0.1, on: .main, in: .common)
+ .autoconnect()
+ .sink { date in
+ if date.millisecondsSince1970 > self.finishedAt.millisecondsSince1970 {
+ completionSubject.send()
+ completionSubjectCancellable.cancel()
+ }
+ Task { @MainActor in
+ self.remainingTime = calcRemainingTime(self.finishedAt, status: self.status)
+ }
+ }.store(in: &timers)
+ }
+
+ @MainActor
+ private func cancelTimer() {
+ self.timers.forEach({ $0.cancel() })
+ self.remainingTime = nil
+ }
+
+ @MainActor
+ private func applySnapshot(_ snapshot: DataSnapshot) {
+ if let value = snapshot.value as? [String:Any] {
+ if let duration = value["duration"] as? Int {
+ self.duration = duration
+ }
+ if let mode = value["mode"] as? String {
+ self.mode = SharedTimerMode(rawValue: mode) ?? SharedTimerMode.timer
+ }
+ if let workDurations = value["timerDurations"] as? [Int] {
+ self.workDurations = workDurations
+ }
+ if let restDurations = value["restDurations"] as? [Int] {
+ self.restDurations = restDurations
+ }
+ if let finishedAt = value["finishedAt"] as? Int64 {
+ let finishedAt = Date(milliseconds: finishedAt)
+ self.finishedAt = finishedAt
+ self.remainingTime = calcRemainingTime(finishedAt, status: self.status)
+ }
+ if let status = value["status"] as? String {
+ self.status = SharedTimerStatus(rawValue: status) ?? SharedTimerStatus.stop
+ }
+ }
+ }
+
+}
+
+private func calcRemainingTime(_ finishedAt: Date, status: SharedTimerStatus) -> DateInterval? {
+ let now = Date()
+
+ guard finishedAt >= now && status == .playing else {
+ return nil
+ }
+
+ return DateInterval(start: Date(), end: finishedAt)
+}
A => MPTimerDesktop/Models/SharedTimerManager.swift +76 -0
@@ 1,76 @@
+//
+// SharedTimerManager.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/03/29.
+//
+
+import Foundation
+import SwiftUI
+import Combine
+
+@MainActor
+class SharedTimerManager: ObservableObject {
+
+ private var subscriptions = Set<AnyCancellable>();
+
+ @MainActor
+ static let shared = SharedTimerManager()
+
+ @AppStorage("timerId") var timerId: String = "" {
+ didSet {
+ // timerIdが更新されたら、タイマーの再生成を行う
+ if let db = FirebaseService.shared.db {
+ let timer = SharedTimer(timerId, withDb: db)
+ self.subscriptions.forEach({ $0.cancel() })
+
+ Task { @MainActor in
+ await timer.ensureTimer()
+ await timer.watch()
+ self.timer = timer
+
+ await timer.$remainingTime.sink {
+ self.remainingTime = $0?.formatHoursAndMinutes()
+ }.store(in: &subscriptions)
+
+ await timer.$status.sink {
+ self.status = $0
+ }.store(in: &subscriptions)
+
+ await timer.$mode.sink {
+ self.mode = $0
+ }.store(in: &subscriptions)
+ }
+ }
+ }
+ }
+
+ @Published private(set) var timer: SharedTimer?
+ @Published private(set) var mode: SharedTimerMode?
+ @Published private(set) var status: SharedTimerStatus?
+ @Published private(set) var remainingTime: String?
+
+ @MainActor
+ private init() {}
+
+ func newTimer(_ id: String = "") {
+ self.timerId = id == "" ? NanoID.new() : id
+ }
+
+ func newTimer(fromURL url: URL) {
+ let timerId = url.pathComponents[1]
+ // タイマーIDの存在チェックを行い、あれば設定する
+ FirebaseService.shared.db?.child("/timers/\(timerId)").getData(completion: { error, snapshot in
+ guard error == nil && snapshot.exists() else {
+ AppNotification.send(
+ title: NSLocalizedString("Failed to change timer", comment: "タイマーIDが存在しなかったときに通知で出す文言(本文)"),
+ body: String(format: NSLocalizedString("Invalid timerID", comment: "タイマーIDが存在しなかったときに通知で出す文言(本文)"), timerId)
+ )
+ return
+ }
+
+ self.timerId = timerId
+ AppNotification.send(title: NSLocalizedString("Changed timer", comment: "タイマーを変更したときに出す通知文言"), body: "")
+ })
+ }
+}
A => MPTimerDesktop/Preview Content/Preview Assets.xcassets/Contents.json +6 -0
@@ 1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
A => +56 -0
@@ 1,56 @@
//
// AddDurationView.swift
// MPTimerDesktop
//
// Created by 朝倉遼 on 2022/04/24.
//
import SwiftUI
extension HorizontalAlignment {
private enum ControlAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
return context[HorizontalAlignment.center]
}
}
static let controlAlignment = HorizontalAlignment(ControlAlignment.self)
}
struct AddDurationView: View {
var onPressAdd: (Int) -> Void
@State private var duration = 0.0
var body: some View {
HStack {
Slider(
value: $duration,
in: 0...60,
step: 5
).labelsHidden()
TextField("", value: $duration, formatter: NumberFormatter())
.multilineTextAlignment(.center)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 60)
.alignmentGuide(.controlAlignment) { $0[.leading] }
// 基本的にMenu Extraで表示している入力欄は操作できないっぽい、が、
// まれにテキスト入力が有効化する場合があるので、無効化しておく(設定画面を表示してからだと操作出来てしまう)
.disabled(true)
Stepper("", value: $duration, in: 0...60)
.labelsHidden()
Button(action: {
onPressAdd(Int(duration))
duration = 0
}) {
Image(systemName: "plus")
}.disabled(duration < 1.0)
}
}
}
//struct AddDurationView_Previews: PreviewProvider {
// static var previews: some View {
// AddDurationView()
// }
//}
A => +32 -0
@@ 1,32 @@
//
// ContentView.swift
// MPTimerDesktop
//
// Created by 朝倉遼 on 2022/03/23.
//
import SwiftUI
import Combine
import FirebaseAuth
import FirebaseAuthCombineSwift
struct ContentView: View {
@StateObject var firebaseService = FirebaseService.shared
@StateObject var appConfig = AppConfig.shared
var body: some View {
if appConfig.isMaintenance {
Text("Under maintenance")
} else if !appConfig.isAvailableAppVersion {
Text("Unusable version")
} else if firebaseService.isLoggedIn {
// initだとStateプロパティを書き換えられないのでここで行う
MainView()
} else {
Text("Logged in...").onAppear {
self.firebaseService.login()
}
}
}
}
A => +48 -0
@@ 1,48 @@
//
// DurationButton.swift
// MPTimerDesktop
//
// Created by 朝倉遼 on 2022/04/01.
//
import SwiftUI
struct DurationButton: View {
var onPress: () -> Void
var onPressDelete: () -> Void
var duration: Int
var mode: SharedTimerMode
@State var width: CGFloat = 0
var body: some View {
GeometryReader { reader in
ZStack {
Button(action: onPress) {
Text("\(duration)")
.font(.title)
.frame(width: reader.size.height, height: reader.size.height)
.background(mode == .timer ? Color.blue : Color.brown)
.clipShape(Circle())
}.buttonStyle(.borderless)
.onAppear {
width = reader.size.height
}
Button(action: onPressDelete) {
Image(systemName: "trash.circle.fill")
.resizable()
.scaledToFit()
.frame(width: reader.size.height * 0.35, height: reader.size.height * 0.35)
.foregroundColor(Color.white)
}.buttonStyle(.borderless)
.position(x: reader.size.height * 0.95, y: reader.size.height * 0.18)
}
}.frame(width: self.width)
}
}
//struct DurationButton_Previews: PreviewProvider {
// static var previews: some View {
// DurationButton(onPress: {}, onPressDelete: {}, duration: 100)
// }
//}
A => +55 -0
@@ 1,55 @@
//
// FeedbackButton.swift
// MPTimerDesktop
//
// Created by 朝倉遼 on 2022/04/30.
//
import SwiftUI
struct FeedbackButton: View {
var label: String
var feedbackLabel: String
var feedbackWaitMillis: UInt64
var feedbackColor: Color = Color.green
var action: () -> Void
@State var labelText = ""
@State var feedbackColorState = Color.clear
@State var initialWidth = 0.0
var body: some View {
Button(action: {
action()
labelText = feedbackLabel
feedbackColorState = feedbackColor
Task {
try await Task.sleep(nanoseconds: feedbackWaitMillis * 1_000_000)
labelText = label
feedbackColorState = Color.clear
}
}, label: {
GeometryReader { reader in
Text(labelText).onAppear {
self.initialWidth = reader.size.width
}.frame(width: initialWidth, alignment: .center)
}
})
.background(feedbackColorState.cornerRadius(4))
.onAppear {
labelText = label
}
}
}
//struct FeedbackButton_Previews: PreviewProvider {
// static var previews: some View {
// FeedbackButton(
// label: "Click",
// feedbackLabel: "Clicked!",
// feedbackWaitMillis: 1000,
// action: {}
// )
// }
//}
A => MPTimerDesktop/Views/Menu/MainView.swift +41 -0
@@ 1,41 @@
+//
+// MainView.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/03/25.
+//
+
+import SwiftUI
+
+struct MainView: View {
+ @StateObject var timerManager: SharedTimerManager
+
+ init() {
+ self._timerManager = StateObject(wrappedValue: SharedTimerManager.shared)
+ }
+
+ @Environment(\.openURL) var openURL
+
+ var body: some View {
+ VStack {
+ if let timer = timerManager.timer {
+ TimerView(timer: timer)
+ Divider()
+ TimerIdView(
+ timerId: timer.id,
+ onPressNew: { self.timerManager.newTimer() },
+ onPressShareToMacOS: {
+ Pasteboard.set("\(AppConfig.shared.urlScheme)://tid/\(self.timerManager.timerId)")
+ },
+ onPressShareToMobile: {
+ if let url = URL(string: "\(AppConfig.shared.urlScheme)://qr") {
+ openURL(url)
+ }
+ }
+ )
+ } else {
+ Text("No Timer")
+ }
+ }.padding(Theme.outerFramePadding)
+ }
+}
A => +49 -0
@@ 1,49 @@
//
// SelectDurationView.swift
// MPTimerDesktop
//
// Created by 朝倉遼 on 2022/03/28.
//
import SwiftUI
struct SelectDurationView: View {
var durations: [Int]
var onPressDuration: (Int) -> Void
var onPressDeleteDuration: (Int) -> Void
var onPressToggleMode: () -> Void
var mode: SharedTimerMode
var body: some View {
GeometryReader { reader in
HStack {
Button(action: {
onPressToggleMode()
}) {
Image(systemName: mode == .timer ? "timer" : "cup.and.saucer")
.resizable()
.scaledToFit()
.frame(width: reader.size.height * 0.8, height: reader.size.height * 0.8)
}.buttonStyle(.borderless)
ScrollView(.horizontal) {
HStack {
ForEach(durations, id: \.self) { duration in
DurationButton(onPress: {
onPressDuration(duration)
}, onPressDelete: {
onPressDeleteDuration(duration)
}, duration: duration, mode: mode)
}
}
}
}
}
}
}
//struct SelectDurationView_Previews: PreviewProvider {
// static var previews: some View {
// SelectDurationView(durations: [5, 10, 15, 20])
// }
//}
A => +40 -0
@@ 1,40 @@
//
// TimerIdView.swift
// MPTimerDesktop
//
// Created by 朝倉遼 on 2022/03/28.
//
import SwiftUI
struct TimerIdView: View {
var timerId: String
var onPressNew: () -> Void
var onPressShareToMacOS: () -> Void
var onPressShareToMobile: () -> Void
var body: some View {
HStack{
Button("Create new Timer") {
onPressNew()
}
FeedbackButton(
label: NSLocalizedString("Share to macOS", comment: "フィードバックボタンの初期ラベル"),
feedbackLabel: NSLocalizedString("Copied!", comment: "フィードバック時のラベル"),
feedbackWaitMillis: 1_000,
action: {
onPressShareToMacOS()
}
)
Button("Share to mobile") {
onPressShareToMobile()
}
}.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0))
}
}
//struct TimerIdView_Previews: PreviewProvider {
// static var previews: some View {
// TimerIdView()
// }
//}
A => +99 -0
@@ 1,99 @@
//
// TimerView.swift
// MPTimerDesktop
//
// Created by 朝倉遼 on 2022/03/25.
//
import SwiftUI
struct TimerView: View {
@ObservedObject var timer: SharedTimer
private var nextMode: SharedTimerMode {
if (timer.mode == .timer) {
return .rest
} else {
return .timer
}
}
var body: some View {
if timer.status == .playing {
GeometryReader { reader in
HStack {
Button(action: {
Task {
try? await self.timer.stop()
}
}) {
Text("STOP")
.font(.title2)
.frame(width: reader.size.height, height: reader.size.height)
.background(Color.red)
.clipShape(RoundedRectangle(cornerRadius: reader.size.height / 10, style: .continuous))
}.buttonStyle(.borderless)
Spacer()
VStack {
Text(timer.remainingTime?.formatHoursAndMinutes() ?? "")
.bold()
.font(.largeTitle)
Text(timer.finishedAt.formatted())
.bold()
.font(.caption)
}
Spacer()
Button(action: {
Task {
try? await self.timer.skip()
}
}) {
Text("SKIP")
.font(.title2)
.frame(width: reader.size.height, height: reader.size.height)
.background(Color.green)
.clipShape(RoundedRectangle(cornerRadius: reader.size.height / 10, style: .continuous))
}.buttonStyle(.borderless)
}
}
} else {
VStack {
AddDurationView(onPressAdd: { duration in
Task {
try? await timer.addDuration(duration, to: timer.mode)
}
})
Divider()
SelectDurationView(
durations: timer.mode == .timer ? timer.workDurations : timer.restDurations,
onPressDuration: { duration in
Task {
try? await timer.start(duration)
}
},
onPressDeleteDuration: { duration in
Task {
try? await timer.removeDuration(duration, to: timer.mode)
}
},
onPressToggleMode: {
Task {
try? await timer.toggleMode()
}
},
mode: timer.mode
)
}
}
}
}
//struct TimerView_Previews: PreviewProvider {
// static var previews: some View {
// TimerView()
// }
//}
A => MPTimerDesktop/Views/Preferences/GeneralSettingsView.swift +29 -0
@@ 1,29 @@
+//
+// GeneralSettingsView.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/05/14.
+//
+
+import SwiftUI
+
+struct GeneralSettingsView: View {
+
+ @State var isEnableNotificationSoud: Bool = false
+
+ var body: some View {
+ VStack {
+ Toggle("Enable the sound when the notification is completed", isOn: $isEnableNotificationSoud).onChange(of: isEnableNotificationSoud) {
+ AppPreferences.shared.isEnableNotificationSound = $0
+ }
+ }.onAppear {
+ self.isEnableNotificationSoud = AppPreferences.shared.isEnableNotificationSound
+ }
+ }
+}
+
+//struct GeneralSettingsView_Previews: PreviewProvider {
+// static var previews: some View {
+// GeneralSettingsView()
+// }
+//}
A => MPTimerDesktop/Views/Preferences/PreferencesView.swift +25 -0
@@ 1,25 @@
+//
+// PreferencesView.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/05/14.
+//
+
+import SwiftUI
+
+struct PreferencesView: View {
+ var body: some View {
+ TabView {
+ GeneralSettingsView()
+ .tabItem {
+ Label("General", systemImage: "gearshape")
+ }
+ }
+ }
+}
+
+//struct PreferencesView_Previews: PreviewProvider {
+// static var previews: some View {
+// PreferencesView()
+// }
+//}
A => MPTimerDesktop/Views/QR/QRView.swift +33 -0
@@ 1,33 @@
+//
+// QRView.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/28.
+//
+
+import SwiftUI
+
+struct QRView: View {
+
+ @MainActor
+ var fbURL: String {
+ let deepLink = "https://\(AppConfig.shared.deepLinkHost)/?tid=\(SharedTimerManager.shared.timerId)".urlEncoded
+ return "https://\(AppConfig.shared.fbDynamicLinkHost)/?link=\(deepLink)"
+ }
+
+ var body: some View {
+ VStack {
+ QRCode.generate(from: fbURL)
+ .interpolation(.none)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 180, height: 180)
+ }.navigationTitle("QR - \(SharedTimerManager.shared.timerId)")
+ }
+}
+
+//struct QRView_Previews: PreviewProvider {
+// static var previews: some View {
+// QRView()
+// }
+//}
A => MPTimerDesktop/en.lproj/Localizable.strings +10 -0
@@ 1,10 @@
+/*
+ Localizable.strings
+ MPTimerDesktop
+
+ Created by 朝倉遼 on 2022/04/29.
+
+*/
+
+"Invalid timerID" = "Invalid timerID: %@";
+"Timer finished At" = "At %@";
A => MPTimerDesktop/infrastructures/AppConfig.swift +40 -0
@@ 1,40 @@
+//
+// Config.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/28.
+//
+
+import Foundation
+import Combine
+
+class AppConfig: ObservableObject {
+
+ static private(set) var shared = AppConfig()
+
+ private init() {}
+
+ @Published var isMaintenance = false
+
+ @Published var isAvailableAppVersion = true
+
+ var urlScheme: String {
+ string(for: "urlScheme")
+ }
+
+ var deepLinkHost: String {
+ string(for: "deepLinkHost")
+ }
+
+ var fbDynamicLinkHost: String {
+ string(for: "fbDynamicLinkHost")
+ }
+
+ var buildNumber: Int {
+ Int(string(for: "CFBundleVersion")) ?? 0
+ }
+
+ private func string(for key: String) -> String {
+ Bundle.main.infoDictionary?[key] as! String
+ }
+}
A => MPTimerDesktop/infrastructures/AppNotification.swift +34 -0
@@ 1,34 @@
+//
+// Notification.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/28.
+//
+
+import Foundation
+import UserNotifications
+
+enum AppNotification {
+
+ static func send(title: String, body: String = "") {
+ let content = UNMutableNotificationContent()
+ content.title = title
+
+ if body.count > 0 {
+ content.body = body
+ }
+
+ // you can alse add a subtitle
+ // content.subtitle = "subtitle here... "
+
+ let uuidString = UUID().uuidString
+ let request = UNNotificationRequest(
+ identifier: uuidString,
+ content: content, trigger: nil)
+
+ let notificationCenter = UNUserNotificationCenter.current()
+ notificationCenter.requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ notificationCenter.add(request)
+ }
+
+}
A => MPTimerDesktop/infrastructures/Firebase.swift +108 -0
@@ 1,108 @@
+//
+// Firebase.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/03/23.
+//
+
+import Firebase
+import Combine
+
+class FirebaseService: ObservableObject {
+ static let shared = FirebaseService()
+
+ @Published private(set) var currentUser: User? {
+ didSet {
+ isLoggedIn = currentUser != nil
+ }
+ }
+ @Published private(set) var isLoggedIn = false
+
+ private var subscriptions = Set<AnyCancellable>()
+ private(set) var db: DatabaseReference?
+ private(set) var remoteConfig: RemoteConfig?
+
+ private init() {}
+
+ func configure() {
+ // Firebaseの設定を初期化する
+ // DEV / PRODで設定ファイルを分けてあり、それぞれのビルドコンフィギュレーションで必要なもののみがバンドルされるようにしている
+#if DEBUG
+ let googleServiceInfoFilePath = Bundle.main.path(forResource: "GoogleService-Info-DEV", ofType: "plist")
+#else
+ let googleServiceInfoFilePath = Bundle.main.path(forResource: "GoogleService-Info-PROD", ofType: "plist")
+#endif
+ let options = FirebaseOptions(contentsOfFile: googleServiceInfoFilePath!)
+ FirebaseApp.configure(options: options!)
+
+ db = Database.database().reference()
+
+ remoteConfig = RemoteConfig.remoteConfig()
+ let settings = RemoteConfigSettings()
+ settings.minimumFetchInterval = 0
+ remoteConfig?.configSettings = settings
+#if DEBUG
+ remoteConfig?.setDefaults(fromPlist: "RemoteConfigDefaults-DEV")
+#else
+ remoteConfig?.setDefaults(fromPlist: "RemoteConfigDefaults-PROD")
+#endif
+
+ isMaintenance.sink(receiveValue: { AppConfig.shared.isMaintenance = $0 }).store(in: &subscriptions)
+ minBuildNumber.sink(receiveValue: {
+ AppConfig.shared.isAvailableAppVersion = AppConfig.shared.buildNumber >= $0
+ }).store(in: &subscriptions)
+ }
+
+ func login() {
+ Auth.auth().signInAnonymously().sink(receiveCompletion: { completion in
+ switch completion {
+ case .finished:
+ print("signin finished")
+ case .failure:
+ print("singin failure")
+ }
+ }, receiveValue: { result in
+ self.currentUser = result.user
+ }).store(in: &subscriptions)
+ }
+
+ func logout() throws {
+ try Auth.auth().signOut()
+ self.currentUser = nil
+ }
+
+ private let TimerPublisher60sec = Deferred { Just(Date()) }.append(Timer.publish(every: 60, on: .main, in: .common).autoconnect())
+
+ private lazy var isMaintenance: AnyPublisher<Bool, Never> = {
+ let remoteConfig = FirebaseService.shared.remoteConfig
+ let subject = CurrentValueSubject<Bool, Never>(false)
+
+ // 1分毎にメンテ状態を取得する
+ TimerPublisher60sec.sink { _ in
+ remoteConfig?.fetchAndActivate { (status, error) -> Void in
+ guard status != .error else { return }
+ let isMaintenance = remoteConfig?["IS_MAINTENANCE"].boolValue
+ subject.send(isMaintenance ?? false)
+ }
+ }.store(in: &subscriptions)
+
+ return subject.eraseToAnyPublisher()
+ }()
+
+ private lazy var minBuildNumber: AnyPublisher<Int, Never> = {
+ let remoteConfig = FirebaseService.shared.remoteConfig
+ let subject = CurrentValueSubject<Int, Never>(0)
+
+ // 1分毎に最低利用可能ビルド番号を取得する
+ TimerPublisher60sec.sink { _ in
+ remoteConfig?.fetchAndActivate { (status, error) -> Void in
+ guard status != .error else { return }
+ let minDesktopBuildNumber = remoteConfig?["MIN_DESKTOP_BUILD_NUMBER"].numberValue.intValue
+ subject.send(minDesktopBuildNumber ?? 0)
+ }
+ }.store(in: &subscriptions)
+
+ return subject.eraseToAnyPublisher()
+ }()
+
+}
A => MPTimerDesktop/infrastructures/NanoID.swift +125 -0
@@ 1,125 @@
+//
+// NanoID.swift
+//
+// Created by Anton Lovchikov on 05/07/2018.
+// Copyright © 2018 Anton Lovchikov. All rights reserved.
+//
+
+import Foundation
+
+/// USAGE
+///
+/// Nano ID with default alphabet (0-9a-zA-Z_~) and length (21 chars)
+/// let id = NanoID.new()
+///
+/// Nano ID with default alphabet and given length
+/// let id = NanoID.new(12)
+///
+/// Nano ID with given alphabet and length
+/// let id = NanoID.new(alphabet: .uppercasedLatinLetters, size: 15)
+///
+/// Nano ID with preset custom parameters
+/// let nanoID = NanoID(alphabet: .lowercasedLatinLetters,.numbers, size:10)
+/// let idFirst = nanoID.new()
+/// let idSecond = nanoID.new()
+
+class NanoID {
+
+ // Shared Parameters
+ private var size: Int
+ private var alphabet: String
+
+ /// Inits an instance with Shared Parameters
+ init(alphabet: NanoIDAlphabet..., size: Int) {
+ self.size = size
+ self.alphabet = NanoIDHelper.parse(alphabet)
+ }
+
+ /// Generates a Nano ID using Shared Parameters
+ func new() -> String {
+ return NanoIDHelper.generate(from: alphabet, of: size)
+ }
+
+ // Default Parameters
+ private static let defaultSize = 21
+ private static let defaultAphabet = NanoIDAlphabet.urlSafe.toString()
+
+ /// Generates a Nano ID using Default Parameters
+ static func new() -> String {
+ return NanoIDHelper.generate(from: defaultAphabet, of: defaultSize)
+ }
+
+ /// Generates a Nano ID using given occasional parameters
+ static func new(alphabet: NanoIDAlphabet..., size: Int) -> String {
+ let charactersString = NanoIDHelper.parse(alphabet)
+ return NanoIDHelper.generate(from: charactersString, of: size)
+ }
+
+ /// Generates a Nano ID using Default Alphabet and given size
+ static func new(_ size: Int) -> String {
+ return NanoIDHelper.generate(from: NanoID.defaultAphabet, of: size)
+ }
+}
+
+fileprivate class NanoIDHelper {
+
+ /// Parses input alphabets into a string
+ static func parse(_ alphabets: [NanoIDAlphabet]) -> String {
+
+ var stringCharacters = ""
+
+ for alphabet in alphabets {
+ stringCharacters.append(alphabet.toString())
+ }
+
+ return stringCharacters
+ }
+
+ /// Generates a Nano ID using given parameters
+ static func generate(from alphabet: String, of length: Int) -> String {
+ var nanoID = ""
+
+ for _ in 0..<length {
+ let randomCharacter = NanoIDHelper.randomCharacter(from: alphabet)
+ nanoID.append(randomCharacter)
+ }
+
+ return nanoID
+ }
+
+ /// Returns a random character from a given string
+ static func randomCharacter(from string: String) -> Character {
+ let randomNum = Int(arc4random_uniform(UInt32(string.count)))
+ let randomIndex = string.index(string.startIndex, offsetBy: randomNum)
+ return string[randomIndex]
+ }
+}
+
+enum NanoIDAlphabet {
+ case urlSafe
+ case uppercasedLatinLetters
+ case lowercasedLatinLetters
+ case numbers
+
+ func toString() -> String {
+ switch self {
+ case .uppercasedLatinLetters, .lowercasedLatinLetters, .numbers:
+ return self.chars()
+ case .urlSafe:
+ return ("\(NanoIDAlphabet.uppercasedLatinLetters.chars())\(NanoIDAlphabet.lowercasedLatinLetters.chars())\(NanoIDAlphabet.numbers.chars())~_")
+ }
+ }
+
+ private func chars() -> String {
+ switch self {
+ case .uppercasedLatinLetters:
+ return "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ case .lowercasedLatinLetters:
+ return "abcdefghijklmnopqrstuvwxyz"
+ case .numbers:
+ return "1234567890"
+ default:
+ return ""
+ }
+ }
+}
A => MPTimerDesktop/infrastructures/Pasteboard.swift +18 -0
@@ 1,18 @@
+//
+// Pasteboard.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/29.
+//
+
+import Foundation
+import AppKit
+
+enum Pasteboard {
+
+ static func set(_ str: String) {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(str, forType: .string)
+ }
+
+}
A => MPTimerDesktop/infrastructures/QRCode.swift +31 -0
@@ 1,31 @@
+//
+// QRCode.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/28.
+//
+
+import Foundation
+import CoreImage
+import CoreImage.CIFilterBuiltins
+import AppKit
+import SwiftUI
+
+let context = CIContext()
+let filter = CIFilter.qrCodeGenerator()
+
+enum QRCode {
+
+ static func generate(from string: String) -> Image {
+ filter.message = Data(string.utf8)
+
+ if let outputImage = filter.outputImage {
+ if let cgimg = context.createCGImage(outputImage, from: outputImage.extent) {
+ return Image(nsImage: NSImage(cgImage: cgimg, size: NSSize(width: 180, height: 180)))
+ }
+ }
+
+ return Image(systemName: "xmark.circle")
+ }
+
+}
A => MPTimerDesktop/infrastructures/Sound.swift +51 -0
@@ 1,51 @@
+//
+// Sound.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/05/14.
+//
+
+import Foundation
+import AppKit
+
+// 音は、/System/Library/Sounds にある
+
+public extension NSSound {
+ static let basso = NSSound(named: .basso)
+ static let blow = NSSound(named: .blow)
+ static let bottle = NSSound(named: .bottle)
+ static let frog = NSSound(named: .frog)
+ static let funk = NSSound(named: .funk)
+ static let glass = NSSound(named: .glass)
+ static let hero = NSSound(named: .hero)
+ static let morse = NSSound(named: .morse)
+ static let ping = NSSound(named: .ping)
+ static let pop = NSSound(named: .pop)
+ static let purr = NSSound(named: .purr)
+ static let sosumi = NSSound(named: .sosumi)
+ static let submarine = NSSound(named: .submarine)
+ static let tink = NSSound(named: .tink)
+}
+
+public extension NSSound.Name {
+ static let basso = NSSound.Name("Basso")
+ static let blow = NSSound.Name("Blow")
+ static let bottle = NSSound.Name("Bottle")
+ static let frog = NSSound.Name("Frog")
+ static let funk = NSSound.Name("Funk")
+ static let glass = NSSound.Name("Glass")
+ static let hero = NSSound.Name("Hero")
+ static let morse = NSSound.Name("Morse")
+ static let ping = NSSound.Name("Ping")
+ static let pop = NSSound.Name("Pop")
+ static let purr = NSSound.Name("Purr")
+ static let sosumi = NSSound.Name("Sosumi")
+ static let submarine = NSSound.Name("Submarine")
+ static let tink = NSSound.Name("Tink")
+}
+
+enum Sound {
+ static func beep() {
+ NSSound.funk?.play()
+ }
+}
A => MPTimerDesktop/infrastructures/Theme.swift +12 -0
@@ 1,12 @@
+//
+// Theme.swift
+// MPTimerDesktop
+//
+// Created by 朝倉遼 on 2022/04/28.
+//
+
+import Foundation
+
+enum Theme {
+ static let outerFramePadding: CGFloat = 12
+}
A => MPTimerDesktop/ja.lproj/Localizable.strings +36 -0
@@ 1,36 @@
+/*
+ Localizable.strings
+ MPTimerDesktop
+
+ Created by 朝倉遼 on 2022/04/29.
+
+*/
+
+/** アプリ本体 */
+
+"No Timer" = "タイマーがありません";
+"Logged in..." = "ログイン中…";
+"STOP" = "停止";
+"SKIP" = "スキップ";
+"Create new Timer" = "新規タイマー";
+"Share to macOS" = "macOS向けに共有";
+"Share to mobile" = "スマホ向けに共有";
+"Quit" = "終了";
+
+"Invalid timerID" = "存在しないタイマーです。: %@";
+"Failed to change timer" = "タイマーの変更に失敗";
+
+"Timer finished" = "タイマー終了";
+"Timer finished At" = "完了: %@";
+
+"Under maintenance" = "メンテナンス中";
+"Unusable version" = "使用不可能なバージョン";
+
+"Changed timer" = "タイマーを変更しました";
+
+"Preferences" = "設定";
+
+/** 設定 */
+
+"General" = "一般";
+"Enable the sound when the notification is completed" = "通知完了時の音を有効化する";
A => renovate.json +5 -0
@@ 1,5 @@
+{
+ "extends": [
+ "config:base"
+ ]
+}