受付アプリ(iPad)をSwiftUI+Google Calendar API+ Slack APIで自作する方法(ソース付) #30

社長のやるべき仕事は幅広い。

最近、ついに我が社もNTT電話が断舎離されることになった。

コスト削減は良いことながら、社内からこんな声が・・

「社長!受付電話が無くなるから流行りの受付アプリ(100万円ぐらい)導入します☆」

ちょっと待った

最強のものづくり集団を目指すbravesoftがその程度のアプリを自作しないでどうするのだ!

・・・かくして、社長自らGWに「率先自作」することになったのだ。

GWに5日ぐらいかけてSwiftUIの学習がてら開発したのがこれ(右側はslack全社ch)。

仕様はシンプルで、

①Googleカレンダーから直近のMTG予定者を取得して表示
②最短2タップで受付登録が完了
③全社slackにお客様名や会議名を投稿

直近の打ち合わせ予定者のみ上に表示するので大体はお目当ての担当が一発で見つかる。

実際社内リリースしてかれこれ1ヶ月ぐらい普通に使われている。たのしき。

そこで、本ブログではものづくり同志のためにこのアプリの開発手順を解説しよう。

今回、SwiftでGoogle Calendar APIに接続した訳だが、まだまだレアケースらしく参考にできる記事は(英語圏も含めて)かなり少なかった。そこでざっと設定手順をまとめ、最後にはソースコードもそのまま記載する。誰かの参考になれば幸い。

ちなみに当方の動作環境はこんな感じだ

(画像はクリックで拡大できる)

①まずはXCodeでSwiftUI実行環境を準備

まずはXCodeをインストール。SwiftUIはXCode11以降なら標準でついてる。(実は当初はFlutterで開発しようと思っていたが、最新のMac(M1チップ)でAndroid Studioがちゃんと動かず、苦労しそうだったので急遽Apple純正のSwiftUIに変更。マルチプラットフォームの開発言語はちょくちょくこういう問題起きるので注意。

SwiftUIはこれまでのStoryBoardとうってかわって分かりやすい。UIを全てコードで記述→HTML感覚で画面を見ながらいじれるので簡単。

さらにライブラリインストール環境のCocoaPodsをインストール。これもM1チップの都合で、Rosettaモードなるものでターミナルを起動する必要あり。

CocoaPodsインストール完了。必要なライブラリもコードで記述。Google APIやHTTPリクエスト関連ライブラリのインストールに重用した。(コード全文は文末に記載)

②Google認証(OAuth)と連携する

全社員の予定状況を知るためには、会社のGoogleアカウントでGooleログイン状態になることが必要になる。これはアプリからGoogleに遷移してGoogleログインし、またアプリに戻ってくるOAuthの流れを実装することで可能になる。(実はもともとは特定個人アカウントが不要な「サービスアカウント方式」で接続しようしたが、手続きが分かりにくく情報も少ないためOAuthを選択した。短期間の目的達成のためこういう判断が重要)

まずはOAuth画面の設定だ。あらかじめ使用する情報(スコープ)を明示しておくと、iPadアプリからGoogleログインする際にどの情報にアクセスされるか明示される。その上で、Googleログインした状態でiPadアプリに戻ってくるという流れ。

次にクライアントIDやURL SchemesをGoogleから取得。これらの識別子をソースコードやplistファイルに指定すれば、OAuthの設定は完了だ。

一つハマったバグあり。AppAuthを使用してログインするところで謎のエラーが発生。

接続エラーです:Error Domain=org.openid.appauth.general Code=-3 "(null)" 
UserInfo={NSUnderlyingError=0x600000a95230 
{Error Domain=com.apple.AuthenticationServices.WebAuthenticationSession Code=2 
"Cannot start ASWebAuthenticationSession without providing presentation context. 
Set presentationContextProvider before calling -start." 
UserInfo={NSDebugDescription=Cannot start ASWebAuthenticationSession 
without providing presentation context. Set presentationContextProvider before calling -start.}}}

調べたところ、最新のiOS14と、AppAuth1.1.0は相性が悪いらしい。そこでインストールバージョンをAppAuth0.9.5にダウングレードしたところ解消。

③Google Calendar APIと連携する

OAuth連携ができると、Google APIが叩けるようになる。今回利用したのは、


Google Directory API..会議室、社員、社員サムネ写真の取得
Google Calendar API…カレンダーイベント情報の取得

Calendar APIの設定は比較的簡単に完了する。その次のDirectory APIで会議室や社員、社員のサムネイル画像などを取得するコーディングは大変だった。英語圏も含めてネットに情報が少ないので、Googleライブラリの原典を読み込んだりしてようやく実装。詳しくは後述のソースコードを参考に。

④Slack APIと連携する

さあ、会議予定がある人を画面に並べて、来客情報を入力させるUIが出来たら、最後はSlackで投稿するための設定。

まずはApp(bot的なもの)をslackに追加、そしてWebhook(=API)を追加する。Webhookはslackチャンネルごとに設定する、投稿用の専用URL。

Webhookをテスト、本番それぞれ作成して、Appの認証情報を取得。割と簡単。

完了!ソースコードを全公開☆

以上で設定は完了! SwiftUIベースでUIを動かしていけば受付アプリが完成する。今回、HTMLかのようにUIを宣言ベースで作れるSwiftUIの良さを理解。またGoogleやSlack等の業務系のAPI連携が充実してきていることで、業務DXは格段に推進しやすい環境が整いつつあると感じた。業務をハックする感覚で、思いついた人がどんどんDXしていけば良いのだ。

下記にソースコードを公開する。ソースの中でも詰まったポイントや工夫した点がいろいろあるが、時間の都合で解説しきれないので、とにかくソースを強引に貼り付けておく。詳しく知りたいエンジニア諸氏はぜひ当社に入社しちゃってください 🙂

Podfile(Swiftライブラリ用)

まずはSwiftライブラリインストールに必要な設定ファイルの内容

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'ReceptBot' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for ReceptBot
  pod 'AppAuth','0.95.1'
  pod 'GTMAppAuth'
  pod 'GoogleAPIClientForREST/Calendar'
  pod 'GoogleAPIClientForREST/Directory'
  pod 'Alamofire'

  target 'ReceptBotTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'ReceptBotUITests' do
    # Pods for testing
  end

end

ReceptBotApp.swift(起動処理)

次にSwiftUIの起動処理。これはもうテンプレ通り。

//
//  ReceptBotApp.swift
//  ReceptBot
//  braver受付アプリの起動処理。viewを呼び出すだけ
//  Created by esga on 2021/04/29.
//
import SwiftUI

@main
struct ReceptBotApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

ContentView.swift(画面処理)

画面まわりのメイン処理。SwiftUIによって「社員情報が取得できたら、画面を自動再描画」してくれるのでコード記述がシンプル。

//
//  ContentView.swift
//  ReceptBot
// 受付アプリのメインビュー。起動後まず会議室でMTG予定者のリストを表示
//  Created by esga on 2021/04/29.
//
import SwiftUI
import Alamofire

/*-------------全体で使うグローバル変数-------------*/

//テストchannel
var SLACK_WEBHOOK = "https://hooks.slack.com/services/TQRK*****1sss"
//本番channel
//var SLACK_WEBHOOK = "https://hooks.slack.com/services/TQRK*****tYz"

/*--------以下はBraverByGoogle内より参照される------*/

//var DEBUG_TODAY = "2021-5-10T10:00"    //今日の日付 本番では未入力。日付が入力されたらテストモード
var DEBUG_TODAY:String!   //本番はこちらを適用

//Google APIの認証関連情報
let CLIENT_ID = "8949**********h31r5.apps.googleusercontent.com"
let URL_SCHEME = "com.googleusercontent.apps.8949**********31r5"
let SCOPES         = ["https://www.googleapis.com/auth/calendar.readonly",
                      "https://www.googleapis.com/auth/calendar.events.readonly",
                      "https://www.googleapis.com/auth/admin.directory.user.readonly",
                      "https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly"]
/*----------------------------------------------*/

struct ContentView: View {
    
    @State private var showInputForm: Bool = false        //モーダル入力フォームの表示指示
    @ObservedObject var braveByGoogle = BraverByGoogle()  //MTG予定があるbraversのリストを保持
    @ObservedObject var selectedBraver = SelectedBraver() //選択されたBraverの情報を入力フォームに引き渡す
    
    let timer = Timer.publish(every: 300, on: .main, in: .common).autoconnect()  //自動リロードタイマーの設定.5分おき
    
    var body: some View {
        
        ZStack(){
            AnimatedBackground().blur(radius:20)  //システム稼働中であることを示すため背景色グラデを揺らす
            
            HStack(alignment: .top){
                VStack(){
                    HStack(alignment: .center) { //画面上部の案内メッセージ
                        VStack(alignment: .leading){
                            
                            Text("ご来社ありがとうございます!")
                                .font(.title)
                                .fontWeight(.semibold)
                                .foregroundColor(Color.white)
                            
                            Text("目的の担当者を選択するか、「その他の担当者」を押してください")
                                .font(.footnote)
                                .fontWeight(.medium)
                                .foregroundColor(Color.white)
                                .lineLimit(0)
                                .padding(.top, 1.0)
                        }
                        Spacer()
                        Button(action: {//その他の担当が押されたら
                            
                            selectedBraver.name = ""
                            selectedBraver.mail = ""
                            selectedBraver.location = ""
                            showInputForm = true;  //モーダルを表示する
                            
                        }){
                            //目的のbraverが表示されてなかったりその他の要件のため
                            Text("その他の担当者>")
                                .font(.system(.title3, design: .rounded))
                                .fontWeight(.semibold)
                                .foregroundColor(Color.white)
                                .padding(.vertical,20)
                                .padding(.horizontal,30)
                        }
                        .overlay(
                            RoundedRectangle(cornerRadius: 10)
                                .stroke(Color.white, lineWidth: 1) //ボタンの枠線
                        )
                        .sheet(isPresented: self.$showInputForm) { //ボタンが押されたらモーダルで入力フォーム表示
                            //モーダル遷移で表示するビュー。その他の担当
                            HStack {
                                InputFormView(selectedBraver:self.selectedBraver, clientName:"", clientCompany:"" ,note:"")
                            }
                        }
                    }
                    .padding(.vertical, 20.0)
                    .padding(.horizontal, 10.0)
                    .background(Color.blue)
                    
                    ScrollView{
                        generateButtons() //ボタン(braver)の配置は座標が動的なため別途定義
                    }
                }
            }.alert(isPresented: $braveByGoogle.networkError) {  // Slack送信したらアラートを表示する
                Alert(title: Text("ネットワークエラーです"),
                      message: Text("ネットワークに接続されていないか、サーバ側でエラーが発生しています。確認のうえ再度お試しください"),
                      dismissButton: .default(Text("了解"),
                                              action: {
                                              }))
            }
            
        }
        .onAppear(){ //画面出現時に実行するメイン処理。Google APIへアクセスし、braversリストを取得
            //OAuthでGoogleに接続して予定参加者を取得
            braveByGoogle.setBraversByGoogle(viewController:UIHostingController(rootView: ContentView()))
        }
        .onReceive(timer, perform: { time in  //定期タイマーでメイン処理を再実行し自動リロードする
            braveByGoogle.setBraversByGoogle(viewController:UIHostingController(rootView: ContentView()))
        })
    }
    
    //braversが表示されるボタンを3列ずつ動的に表示。
    private func generateButtons() -> some View {
        
        var width = CGFloat.zero
        var height = CGFloat.zero
        var i = 0
        
        return ZStack(alignment: .topLeading) {
            ForEach(braveByGoogle.braverList, id: \.id) { braver in //取得済みのbraverを順に表示

                buttonItem(braver:braver)
                    .padding(.vertical, 10)
                    .padding(.horizontal, 15)
                    .alignmentGuide(.leading, computeValue: { d in
                        //print("name:\(braver.name) i:\(i) dw.:\(d.width) dh.:\(d.height)")
                        if i%3 == 0  { //4人めで左下へ座標を移動させる
                            width = 0
                            height -= d.height
                        }
                        let result = width
                        if i >= braveByGoogle.braverList.count { //全員分終了で座標をもとに戻しておく
                            width = 0
                        } else {
                            width -= d.width //ボタンのサイズ分右にずらす
                        }
                        i += 1
                        return result
                    })
                    .alignmentGuide(.top, computeValue: { d in
                        let result = height
                        if i >= braveByGoogle.braverList.count {
                            height = 0
                            i = 0
                        }
                        return result
                    })
                }
        }.frame(maxWidth: .infinity) //横幅いっぱいまで画面を使用する
    }
    //ボタン1つ1つの設定
    func buttonItem(braver:Braver) -> some View {
        Button(action: {
            print("ボタンが押されました\(braver.name)")
            selectedBraver.name = braver.name
            selectedBraver.mail = braver.mail
            selectedBraver.location = braver.location
            showInputForm = true //モーダルで入力フォームを表示
        }){
            HStack(){
                Text("")  //バランス調整のためダミーのTextを挿入
                Image(uiImage:braver.image!) //braverのサムネを表示
                    .resizable()
                    .frame(width: 50, height: 50)
                    .clipShape(Circle())
                Text(braver.name) //braverの名前を表示
                    .foregroundColor(Color.blue)
                    .font(.system(.title3, design: .rounded))
                    .fontWeight(.semibold)
                    .frame(width:130, height:70, alignment: .leading)
            }
        }
        .background(Color.white)
        .cornerRadius(10)
        .overlay(
            RoundedRectangle(cornerRadius: 10)
                .stroke(Color.blue, lineWidth: 1) //ボタンの枠線
        )
        .compositingGroup()        // shodow用にグループ化
        .shadow(color: .gray.opacity(0.6), radius: 5, x: 7, y: 7)
        .sheet(isPresented: self.$showInputForm) { //ボタンが押されたらモーダルで入力フォーム表示
            //モーダル遷移した後に表示するビュー
            HStack {
                InputFormView(selectedBraver:self.selectedBraver)
            }
        }
    }
}

//受付用の入力フォーム。入力内容をslackへ通知
struct InputFormView : View {
    
    @Environment(\.presentationMode) var presentationMode //閉じるボタン用
    @ObservedObject var selectedBraver: SelectedBraver //選択されたBraverの情報受付用
    @State var clientName = ""           //お客様の氏名保存用
    @State var clientCompany = ""        //お客様の社名保存用
    @State var note = ""                  //備考保存用
    @State private var showAlert = false  // 呼び出し結果アラート表示用
    @State private var sendError = false  // ネットワーク接続結果表示
    @State private var SUBJECT = ""    // ダイアログ表示タイトル。成功か失敗かで変わる
    @State private var BODY = ""       // ダイアログ表示文

    var body: some View {
        VStack(alignment: .leading){
            Spacer()
            HStack{
                Text("お客様情報を入力して担当者を呼び出してください")
                    .font(.title3)
                    .fontWeight(.semibold)
                    .foregroundColor(Color.white)
                    .padding(.vertical,10)
                    .padding(.horizontal,15)
                Spacer()
                Button(action: {  //閉じるボタン
                    self.presentationMode.wrappedValue.dismiss() //このフォームを閉じる
                }){
                    Text("閉じる")
                        .font(.subheadline)
                        .foregroundColor(Color.white)
                        .padding(.vertical,10)
                        .padding(.horizontal,15)
                }
            }
            Form {
                Section(header: Text(" ")){}
                Section(header: Text("お客様の氏名(必須)").font(.headline)){
                    TextField("カンタンでOK", text:$clientName)
                }
                Section(header: Text("お客様の会社名(任意)").font(.headline)){
                    TextField("カンタンでOK", text:$clientCompany)
                }
                Section(header: Text("bravesoftの担当者(任意)").font(.headline)){
                    TextField("カンタンでOK", text: $selectedBraver.name)
                }
                Section(header: Text("備考や目的など、必要に応じて入力ください(任意)").font(.headline)){
                    TextEditor(text:$note)
                }
            }
            //担当者を呼ぶボタン
            Button(action: {
                sendError = false  //エラーだった場合あらためてリセット
                //登録された内容を元にSlackへ送信
                postToSlack(braverName:selectedBraver.name, location:selectedBraver.location,clientName:clientName, clientCompany:clientCompany, note:note )
            }) {
                Text("担当者を呼ぶ(Slackで通知します)")
                    .font(.largeTitle)
                    .fontWeight(.semibold)
                    .foregroundColor(Color.white)
                    .frame(maxWidth: .infinity, maxHeight: 100, alignment: .center)
            }
            .background(Color.blue)
            .alert(isPresented: $showAlert) {  // Slack送信後のダイアログ表示
                Alert(title: Text(SUBJECT),
                      message: Text(BODY),
                      dismissButton: .default(Text("了解"),
                                              action: {
                                                self.presentationMode.wrappedValue.dismiss() //了解が押されたらフォームを閉じる
                                              }))
            }
            Spacer()
        }
        .background(Color.blue)
    }
    
    // Slackへ受付情報を投稿する
    func postToSlack(braverName:String, location:String, clientName: String, clientCompany: String, note: String) {
        
        let headers: HTTPHeaders = [
            "Content-Type": "application/json"
        ]
        let parameters: Parameters = [
            "attachments": [
                [
                    "color": "#0066FF",
                    "text": "\nお客様が「\(braverName)」さんを呼出してます!「\(braverName)」さんが気付いてない場合、同部署や近所のbraverが教えてあげる or 変わりに会議室に案内ください! 絶対に1分以上待たせないで!!",
                    "fields": [
                        [
                            "title": "bravesoftの担当者",
                            "value": braverName,
                        ],
                        [
                            "title": "お客様社名",
                            "value": clientCompany,
                            "short": true
                        ],                   [
                            "title": "お客様氏名",
                            "value": clientName,
                            "short": true
                        ],
                        [
                            "title": "会議名と場所(あくまで予想)",
                            "value": location,
                        ],
                        [
                            "title": "備考・目的",
                            "value": note,
                        ]
                    ]
                ]
            ]
        ]
        
        AF.request(SLACK_WEBHOOK,
                   method: .post,
                   parameters: parameters,
                   encoding: JSONEncoding.default,
                   headers: headers).responseString { response in

                    switch(response.result) {
                    case .success(let value):
                        sendError = false
                        SUBJECT = "担当者を呼び出しました!"
                        BODY = "1分経過しても担当者が現れない場合は、申し訳ございませんが再度お呼び出し頂くか、お近くのスタッフまでお声がけください。"
                        print("Slack接続成功:\(value)")
                    case .failure(let error):
                        sendError = true   //送信失敗アラート表示
                        SUBJECT = "ネットワークエラーです"
                        BODY = "ネットワーク接続か、サーバに問題があります。設定を確認のうえ、再度お試しください"
                        print("Slack接続エラー:\(error)")
                    }
                    showAlert = true   //アラートはどちらにせよ表示表示
                   }
    }
}

//選択されたbraverの情報を保持し共有する
final class SelectedBraver: ObservableObject {
    @Published var name = ""
    @Published var mail = ""
    @Published var location = ""
}



//背景画像をグラデーションで動かすためのView
struct AnimatedBackground: View{
    @State var start = UnitPoint(x:0, y:-2)
    @State var end = UnitPoint(x:4, y:0)
    
    let timer = Timer.publish(every:1, on: .main, in: .default).autoconnect()
    let colors = [Color.white,
                  Color(red: 190/255, green: 220/255, blue: 255/255), //水色系の色でグラデをかける
                  Color(red:  90/255, green: 180/255, blue: 255/255),
                  Color(red: 150/255, green: 200/255, blue: 255/255)
    ]
    var body: some View{
        LinearGradient(gradient: Gradient(colors: colors), startPoint: start, endPoint: end)
            .animation(Animation.easeInOut(duration:3)
                        .repeatForever()
            )
            .edgesIgnoringSafeArea(.bottom)  //これしないとキーボード出現時に下部が無効化されてしまう
            .onReceive(timer, perform: { _ in
                start = UnitPoint(x:4, y:0)
                end   = UnitPoint(x:0, y:2)
                start = UnitPoint(x:-4, y:20)
                start = UnitPoint(x:4, y:0)
            })
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ContentView()
                .padding(9.0)
        }
    }
}

BraverByGoogle.swift(Google通信処理)

Google APIに接続して必要データを取得してくる処理。

//
//  BraverByGoogle.swift
//  ReceptBot
//  Google APIに接続してOAuth認証・Directoryよりbraver取得・Calendar APIより予定を取得
// 本日会議室で予定が入っているbraverのリストを維持する
//  Created by esga on 2021/05/01.
//

import Foundation
import SwiftUI
import GTMAppAuth
import AppAuth
import GoogleAPIClientForREST

//会議室(リソース)のデータ構造
struct Resource: Identifiable{
    let id =  UUID()
    let rid:  String //リソースID
    let name: String //会議室名
    let mail: String  //リソースのメールアドレス(=カレンダー取得のキー)
}
//User(Directoryから取得した社員)のデータ構造
struct User: Identifiable{
    let id =  UUID()
    let mail: String //メールアドレス
    let name: String //氏名
}

//braver一人ひとりのデータ構造
struct Braver: Identifiable{
    let id = UUID()
    let mail: String
    var name: String
    var startDate: Date  //会議が始まる時間
    var score: Int       //現在時刻からの時間的距離(単位:秒)
    var location:String  //会議が行われる場所
    var image:UIImage?   //サムネ画像
}

//Google APIよりカレンダーデータを取得して保持する。
class BraverByGoogle: ObservableObject{
    
    var nowDate:Date!
    var resourceList: [Resource] = []      //会議室リスト配列
    var userList: [String: User] = [:]     //全社員(Braver)リストDictionary メアドと名前のリストを保管
    
    @Published var braverList: [Braver] = []    //メインとなるbraverリスト配列。一意でMTGが近い順に並べる
    @Published var networkError: Bool = false   //ネットワークエラーならtrueとしviewでアラート表示する
    
    private var authorization: GTMAppAuthFetcherAuthorization? //OAuth認証済み情報。一度取得したらローカル保存
    private var currentAuthorizationFlow: OIDExternalUserAgentSession? //認証セッションの情報
    typealias callBackAlias = ((Error?) -> Void) //クロージャ共通宣言
    
    //Google Calenderから取得する予定データの構造体
    struct GoogleCalendarEvent {
        var id: String
        var name: String
        var startDate: Date?
        var endDate: Date?
    }
    private var backViewController: UIViewController?  //呼び出し元クラスのViewを保持。OAuthでログイン画面表示制御に活用
    private var googleCalendarEventList: [GoogleCalendarEvent] = []    //取得してきた予定データの配列
    
    //初期データ処理。Viewを預かり、OAuthを確立する。接続済みならローカルから取得。ない場合はOAuthログイン
    func setBraversByGoogle(viewController: UIViewController){
        
        print("init処理を行います")
        self.braverList.removeAll() //取得のたびにリストを初期化しておく
        networkError = false        //ネットワークエラー状態の場合も初期化
        nowDate = Date()   //日付をセット(デバッグの場合はデバッグ定義した日付をセット)
        
        //今日の日時(デバッグ用)をセット
        if DEBUG_TODAY != nil {
            let formatter = DateFormatter()
            formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
            formatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
            nowDate = formatter.date(from: DEBUG_TODAY)! //デバッグ用
        }
        self.backViewController = viewController //呼び出し元のViewをクラス変数で預かる
        
        //ローカルから過去のOAuth認証情報を取得してGoogleへ接続
        if GTMAppAuthFetcherAuthorization(fromKeychainForName: "authorization") != nil {
            self.authorization = GTMAppAuthFetcherAuthorization(fromKeychainForName: "authorization")!
            self.setBraversList()
        }
        
        if self.authorization == nil { //もしローカルになければOAuth接続した上でGoogleへ接続
            showAuthorizationDialog(callBack: {(error) -> Void in
                self.setBraversList()
            })
        }
    }
    
    //OAuthへの接続処理。ログイン成功したらOAuth情報をローカル保存
    private func showAuthorizationDialog(callBack: @escaping callBackAlias) {
        
        let configuration = GTMAppAuthFetcherAuthorization.configurationForGoogle()
        let redirectURL = URL.init(string: URL_SCHEME + ":/oauthredirect") //ログイン成功後戻りURL
        let request = OIDAuthorizationRequest.init(configuration: configuration,
                                                   clientId: CLIENT_ID,
                                                   scopes: SCOPES,
                                                   redirectURL: redirectURL!,
                                                   responseType: OIDResponseTypeCode,
                                                   additionalParameters: nil)
        print("requestをセットしました..\(request)")
        
        //OAuthログイン。ログイン画面を呼び出し元Viewに表示させた後、Schemeで戻す
        currentAuthorizationFlow = OIDAuthState.authState(
            byPresenting: request,
            presenting: self.backViewController!,
            callback: { (authState, error) in
                if let error = error {
                    print("接続エラーです:\(error)")
                    self.networkError = true
                } else {
                    if let authState = authState {
                        print("認証成功しました:\(authState)")
                        // 認証情報オブジェクトを生成し、ローカル保存
                        self.authorization = GTMAppAuthFetcherAuthorization.init(authState: authState)
                        GTMAppAuthFetcherAuthorization.save(self.authorization!, toKeychainForName: "authorization")
                    }
                }
                callBack(error)
            })
    }
    
    //Google APIより会議室データを取得し、参加者braverをクラス変数に保存する
    func setBraversList() {
        
        //まずは会議室情報、全社員情報をGoogle APIから取得してからresourceList,userList辞書にセット
        self.setResources(callBack: {(error) -> Void in
            self.setUsers(callBack: {(error) -> Void in
                
                //現在から30分前からの全データを取得(ちょっと前のMTGまで念の為取得する)
                let startDateTime = Calendar.init(identifier: .gregorian).date(byAdding: .minute, value: -30, to: self.nowDate)
                //現在から5時間後までの全データを取得
                let endDateTime = Calendar.init(identifier: .gregorian).date(byAdding: .hour, value: +3, to: self.nowDate)
                
                print("会議室のCalendarを取得します")
                //会議室のメアドをキーにカレンダー予定を取得、参加braverを配列へセットする
                for resource in self.resourceList{
                    print("会議室:\(resource.name),mail=\(resource.mail)の予定を取得します")
                    self.setBraverByCalendar(calendarId:resource.mail, startDateTime: startDateTime!, endDateTime: endDateTime!)
                }
            })
        })
    }
    
    //Directory APIより会議室の一覧を取得
    private func setResources(callBack: @escaping callBackAlias) {
        print("会議室取得のため、Directoryに接続します")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_ResourcesCalendarsList.query(withCustomer: "my_customer")
        
        directoryService.executeQuery(query, completionHandler: { (ticket, directory, error) -> Void in
            if let error = error {
                print("接続エラーが発生..\(error)")
                self.networkError = true
                
            } else {
                print("会議室を取得します")
                if let directory = directory as? GTLRDirectory_CalendarResources, let items = directory.items {
                    self.resourceList.removeAll() //現状のリストを初期化
                    
                    for item in items {
                        let rid:  String = item.resourceId ?? ""
                        let name: String = item.resourceName ?? ""
                        let mail: String = item.resourceEmail ?? ""
                        self.resourceList.append(Resource(rid:rid,name:name,mail:mail)) //会議室配列に保管
                        print("会議室:\(name),\(mail)")
                    }
                }
            }
            callBack(error)
        })
    }
    //Directory APIよりbraver全員の一覧を取得(予定データにて氏名がnilのケースがあるので補完のため)
    private func setUsers(callBack: @escaping callBackAlias) {
        print("全User取得のため、Directoryに接続します")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_UsersList.query()
        query.customer = "my_customer"
        
        directoryService.executeQuery(query, completionHandler: { (ticket, user, error) -> Void in
            if let error = error {
                print("接続エラーが発生..\(error)")
                self.networkError = true
            } else {
                print("全Userを取得します")
                if let user = user as? GTLRDirectory_Users, let items = user.users {
                    self.userList.removeAll() //現状のリストを初期化
                    
                    for item in items {
                        let mail: String = item.primaryEmail ?? ""
                        let name: String = item.name?.fullName ?? ""
                        self.userList[mail] = User(mail:mail,name:name) //社員リストに保管
                        print("社員:\(name),\(mail)")
                    }
                }
            }
            callBack(error)
        })
    }
    
    //指定されたカレンダー(会議室カレンダー)に登録されているEventよりbraverを抽出しリストへセットする
    private func setBraverByCalendar(calendarId:String, startDateTime: Date, endDateTime: Date) {
        
        //Google CalenderAPIへ接続
        print("カレンダーに接続します!")
        let calendarService = GTLRCalendarService()
        calendarService.authorizer = self.authorization
        calendarService.shouldFetchNextPages = true
        let query = GTLRCalendarQuery_EventsList.query(withCalendarId: calendarId)
        query.timeMin = GTLRDateTime(date: startDateTime)
        query.timeMax = GTLRDateTime(date: endDateTime)
        
        calendarService.executeQuery(query, completionHandler: { (ticket, event, error) -> Void in
            if let error = error {
                print("\(error)")
            } else {
                if let event = event as? GTLRCalendar_Events, let items = event.items {
                    //会議室の予定に含まれるすべての参加者をチェックしていく
                    for item in items {
                        
                        //dump("dump item attendee...\(item.attendees)")
                        let name: String = item.summary ?? ""
                        let startDate: Date? = item.start?.dateTime?.date
                        let location: String = item.location ?? ""
                        
                        if startDate == nil {continue} //日付未入力はスキップ
                        
                        print("loc:\(location),name:\(name),\(startDate!)")
                        
                        if item.attendees != nil {
                            item.attendees!.forEach({(attendee) in //予定から全参加者の抽出
                                if attendee.email != nil && self.userList.keys.contains(attendee.email!) {  //社員リストに当メアドがなければ非社員として無視
                                    
                                    //参加者braverをbraverList配列へ登録する (APIバグで氏名がnilのケースがあるので社員リストから指名を取得
                                    self.setBraver(mail:attendee.email!, name:self.userList[attendee.email!]!.name, startDate:startDate!, location:name+":"+location)
                                    print("attendee:\(self.userList[attendee.email!]!.name),\(attendee.email!),\(location)")
                                }
                            })
                        }
                        print("loc:\(location),name:\(name),\(startDate!)")
                    }
                }
            }
            //直近の予定者ほど上に表示させるため、Scoreで昇順ソート
            self.braverList.sort(by: {$0.score < $1.score})
        })
    }
    
    //名前とメールアドレスをbraverListに登録していく。重複は排除。
    private func setBraver(mail:String, name:String, startDate:Date, location:String){
        
        let replacedName:String = name.replacingOccurrences(of:"(bravesoft)", with:"") //(bravesoft)を消す
        let score:Int = abs(Int(nowDate.timeIntervalSince(startDate))) //現在時刻と予定日時の差分。スコアが小さいほど直近なので優先表示
        var braverIndex:Int? = searchBraverIndex(mail:mail, braverArray:self.braverList) //配列内に既に存在するbraverか判定
        print("setします..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
        
        //既に存在する場合で、score判定によりより直近の予定なら予定日時を上書き。存在しないなら配列に追加する
        if braverIndex != nil {
            if(self.braverList[braverIndex!].score > score){
                print("braverをより直近として上書きします..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
                self.braverList[braverIndex!].startDate = startDate
                self.braverList[braverIndex!].score     = score
                self.braverList[braverIndex!].location  = location //会議室の場所も直近の予測として保存
            }
        }
        else{
            print("braverを登録します..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
            //取得したbraver情報を格納。(サムネイルは一旦デフォルト画像.後で差し替える)
            let braver = Braver(mail:mail, name:replacedName, startDate:startDate, score:score, location:location, image:UIImage(named:"defaultuser"))
            braverIndex = self.braverList.count  //setBraverImageにこのbraverの配列上の位置を伝える
            self.braverList.append(braver)
        }
        setBraverImage(mail:mail)  //braverのサムネイル画像を取得してbraverList配列へセットする
    }
    
    //配列内の構造体に、特定のmailの値がすでに存在するかチェック
    func searchBraverIndex(mail: String, braverArray: [Braver]) -> Int? {
        return braverArray.firstIndex { $0.mail == mail }
    }
    
    //braverのサムネ画像を取得してセット.API的にbraverそれぞれ1件ずつ取得してセットしていく
    func setBraverImage(mail:String){
        print("ユーザ画像を取得します:\(mail)")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_UsersPhotosGet.query(withUserKey: mail)
        
        directoryService.executeQuery(query, completionHandler: { (ticket, directory, error) -> Void in
            if let error = error {
                if error.localizedDescription.starts(with:"Resource Not Found") {
                    print("画像が見つかりません(404)")  //画像が無いと404が返されるが、エラー処理にはしない
                }
                else{
                    print("接続エラーが発生..\(error)")
                    self.networkError = true
                }
            } else {
                print("ユーザ画像を取得します")
                if let directory = directory as? GTLRDirectory_UserPhoto, let photo = directory.photoData {
                    var urlSafePhoto = photo.replacingOccurrences(of: "-", with: "+")  //URL Safeを解除したBase64文字列に変換
                    urlSafePhoto     = urlSafePhoto.replacingOccurrences(of: "_", with: "/")
                    let imageData = Data(base64Encoded: urlSafePhoto)
                    let braverIndex:Int! = self.searchBraverIndex(mail:mail, braverArray:self.braverList) //このbraverの場所を探す
                    self.braverList[braverIndex!].image = UIImage(data:imageData!)!  //配列内に既に存在するbraverか判定
                }
            }
        })
    }
}

以上2つの主なSwiftファイルだけで受付アプリが出来て、100万円の価値を発揮する。

Enjoy!

菅澤 英司
菅澤 英司
bravesoft CEO&CTO@つよつよエンジニア社長です