【オフィスハック】Apple Watchを使って日報の各アクティビティをSlackに投稿させてみる

ゆめみの iOS アプリエンジニアの麻生です。
これは[ゆめみの Advent Calendar](https://qiita.com/advent-calendar/2018/yumemi) 第 25 日目の記事です。

## はじめに

ゆめみでは少し前から，多くのエンジニアが裁量労働制からフレックスタイム制に変わりました。
日報も 15分単位でだいぶ詳しくつけるようになりました。

それ自体は良いのですが，追加で各チームの勤怠 Slackチャネルに
毎日**「出社」「ランチ」「退勤」** などの
各アクティビティをタイムスタンプ代わりに投稿するように決まりができました。
最初の頃は良かったのですが，だんだんスマホやパソコンの Slack アプリを
わざわざ開いてアクティビティを投稿するのを手間に感じてきました。

Apple Watch 使って手首だけで完結出来たら楽かもなぁと思って，
Advent Calender のネタにしようと思って眠らせておいたので
今回実際に **日報ちゃん** というアプリを作ってみました。

オフィスハックというのかはわかりませんが，
少なくとも私は業務改善しそうなのでタイトル付けしています。

## 要件定義と概念図

要件定義はシンプルです。
Apple Watch で「出社」や「退勤」などのテキストを選択して
ボタンなどの押下で，Slack のチーム勤怠チャネルに投稿する。

Apple Watch の入力インターフェース(音声と手書き？)を考えると
これらの選択ワードを Apple Watch 単体で用意することが難しいと思ったので，
iPhone アプリ側に入力は任せることにして，iPhone から Apple Watch への
データ送信は **Watch Connectivity**[^1] を使うことにしました。
**Background transfers** と **Interactive messaging** の 2 種類ありますが，
送信時は Apple Watch のアプリ画面を開いておくことを前提としたため後者を選びました。

<img width="897" alt="日報ちゃん概念図.png" src="https://qiita-image-store.s3.amazonaws.com/0/88266/752e2048-5cb9-8ec5-f8a5-27a38866027a.png">

iPhone 側でユーザに入力してもらう項目は下記の通りです。

* ユーザ名(必須)
* Slack の Webhook URL(必須)
* Slack に投稿するワード群(必須)
* 会社名(任意)
* メンバーID(任意)
* ユーザ画像用URL(任意)
* ユーザのプロフィールURL(任意)
* お好きな色(任意)

ユーザ名は Slack 投稿時に誰からの送信かわからなくなるため必須にしました。
会社名とメンバーIDはプロフィール画面のみの使用です。
残りの項目は Slack の表示に使われます。後の項目でどこに使われるか説明します。

## 開発環境・動作条件・画面

このサンプルアプリは Github にアップしました。
気になる方がいらっしゃったらご覧ください。

https://github.com/MilanistaDev/Nippochan

* Xcode 10 以上
* iOS 10 以上
* watchOS 5 以上
* Slack のチームの登録

アプリの画面は iPhone 側は 6 画面で Watch 側は 1 画面です。

<img width="869" alt="App画面数.png" src="https://qiita-image-store.s3.amazonaws.com/0/88266/d7aa732a-127d-f925-79ba-81255b8b22a5.png">


## Slack の Webhook URL の取得

Slack のチーム作成方法や取得に関して詳しくは省きます。
公式ページ[^2]や下記の記事などを参考にチームの勤怠チャネルのものを取得し，
Slack 設定画面に入力します。CI/CD の環境構築の際にも一度は調べたことあるやつです。

```
// こういう URL を取得する
https://hooks.slack.com/services/XXX...
```

**SlackのWebhook URL取得手順**
https://qiita.com/vmmhypervisor/items/18c99624a84df8b31008

## Slack での投稿表示を決めて送るべき JSON を確認

このページ[^3]で Slack の投稿のお試し編集ができます。
JSON のパラメタを調整して投稿時に必要な JSON 形式を決めます。

Apple Watch 側で取得した Webhook URL に
JSON をパラメタとして POSTリクエストすればいいということになります。
このパラメタに対する値として入力した各値を用います。
このアプリの場合は以下のような感じです。

<img width="752" alt="Slack投稿例.png" src="https://qiita-image-store.s3.amazonaws.com/0/88266/ddb4edf6-997b-39e0-05c5-08a68b19e5c5.png">

文字列のみでよければ JSON はテキストのパラメタだけで大丈夫でした。
アプリの登録をすればユーザネームとそのアイコンが設定されるようですが省きました。

## Slack へ送信するワードを登録

画面右下の + のスレートボタンを押し，上部に出てくる入力欄に
Slack に登録したいワードたちを入力して Add ボタンを押すと
リストに追加されます。ナビゲーションバーの右側の Edit ボタンを押せば削除も可能です。

![AddWords.PNG](https://qiita-image-store.s3.amazonaws.com/0/88266/57a2c84c-7d74-8f98-1374-7b10b8e57ea8.png)


## Watch Connectivity の実装

先ほど述べた通り，**Interactive messaging** の実装を行います。
そのため，データ送信は各画面を表示させている状態で行います。

iPhone 側は，投稿したいワードを追加する画面の ViewController に，
Watch 側は，起動時に表示される画面の InterfaceController に
それぞれ実装を書きます。

### iPhone 側

WatchConnectivity の準備をします。

```swift:ShareDataWithWatchViewController.swift
import WatchConnectivity

final class ShareDataWithWatchViewController: UIViewController {

    var session = WCSession.default

    override func viewDidLoad() {
        super.viewDidLoad()
        self.setUpWCSession()
    }

    private func setUpWCSession() {
        if WCSession.isSupported() {
            self.session = WCSession.default
            self.session.delegate = self    // 不要そうだがないとApple Watchと通信できない
            self.session.activate()
        }
    }
}

// 不要そうだがないとApple Watchと通信できない
extension ShareDataWithWatchViewController: WCSessionDelegate {
    func sessionDidBecomeInactive(_ session: WCSession) {
    }
    func sessionDidDeactivate(_ session: WCSession) {
    }
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
    }
}

```


**Share data with  Watch** ボタンを押下するのをトリガーにします。
送信が完了すれば Apple Watch 側からも Dictionary が返ってきます。
エラー時はペアリングされていない，アプリが Watch にインストールされていないなどのエラーが出ます。
この際 Apple Watch に送るデータは `[String: String]` がほとんどですが，
投稿するワードたちは `Array` となるので `[String: Any]` になります。

```swift:ShareDataWithWatchViewController.swift

@IBAction func shareDataWithWatchAction(_ sender: Any) {
    // 必須の情報が登録されているかのチェック
    ...
    // Apple Watch へ送る Dictionary 生成
    let dic = ...
    // Apple Watch へ送信
    self.session.sendMessage(dic, replyHandler: { (replyDic) in
        self.showAlertDialog(title: replyDic["replyStatus"] as? String ?? "?",
                             message: "Successfully sent necessary data to Apple Watch.")
    }) { (error: Error) in
        self.showAlertDialog(title: "Error", message: error.localizedDescription)
    }
}
```

### Watch 側

Watch 側も準備は大体同じです。

```swift:InterfaceController.swift

import WatchConnectivity

class InterfaceController: WKInterfaceController {

    var session = WCSession.default

    override func willActivate() {
        super.willActivate()
        self.setUpWCSession()
    }

    private func setUpWCSession() {
        if WCSession.isSupported() {
            self.session = WCSession.default
            self.session.delegate = self
            self.session.activate()
        }
    }
}

extension InterfaceController: WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
    }
}
```

iPhone 側からのデータを受信する Delegate メソッドが用意されています。
受信した各データはそれぞれ UserDefaults に保存するようにしました。
受け取ったワードアレイから投稿したいワードを選択できるようにピッカーのデータを生成します。
この処理は省きますが Watch 用の Picker `WKInterfacePicker` は
UIKit の `UIPickerView` などとは違い大分シンプルだなと感じました。

```swift:InterfaceController.swift
extension InterfaceController: WCSessionDelegate {

    ...

    func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
        // Apple Storeの不揮発領域にデータを保存
        // 受け取ったワードアレイから投稿したいワードを選択できるようにピッカーのデータを生成
        // iPhoneに返却
        replyHandler(["replyStatus": "Success"])
    }
}
```

## Slack へ投稿

Picker を Digital Crown やスワイプで操作した際に選択されたワードを
最下部のボタンのタイトルに設定し，そのテキストを投稿するようにしました。

![App_06.PNG](https://qiita-image-store.s3.amazonaws.com/0/88266/008377dd-4e25-062b-00c1-c0b9c359aeb1.png)

```swift:InterfaceController.swift
// Digital Crown や指でスワイプして値が決まった際に呼ばれる
@IBAction func selectWordAction(_ value: Int) {
    self.sendButton.setEnabled(true)
    self.selectedWord = self.pickerArray[value].title
    self.sendButton.setTitle(self.selectedWord)
}
// 最下部の送信ボタンが押されると呼ばれる
@IBAction func sendAction() {
    self.sendButton.setEnabled(false)
    // Slackに送信
    if let selectedWord = self.selectedWord {
        PostWordsManager.postWords(word: selectedWord)
    }
}
```

JSON を作成し，パラメタとします。
この部分で JSON の構造をすごく勘違いをしていて
なかなか送信できなかったので丁寧に作るようにしたら動きました。
あとは，Webhook URL を使って POST でリクエストするだけで
ここも新規性はないです。

気持ち，エラーハンドリングしましたが，
内容はエラーはエラーとして送っているのでここはもう少しうまくハンドリングしたいところです。

```swift:PostWordsManager.swift
import Foundation

class PostWordsManager {
    class func postWords(word: String) {
        let userDefaults = UserDefaults.standard
        let webhookUrl = userDefaults.object(forKey: UserDefaultsKey.Slack.webhookUrl) as! String
        let authorName = userDefaults.object(forKey: UserDefaultsKey.Profile.name) as! String
        let color = userDefaults.object(forKey: UserDefaultsKey.Slack.favoriteColor) as! String
        let authorLink = userDefaults.object(forKey: UserDefaultsKey.Slack.authorLink) as! String
        let authorIcon = userDefaults.object(forKey: UserDefaultsKey.Profile.imageUrl) as! String

        var request = URLRequest(url: URL(string: webhookUrl)!)
        request.httpMethod = "POST"
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")

        // Slackへ送信するJSONを作成
        var attachmentsDic = Dictionary<String, String>()
        attachmentsDic["color"] = color
        attachmentsDic["author_name"] = authorName
        attachmentsDic["author_link"] = authorLink
        attachmentsDic["author_icon"] = authorIcon
        attachmentsDic["text"] = word
        attachmentsDic["footer"] = "via 日報ちゃん"
        attachmentsDic["footer_icon"] = URLLink.appIcon

        var attachments = Array<Any>()
        attachments.append(attachmentsDic)

        var params = Dictionary<String, Any>()
        params["username"] = "ん？Apple Watchからメッセージが・・・"
        params["icon_emoji"] = ":face_with_monocle:"
        params["attachments"] = attachments

        do {
            request.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
        } catch {
            NotificationCenter.default.post(name: Notification.Name("sentFailed"), object: nil)
            //print(error.localizedDescription)
        }

        let task = URLSession.shared.dataTask(with: request)
        { (data: Data?, response: URLResponse?, error: Error?) in
            if let error = error {
                NotificationCenter.default.post(name: Notification.Name("sentFailed"), object: nil)
                 //print(error.localizedDescription)
                return
            }
            guard let _ = data, let response = response as? HTTPURLResponse else {
                NotificationCenter.default.post(name: Notification.Name("sentFailed"), object: nil)
                 //print("No data or No response")
                return
            }

            if response.statusCode == 200 {
                NotificationCenter.default.post(name: Notification.Name("sentSuccess"), object: nil)
                // print(data)
            } else {
                NotificationCenter.default.post(name: Notification.Name("sentFailed"), object: nil)
                 print(response.statusCode)
            }
        }
        task.resume()
    }
}
```

## 実際の動作

出社と終了をそれぞれ選択してボタンを押下してみた結果・・・🤔

<img width="913" alt="result.png" src="https://qiita-image-store.s3.amazonaws.com/0/88266/a38fa406-06bc-8852-2b07-84dc28c8cbca.png">

投稿完了:ok_hand: 

思っていた通り，iPhone 側がオフラインでも
Apple Watch が Wi-Fi に接続されていれば送信可能でした。

これで少しはストレス減るといいなぁ。(試すのは今日の業務から😅)

## 感想

毎年何かテーマを決めて深掘りしたり，何かアプリを実際に作ってみたり，
Qiita Advent Calendar のおかげでそういう時間を確保できています。
とてもいい機会となっており感謝しています。

12/7の iOS の Advent Calendar の記事執筆後に
10%ルールと業務後の空き時間を使って
電車の中では妄想，デスクでは iPad Pro にスケッチしながらぼちぼち作り始めました。
iPhone 側に時間かけてしまい，Watch 側には時間を避けなかったのが反省点ですが，
端末間ですんなり通信できて，Slack への投稿はできたのでなんとかなりました。

もう少しやれるかなと思っているのは下記になります。

* 各種 SNS でのログイン機能を使ってユーザデータ取得
* Apple Watch で送信したデータを iPhone 側で集計して1日のタイムラインを作成
* 投稿時の見栄え attachments の充実
* UserDefaults への保存はやめる
* Complications 対応

今年は業務のほか，個人アプリ更新・UIStudy というUI研究用リポジトリを
中心に GitLab.com の草をほぼ毎日生やせました。
また，WWDC18 にも参加でき，とても充実した 1年を過ごせました。
来年はもう少しアウトプットを多めにしようかなと思っています。
力入れるとしたら ARKit，watchOS，UI研究かなぁ。

## おわりに

今回は日報ちゃんというアプリを作成して見ました。
Watch Connectivity を使って iPhone から Apple Watch にデータを送信し，
Apple Watch 側で　URLSession を用いて Slack に日報のアクティビティを投稿する実装をしました。

ご覧いただきありがとうございました。
内容・コードに関して何かありましたらぜひコメントお願いいたします。

## 追記

Graphic Circular のみですが Complication 対応させてみました。
これで 1~2秒時短できます。

<img width="913" alt="result.png" src="https://user-images.githubusercontent.com/8732417/50421394-23befa80-0882-11e9-9bf0-822d82350ef1.PNG">

フルカラー画像もいけますね。ちょっとダサいかもですが・・・

**参考**
[Apple Watch Series 4 の新しい Complication Families の実装を試してみる](https://qiita.com/MilanistaDev/items/6a09bb787d9eee509c0d)


[^1]: https://developer.apple.com/documentation/watchconnectivity
[^2]: https://slack.com/services/new/incoming-webhook
[^3]: https://api.slack.com/docs/messages/builder