7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

LiveViewとGoogleMapAPIで作る GPS Logger Client watchOS App + SwiftUI

Posted at

本記事ではPhoenixのLiveViewでGoogleMapAPIを使用してGPS Loggerを作成していきます
今回はClientとしてwatchOS App+SwiftUIで作成していきます

LiveViewとGoogleMapAPIで作る GPS Logger 1
LiveViewとGoogleMapAPIで作る GPS Logger 2
LiveViewとGoogleMapAPIで作る GPS Logger 3
LiveViewとGoogleMapAPIで作る GPS Logger short ver
LiveViewとGoogleMapAPIで作る GPS Logger Client watchOS APP + SwiftUI

要件

  • 15桁の認証トークンを入力して JWTを取得しUserDefaultsに保存すること
  • アプリ起動時にトークンリフレッシュすること
  • 期限が切れたトークンでアクセスした場合はUserDefaultsのJWTを破棄し、ログイン画面に移動すること
  • 認証後 新しいMap作成ボタンが表示されること
  • 認証後 Mapの一覧が表示されること
  • MapをタップするとLogger画面に移動すること
  • Logger画面のスタートボタンをタップしてloggingが開始されること
  • loggingはbackgroundモードでも継続されること
  • Logger画面でlogging中は一時停止ボタンが表示されること
  • 一時停止ボタンをタップしてloggingが中止されること
  • 一時停止ボタンタップするとスタートボタンが表示されれ、loggingが再開できること
  • logging中は緯度と経度が表示されること

用語集

UserDefaults -> iOS,WatchOSで使える簡易ストレージ
JWT -> JSON Web Token、ユーザーと紐付けられた認証トークン、有効期限がある
Map -> GPS Loggingしたデータを保持するモデル
live_map -> GPS Logger Backend
トークン -> live_mapのsetting画面で取得できるjwtを取得するための認証トークン

ユースケース

  • ユーザーはアプリを起動する
  • システムはトークン入力画面を表示する
  • ユーザーはlive_mapのsetting画面の15桁のトークンを入力して goボタンをタップする
  • システムはユーザーを認証しlive_mapからjwtを取得しUserDefaultに保存する
  • システムは新しいMap作成ボタン「start new logging」を表示する
  • システムはMap一覧を表示する
  • ユーザーは start new loggingをタップする
  • システムはLogger画面を表示する
  • ユーザーはスタートボタンをタップする
  • システムはGPS Loggingを開始する
  • システムは20m毎にGPSデータをlive_mapに送信し保存する
  • ユーザーは一時停止ボタンタップする
  • システムはGPS Loggingを一時停止する
  • ユーザーは再度スタートボタンをタップする
  • システムはGPS Loggingを再開する

代替コース

  • 入力したトークンが間違っていた場合
    • システムは auth errorと表示する
  • backendサーバーが落ちていた場合
    • システムは「no data or no result」と表示する
  • トークンが期限切れだった場合
    • 保存しているトークンを削除してログイン画面へ移動する

準備

GPS Logger Client用にPhoenix側に手を加えます
変更前コード

lib/live_map/loggers/map.ex
defmodule LiveMap.Loggers.Map do
  ...
  schema "maps" do
    field :description, :string, default: "" # default値に空文字をセット
    ...
end
lib/live_map/loggers.ex
defmodule LiveMap.Loggers do
  ...
  # user_idにマッチしたmapのみを取得するメソッド追加
  def list_maps(user_id) do
    Map
    |> where([m], m.user_id == ^user_id)
    |> Repo.all
  end
  ...
end
lib/live_map_web/controllers/api/map_controller.ex
defmodule LiveMapWeb.Api.MapController do
  ...
  def index(conn, _params) do
    render(conn, "index.json", maps: Loggers.list_maps(conn.user_id)) # list_maps/1に変更
  end
  ...
end
lib/live_map_web/views/api/map_view.ex
defmodule LiveMapWeb.Api.MapView do
  ...
  def render("index.json", %{maps: maps}) do
    render_many(maps, MapView, "map.json") # %{data: maps} から mapsを返すように変更
  end
  ...
end
lib/live_map_web/controllers/api/user_contoller.ex
defmodule LiveMapWeb.Api.UserController do
  action_fallback LiveMapWeb.FallbackController
  ...
  # トークンリフレッシュ追加
  def refresh_token(conn, _params) do
    token = conn.private.guardian_default_token
    with {:ok, {old_token, _old_claims}, {new_token, _new_claims}}
      <- LiveMap.Guardian.refresh(token) do
        LiveMap.Guardian.revoke(old_token)
        conn |> render("jwt.json", token: new_token)
    end
  end
end

API Error時の共通処理
今回は手動でしたが、phx.gen.jsonでapiを作ると自動で作ってくれます

lib/live_map_web/controllers/fallback_controller.ex
defmodule LiveMapWeb.FallbackController do
  @moduledoc """
  Translates controller action results into valid `Plug.Conn` responses.

  See `Phoenix.Controller.action_fallback/1` for more details.
  """
  use LiveMapWeb, :controller

  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(:unauthorized)
    |> put_view(LiveMapWeb.ErrorView)
    |> render(:"401")
  end

  def call(conn, {:error, _param}) do
    conn
    |> put_status(:internal_server_error)
    |> put_view(LiveMapWeb.ErrorView)
    |> render(:"500")
  end
end
lib/live_map_web/router.ex
defmodule LiveMapWeb.Router do
  ...
  scope "/api", LiveMapWeb do
    pipe_through [:api, :jwt_authenticated]

    resources "/maps", Api.MapController, only: [:index, :show, :create]
    resources "/points", Api.PointController, only: [:create]
    post "/users/refresh_token", Api.UserController, :refresh_token # ここを追加
  end
  ...
end

プロジェクト作成

watchOSのWatch APPを選択してください
スクリーンショット 2021-08-15 22.25.16.png
次の画面はプロジェクト名を入力し、bundle identifierはそのままでnextを押してください
今回はtrarecoとしています
スクリーンショット 2021-08-15 22.25.34.png

info.plistの編集

次にapp名 WatchKit Extensionのinfo.plistを編集します

ローカルサーバーと通信させるのに
App Transport Security Settingsを追加し、配下に以下を追加します
Allow Local Networking -> Yes
Allow Arbitrary Loads -> Yes

backgoundでも実行されるように
Required background modesを追加し、配下に以下を追加します
item0 -> App registers for location updates

位置情報(CoreLocation)を取得できるように以下を追加します
Privacy - Location When In Use Usage Description -> このアプリは位置情報を取得します
Privacy - Location Always and When In Use Usage Description -> このアプリは位置情報を取得します
Required device capabilities を追加し、配下に以下を追加します
item0 -> Location Services
item1 -> GPS

CoreLocationの追加

一番上のapp名のフォルダを選択して、app名 WatchKit ExtensionのFrameworkに +ボタンを押して CoreLocationを追加します
スクリーンショット 2021-08-15 23.16.11.png

ログイン画面の実装

以下を実装します

  • ユーザーはアプリを起動する
  • システムはトークン入力画面を表示する
  • ユーザーはlive_mapのsetting画面の15桁のトークンを入力して goボタンをタップする
  • システムは入力したトークンをlive_mapへ送信する
  • live_mapはユーザーを認証しjwtを返えす
  • システムは返却されたjwtをUserDefaultsに保存する
  • システムは起動時にjwtが保存していた場合トークンリフレッシュを行う

アプリ全体

  • 起動時にjwtが保存していた場合トークンリフレッシュを行う

init()内にアプリ起動時に実行する処理を記述します

trarecoApp.swift
import SwiftUI

@main
struct trarecoApp: App {
    @ObservedObject var tokenModel = TokenModel()
    @SceneBuilder var body: some Scene { ... }
    
    init() {
        let token = UserDefaults.standard.string(forKey: "token") ?? ""
        if (token != "") { tokenModel.refresh() }
    }
}

トークン入力画面

  • 10key + del, enterの入力画面を作ります
  • mount時にjwtの有無をチェックして空の場合に入力画面を表示します
  • 数字のボタンをタップすると inputが呼ばれtokenの末尾に追加されます
  • delのボタンをタップすると delが呼ばれtokenの末尾が削除されます
  • goのボタンをタップすると tokenModelを通してtokenの値をlive_mapに送信します
ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var token: String = ""
    @State private var jwt: String = ""
    @ObservedObject var tokenModel = TokenModel()
    
    func input(i: Int) {
        if(token == "auth error") { self.token = "" }
        self.token = token + String(i)
    }
    func del() {
        self.token = String(token.dropLast())
    }
    var body: some View {
        VStack {
            if(jwt != "" || token == "authenticated") {
                MapListView()
            } else {
                Text(self.token.count == 0 ? "input token" : self.token)
                HStack{
                    Button("1", action: {input(i: 1)}).scaleEffect(0.7)
                    Button("2", action: {input(i: 2)}).scaleEffect(0.7)
                    Button("3", action: {input(i: 3)}).scaleEffect(0.7)
                }.frame(height: 30)
                HStack{
                    Button("4", action: {input(i: 4)}).scaleEffect(0.7)
                    Button("5", action: {input(i: 5)}).scaleEffect(0.7)
                    Button("6", action: {input(i: 6)}).scaleEffect(0.7)
                }.frame(height: 30)
                HStack{
                    Button("7", action: {input(i: 7)}).scaleEffect(0.7)
                    Button("8", action: {input(i: 8)}).scaleEffect(0.7)
                    Button("9", action: {input(i: 9)}).scaleEffect(0.7)
                }.frame(height: 30)
                HStack{
                    Button("del", action: del ).scaleEffect(0.7)
                    Button("0", action: {input(i: 0)}).scaleEffect(0.7)
                    Button(action: {
                        self.tokenModel.post(token: self.token) {message in self.token = message!}
                    }){
                        Text("go")
                            .fontWeight(.bold)
                            .padding()
                            .background(Color.orange)
                            .cornerRadius(29)
                    }.scaleEffect(0.7)
                }.frame(height: 30)

            }
        }
        .onAppear{
            self.jwt = UserDefaults.standard.string(forKey: "token") ?? ""
        }
    }
}

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

tokenModel
post関数は、入力したトークンをlive_mapへ送信します
通信に失敗した場合 -> auth errorをトークン入力画面に表示します
認証に失敗した場合 -> auth errorをトークン入力画面に表示します
認証に成功した場合 -> live_mapから返ってきたjwtをUserDefaultに保存し、Map一覧画面を表示

非同期で入力画面の値を変更するのに
completion関数を以下のように設定し
completion: @escaping (String?) -> Void
メインスレッドを更新するために以下のブロック内で実行しています
DispatchQueue.main.async { completion("auth error")}
呼び出す側はこのように書きます
self.tokenModel.post(token: self.token) {message in self.token = message!}

tokenModel.swift
import Foundation

final class TokenModel: ObservableObject {
    func post(token: String, completion: @escaping (String?) -> Void) {
        let url = "http://localhost:4000/api/sign_in"
        var params = Dictionary<String, String>()
        var request = URLRequest(url: URL(string: url)!)
        params["token"] = token
        request.httpMethod = "POST"
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: [])
        
        let task = URLSession.shared.dataTask(with: request)
        { (data: Data?, response: URLResponse?, error: Error?) in
            guard let _ = data, let response = response as? HTTPURLResponse else {
                print("No data or No response.")
                DispatchQueue.main.async { completion("auth error")}
                return
            }
            if response.statusCode == 200 {
                if let data = data {
                    do {
                        let responseJSON = try? JSONSerialization.jsonObject(with: data, options: [])
                        if let responseJSON = responseJSON as? [String: Any] {
                            DispatchQueue.main.async { completion("authenticated") }
                            UserDefaults.standard.set(responseJSON["token"], forKey: "token")
                        }
                    }
                }
            } else {
                print("Status Code: " + String(response.statusCode))
                DispatchQueue.main.async { completion("auth error")}
            }
        }
        task.resume()
    }
    
    func refresh() {
        let url = "http://localhost:4000/api/users/refresh_token"
        let jwt = UserDefaults.standard.string(forKey: "token") ?? ""
        var request = URLRequest(url: URL(string: url)!)
        request.httpMethod = "POST"
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.addValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")

        
        let task = URLSession.shared.dataTask(with: request)
        { (data: Data?, response: URLResponse?, error: Error?) in
            guard let _ = data, let response = response as? HTTPURLResponse else {
                print("No data or No response.")
                return
            }
            if response.statusCode == 200 {
                if let data = data {
                    do {
                        let responseJSON = try? JSONSerialization.jsonObject(with: data, options: [])
                        if let responseJSON = responseJSON as? [String: Any] {
                            UserDefaults.standard.set(responseJSON["token"], forKey: "token")
                        }
                    }
                }
            } else {
                print("Status Code: " + String(response.statusCode))
            }
        }
        task.resume()
    }
}

Map 一覧画面の実装

以下を実装します

  • システムは新しいMap作成ボタン「start new logging」を表示する
  • システムはMap一覧を表示する
  • ユーザーは「start new logging」または取得した一覧のMapをタップする
  • システムはLogger画面を表示する

mount時にMap一覧をlive_mapから取得します
trarecoApp.swiftでアプリ全体がNavigationViewとなっているので、NavigationLink単体だけでリンクを作成します

MapListView.swift
import SwiftUI

struct MapListView: View {
    @State var maps: [[String: Any]] = []
    @ObservedObject var mapViewModel = MapViewModel()
    
    var body: some View {
        List {
            NavigationLink(destination: LoggerView(id: 0, name: "new")) {
                Text("start new logging")
            }.listRowPlatterColor(Color.green)
            ForEach(0 ..< maps.count, id: \.self) { index in
                NavigationLink(
                    destination: LoggerView(id: maps[index]["id"] as! Int, name: maps[index]["name"] as! String )) {
                    Text(maps[index]["name"] as! String)
                }
            }
        }
        .onAppear{
            self.mapViewModel.get() {maps in self.maps = maps as! [[String : Any]]}
        }
    }
}

struct MapListView_Previews: PreviewProvider {
    static var previews: some View {
        MapListView()
    }
}

mapViewModel

map一覧画面を取得します
jwtをUserDefaultsから読み込みます
jwt認証を行うためrequestにaddValueで認証情報を追加します
取得に成功した場合は最大数をUserDefaultsに保存して、取得したmapをcomplationで渡します
最大数は新規作成時のmap名に使用します

取得できなかった場合は空の配列を返します
statusコードが401(認証失敗)の場合は期限切れなのでjwtを削除してログイン画面に戻ります

mapViewModel.swift
import Foundation

final class MapViewModel: ObservableObject {
    func get(completion: @escaping (Any?) -> Void) {
        let url = "http://localhost:4000/api/maps"
        let jwt = UserDefaults.standard.string(forKey: "token") ?? ""
        var request = URLRequest(url: URL(string: url)!)

        request.httpMethod = "GET"
        request.addValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
        let task = URLSession.shared.dataTask(with: request)
        {  (data: Data?, response: URLResponse?, error: Error?) in
            guard let _ = data, let response = response as? HTTPURLResponse else {
                print("No data or No response.")
                return
            }
            if response.statusCode == 200 {
                if let data = data {
                    do {
                        let responseJSON = try JSONSerialization.jsonObject(with: data, options: []) as! [Any]
                        let maps = responseJSON.map { (map) -> [String: Any] in return map as! [String: Any]}
                        UserDefaults.standard.set(maps.count, forKey: "count")
                        DispatchQueue.main.async { completion(maps) }
                    } catch let error {
                        print(error)
                    }
                }
            } else {
                print("Status Code: \(response.statusCode)")
                if (response.statusCode == 401) { UserDefaults.standard.set("", forKey: "token") }
                DispatchQueue.main.async { completion([]) }
            }
        }
        task.resume()
    }
}

Logger画面の実装

以下を実装します

  • システムはLogger画面を表示する
  • ユーザーはスタートボタンをタップする
  • システムはGPS Loggingを開始する
  • システムは100m毎にGPSデータをlive_mapに送信し保存する
  • ユーザーは一時停止ボタンタップする
  • システムはGPS Loggingを一時停止する
  • ユーザーは再度スタートボタンをタップする
  • システムはGPS Loggingを再開する

Logger画面
NavigationLink(destination: LoggerView(id: 0, name: "new")) の props部分は無印のvar or letで定義? ここはよくわからん
マウント時に propsが id: 0, name: "new"の場合は新規にMapを作成してMapIDをUserDefaultsに保存
マウント時に propsが既存のものだった場合UserDefaultsにMapIDを保存
スタートボタンをタップするとrecordStart()が実行されGPS loggingが開始され、スタートボタンが一時停止ボタンに変わる
一時停止ボタンをタップするとrecordStop()が実行されGPS loggingが停止され、一時停止ボタンがスタートボタンに変わる
GPS Logging開始時や20m移動すると緯度経度がlive_mapに送信される
緯度経度をlive_mapに送信する際にlat/lngが更新される

LoggerView.swift
import SwiftUI

struct LoggerView: View, Identifiable {
    @State private var recoding: Bool = false
    @State private var pause: Bool = true
    @ObservedObject var loggerViewModel = LoggerViewModel()
    var id: Int
    let name: String
    func recordStart() {
        loggerViewModel.manager.startUpdatingLocation()
        self.pause = false
    }
    
    func recordPause() {
        loggerViewModel.manager.stopUpdatingLocation()
        self.pause = true
    }
    
    var body: some View {
        VStack {
            Text(name)
            VStack {
                if(pause) {
                    Button(action: { recordStart() }) {
                        Image(systemName: "play")
                    }.background(Color.green).cornerRadius(30)
                } else {
                    Button(action: { recordPause() }) {
                        Image(systemName: "pause")
                    }
                }
            }
            VStack {
                Text("lat: \(loggerViewModel.userLatitude)")
                Text("lng: \(loggerViewModel.userLongitude)")
            }
        }
        .onAppear{
            if(id == 0 && name == "new") {
                loggerViewModel.startLogger()
            } else {
                UserDefaults.standard.set(id, forKey: "mapId")
            }
        }
    }
}

struct LoggerView_Previews: PreviewProvider {
    static var previews: some View {
        LoggerView(id: 0, name: "new")
    }
}

loggerViewModel

override init() で init時にCoreLocationのパラメーターをセット
startLogger()はnameにmap + 取得したmap数をセットしてリクエストを実行、返却されたMapIDを保存
startLogger()はMap一覧画面の新規作成ボタンをタップしてLogger画面に移動した際に実行される
post()はjwtをUserDefaultsから読み込み、lat,lng,mapIdを送信する
postは20mの移動を検知した際にlocationManager内で実行される
失敗時にstatusCodeが401(認証失敗)の場合はUserDefaultsのjwtを削除してログイン画面に戻る

extension LoggerViewModel: CLLocationManagerDelegate内では20m移動の検知した際の処理を記述する

loggerViewModel.swift
import Foundation
import Combine
import CoreLocation

class LoggerViewModel: NSObject, ObservableObject{
    @Published var userLatitude: Double = 0
    @Published var userLongitude: Double = 0
    @Published var manager = CLLocationManager()
    private var mapId: Int = 0
    
    override init() {
        super.init()
        self.manager.delegate = self
        self.manager.desiredAccuracy = kCLLocationAccuracyBest // 最大制度
        self.manager.requestWhenInUseAuthorization() // 位置情報を取得する許可を得るダイアログ表示
        self.manager.allowsBackgroundLocationUpdates = true // backgroundでも実行する
        self.manager.distanceFilter = 20 // 20m移動を検知したら func locationManager を実行する
    }
    
    func post() {
        let url = "http://localhost:4000/api/points"
        let jwt = UserDefaults.standard.string(forKey: "token") ?? ""
        var request = URLRequest(url: URL(string: url)!)
        var params = Dictionary<String, Any>()
        params["lat"] = userLatitude
        params["lng"] = userLongitude
        params["map_id"] = mapId

        request.httpMethod = "POST"
        request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: [])
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.addValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
        
        let task = URLSession.shared.dataTask(with: request){
            (data: Data?, response: URLResponse?, error: Error?) in
            guard let data = data, let response = response as? HTTPURLResponse else {
                print("no data or no response")
                return
            }
            
            if response.statusCode == 200 {
                print(data)
            } else {
                print("intenal server error: \(response.statusCode)\n")
                if (response.statusCode == 401) { UserDefaults.standard.set("", forKey: "token") }
            }
        }
        task.resume()
    }
    
    func startLogger() {
        let url = "http://localhost:4000/api/maps"
        let jwt = UserDefaults.standard.string(forKey: "token") ?? ""
        var request = URLRequest(url: URL(string: url)!)
        var params = Dictionary<String, Any>()
        params["name"] = "map \(UserDefaults.standard.integer(forKey: "count"))"

        request.httpMethod = "POST"
        request.httpBody = try? JSONSerialization.data(withJSONObject: params, options: [])
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.addValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
        let task = URLSession.shared.dataTask(with: request){
            (data: Optional?, response: URLResponse?, error: Error?) in
            guard let data = data, let response = response as? HTTPURLResponse else {
                print("no data or no response")
                return
            }
            if response.statusCode == 200 {
                if let data = data {
                    do {
                        let responseJSON = try? JSONSerialization.jsonObject(with: data, options: [])
                        if let responseJSON = responseJSON as? [String: Any] {
                            UserDefaults.standard.set(responseJSON["id"], forKey: "mapId")
                        }
                    }
                }
            } else {
                print("intenal server error: \(response.statusCode)\n")
                if (response.statusCode == 401) { UserDefaults.standard.set("", forKey: "token") }
            }
        }
        task.resume()
    }
}

extension LoggerViewModel: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.last else { return }
        mapId = UserDefaults.standard.integer(forKey: "mapId")
        userLatitude = location.coordinate.latitude
        userLongitude = location.coordinate.longitude
        post()
    }
}

完成品

そして出来上がったものがこちらになります
Image from Gyazo

本記事は以上になりますありがとうございました

参考ページ
https://kasika.xyz/articles/core-location-unintended-map-match/
https://grandbig.github.io/blog/2019/12/22/ios13-core-location/
https://qiita.com/shngt/items/ee3c1d283acd5a85fa63
https://www.hackingwithswift.com/quick-start/swiftui/how-to-fix-initializer-init-rowcontent-requires-that-sometype-conform-to-identifiable
https://qiita.com/Ajyarimochi/items/50cdc57f898b79cfb48e
https://chusotsu-program.com/swiftui-list-navigation-link/
https://www.am10.blog/archives/422
https://tech.playground.style/swift/simulater-local-server/
https://qiita.com/shungo_m/items/64564fd822a7558ac7b1
https://www.toyship.org/2020/07/17/150247

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?