見出し画像

Xcodeで猫が休憩を促してくるmacOSアプリを作ろう

こんにちは、ハナミズキです。MacにインストールされていたXcodeを使って、休憩を促してくる猫(その名も”仕事監視Cat”)を作ってみましょう。これはmacOS向けのジョークアプリ作成のチュートリアルコンテンツになります。

猫監視モニタ通知0130

はじめに

『ちょっとSwiftを使ってコーディングしてみたい。』『Xcodeってどんなものかちょっと触れてみたい。』というような初心者向けになっています。

また、Mac App Storeへの公開・リリース方法などには触れておりません。あくまで個人PC内で開発を楽しむためのチュートリアルとなっておりますのでご了承ください。

利用バージョン
・Xcode Version 12.3 
・Apple Swift version 5.3.2

どんなジョークアプリなの?

簡単に言うと、長時間キーボードやマウスを触り続けていると、休憩しろーって促してくれるアプリです。肩こりやストレッチするタイミングなどに使えます。

以下は簡単に仕組みを図解整理してみました。

休憩しろ猫通知の仕組み (1)


事前準備するもの

・XcodeがインストールされたMac
・[画像]アプリアイコン
・[画像]休憩中の猫
・[画像]仕事中なのを寝て待っている猫
・[画像]仕事中なのを伸び伸びしながら待っている猫(0:30経過で表示切替)
・[画像]ソワソワしている猫(1:00経過で表示切替)
・[画像]走っている猫もしくは休憩を促してくる猫(1:30経過で表示切替)

今回のプロジェクトで私が用意した画像は以下に格納しております。

事前準備した画像: https://github.com/hanamizuki10/cat-urging-a-break-for-mac/tree/master/images

1.Xcodeでプロジェクトの作成

Xcodeを起動し、Xcodeのメニューより[File] - [New] - [Project...] を選択して新しいプロジェクトを作成 します。

スクリーンショット 2020-11-13 13.46.14

macOSを選択し、Appを洗濯した状態でNextボタンをクリックします。

スクリーンショット 2020-11-13 15.56.59

 Product Nameに  "cat-urging-a-break-for-mac" を入力してNextボタンをクリックします。

スクリーンショット 2020-11-13 15.58.49

プロジェクトを保存するフォルダを指定して Createボタンをクリックします。

スクリーンショット 2020-11-13 15.59.36

以上でプロジェクト作成完了です。

スクリーンショット 2020-12-25 13.43.42

トップの実行ボタンをクリックすることで、ソースコードがビルドされデバック実行ができます。基本的に、こちらのデバック実行をしながら動きを確認していきましょう。

なお、ビルドしたら以下にフォルダにappファイルができます。

スクリーンショット 2020-12-25 13.58.41

このapp拡張子のファイルがアプリケーションの本体です。

右クリックすると、以下のように ”Show in Finder” というメニューが出てきます。

スクリーンショット 2020-12-25 13.59.40

これをクリックするとFinderが開き、appファイルを物理的にアプリケーションフォルダにドラック&ドロップでインストールできます。なおアプリケーションフォルダから削除すればアンインストールできます。

開発が終わった後、アプリを気軽に呼び出したい時は参考にしてみてください。

2.画像リソースをプロジェクトに登録

アプリで使用する画像をアセットカタログ( Assets Catalog )に追加していきます。

プロジェクトを新規に作成すると、Assets.xcassetsというフォルダ名があるので、クリックして事前準備した画像をドラックアンドドロップしましょう。

スクリーンショット 2020-11-20 16.43.46

アプリアイコンを登録するときは、AppIconを事前にクリックした上で、画像を一括でドラックアンドドロップすると気持ちよく登録できます。

スクリーンショット 2020-11-20 16.45.54

3.イベント発生時刻の記録するようにする

まず、マウスやキーボードなど様々なイベントが発生した時刻をアプリ内部で記録する仕組みを作ります。

// AppDelegate.swift

// イベント発生時時間
var eventDate:Date = Date()

// イベント監視処理を登録
func addMonitorForEvents() {
   // ウィンドウ外でイベントが発生するたびに検知してイベント日付を更新する
   NSEvent.addGlobalMonitorForEvents(matching: .any, handler: { (event) in
       self.eventDate = Date()
   })
   // ウィンドウ内でイベントが発生するたびに検知してイベント日付を更新する
   NSEvent.addLocalMonitorForEvents(matching: .any, handler: { (event) in
       self.eventDate = Date()
       return event
   })
}

次に、イベント監視処理をアプリ起動時(applicationDidFinishLaunching)に呼び出せる様に実装します。

// AppDelegate.swift

func applicationDidFinishLaunching(_ aNotification: Notification) {
   // イベント監視処理を登録
   addMonitorForEvents()

   // Create the SwiftUI view that provides the window contents.
   let contentView = ContentView()
   //
   // …省略 
   //
}

定義されているイベントは以下の様に数が多いため、今回はNSEvent.EventTypeMaskに対して `.any` を指定して、全てのイベントを検出する様に設定しております。

・ leftMouseDown: 左マウスダウンイベント
・ leftMouseUp: 左マウスアップイベント
・ rightMouseDown: 右マウスダウンイベント
・ rightMouseUp: 右マウスアップイベント
・ mouseMoved: マウスで移動したイベント
・ leftMouseDragged: 左マウスドラッグイベント
・ rightMouseDragged: 右マウスドラッグイベント
・ mouseEntered: マウスで入力されたイベント
・ mouseExited: マウスで終了したイベント
・ keyDown: キーダウンイベント
・ keyUp: キーアップイベント
・ flagsChanged: フラグが変更されたイベント
・ appKitDefined: AppKitで定義されたイベント
・ systemDefined: システム定義のイベント
・ applicationDefined: アプリ定義のイベント
・ periodic: 定期的なイベント
・ cursorUpdate: カーソル更新イベント
・ scrollWheel: スクロールホイールイベント
・ tabletPoint: タブレットポイントイベント
・ tabletProximity: タブレット近接イベント
・ otherMouseDown: 3次マウスダウンイベント
・ otherMouseUp: 3次マウスアップイベント
・ otherMouseDragged: マウスがドラッグした3次イベント
・ gesture: 一般的なジェスチャイベント
・ magnify: 拡大ジェスチャイベント
・ swipe: スワイプジェスチャイベント
・ rotate: 回転ジェスチャイベント
・ beginGesture: ジェスチャー開始イベント
・ endGesture: ジェスチャー終了イベント
・ smartMagnify: スマートズームジェスチャイベント
・ pressure: 圧力変化イベント
・ directTouch: タッチイベント
・ changeMode: 変更モードイベント
・ any: あらゆるタイプのイベント

詳細は以下を参考にして下さい。

補足ですが、このコードだけではキーボード入力関連のイベント監視はできません。なぜならば、キーボード監視ができるとユーザーの個人情報を盗むプログラムが作れるからです。よって様々な制限があります。

3.1.(補足)キーボード監視を有効にする方法

正直マウス監視だけで十分だったりするのですが、キーボード監視をしたい場合は参考にしてください。

まず初めに、アプリのサンドボックス化を無効化します。なお、サンドボックス化されていないアプリは基本的に不正なアプリと判断され、MacのApp Storeに配布できません。個人の環境で楽しみましょう。

cat_urging_a_break_for_mac.entitlementsファイルを開き App Sandbox を NOに変更し、Xcodeのメニューより[Product] - [Clean Build Folder]を実行してください。

スクリーンショット 2020-11-13 15.09.26

サンドボックスについての詳細は以下リンク先の情報を参考にしてください。

サンドボックスを無効にしたら、次は、信頼できるアクセシビリティクライアントアプリであるかをチェックする処理を導入します。キーボードのイベント監視は、信頼できるアクセシビリティクライアントでなければ、イベント検知できない為、必ず必要です。

以下のコードをイベント監視処理(addMonitorForEvents)呼び出し前に埋め込んでください。

// ウィンドウ外でキーボードイベントも検知したい場合
// [システム環境設定]->[セキュリティーとプライバシー]->[アクセシビリティ]で有効にするのを促す
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true]
let accessEnabled = AXIsProcessTrustedWithOptions(options)

if !accessEnabled {
   let alert = NSAlert()
   alert.messageText = "cat-urging-a-break-for-mac.app"
   alert.informativeText = "システム環境設定でcat-urging-a-break-for-mac.app(このダイアログの後ろにあるダイアログを参照)のアクセシビリティを有効にして、このアプリを再度起動する必要があります"
   alert.addButton(withTitle: "OK")
   alert.runModal()
   // 設定できたらアプリを再起動しないと意味ないためアプリ強制終了
   NSApplication.shared.terminate(self)
}

このコードを埋め込むことで、セキュリティとプライバシーのアクセシビリティに当アプリが追加されます。

スクリーンショット 2020-11-27 18.42.46

4.通知の許可リクエストを実行するロジックを追加

このアプリでは指定の時間になったら、エンドユーザに通知を送る仕様です。

なお、エンドユーザが通知許可してくれないと、いくら通知を出すコードを書いても、通知イベントは発生しません。

よって、事前にユーザーに通知許可を要求するリクエストを中身に実装します。

// AppDelegate.swift

import Cocoa
import SwiftUI
import UserNotifications    // 通知許可リクエスト送信のために追加※class外(ソースの一上)に追加


// ユーザーに通知許可を要求するメソッドをclass内に追加
func requestNotificationAuthorization() {
   let center = UNUserNotificationCenter.current()
   center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in
       if granted {
           print("通知許可") // 動作確認のためログに出力
       } else {
           print("通知拒否")
       }
   }
}


func applicationDidFinishLaunching(_ aNotification: Notification) {
   // イベント監視処理を登録
   addMonitorForEvents()

   // ユーザへ通知許可を要求
   requestNotificationAuthorization()

   // Create the SwiftUI view that provides the window contents.
   let contentView = ContentView()
   //
   // …省略 
   //
}

5.画面構築

Xcodeは便利なことに、コーディングしつつリアルタイムでプレビューを確認できる機能が提供されています。

プレビューを確認する方法をここでは記載します。

まずプレビューを確認したいファイル(ContentView.swift)をクリックしてください。

右側にCanvasが表示されていない場合は、右上のメニューよりCanvasをクリックします。

スクリーンショット 2020-11-06 17.10.26

次にResumeボタンをクリックする。

スクリーンショット 2020-12-04 14.17.43

すると、プレビューが見れるようになるので、まずは望んだレイアウトをプレビューを確認しからボタンなど設置していきます。

設置する際には右上にある+ボタンを押す事でObject Libraryを表示することが可能です。

スクリーンショット 2020-12-04 14.14.25

ボタンやラベルなど様々なコントロールをドラック&ドロップでプレビューに持っていくことでレイアウトを整えることができるようになります。

スクリーンショット 2020-12-04 14.15.04

このドラックアンドロップやプロパティの変更をすることで、自動的にコードが生成されます。もちろん、この機能を使わずに直接レイアウトをコーディングすることもできます。

プレビューの下側もしくは、右側には追加したオブジェクトのプロパティをいじることができる機能が用意されているため、うまく活用しながら理想のレイアウトになるように調整していきます。

スクリーンショット 2020-12-04 14.38.48

以下は実際にレイアウトだけを調整した例です。

スクリーンショット 2020-12-18 18.06.44

・Horizontal Stack(配置系、横にオブジェクトを配置する)
・Divider(区切り線)
・Vertical Stack(配置系、縦にオブジェクトを配置する)
・Button(ボタン、※注意※事前にボタンクリックした時に反応するアクションをコーディングしておく必要がある)
・Text(テキスト文字列)
・Image(画像、※注意※事前にリソースをプロジェクトに登録しておく必要あり、なお、画像サイズ調整に関係するresizableやscaledToFitを埋め込みたい時はコーディングが必要)

この時に、埋め込まれたコードは以下の通りです。

// ContentView.swift

import SwiftUI

struct ContentView: View {
   var body: some View {
       VStack() {
           Text("仕事監視Cat")
               .font(.title)
               .multilineTextAlignment(.leading)
           Divider()
           HStack {
               VStack {
                   VStack {
                       Text("ステータス")
                           .font(.caption)
                       Text("仕事中")
                           .font(.body)
                   }
                   .padding(.vertical)
                   VStack {
                       Text("仕事経過時間")
                           .font(.caption)
                       Text("00:00:00")
                           .font(.body)
                   }
                   .padding(.bottom)
               }
               .frame(width: 100.0)
               VStack {
                   VStack {
                       Image("coffeeblakecat1")
                           .resizable()    // 画像サイズをフレームサイズに合わせる
                           .scaledToFit()     //縦横比を維持しながらフレームに収める
                           .frame(width: 100.0, height: 100.0)
                       Text("お仕事がんばってにゃ〜!")
                           .font(.caption)
                           .frame(height: 50.0)
                   }
                   .frame(width: 250.0)
               }
           }
           Divider()
           HStack {
               Text("休憩経過時間->")
                   .font(.caption)
               Text("00:00:00")
                   .font(.caption)
               Text(", 最後にイベント検知した時刻->")
                   .font(.caption)
               Text("00:00:00")
                   .font(.caption)
           }
       }
       .frame(width: 400.0)
   }
}


struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
       ContentView()
   }
}

5.1.画面構築補足(プレビューに対する注意事項)

アプリのProduct Nameが、日本語だとプレビューは動きません。
以下のエラーメッセージが出てきます。

Unknown preview provider "ContentView_Previews"エラー

もし、このエラーが出ている場合、解決方法は、Product Nameを英語にする事です。

スクリーンショット 2020-11-06 17.10.09

※もしも、メインメニューバーに表示されている表記を日本語に変えたくてProductNameを日本語に変更していたというような過去がある場合は、Info.plist > Builed Nameを変更するだけで解決します。

スクリーンショット 2020-11-06 17.22.20

6.変数を用意して切り替える

動的に表示を変更したい部分に対応した変数を用意して変数の情報を表示するように対応します。

現時点で必要な画面描画イベントは以下の6イベントです。

 ・ステータス(仕事中or休憩中) の表示と切り替え
 ・仕事経過時間 の表示と切り替え
 ・猫画像の表示と切り替え
 ・猫の一言呟きの表示と切り替え
 ・休憩経過時間 の表示と切り替え
 ・イベント検出した時刻の表示と切り替え
実装順番は大事です。まずは最初に”イベント検出した時刻”をプログラム内で管理できるようにすることで、その後の制御の判断が可能になります。改めて、描画切り替えの制御のために、以下の順番で実装していきます。

 1.イベント検出した時刻の表示と切り替え
 2.仕事経過時間 の表示と切り替え
 3.休憩経過時間 の表示と切り替え
 4.ステータス(仕事中or休憩中) の表示と切り替え
 5.猫の一言呟きの表示と切り替え
 6.猫画像の表示と切り替え

6.1.イベント検出した時刻の表示と切り替え

イベント検出した時刻は、AppDelegate.swiftファイル内の変数「eventDate」に格納しています。

今回は、initメソッドを活用し、ContentView.swiftからAppDelegateクラスで定義している変数にアクセスできる環境を用意します。

// ContentView.swift 

var appDelegate: AppDelegate
init() {
   self.appDelegate = NSApplication.shared.delegate as! AppDelegate
}

上記のようにすることで、以下のようにイベント検出した時刻が格納された変数を呼び出すことができます。

self.appDelegate.eventDate

なお、eventDateはDate型です。文字列型ではないため、そのままでは利用できません。

Date型を"00:00:00"形式の文字列型に変換してあげるメソッドを追加しましょう。

// ContentView.swift 

func ToStringTime(date:Date)->String{
   let formatter = DateFormatter()
   formatter.locale = Locale.current
   formatter.calendar = Calendar(identifier: .japanese)
   formatter.dateFormat = "HH:mm:ss"
   return formatter.string(from: date)
}

次に、変更を加えたいポイントを確認します。

以下の画面キャプチャでいう赤枠の範囲です。

スクリーンショット 2020-12-18 18.07.05

こちらのロジックを以下のように変更を加えます。

// ContentView.swift

Text(", 最後にイベント検知した時刻->")
   .font(.caption)
Text("00:00:00")
   .font(.caption)
   
   ↓
   ↓

Text(", 最後にイベント検知した時刻->")
   .font(.caption)
Text(ToStringTime(date: self.appDelegate.eventDate))
   .font(.caption)

すると、イベント検出時刻を表示してくれるようになります。

6.2.仕事経過時間 の表示と切り替え

仕事経過時間を保管する変数を先に定義します。

// ContentView.swift 

// 画面構築に利用する変数:仕事経過時間(秒数)
@State var workTimeInterval:Int = 0

定義するだけじゃなく、一定時間置きに状態を更新する仕組みを追加する必要があります。以下は、1秒おきに実行されるタイマーです。このタイマー関数の中でまずは時間をインクリメントする機構を作りましょう。

// ContentView.swift 

// 作業監視モニター
var workMonitoringTimer: Timer {
   Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {_ in
       // TODO:休憩中、仕事中の判断は後ほど再対応
       self.workTimeInterval += 1
   }
}

タイマー関数を作るだけだと動かないので、以下のようにbodyレイアウトの中にてタイマーが呼び出されるように追加します。

// ContentView.swift 

// レイアウト
var body: some View {
   VStack() {
     // 省略
   }
   .frame(width: 400.0)
   .onAppear(perform: {
       _ = self.workMonitoringTimer
   })
}

なお、workTimeIntervalはInt型です。文字列型ではないため、そのままでは利用できません。

Int型を"00:00:00"形式の文字列型に変換してあげるメソッドを追加しましょう。

// ContentView.swift 

func ToStringTime(timeInterval interval:Int)->String{
   let calendar = Calendar(identifier: .japanese)
   let time000 = calendar.startOfDay(for: Date())
   let dispTime = Calendar.current.date(byAdding: .second, value: interval, to: time000)!
   let formatter = DateFormatter()
   formatter.locale = Locale.current
   formatter.calendar = Calendar(identifier: .japanese)
   formatter.dateFormat = "HH:mm:ss"
   return formatter.string(from: dispTime)
}

次に、変更を加えたいポイントを確認します。

以下の画面キャプチャでいう赤枠の範囲です。

スクリーンショット 2020-12-18 18.10.50

こちらのロジックを以下のように変更を加えます。

// ContentView.swift

Text("仕事経過時間")
   .font(.caption)
Text("00:00:00")
   .font(.body)
  
  ↓
  ↓

Text("仕事経過時間")
   .font(.caption)
Text(ToStringTime(timeInterval: self.workTimeInterval))
   .font(.body)

すると、仕事経過時間が1秒ずつカウントアップされているような形に時刻を表示してくれるようになります。

※なお、この時点では、休憩中/仕事中の条件が入っていないです。この部分に関する実装方法については”6.4 ステータス(仕事中or休憩中) の表示と切り替え ”にて解説します。

6.3.休憩経過時間 の表示と切り替え

休憩時間も仕事経過時間と同じように、時間を保管する変数を先に定義します。

// ContentView.swift

// 画面構築に利用する変数:休憩経過時間(秒数)
@State var breakTimeInterval:Int = 0

同時に、仕事経過時間の利用に使ったタイマー内に同じように埋め込み、1秒おきに時間をインクリメントするようにしましょう。

// ContentView.swift

// 作業監視モニター
var workMonitoringTimer: Timer {
   Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {_ in
       // TODO:休憩中、仕事中の判断は後ほど再対応
       self.workTimeInterval += 1
       self.breakTimeInterval += 1
   }
}

次に、変更を加えたいポイントを確認します。

以下の画面キャプチャでいう赤枠の範囲です。

スクリーンショット 2020-12-18 18.16.30

こちらのロジックを以下のように変更を加えます。

// ContentView.swift

Text("休憩経過時間->")
   .font(.caption)
Text("00:00:00")
   .font(.caption)
  
  ↓
  ↓

Text("休憩経過時間->")
   .font(.caption)
Text(ToStringTime(timeInterval: self.breakTimeInterval))
   .font(.caption)

すると、仕事経過時間と同様に1秒ずつカウントアップされているような形に時刻を表示してくれるようになります。

6.4.ステータス(仕事中or休憩中) の表示と切り替え

まず、仕事中と休憩中の状態を切り替えるための基本的な変数を追加します。

// ContentView.swift

// 画面構築に利用する変数:ステータス
@State var statusText:String = "仕事中"

変更箇所は以下のポイントです。

スクリーンショット 2020-12-18 18.17.32

以下のようにロジックを変更しましょう。

// ContentView.swift

Text("ステータス")
   .font(.caption)
Text("仕事中")
   .font(.body)

 ↓
 ↓

Text("ステータス")
   .font(.caption)
Text(self.statusText)
   .font(.body)

次に、仕事時間と休憩時間の判断ロジックを作り込みます。

まず仕事中であるかどうか判断が必要です。今回、10分を区切りにしています。マウスやキーボードなど様々なMacPC上のイベントが最後に発生してから、10分以上何もイベントを受診しなければ、「休憩中」と判断するようなロジックを追加します。

// ContentView.swift

// 定数:仕事中か休憩中かを判断するキーとなる時間(秒):10分
let WORK_OR_BREK_JUDGE_TIME: Double = (10 * 60)

// 現在動作中であるかどうか確認する
//(最後にイベント経過してから指定時間以内ならマウスやキーボード動かし作業中)
func isWorking()->Bool{
   // 最後にイベントが発生してから経過した時間を取得する
   let timeIntervalSince = appDelegate.eventDate.timeIntervalSinceNow
   if ( -timeIntervalSince < WORK_OR_BREK_JUDGE_TIME ) {
       // 前回のイベント発生時と比べて10分未満である。
       return true
   }
   return false
}

次にこの判断条件メソッドを利用して仕事中であるか休憩中であるかを切り替えるロジックを実装します。

場所は作業監視モニターに埋め込みます。

変更前ロジック:

// ContentView.swift

// 作業監視モニター
var workMonitoringTimer: Timer {
   Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {_ in
       // TODO:休憩中、仕事中の判断は後ほど再対応
       self.workTimeInterval += 1
       self.breakTimeInterval += 1
   }
}

変更後ロジック:

// ContentView.swift

// 作業監視モニター
var workMonitoringTimer: Timer {
   Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {_ in
       if (isWorking()) {
           self.workTimeInterval += 1
           if(self.statusText == "休憩中") {
               // 状態が休憩中から仕事中になった直後
               self.breakTimeInterval = 0
               self.workTimeInterval = 0
               self.statusText = "仕事中"
           }
       } else if(self.statusText == "仕事中") {
           // 状態が仕事中から休憩中になった直後
           self.breakTimeInterval = 0
           self.statusText = "休憩中"
       } else {
           self.breakTimeInterval += 1
       }
   }
}

これで10分以上パソコンを放置すると、休憩中のステータスとなり、休憩経過時間がカウントアップ。少しでもパソコンを触っていると仕事中のステータスとなり、仕事経過時間がカウントアップする仕組みを組み込むことができました。

6.5.猫の一言呟きの表示と切り替え

仕事時間の経過に合わせて猫の呟きを変更するロジックを追加します。

その上で、まずはどんな呟きをしてほしいか以下に整理します。

// 仕事レベル0 (休憩中)の場合
"(休憩は良いことにゃ〜リフレッシュにゃ〜)"

// 仕事レベル1 (仕事中:仕事開始直後)の場合
"(お仕事がんばってにゃ〜ねむねむにゃ…)"

// 仕事レベル2 (仕事中:仕事開始から30分経過後)の場合
"(お仕事に集中することは良いことにゃ〜!)"

// 仕事レベル3 (仕事中:仕事開始から1時間経過後)の場合
"(結構、長い間仕事してるにゃね?\n集中力すごいのにゃ〜)",

// 仕事レベル4 (仕事中:仕事開始から1時間30分経過後)の場合
"(なんか長時間仕事しすぎにゃ!\nそれじゃあ肩凝るにゃ!\nそろそろ構えにゃ〜!!)"

仕事レベルという表現をしましたが、仕事の経過時間別にレベルアップして、レベルアップした情報を元に呟きが切り替わるように実装します。

まずは、仕事レベルの変数と猫の呟きを定義します。

// ContentView.swift

// 画面構築に利用する変数:仕事レベル
@State var workLevel:Int = 1

// 画面構築に利用する変数:猫の呟き
@State var catTweet: String = "(お仕事がんばってにゃ〜ねむねむにゃ…)"

次に猫の呟きと判断条件に利用する呟き情報を保持した構造体の宣言します。

// ContentView.swift


var judgeConfigs: [WorkJudge] = []
// 状態変更に関係する判断設定
class WorkJudge {
   // 経過時間
   var workTime: Double = 0
   // 経過時間を経過時に呟く内容
   var catTweet: String = ""
   init(workTime:Double, catTweet: String) {
       self.workTime = workTime
       self.catTweet = catTweet
   }
}

初回画面構築時に登録します。

// ContentView.swift

init() {
   self.appDelegate = NSApplication.shared.delegate as! AppDelegate

   // 仕事レベル0(休憩中)
   let judge0 = WorkJudge(workTime: 0, catTweet: "(休憩は良いことにゃ〜リフレッシュにゃ〜)")
   // 仕事レベル1(仕事開始した直後)〜30分以内
   let judge1 = WorkJudge(workTime: 0, catTweet: "(お仕事がんばってにゃ〜ねむねむにゃ…)")
   // 仕事レベル2(仕事開始から30分経過後)
   let judge2 = WorkJudge(workTime: (30 * 60), catTweet: "(お仕事に集中することは良いことにゃ〜!)")
   // 仕事レベル3(仕事開始から1時間経過後)
   let judge3 = WorkJudge(workTime: (1 * 60 * 60), catTweet: "(結構、長い間仕事してるにゃね?\n集中力すごいのにゃ〜)")
   // 仕事レベル4(仕事開始から1時間30分経過後)
   let judge4 = WorkJudge(workTime: (1 * 60 * 60)+(30 * 60), catTweet: "(なんか長時間仕事しすぎにゃ!\nそれじゃあ肩凝るにゃ!\nそろそろ構えにゃ〜!!)")

   judgeConfigs.append(judge0)
   judgeConfigs.append(judge1)
   judgeConfigs.append(judge2)
   judgeConfigs.append(judge3)
   judgeConfigs.append(judge4)
}

また、仕事レベルに合わせた猫の呟き情報を取得する関数を追加します。

// ContentView.swift

// 仕事レベルにあった猫の呟きに更新する
func updateCatTweet() {
   if (self.workLevel < self.judgeConfigs.count) {
       let judge = self.judgeConfigs[self.workLevel]
       self.catTweet = judge.catTweet
   }
}

ここまで作成した条件を元に以下の変更する場所を確認します。

スクリーンショット 2020-12-18 18.18.25

これまで固定で文字列を入力していたところを、猫の呟きを取得する関数を呼び出して切り替えるように変更します。

// ContentView.swift

Text("お仕事がんばってにゃ〜!")

 ↓
 ↓

Text(self.catTweet)

次に仕事経過時間別にレベルアップしていくロジックを追加します。

今回WorkJudgeクラス配列と仕事レベルを活用します。

以下のような”仕事経過時間(workTimeInterval)”が、WorkJudgeクラスの”指定時間(workTime)”を超えているかどうかをチェックして、超えている場合は仕事レベルをあげる関数を作ります。

// ContentView.swift

// 仕事経過時間が、指定時間を超えているかどうかを確認して、仕事レベルUpの対象ならレベルアップする
func workLevelUp() {
   // 現在の仕事レベルを元に次の判断情報を取得する
   if ((self.workLevel + 1) < self.judgeConfigs.count) {
       let judge = self.judgeConfigs[(self.workLevel + 1)]
       if ( Double(self.workTimeInterval) < judge.workTime ) {
           // 仕事経過時間は、指定時間未満である。
           return
       }
       // 仕事経過時間は、指定時間以上の連続作業である
       self.workLevel += 1
       self.updateCatTweet()
   }
}

そして、仕事中、休憩中で仕事レベルを更新するように既存ロジックに修正を加えましょう。 ついでにIf文の比較条件も仕事レベルを利用するように変更します。

// ContentView.swift

// 作業監視モニター
var workMonitoringTimer: Timer {
   Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {_ in
       // 最後にイベント(マウスやキーボード動かす)経過してから
       if (isWorking()) {
           self.workTimeInterval += 1
           if(self.workLevel == 0) {
               self.breakTimeInterval = 0
               self.workTimeInterval = 0
               self.statusText = "仕事中"
           }
           workLevelUp()
       } else if(self.workLevel != 0) {
           self.breakTimeInterval = 0
           self.statusText = "休憩中"
           self.workLevel = 0  // 0は休憩
           self.updateCatTweet()
       } else {
           self.breakTimeInterval += 1
       }
   }
}

これによって、時間経過とともに猫の呟きが変わっていくことが確認できます。手っ取り早く確認したいときはinitで定義している各時間を変更することで確認できます。

6.6.猫画像の表示と切り替え

猫画像の切り替えの前に事前に登録されているリソース情報を開き、画像のファイル名を確認しましょう。

スクリーンショット 2020-12-11 18.06.38

改めて動きのある画像を作るため、以下に条件を整理します。

// 仕事レベル0 (休憩中)の場合
coffeeblakecat1とcoffeeblakecat2の画像をを1秒置きに切り替える

// 仕事レベル1 (仕事中:仕事開始直後)の場合
sleepcat1とsleepcat2の画像をを1秒置きに切り替える

// 仕事レベル2 (仕事中:仕事開始から30分経過後)の場合
nobicat1とnobicat1の画像をを1秒置きに切り替える

// 仕事レベル3 (仕事中:仕事開始から1時間経過後)の場合
sowasowa1とsowasowa1の画像をを1秒置きに切り替える

// 仕事レベル4 (仕事中:仕事開始から1時間30分経過後)の場合
runcat1とruncat2とruncat3とruncat4の画像をを0.1秒置きに切り替える

あとはこれまで作ってきた環境を少し改良して利用することで簡単に機能拡張できます。

まずは、WorkJudgeクラスを少し拡張して利用する画像データ(catFramesImg)と画像切り替え秒数(switchInterval)を登録できるようにします。

// ContentView.swift

class WorkJudge {
   // 経過時間
   var workTime: Double = 0
   // 経過時間を経過時に呟く内容
   var catTweet: String = ""
   // 経過時間を経過時に表示する画像データ
   var catFramesImg: [NSImage] = []
   // 画像データを切り替えるタイミング(秒)
   var switchInterval: Double = 0
   // 画像のINDEX値
   var imgIndex:Int = 0
   init(workTime:Double, catTweet: String, catFramesImg: [NSImage], switchInterval: Double) {
       self.workTime = workTime
       self.catTweet = catTweet
       self.catFramesImg = catFramesImg
       self.switchInterval = switchInterval
       self.imgIndex = 0
   }
   // 指定の画像INDEXの情報を元に表示する画像情報を取得する
   func getCatImage()->NSImage{
       if (self.imgIndex < self.catFramesImg.count) {
           return self.catFramesImg[self.imgIndex]
       }
       return self.catFramesImg[0]
   }
   // 画像表示のキーになる画像INDEXをカウントアップする
   func updateCatFramesCount() {
       self.imgIndex += 1
       if( self.imgIndex == self.catFramesImg.count) {
           self.imgIndex = 0
       }
   }
}

今回、catFramesImg、switchInterval、imgIndexの変数追加と、関数としてgetCatImage、updateCatFramesCountを追加しました。

次に画面初期ロード時に利用するリソースファイル名を登録します。

// ContentView.swift

init() {
   self.appDelegate = NSApplication.shared.delegate as! AppDelegate

   // 仕事レベル0 (休憩中)の場合
   let judge0 = WorkJudge(workTime: 0, catTweet: "(休憩は良いことにゃ〜リフレッシュにゃ〜)", catFramesImg: [
       NSImage(imageLiteralResourceName: "coffeeblakecat1")
       ,NSImage(imageLiteralResourceName: "coffeeblakecat2")
   ], switchInterval:1)
   // 仕事レベル1 (仕事中:仕事開始直後)の場合
   let judge1 = WorkJudge(workTime: 0, catTweet: "(お仕事がんばってにゃ〜ねむねむにゃ…)", catFramesImg: [
       NSImage(imageLiteralResourceName: "sleepcat1")
       ,NSImage(imageLiteralResourceName: "sleepcat2")
   ], switchInterval:1)
   // 仕事レベル2 (仕事中:仕事開始から30分経過後)の場合
   let judge2 = WorkJudge(workTime: (30 * 60), catTweet: "(お仕事に集中することは良いことにゃ〜!)", catFramesImg: [
       NSImage(imageLiteralResourceName: "nobicat1")
       ,NSImage(imageLiteralResourceName: "nobicat2")
   ], switchInterval:1)
   // 仕事レベル3 (仕事中:仕事開始から1時間経過後)の場合
   let judge3 = WorkJudge(workTime: (1 * 60 * 60), catTweet: "(結構、長い間仕事してるにゃね?\n集中力すごいのにゃ〜)", catFramesImg: [
       NSImage(imageLiteralResourceName: "sowasowa1")
       ,NSImage(imageLiteralResourceName: "sowasowa2")
   ], switchInterval:1)
   // 仕事レベル4 (仕事中:仕事開始から1時間30分経過後)の場合
   let judge4 = WorkJudge(workTime: (1 * 60 * 60)+(30 * 60), catTweet: "(なんか長時間仕事しすぎにゃ!\nそれじゃあ肩凝るにゃ!\nそろそろ構えにゃ〜!!)", catFramesImg: [
       NSImage(imageLiteralResourceName: "runcat1")
       ,NSImage(imageLiteralResourceName: "runcat2")
       ,NSImage(imageLiteralResourceName: "runcat3")
       ,NSImage(imageLiteralResourceName: "runcat4")
       ,NSImage(imageLiteralResourceName: "runcat3")
       ,NSImage(imageLiteralResourceName: "runcat2")
   ], switchInterval:0.1)

   judgeConfigs.append(judge0)
   judgeConfigs.append(judge1)
   judgeConfigs.append(judge2)
   judgeConfigs.append(judge3)
   judgeConfigs.append(judge4)
}

次に実際に猫画像の表示する部分に関係する変数を追加します。

// ContentView.swift

// 画面構築に利用する変数:猫の画像
@State var catImg:NSImage =  NSImage(imageLiteralResourceName: "sleepcat1")

以下赤枠の範囲です。

スクリーンショット 2020-12-18 18.19.22

これまで固定で登録したリソース画像を表示していたところを、猫の画像を取得する関数を呼び出して切り替えるように変更します。

// ContentView.swift

Image("coffeeblakecat1")

 ↓
 ↓

Image(nsImage: self.catImg)

次に、1秒おき、0.1秒おきに指定の画像データを切り替えるために以下の関数を作ります。

// ContentView.swift

// 定義されている猫画像INDEX値を更新して猫画像の表示を切り替える
func updateCatFramesCount(withTimeInterval: Double) {
   if (self.workLevel < self.judgeConfigs.count) {
       let judge = self.judgeConfigs[self.workLevel]
       if (judge.switchInterval == withTimeInterval) {
           judge.updateCatFramesCount()
           self.catImg = judge.getCatImage()
       }
   }
}

そして、タイマー関数を利用して画像INDEX情報を更新する関数を呼び出しするように定義します。

// ContentView.swift

// 画像を高速に切り替える用のタイマー
var imgHighSpeedSwitchingTimer: Timer {
   Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) {_ in
       updateCatFramesCount(withTimeInterval: 0.1)
   }
}

// 作業監視モニター
var workMonitoringTimer: Timer {
   Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {_ in
       updateCatFramesCount(withTimeInterval: 1)
       // 最後にイベント(マウスやキーボード動かす)経過してから
       if (isWorking()) {
           self.workTimeInterval += 1
           if(self.workLevel == 0) {
               self.breakTimeInterval = 0
               self.workTimeInterval = 0
               self.statusText = "仕事中"
           }
           workLevelUp()
       } else if(self.workLevel != 0) {
           self.breakTimeInterval = 0
           self.statusText = "休憩中"
           self.workLevel = 0  // 0は休憩
           self.updateCatTweet()
       } else {
           self.breakTimeInterval += 1
       }
   }
}​

また猫の呟きと同時に画像が切り替わるようにいかにも追加しましょう。

// ContentView.swift

// 仕事レベルにあった猫の呟きに更新する
func updateCatTweet() {
   if (self.workLevel < self.judgeConfigs.count) {
       let judge = self.judgeConfigs[self.workLevel]
       self.catTweet = judge.catTweet
       // 呟き変更と共に画像を更新
       self.updateCatFramesCount(withTimeInterval: judge.switchInterval)
   }
}

最後に追加作成したタイマーimgHighSpeedSwitchingTimerをレイアウトに登録します。

// ContentView.swift

// レイアウト
var body: some View {
   VStack() {
     // ** 省略 **
     // ** 省略 **
   }
   .frame(width: 400.0)
   .onAppear(perform: {
       _ = self.imgHighSpeedSwitchingTimer
   })
   .onAppear(perform: {
       _ = self.workMonitoringTimer
   })
}

これで仕事時間経過に合わせて画像が切り替わるようになりました!

スクリーンショット 2020-12-25 10.58.31

7.通知機構の追加

通知機能をつくります。通知は1時間30分後、仕事レベルが最大値になったら通知する仕組みで、かつ、何度も通知が発生しないように実装を検討していく必要があります。

よって、まずは通知時間を把握するために変数を追加します。

// ContentView.swift

// 画面構築に利用する変数:通知した時間
@State var notificationDate:Date = Date()

通知する仕組みを追加して、最後に通知時間を保存するように対応します。

// ContentView.swift

import SwiftUI
import UserNotifications    // 通知用に追加する

// 通知する
func requestNotification() {
   // 通知内容の構築
   let outputDateString = ToStringTime(timeInterval:self.workTimeInterval)
   let content = UNMutableNotificationContent()
   content.title = "そろそろ構えにゃ"
   content.subtitle = "長時間パソコン触りすぎにゃ!"
   content.body = "仕事し続けて [" + outputDateString + "] 経過してるにゃ。そろそろ休憩しようにゃー。気分転換しようにゃー。"
   content.userInfo = ["title" : "そろそろ構えにゃ"]
   content.sound = UNNotificationSound.default
   if let imageUrl = Bundle.main.url(forResource: "nobinobicat", withExtension: "png"),
       let imageAttachment = try? UNNotificationAttachment(identifier: "ImageAttachment", url: imageUrl, options: nil) {
       content.attachments.append(imageAttachment)
   }

   // 1秒数後に通知するトリガーを作成
   let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1,
                                                   repeats: false)

   // 通知内容をトリガー条件を踏まえたリクエストを作成
   let request = UNNotificationRequest(identifier: "breakCat_\(outputDateString)",
                                       content: content,
                                       trigger: trigger)

   // 通知センターに通知リクエストを登録
   let center = UNUserNotificationCenter.current()
   center.add(request) { (error) in
       if let error = error {
           print(error.localizedDescription)
       }
   }
   // 通知した時刻を登録
   self.notificationDate = Date()
}

次に最後に通知してから指定時間以上経過しているか確認する仕組みを追加します。

// ContentView.swift

// 通知する時間であるかどうか
// 最後に通知してから指定時間以上経過しているかどうか
func notifyByTime(TimeInterval interval:Double){
   if (self.workLevel != self.judgeConfigs.count - 1) {
       // 通知対象の最終レベル対象ではない。
       return
   }
   let timeIntervalSince = self.notificationDate.timeIntervalSinceNow
   if ( -timeIntervalSince < interval ) {
       // 前回のイベント時と比べて指定時間経過未満である。
       return
   }
   // 通知対象である
   requestNotification()
}

仕事監視タイマー(workMonitoringTimer)内の仕事レベルアップ(workLevelUp)の後にでも通知可能ならば通知リクエストをする仕組みを追加しましょう。

// ContentView.swift

// 作業監視モニター
var workMonitoringTimer: Timer {
   Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {_ in
       updateCatFramesCount(withTimeInterval: 1)
       // 最後にイベント(マウスやキーボード動かす)経過してから
       if (isWorking()) {
           self.workTimeInterval += 1
           if(self.workLevel == 0) {
               self.breakTimeInterval = 0
               self.workTimeInterval = 0
               self.statusText = "仕事中"
           }
           // 仕事レベルを仕事経過時間に合わせてアップ
           workLevelUp()
           // 前回の通知時間との差分が30分以上ある場合、通知する
           notifyByTime(TimeInterval: (30*60))
       } else if(self.workLevel != 0) {
           self.breakTimeInterval = 0
           self.statusText = "休憩中"
           self.workLevel = 0  // 0は休憩
           self.updateCatTweet()
       } else {
           self.breakTimeInterval += 1
       }
   }
}

このように実装することで、通知は連続で仕事し続け1時間30分後、仕事レベルが最大値になったら通知する仕組み。その後は30分おきに通知されるような仕組みが実装できます。

実際に1時間30分間マウスやキーボードを動かし続けると以下のように通知が飛んできます。

猫監視モニタ通知0130

そして、それを無視していると、以下のように30分置きに通知履歴が溜まっていき、動きとして悪くない感じです。

猫監視モニタ。放置すると30分おきにおくる

ちょっと時間がズレているのは少し気になる所ではあります、作り込みが甘い部分があるのだろうと感じてます。ただ動くものとしては満足です。

なお、余談ですが、ここまで出てきた1時間30分とか30分置きに通知とかは、定数で登録しておく事で、あとでコード上で調整しやすいです。

また機能拡張として設定画面などを用意しても良いのかもしれないですね。

8.アプリのメニューを見直す

現時点でのMac上のメニューは以下のようになっているため、不要なメニューを削除する必要があります。

スクリーンショット 2020-12-18 15.22.26

Main.storyboardを開き、不要なメニューは選択した状態でDeleteキーを押すと削除できます。ラベルを修正したい場合は修正したいメニューを選択しTitleを修正するとで見た目を変更できます。

スクリーンショット 2020-12-18 17.33.03

スクリーンショット 2020-12-18 15.22.51

9.閉じるボタンでアプリ終了

最後に今の現状だとアプリの閉じるボタンを押してもアプリは終了しないので、アプリが終了できるようにします。

スクリーンショット 2020-12-18 18.21.16

AppDelegate.swiftに以下のコードを追加してください。

// AppDelegate.swift

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
   // ウインドウのクローズとともにアプリケーションを終了させる
   return true
}

これで、閉じるボタンを押すとアプリが同時に終了するようになります。

10.最終ソース

最終的なソースの状態です。一部のマジックナンバーは定数化しました。

アプリ起動時終了時に関わる基礎部分は以下の通りです。

// AppDelegate.swift

import Cocoa
import SwiftUI
import UserNotifications

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

   var window: NSWindow!
   // イベント発生時時間
   var eventDate:Date = Date()
   // キーボード監視も許可する否かフラグ(trueにしても、App Sandboxの有効化を解除しなければ動きません。意味がわからない場合はfalseにする事)
   let IS_KEY_EVENT_ALLOW:Bool = false

   func applicationDidFinishLaunching(_ aNotification: Notification) {
       // キーボード状態監視も有効にする(有効になってなければ通知を促す)
       if (self.IS_KEY_EVENT_ALLOW) {
           // [システム環境設定]->[セキュリティーとプライバシー]->[アクセシビリティ]で有効にするのを促す
           requestKeyEventAuthorization()
       }
       // イベント監視処理を登録
       addMonitorForEvents()
       // ユーザへ通知許可を要求
       requestNotificationAuthorization()

       // Create the SwiftUI view that provides the window contents.
       let contentView = ContentView()
       // Create the window and set the content view. 
       window = NSWindow(
           contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
           styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
           backing: .buffered, defer: false)
       window.center()
       window.setFrameAutosaveName("Main Window")
       window.contentView = NSHostingView(rootView: contentView)
       window.makeKeyAndOrderFront(nil)
   }

   func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
       // ウインドウのクローズとともにアプリケーションを終了させる
       return true
   }

   // キーボードイベントの許可要求
   func requestKeyEventAuthorization() {
       // ウィンドウ外でキーボードイベントも検知したい場合
       // [システム環境設定]->[セキュリティーとプライバシー]->[アクセシビリティ]で有効にするのを促す
       let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true]
       let accessEnabled = AXIsProcessTrustedWithOptions(options)
       if !accessEnabled {
           let alert = NSAlert()
           alert.messageText = "cat-urging-a-break-for-mac.app"
           alert.informativeText = "システム環境設定でcat-urging-a-break-for-mac.app(このダイアログの後ろにあるダイアログを参照)のアクセシビリティを有効にして、このアプリを再度起動する必要があります"
           alert.addButton(withTitle: "OK")
           alert.runModal()
           // 設定できたらアプリを再起動しないと意味ないためアプリ強制終了
           NSApplication.shared.terminate(self)
       }
   }

   // イベント監視処理を登録
   func addMonitorForEvents() {
       // ウィンドウ外でイベントが発生するたびに検知してイベント日付を更新する
       NSEvent.addGlobalMonitorForEvents(matching: .any, handler: { (event) in
           self.eventDate = Date()
       })
       // ウィンドウ内でイベントが発生するたびに検知してイベント日付を更新する
       NSEvent.addLocalMonitorForEvents(matching: .any, handler: { (event) in
           self.eventDate = Date()
           return event
       })
   }

   // ユーザーに通知許可を要求する
   func requestNotificationAuthorization() {
       let center = UNUserNotificationCenter.current()
       center.requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in
           if granted {
               print("通知許可")
           } else {
               print("通知拒否")
           }
       }
   }
}
​

レイアウトや通知処理を構成している部分は以下の通りです。

// ContentView.swift

import SwiftUI
import UserNotifications
import AppKit
struct ContentView: View {
   
   var appDelegate: AppDelegate
   var judgeConfigs: [WorkJudge] = []
   // 状態変更に関係する判断設定
   class WorkJudge {
       // 経過時間
       var workTime: Double = 0
       // 経過時間を経過時に呟く内容
       var catTweet: String = ""
       // 経過時間を経過時に表示する画像データ
       var catFramesImg: [NSImage] = []
       // 画像データを切り替えるタイミング(秒)
       var switchInterval: Double = 0
       // 画像のINDEX値
       var imgIndex:Int = 0
       init(workTime:Double, catTweet: String, catFramesImg: [NSImage], switchInterval: Double) {
           self.workTime = workTime
           self.catTweet = catTweet
           self.catFramesImg = catFramesImg
           self.switchInterval = switchInterval
           self.imgIndex = 0
       }
       // 指定の画像INDEXの情報を元に表示する画像情報を取得する
       func getCatImage()->NSImage{
           if (self.imgIndex < self.catFramesImg.count) {
               return self.catFramesImg[self.imgIndex]
           }
           return self.catFramesImg[0]
       }
       // 画像表示のキーになる画像INDEXをカウントアップする
       func updateCatFramesCount() {
           self.imgIndex += 1
           if( self.imgIndex == self.catFramesImg.count) {
               self.imgIndex = 0
           }
       }
   }
   // 定数:仕事中か休憩中かを判断するキーとなる時間(秒):10分
   let WORK_OR_BREK_JUDGE_TIME: Double = (10 * 60)
   // 定数:仕事レベル切り替え時間感覚(30分おきにレベルアップ)
   let WORK_SWITCH_INTERVAL:Double = (30 * 60)
   // 定数:通知時間感覚(30分おき)
   let NOTICE_TIME_INTERVAL:Double = (30 * 60)
   // 定数:仕事監視タイマーの実行間隔
   let WORK_MONITORING_TIMER_INTERVAL:Double = 1
   // 定数:高速切り替えタイマーの実行間隔(パラパラ表示させたい猫画像用)
   let HIGH_SPEED_SWITCHINGTIMER_INTERVAL: Double = 0.1
   
   // 画面構築に利用する変数:仕事経過時間(秒数)
   @State var workTimeInterval:Int = 0
   // 画面構築に利用する変数:休憩経過時間(秒数)
   @State var breakTimeInterval:Int = 0
   // 画面構築に利用する変数:ステータス
   @State var statusText:String = "仕事中"
   // 画面構築に利用する変数:仕事レベル
   @State var workLevel:Int = 1
   // 画面構築に利用する変数:通知した時間
   @State var notificationDate:Date = Date()
   // 画面構築に利用する変数:猫の画像
   @State var catImg:NSImage =  NSImage(imageLiteralResourceName: "sleepcat1")
   // 画面構築に利用する変数:猫の呟き
   @State var catTweet: String = "(お仕事がんばってにゃ〜ねむねむにゃ…)"


   init() {
       self.appDelegate = NSApplication.shared.delegate as! AppDelegate

       // 仕事レベル0 (休憩中)の場合
       let judge0 = WorkJudge(workTime: 0, catTweet: "(休憩は良いことにゃ〜リフレッシュにゃ〜)", catFramesImg: [
           NSImage(imageLiteralResourceName: "coffeeblakecat1")
           ,NSImage(imageLiteralResourceName: "coffeeblakecat2")
       ], switchInterval:self.WORK_MONITORING_TIMER_INTERVAL)
       // 仕事レベル1 (仕事中:仕事開始直後)の場合
       let judge1 = WorkJudge(workTime: 0, catTweet: "(お仕事がんばってにゃ〜ねむねむにゃ…)", catFramesImg: [
           NSImage(imageLiteralResourceName: "sleepcat1")
           ,NSImage(imageLiteralResourceName: "sleepcat2")
       ], switchInterval:self.WORK_MONITORING_TIMER_INTERVAL)
       // 仕事レベル2 (仕事中:仕事開始から30分経過後)の場合
       let judge2 = WorkJudge(workTime: self.WORK_SWITCH_INTERVAL, catTweet: "(お仕事に集中することは良いことにゃ〜!)", catFramesImg: [
           NSImage(imageLiteralResourceName: "nobicat1")
           ,NSImage(imageLiteralResourceName: "nobicat2")
       ], switchInterval:self.WORK_MONITORING_TIMER_INTERVAL)
       // 仕事レベル3 (仕事中:仕事開始から1時間経過後)の場合
       let judge3 = WorkJudge(workTime: (self.WORK_SWITCH_INTERVAL * 2), catTweet: "(結構、長い間仕事してるにゃね?\n集中力すごいのにゃ〜)", catFramesImg: [
           NSImage(imageLiteralResourceName: "sowasowa1")
           ,NSImage(imageLiteralResourceName: "sowasowa2")
       ], switchInterval:self.WORK_MONITORING_TIMER_INTERVAL)
       // 仕事レベル4 (仕事中:仕事開始から1時間30分経過後)の場合
       let judge4 = WorkJudge(workTime: (self.WORK_SWITCH_INTERVAL * 3), catTweet: "(なんか長時間仕事しすぎにゃ!\nそれじゃあ肩凝るにゃ!\nそろそろ構えにゃ〜!!)", catFramesImg: [
           NSImage(imageLiteralResourceName: "runcat1")
           ,NSImage(imageLiteralResourceName: "runcat2")
           ,NSImage(imageLiteralResourceName: "runcat3")
           ,NSImage(imageLiteralResourceName: "runcat4")
           ,NSImage(imageLiteralResourceName: "runcat3")
           ,NSImage(imageLiteralResourceName: "runcat2")
       ], switchInterval:self.HIGH_SPEED_SWITCHINGTIMER_INTERVAL)

       judgeConfigs.append(judge0)
       judgeConfigs.append(judge1)
       judgeConfigs.append(judge2)
       judgeConfigs.append(judge3)
       judgeConfigs.append(judge4)
   }


   // レイアウト
   var body: some View {
       VStack() {
           Text("仕事監視Cat")
               .font(.title)
               .multilineTextAlignment(.leading)
           Divider()
           HStack {
               VStack {
                   VStack {
                       Text("ステータス")
                           .font(.caption)
                       Text(self.statusText)
                           .font(.body)
                   }
                   .padding(.vertical)
                   VStack {
                       Text("仕事経過時間")
                           .font(.caption)
                       Text(ToStringTime(timeInterval: self.workTimeInterval))
                           .font(.body)
                   }
                   .padding(.bottom)

               }
               .frame(width: 100.0)
               VStack {
                   Image(nsImage: self.catImg)
                       .resizable()    // 画像サイズをフレームサイズに合わせる
                       .scaledToFit()      // 縦横比を維持しながらフレームに収める
                       .frame(width: 100.0, height: 100.0)

                   Text(self.catTweet)
                       .font(.caption)
                       .frame(height: 50.0)
               }
               .frame(width: 250.0)
           }
           Divider()
           HStack {
               Text("休憩経過時間->")
                   .font(.caption)
               Text(ToStringTime(timeInterval: self.breakTimeInterval))
                   .font(.caption)
               Text(", 最後にイベント検知した時刻->")
                   .font(.caption)
               Text(ToStringTime(date: self.appDelegate.eventDate))
                   .font(.caption)

           }
       }
       .frame(width: 400.0)
       .onAppear(perform: {
           _ = self.imgHighSpeedSwitchingTimer
       })
       .onAppear(perform: {
           _ = self.workMonitoringTimer
       })
   }
   
   // 指定数秒をHH:mm:ssの文字列にフォーマットする
   func ToStringTime(timeInterval interval:Int)->String{
       let calendar = Calendar(identifier: .japanese)
       let time000 = calendar.startOfDay(for: Date())
       let dispTime = Calendar.current.date(byAdding: .second, value: interval, to: time000)!
       let formatter = DateFormatter()
       formatter.locale = Locale.current
       formatter.calendar = Calendar(identifier: .japanese)
       formatter.dateFormat = "HH:mm:ss"
       return formatter.string(from: dispTime)
   }
   
   // 指定Date型をHH:mm:ssの文字列にフォーマットする
   func ToStringTime(date:Date)->String{
       let formatter = DateFormatter()
       formatter.locale = Locale.current
       formatter.calendar = Calendar(identifier: .japanese)
       formatter.dateFormat = "HH:mm:ss"
       return formatter.string(from: date)
   }
   
   
   // 画像を高速に切り替える用のタイマー
   var imgHighSpeedSwitchingTimer: Timer {
       Timer.scheduledTimer(withTimeInterval: HIGH_SPEED_SWITCHINGTIMER_INTERVAL, repeats: true) {_ in
           updateCatFramesCount(withTimeInterval: HIGH_SPEED_SWITCHINGTIMER_INTERVAL)
       }
   }

   // 作業監視モニター
   var workMonitoringTimer: Timer {
       Timer.scheduledTimer(withTimeInterval: WORK_MONITORING_TIMER_INTERVAL, repeats: true) {_ in
           updateCatFramesCount(withTimeInterval: WORK_MONITORING_TIMER_INTERVAL)
           // 最後にイベント(マウスやキーボード動かす)経過してから
           if (isWorking()) {
               self.workTimeInterval += 1
               if(self.workLevel == 0) {
                   self.breakTimeInterval = 0
                   self.workTimeInterval = 0
                   self.statusText = "仕事中"
               }
               // 仕事レベルを仕事経過時間に合わせてアップ
               workLevelUp()
               // 前回の通知時間との差分が30分以上ある場合、通知する
               notifyByTime(TimeInterval: NOTICE_TIME_INTERVAL)

           } else if(self.workLevel != 0) {
               self.breakTimeInterval = 0
               self.statusText = "休憩中"
               self.workLevel = 0  // 0は休憩
               self.updateCatTweet()
           } else {
               self.breakTimeInterval += 1
           }

       }
   }

   // 現在動作中であるかどうか確認する
   //(最後にイベント経過してから指定時間以内ならマウスやキーボード動かし作業中)
   func isWorking()->Bool{
       // 最後にイベントが発生してから経過した時間を取得する
       let timeIntervalSince = appDelegate.eventDate.timeIntervalSinceNow
       if ( -timeIntervalSince < WORK_OR_BREK_JUDGE_TIME ) {
           // 前回のイベント発生時と比べて10分未満である。
           return true
       }
       return false
   }
   
   // 仕事経過時間が、指定時間を超えているかどうかを確認して、仕事レベルUpの対象ならレベルアップする
   func workLevelUp() {
       // 現在の仕事レベルを元に次の判断情報を取得する
       if ((self.workLevel + 1) < self.judgeConfigs.count) {
           let judge = self.judgeConfigs[(self.workLevel + 1)]
           if ( Double(self.workTimeInterval) < judge.workTime ) {
               // 仕事経過時間は、指定時間未満である。
               return
           }
           // 仕事経過時間は、指定時間以上の連続作業である
           self.workLevel += 1
           self.updateCatTweet()
       }
   }

   // 仕事レベルにあった猫の呟きに更新する
   func updateCatTweet() {
       if (self.workLevel < self.judgeConfigs.count) {
           let judge = self.judgeConfigs[self.workLevel]
           self.catTweet = judge.catTweet
           // 呟き変更と共に画像を更新
           self.updateCatFramesCount(withTimeInterval: judge.switchInterval)
       }
   }

   
   // 定義されている猫画像INDEX値を更新して猫画像の表示を切り替える
   func updateCatFramesCount(withTimeInterval: Double) {
       if (self.workLevel < self.judgeConfigs.count) {
           let judge = self.judgeConfigs[self.workLevel]
           if (judge.switchInterval == withTimeInterval) {
               judge.updateCatFramesCount()
               self.catImg = judge.getCatImage()
           }
       }
   }

   // 通知する時間であるかどうか
   // 最後に通知してから指定時間以上経過しているかどうか
   func notifyByTime(TimeInterval interval:Double){
       if (self.workLevel != self.judgeConfigs.count - 1) {
           // 通知対象の最終レベル対象ではない。
           return
       }
       let timeIntervalSince = self.notificationDate.timeIntervalSinceNow
       if ( -timeIntervalSince < interval ) {
           // 前回のイベント時と比べて指定時間経過未満である。
           return
       }
       // 通知対象である
       requestNotification()
   }
   
   // 通知する
   func requestNotification() {
       // 通知内容の構築
       let outputDateString = ToStringTime(timeInterval:self.workTimeInterval)
       let content = UNMutableNotificationContent()
       content.title = "そろそろ構えにゃ"
       content.subtitle = "長時間パソコン触りすぎにゃ!"
       content.body = "仕事し続けて [" + outputDateString + "] 経過してるにゃ。そろそろ休憩しようにゃー。気分転換しようにゃー。"
       content.userInfo = ["title" : "そろそろ構えにゃ"]
       content.sound = UNNotificationSound.default
       if let imageUrl = Bundle.main.url(forResource: "nobinobicat", withExtension: "png"),
           let imageAttachment = try? UNNotificationAttachment(identifier: "ImageAttachment", url: imageUrl, options: nil) {
           content.attachments.append(imageAttachment)
       }

       // 1秒数後に通知するトリガーを作成
       let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1,
                                                       repeats: false)

       // 通知内容をトリガー条件を踏まえたリクエストを作成
       let request = UNNotificationRequest(identifier: "breakCat_\(outputDateString)",
                                           content: content,
                                           trigger: trigger)

       // 通知センターに通知リクエストを登録
       let center = UNUserNotificationCenter.current()
       center.add(request) { (error) in
           if let error = error {
               print(error.localizedDescription)
           }
       }
       // 通知した時刻を登録
       self.notificationDate = Date()
   }

}


struct ContentView_Previews: PreviewProvider {
   static var previews: some View {
       ContentView()
   }
}

終わりに

なお、ライセンスフリーの猫画像を元に、雑にパラパラ漫画のように動く猫ちゃん画像を用意してみたのですが、1時間30分経過後に動くものは猫ではなく化物のような見た目になってしまいました。・・・が、まぁ良い感じに作れたと思ってます。

長時間仕事しすぎGIF


是非『ちょっとSwiftを使ってコーディングしてみたい。』『Xcodeってどんなものかちょっと触れてみたい。』という方、参考にしてみてください。

プロジェクトは以下に公開しています。改めて誰かの参考になれば幸いです。
以上です、ではでは!
GitHub: https://github.com/hanamizuki10/cat-urging-a-break-for-mac

素材を頂いたサイト


この記事が気に入ったらサポートをしてみませんか?