Edited at
ゆめみDay 25

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

ゆめみの iOS アプリエンジニアの麻生です。

これはゆめみの Advent Calender 第 25 日目の記事です。


はじめに

ゆめみでは少し前から,多くのエンジニアが裁量労働制からフレックスタイム制に変わりました。

日報も 15分単位でだいぶ詳しくつけるようになりました。

それ自体は良いのですが,追加で各チームの勤怠 Slackチャネルに

毎日「出社」「ランチ」「退勤」 などの

各アクティビティをタイムスタンプ代わりに投稿するように決まりができました。

最初の頃は良かったのですが,だんだんスマホやパソコンの Slack アプリを

わざわざ開いてアクティビティを投稿するのを手間に感じてきました。

Apple Watch 使って手首だけで完結出来たら楽かもなぁと思って,

Advent Calender のネタにしようと思って眠らせておいたので

今回実際に 日報ちゃん というアプリを作ってみました。

オフィスハックというのかはわかりませんが,

少なくとも私は業務改善しそうなのでタイトル付けしています。


要件定義と概念図

要件定義はシンプルです。

Apple Watch で「出社」や「退勤」などのテキストを選択して

ボタンなどの押下で,Slack のチーム勤怠チャネルに投稿する。

Apple Watch の入力インターフェース(音声と手書き?)を考えると

これらの選択ワードを Apple Watch 単体で用意することが難しいと思ったので,

iPhone アプリ側に入力は任せることにして,iPhone から Apple Watch への

データ送信は Watch Connectivity1 を使うことにしました。

Background transfersInteractive messaging の 2 種類ありますが,

送信時は Apple Watch のアプリ画面を開いておくことを前提としたため後者を選びました。

日報ちゃん概念図.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 画面です。

App画面数.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リクエストすればいいということになります。

このパラメタに対する値として入力した各値を用います。

このアプリの場合は以下のような感じです。

Slack投稿例.png

文字列のみでよければ JSON はテキストのパラメタだけで大丈夫でした。

アプリの登録をすればユーザネームとそのアイコンが設定されるようですが省きました。


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

画面右下の + のスレートボタンを押し,上部に出てくる入力欄に

Slack に登録したいワードたちを入力して Add ボタンを押すと

リストに追加されます。ナビゲーションバーの右側の Edit ボタンを押せば削除も可能です。

AddWords.PNG


Watch Connectivity の実装

先ほど述べた通り,Interactive messaging の実装を行います。

そのため,データ送信は各画面を表示させている状態で行います。

iPhone 側は,投稿したいワードを追加する画面の ViewController に,

Watch 側は,起動時に表示される画面の InterfaceController に

それぞれ実装を書きます。


iPhone 側

WatchConnectivity の準備をします。


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] になります。


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 側も準備は大体同じです。


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 などとは違い大分シンプルだなと感じました。


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


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 でリクエストするだけで

ここも新規性はないです。

気持ち,エラーハンドリングしましたが,

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


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()
}
}



実際の動作

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

result.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秒時短できます。

result.png

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

参考

Apple Watch Series 4 の新しい Complication Families の実装を試してみる