iT邦幫忙

2024 iThome 鐵人賽

DAY 24
0

昨天我們實作 QRCode 掃描並取得資料,今天我們將繼續這個功能,實作掃描完成後,將購物清單顯示出來並讓使用者可以進行編輯、刪除等操作。

目標

今天的實作主要目標是:

  • 顯示掃描後的購物清單:掃描完 QRCode 後,我們會將購物清單以列表形式呈現,並顯示購物品的名稱、數量與價格。
  • 支援列表操作:列表中的物品可以進行滑動刪除。

主要實作

建立 ScannedItem

先來建立一個新的 Model - ScannedItem,用來儲存發票的明細。

struct ScannedItem: Identifiable, Equatable {
    var id = UUID()
    var name: String
    var quantity: Int
    var price: Double
}

更新 AddItemView

這裡宣告的變數做了以下四點更新:

  • 修改點選相機按鈕,使用 fullScreenCover 方式開啟相機,新增 isShowScanner 控制相機 UI 開關。
  • 為了接收回傳回來的商品清單,更新 scanResult 的資料型態為 [ScannedItem]。
  • 收到 scanResult 後需要跳轉到新的列表頁,因此需要宣告 isNavigatingToShoppingList 控制跳轉。
@State private var isShowScanner = false
@State private var scanResult: [ScannedItem] = []
@State private var isNavigatingToShoppingList = false

接著,我們修改右上角的相機按鈕,使用 fullScreenCover 開啟掃描畫面,並在掃描完成後將使用者導向購物清單頁面。這裡使用 onChange,當 scanResult 更新時,畫面就會跳轉到清單頁。

.navigationBarItems(trailing: Button(action: {
    isShowScanner = true  // 開啟相機視圖
}) {
    Image(systemName: "camera")
        .font(.title2)
})
.fullScreenCover(isPresented: $isShowScanner) {
    // 使用 fullScreenCover 開啟相機掃描畫面
    QRScannerView(result: $scanResult, isPresented: $isShowScanner)
}
.onChange(of: scanResult) { newResult in
    if !newResult.isEmpty {
        // 當掃描完成後,處理掃描結果並跳轉到購物清單頁
        isNavigatingToShoppingList = true
    }
}
.background(
    // 隱藏的 NavigationLink,用於在掃描結果更新後進行跳轉
    NavigationLink(destination: ShoppingListView(viewModel: ShoppingListViewModel(shoppingItems: scanResult)), isActive: $isNavigatingToShoppingList) {
        EmptyView()
    }
)

更新 QRScannerView

我們來更新 QRScannerView,實作掃描完成後處理購物資料。當掃描完成後將資料存入 result,將掃描結果傳遞到 AddItemView,並且透過 isPresented 控制掃描頁面的開啟與關閉。

struct QRScannerView: UIViewControllerRepresentable {
    @Binding var result: [ScannedItem]
    @Binding var isPresented: Bool

    func makeUIViewController(context: Context) -> QRScannerController {
        let scannerController = QRScannerController()
        scannerController.onQRCodeScanned = { scannedItems in
            if scannedItems.isEmpty {
                // 如果沒有商品明細,保持相機頁面
                isPresented = false
            } else {
                // 如果掃描成功,將結果返回並關閉相機頁面
                result = scannedItems
                isPresented = false
            }
        }
        return scannerController
    }

    func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {
        // 無需更新
    }

    func makeCoordinator() -> Coordinator {
        Coordinator($result, $isPresented)
    }
}

class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
    @Binding var scanResult: [ScannedItem]
    @Binding var isPresented: Bool

    init(_ scanResult: Binding<[ScannedItem]>, _ isPresented: Binding<Bool>) {
        self._scanResult = scanResult
        self._isPresented = isPresented
    }
}

更新 QRScannerController

既然開啟相機頁面已經不是使用 NavigationLink,那我們需要建立一個按鈕方便使用者可以關閉相機。

override func viewDidLoad() {
    super.viewDidLoad()
    checkCameraAuthorization()
    let closeButton = UIButton(frame: CGRect(x: view.bounds.width - 60, y: 40, width: 40, height: 40))
    closeButton.setTitle("X", for: .normal)
    closeButton.setTitleColor(.white, for: .normal)
    closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.7)
    closeButton.layer.cornerRadius = 20
    closeButton.addTarget(self, action: #selector(closeScanner), for: .touchUpInside)
    view.addSubview(closeButton)
}

@objc func closeScanner() {
    self.dismiss(animated: true, completion: nil)  // 關閉相機頁面
}

當掃描成功後,將結果解析並儲存在 ScannedItem 中,然後傳回給 AddItemView。至於要怎麼解析發票的 QRCode 呢?我們可以上網找到 電子發票證明聯一維及二維條碼規格說明 這份文件。

參考資料:電子發票證明聯一維及二維條碼規格說明

我們來更新 metadataOutput,需要讓它呼叫解析 QRCode 的程式,並且實作錯誤提示。

func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
    if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, metadataObject.type == .qr {
        if let stringValue = metadataObject.stringValue {
            // 解析 QRCode
            let scannedItems = parseQRCode(stringValue)
            
            if scannedItems.isEmpty {
                // 如果無法解析或沒有商品明細,顯示錯誤提示
                showAlertAndRestartCamera()
            } else {
                // 傳遞解析結果並關閉相機
                onQRCodeScanned?(scannedItems)
            }
        }
    } else {
        // 如果沒有掃描到有效的 QRCode,顯示錯誤提示
        showAlertAndRestartCamera()
    }
}

// 顯示錯誤提示並重啟相機
func showAlertAndRestartCamera() {
    let alert = UIAlertController(title: "無效的 QRCode", message: "無法解析商品明細,請重試。", preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "確定", style: .default) { [weak self] _ in
        self?.restartCameraSession()
    })
    present(alert, animated: true)
}

// 重啟相機
func restartCameraSession() {
    captureSession.stopRunning()
    DispatchQueue.global(qos: .background).async {
        self.captureSession.startRunning()
    }
}

// 停止會話
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    captureSession.stopRunning()
}

接著就開始實作解析 QRCode 的部分,根據文件所述,左邊 QRCode 和 右邊 QRCode 格式不相同。左邊會在第96個字才開始是商品明細,前面大多描述發票號碼、開立日期、銷售額、總計額等等資訊。而右邊 QRCode 會直接以 「**」作為開頭,用來區分左邊和右邊的QRCode。

  • 左邊 QRCode 範例資訊:AB112233441020523999900000144000001540000000001234567ydXZt4LAN1UHN/j1juVcRA==:**:3:3:0:乾電池:1:105:
  • 右邊 QRCode 範例資訊:**口罩:1:210:牛奶:1:25
// 解析 QRCode 的函數
func parseQRCode(_ qrCode: String) -> [ScannedItem] {
    var items: [ScannedItem] = []
    
    // 檢查 QRCode 是否為右邊的格式(以 ** 開頭)
    if qrCode.hasPrefix("**") {
        let rightPart = String(qrCode.dropFirst(2))
        items = parseProductDetails(from: rightPart)
    } else if qrCode.count > 95 {
        // 左邊的 QRCode 從第 95 個字元開始解析商品資訊
        let leftPart = String(qrCode.dropFirst(95))
        
        // 取得編碼參數 (第 3 個欄位)
        items = parseProductDetails(from: leftPart)
    }
    
    return items
}

QRCode 的資訊會以「:」作為區隔。根據上方的範例資訊,我們要取得的是「乾電池:1:105:」或「口罩:1:210:牛奶:1:25」。它的格式是「商品名稱:數量:單價」,三個資訊為一組。因此我們使用「:」將資訊切割分別放入相對應的欄位。

// 解析商品明細的輔助函數
func parseProductDetails(from details: String) -> [ScannedItem] {
    var items: [ScannedItem] = []

    let components = details.components(separatedBy: ":")
    
    
    // 每 3 個為一組:品名:數量:單價
    for i in stride(from: 0, to: components.count, by: 3) {
        if i + 2 < components.count {
            let name = components[i]  // 商品名稱
            if let count = Int(components[i + 1]),  // 數量
               let price = Double(components[i + 2]) {  // 單價
                let item = ScannedItem(name: name, quantity: count, price: price)
                items.append(item)
            }
        }
    }
    
    
    return items
}

如此一來,就可以取到我們想要的商品資訊了。

建立 ShoppingListView 與 ShoppingListViewModel

建立 ShoppingListView 與 ShoppingListViewModel 來顯示我們取得的商品清單。

class ShoppingListViewModel: ObservableObject {
    @Published var shoppingItems: [ScannedItem] = []

    init(shoppingItems: [ScannedItem] = []) {
        self.shoppingItems = shoppingItems
    }
}
    
struct ShoppingListView: View {
    @ObservedObject var viewModel: ShoppingListViewModel

    var body: some View {
        VStack {
            List {
                ForEach(viewModel.shoppingItems) { item in
                    HStack {
                        Text(item.name)
                        Spacer()
                        Text("\(item.quantity) x \(String(format: "%.2f", item.price))")
                    }
                }
                .onDelete(perform: deleteItem)
            }
            
            Button(action: {
                viewModel.addItemsToInventory()
            }) {
                Text("新增物品")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            .padding()
        }
        .navigationBarTitle("購物清單", displayMode: .inline)
    }

    private func deleteItem(at offsets: IndexSet) {
        viewModel.shoppingItems.remove(atOffsets: offsets)
    }
}

#Preview {
    ShoppingListView(viewModel: ShoppingListViewModel())
}

Day24成果

總結

今天我們實作掃描 QRCode 並解析消費明細的功能。成功的抓取到 QRCode 並取得消費明細,並且讓明細可以傳遞到下一個列表的頁面。只不過在電子發票整合服務平台的文件有說到,QRCode 其實有三種格式:Big5、UTF-8 和 base64。我們今天只用 UTF-8 解析 QRCode。這部分我們明天再來解決吧,今天就先到這裡,明天見囉~


上一篇
Day 23: 掃描發票 QRCode 與取得內容
下一篇
Day 25: SwiftUI 轉換 Big5&Base64 為 UTF-8
系列文
用 SwiftUI 掌控家庭日用品庫存30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言