#031 訂飲料App — Part1資料建立

功能介紹

  • 查看飲料清單
  • 飲料介紹
  • 選擇甜度、冰塊及配料
  • 加入訂單後可編輯杯數或刪除
  • 編輯訂購人名稱
  • 送出訂單上傳至 AirTable

資料建立

Part1 資料建立

串接 JSON API,建立 function 搭配 Result Type & Completion Closure

記得要先建立飲料菜單在 AirTable 上,想好畫面需要顯示什麼資料,設計相對應的欄位

建立 Class MenuController 讀取資料及上傳資料到 AirTable,學習 Apple 大大的寫法搭配 completion 參數及 ResultType

MenuController 中建立變數 shared 方便在任何地方都能呼叫自己,接著建立常數 baseUrl,儲存 API 讀取的基本網址,後面可以接 path & queryItem ,這邊我想要讀取資料的時候依照 genre 排序,因此加了URLQueryItem(name: “sort[][field]”, value: “genre”)

url的組成
class MenuController {
static let shared = MenuController()

let baseUrl = URL(string: "https://api.airtable.com/v0/appdWBgA90KG0CsDY")!
func fetchMenu(completion: @escaping (Result<[Item], NetworkError>) -> Void) {
let newBaseUrl = baseUrl.appendingPathComponent("DrinkMenu")
var component = URLComponents(url: newBaseUrl, resolvingAgainstBaseURL: true)
component?.queryItems = [URLQueryItem(name: "sort[][field]", value: "genre"),
URLQueryItem(name: "sort[][direction]", value: "asc"),
URLQueryItem(name: "api_key", value: "keygO9mRKCJniMkNL")]
if let url = component?.url{
URLSession.shared.dataTask(with: url) { data, urLResponse, error in
if let data {
let decoder = JSONDecoder()
do {
let menu = try decoder.decode(MenuResponse.self, from: data)
completion(.success(menu.records))
}catch{
completion(.failure(.jsonDecodeFailed))
}
} else {
completion(.failure(.requestFailed))
}
}.resume()
} else {
completion(.failure(.invalidUrl))
}
}

func uploadOrder(list: OrderPost, completion: @escaping (Result<[ListResponse], NetworkError>) -> Void){
let newBaseUrl = baseUrl.appendingPathComponent("Order")
var urlReuest = URLRequest(url: newBaseUrl)
urlReuest.httpMethod = "POST"
urlReuest.setValue("Bearer keygO9mRKCJniMkNL", forHTTPHeaderField: "Authorization")
urlReuest.setValue("application/json", forHTTPHeaderField: "Content-Type")

let encoder = JSONEncoder()
urlReuest.httpBody = try? encoder.encode(list)

URLSession.shared.dataTask(with: urlReuest) { data, urlresponse, error in
if let data {
do {
let decoder = JSONDecoder()
let orderResponse = try decoder.decode(OrderResponse.self, from: data)
let orderList = orderResponse.records
completion(.success(orderList))
} catch {
completion(.failure(.jsonDecodeFailed))
}
} else {
completion(.failure(.requestFailed))
}
}.resume()
}
}

//MainViewController 呼叫
MenuController.shared.fetchMenu { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let drinks):
self.updateTabUI(drinks: drinks)
case .failure(let error):
print(error)
}
}

避免 closure 造成 capture list 因此在呼叫的 result 前面加上 [weak self]

參考文章:

定義 NetworkError
報錯時就能夠清楚知道是哪個環節出問題,能較快速修改

enum NetworkError: Error {
case invalidUrl
case requestFailed
case responseFaild
case jsonDecodeFailed
}

✨Parse ISO 8601 date format

讀取型別 / 上傳型別

上傳資料後發現,怎麼AirTable上顯示的跟我傳的時間不一樣,閱讀 API 文件後發現他要的型別是 String 但要 ISO8601 型別轉過去的文字才能正確被讀取

//將Date.now轉換成ISO8601DateFormat String
let formatter = ISO8601DateFormatter()
formatter.formatOptions.insert(.withFractionalSeconds)
let orderTimeStr = formatter.string(from: Date.now) //print "2023-02-08T15:36:35.326Z"

轉換完就跟 AirTable 要的一樣,可喜可賀! 但這樣的形式顯示在 UI 畫面上實在會讓人一頭霧水,於是讀取的時候要再轉換成我們想要顯示的格式

let formatter = ISO8601DateFormatter()
formatter.formatOptions = [
.withInternetDateTime,
.withFractionalSeconds
]
if let date = formatter.date(from: orderTime) {
dateLabel.text = date.formatted() //print "2/8/2023, 11:36 PM"
}

參考文章:

Segue 向下傳資料,Notification & Delegate 回傳資料

  • 飲料選項頁面下單後的資料,用 NotificationCenter 同時傳給首頁底部色塊顯示幾杯,以及訂單總覽的頁面
  • 在訂單總覽頁有時會想更改杯數或直接刪除訂單,或想再回到首頁逛逛看看,所以訂單總覽頁面的資料也要回傳首頁,更新訂購杯數,因為訂單總覽 View 回上一個首頁 View 是用 dismiss,因此首頁的 View不會經過任何生命週期,選擇使用 delegate 回傳
//發送通知
let name = Notification.Name("orderUpdateNotification")
NotificationCenter.default.post(name: name, object: nil, userInfo: ["order" : order!])

//接收通知
let name = Notification.Name("orderUpdateNotification")
NotificationCenter.default.addObserver(self, selector: #selector(updateToNoti(noti:)), name: name, object: nil)

@objc func updateToNoti(noti:Notification){
if let userInfo = noti.userInfo,
let order = userInfo["order"] as? DrinkDetail {
orders.append(order)
}
}

Enum 儲存飲料選項資料

甜度、冰塊與配料的按鈕圖案名稱設定與enum相同,但有選取與否的兩種圖片,前面用 select 跟 unselect 區分

另外宣告變數 selectSugarIndex 儲存選取糖度的第幾個按鈕,方便用 array 讀取裡面的名稱

enum Sugar:String, CaseIterable {
case NoSugar = "無糖"
case OneSugar = "一度糖"
case ThreeSugar = "三度糖"
case FiveSugar = "五度糖"
case NormalSugar = "正常糖"
}

enum Ice:String, CaseIterable {
case NoIce = "去冰"
case LittleIce = "微冰"
case Hot = "熱"
}

enum Add: String, CaseIterable {
case AddPearl = "+ 白玉"
case None = ""
}

------------------------------------------------
@IBAction func selectSugar(_ sender: UIButton) {
if let index = sugarBtn.firstIndex(of: sender){
selectSugarIndex = index
let selectImage = UIImage(named: "select\(Sugar.allCases[index])")
//先把全部按鈕變成未選取
for i in 0..<sugarBtn.count{
sugarBtn[i].setImage(UIImage(named: "unselect\(Sugar.allCases[i])"), for: .normal)
}
sugarBtn[index].setImage(selectImage, for: .normal)
}
}

利用 Enum 的 CaseIterable 跟 rawValue 兩個方法特性,就可以有很多樣的運用,看是要印出中文還是印出英文都可以呦!

接著下一篇會介紹畫面的組成,其中 TableViewCell 中呼叫 UIPicker 跟填寫textField 時不被 Keyboard 遮住,這兩個地方是卡最久的,好在谷歌與Peter大神的幫助下,都過關斬將拉!

第二篇

GitHub

--

--