本記事では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側に手を加えます
変更前コード
defmodule LiveMap.Loggers.Map do
...
schema "maps" do
field :description, :string, default: "" # default値に空文字をセット
...
end
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
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
defmodule LiveMapWeb.Api.MapView do
...
def render("index.json", %{maps: maps}) do
render_many(maps, MapView, "map.json") # %{data: maps} から mapsを返すように変更
end
...
end
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を作ると自動で作ってくれます
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
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を選択してください
次の画面はプロジェクト名を入力し、bundle identifierはそのままでnextを押してください
今回はtrarecoとしています
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を追加します
ログイン画面の実装
以下を実装します
- ユーザーはアプリを起動する
- システムはトークン入力画面を表示する
- ユーザーはlive_mapのsetting画面の15桁のトークンを入力して goボタンをタップする
- システムは入力したトークンをlive_mapへ送信する
- live_mapはユーザーを認証しjwtを返えす
- システムは返却されたjwtをUserDefaultsに保存する
- システムは起動時にjwtが保存していた場合トークンリフレッシュを行う
アプリ全体
- 起動時にjwtが保存していた場合トークンリフレッシュを行う
init()内にアプリ起動時に実行する処理を記述します
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に送信します
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!}
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単体だけでリンクを作成します
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を削除してログイン画面に戻ります
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が更新される
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移動の検知した際の処理を記述する
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()
}
}
完成品
本記事は以上になりますありがとうございました
参考ページ
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