~mk2/mptimer-desktop

dd15fa415bbcdc9dde88a4cc30ee54798147f7ce — asakura 11 months ago main
commit for pub
49 files changed, 1908 insertions(+), 0 deletions(-)

A .gitignore
A Documents/icon.png
A Documents/website/AppStoreImage.png
A Documents/website/app-image-1.png
A Documents/website/app-image-2.png
A MPTimerDesktop/Assets.xcassets/AccentColor.colorset/Contents.json
A MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/1024.png
A MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/128.png
A MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/16.png
A MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/256.png
A MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/32.png
A MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/512.png
A MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/64.png
A MPTimerDesktop/Assets.xcassets/AppIcon.appiconset/Contents.json
A MPTimerDesktop/Assets.xcassets/Contents.json
A MPTimerDesktop/Extensions/Combine+Pairwise.swift
A MPTimerDesktop/Extensions/Date+Milliseconds.swift
A MPTimerDesktop/Extensions/DateInterval+formats.swift
A MPTimerDesktop/Extensions/NSAttributedString+formats.swift
A MPTimerDesktop/Extensions/String+urlEncoding.swift
A MPTimerDesktop/Extensions/Task+SleepInMilliseconds.swift
A MPTimerDesktop/Info.plist
A MPTimerDesktop/MPTimerDesktopApp.swift
A MPTimerDesktop/Models/AppPreferences.swift
A MPTimerDesktop/Models/SharedTimer.swift
A MPTimerDesktop/Models/SharedTimerManager.swift
A MPTimerDesktop/Preview Content/Preview Assets.xcassets/Contents.json
A MPTimerDesktop/Views/Menu/AddDurationView.swift
A MPTimerDesktop/Views/Menu/ContentView.swift
A MPTimerDesktop/Views/Menu/DurationButton.swift
A MPTimerDesktop/Views/Menu/FeedbackButton.swift
A MPTimerDesktop/Views/Menu/MainView.swift
A MPTimerDesktop/Views/Menu/SelectDurationView.swift
A MPTimerDesktop/Views/Menu/TimerIdView.swift
A MPTimerDesktop/Views/Menu/TimerView.swift
A MPTimerDesktop/Views/Preferences/GeneralSettingsView.swift
A MPTimerDesktop/Views/Preferences/PreferencesView.swift
A MPTimerDesktop/Views/QR/QRView.swift
A MPTimerDesktop/en.lproj/Localizable.strings
A MPTimerDesktop/infrastructures/AppConfig.swift
A MPTimerDesktop/infrastructures/AppNotification.swift
A MPTimerDesktop/infrastructures/Firebase.swift
A MPTimerDesktop/infrastructures/NanoID.swift
A MPTimerDesktop/infrastructures/Pasteboard.swift
A MPTimerDesktop/infrastructures/QRCode.swift
A MPTimerDesktop/infrastructures/Sound.swift
A MPTimerDesktop/infrastructures/Theme.swift
A MPTimerDesktop/ja.lproj/Localizable.strings
A renovate.json
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  => MPTimerDesktop/Views/Menu/AddDurationView.swift +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  => MPTimerDesktop/Views/Menu/ContentView.swift +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  => MPTimerDesktop/Views/Menu/DurationButton.swift +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  => MPTimerDesktop/Views/Menu/FeedbackButton.swift +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  => MPTimerDesktop/Views/Menu/SelectDurationView.swift +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  => MPTimerDesktop/Views/Menu/TimerIdView.swift +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  => MPTimerDesktop/Views/Menu/TimerView.swift +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"
  ]
}