[2024 鐵人賽] Day 26: SwiftUI 編輯與儲存掃描到的消費清單

今天我們要繼續昨天的進度,實作消費清單的編輯與儲存功能。當使用者掃描到 QRCode 後,會顯示出消費清單,點擊每一個項目後可以進行編輯,並且按下完成按鈕時,會將這個項目更新至快取中,最終當所有編輯完成後,使用者可以將清單中的物品儲存到資料庫。如果使用者在清單中還有未儲存的物品卻嘗試返回上一頁,會顯示提示,確認是否要放棄所有未儲存的物品。

目標

今天的實作主要目標為:

  1. 編輯消費清單中的物品。
  2. 按下完成按鈕後將變更儲存至快取。
  3. 將所有物品一次性儲存到資料庫中。
  4. 若有未儲存的物品,返回上一頁時顯示提示,確認是否要放棄編輯。

主要實作

編輯與儲存消費清單

更新 ScannedItem

昨天我們建立的 ScannedItem 只有簡單的名稱、數量和價錢,並沒有像 Item 一樣有分類、地點、日期等資訊,所以我們要擴充 ScannedItem,方便我們後續在資料傳遞使用。

Swift
struct ScannedItem: Identifiable, Equatable {
    var id = UUID()
    var name: String
    var quantity: Int
    var price: Double
    var category: ItemCategory?
    var location: Location?
    var dateAdded: Date?
    var expiryDate: Date?
    
    init(id: UUID = UUID(), name: String, quantity: Int, price: Double, category: ItemCategory? = nil, location: Location? = nil, dateAdded: Date? = nil, expiryDate: Date? = nil) {
        self.id = id
        self.name = name
        self.quantity = quantity
        self.price = price
        self.category = category
        self.location = location
        self.dateAdded = dateAdded
        self.expiryDate = expiryDate
    }
}

實作 ShoppingModifyItemViewModel

關於實作 ViewModel 的部分,和 Day12 的 ViewModel 十分相近,這邊就不重複贅述了。不過我們有新增一個返回 ScannedItem 的 func。

Swift
func updateItem() -> ScannedItem {
    return ScannedItem(
        id: originalItem.id,
        name: name,
        quantity: quantity,
        price: Double(price) ?? 0,
        category: category,
        location: location,
        dateAdded: dateAdded
    )
}

這裡的 categories 和 locations 會從 ShoppingListViewModel 傳遞過來,這樣就不用一直重複抓取了。以下提供完整程式碼:

Swift
class ShoppingModifyItemViewModel: ObservableObject {
    @Published var name: String
    @Published var quantity: Int
    @Published var price: String = ""
    @Published var category: ItemCategory?
    @Published var location: Location?
    @Published var dateAdded: Date = Date()
    @Published var expiryDate: Date = Date()
    @Published var shouldRemindExpiryDate = false
    @Published var categories: [ItemCategory] = []
    @Published var locations: [Location] = []
    
    var originalItem: ScannedItem
    
    init(item: ScannedItem, categories: [ItemCategory], locations: [Location]) {
        self.originalItem = item
        self.name = item.name
        self.quantity = item.quantity
        self.price = String(item.price)
        self.dateAdded = item.dateAdded ?? Date()
        self.categories = categories
        self.locations = locations
        self.category = item.category ?? categories[0]
        self.location = item.location ?? locations[0]
    }
    
    func updateItem() -> ScannedItem {
        return ScannedItem(
            id: originalItem.id,
            name: name,
            quantity: quantity,
            price: Double(price) ?? 0,
            category: category,
            location: location,
            dateAdded: dateAdded
        )
    }
}

實作 ShoppingModifyItemView

我們需要一個 ShoppingModifyItemView 來進行物品編輯。編輯完成後,按下完成按鈕將資料回傳到 ShoppingListView,這些資料暫時儲存在快取中,直到使用者確認將清單中的物品全部儲存到資料庫。

ShoppingModifyItemView 的 UI 畫面和新增物品的畫面差不多,所以我們可以從 AddItemView 複製程式碼。

Swift
struct ShoppingModifyItemView: View {
    @ObservedObject var viewModel: ShoppingModifyItemViewModel
    
    var body: some View {
        VStack {
            Form {
                Section(header: Text("基本資料")) {
                    TextField("物品名稱", text: $viewModel.name)
                    
                    Stepper(value: $viewModel.quantity, in: 1...100) {
                        Text("數量: \(viewModel.quantity)")
                    }
                    
                    TextField("價格", text: $viewModel.price)
                        .keyboardType(.decimalPad)
                }
                
                Section(header: Text("分類與地點")) {
                    Picker("選擇分類", selection: $viewModel.category) {
                        ForEach(viewModel.categories, id: \.id) { category in
                            Text(category.name).tag(category as ItemCategory?)
                        }
                    }
                    Picker("選擇地點", selection: $viewModel.location) {
                        ForEach(viewModel.locations, id: \.id) { location in
                            Text(location.name).tag(location as Location?)
                        }
                    }
                }
                
                Section(header: Text("日期")) {
                    DatePicker("加入日期", selection: $viewModel.dateAdded, displayedComponents: .date)
                    
                    Toggle("提醒到期日", isOn: $viewModel.shouldRemindExpiryDate)
                    
                    if viewModel.shouldRemindExpiryDate {
                        DatePicker("到期日", selection: $viewModel.expiryDate, displayedComponents: .date)
                    }
                }
                
                Button(action: {

                }) {
                    Text("完成")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
                .padding()
            }
        }
        .navigationBarTitle("修改物品", displayMode: .inline)
    }
}

ShoppingModifyItemView 中,必須要將編輯完成的資料回傳到列表(ShoppingListView)並自動回到上一頁,因此我們必須宣告兩個變數:presentationModeonSave

Swift
@Environment(\.presentationMode) var presentationMode

var onSave: (ScannedItem) -> Void
  • presentationMode:用來關閉當前頁面。
  • onSave:用來傳遞資料的 Closure。

接著我們在「完成」按鈕中實作「傳遞資料」和「關閉頁面」這兩個動作。

Swift
Button(action: {
    let updatedItem = viewModel.updateItem()
    onSave(updatedItem)
    self.presentationMode.wrappedValue.dismiss()
}) // 略...

快取與資料儲存

更新 ShoppingListViewModel

剛剛在實作 ShoppingModifyItemViewModel 時有說到,categories 和 locations 會從這裡傳遞到下一頁,因此我們必須在這裡先把資料抓出來。

Swift
class ShoppingListViewModel: ObservableObject {
    // 略...
    private let dataManager: DataManager
    var categories: [ItemCategory] = []
    var locations: [Location] = []
    
    init(shoppingItems: [ScannedItem], dataManager: DataManager = DataManager()) {
        self.shoppingItems = shoppingItems
        self.dataManager = dataManager
        fetchItemCategory()
        fetchLocation()
    }
    
    func fetchItemCategory() {
        categories = dataManager.fetchItemCategories()
    }
    
    func fetchLocation() {
        locations = dataManager.fetchLocations()
    }
    
    // 略...
}

當收到編輯完成回傳的資料後,要讓消費清單中的物品暫存在 ShoppingListViewModel,因此需要實作一個 updateItem 來更新 shoppingItems 的資料。這樣一來,收到回傳資料後 UI 也就馬上會更新。

Swift
func updateItem(_ item: ScannedItem) {
    if let index = shoppingItems.firstIndex(where: { $0.id == item.id }) {
        shoppingItems[index] = item
    }
}

最後要實作點擊按鈕後將清單內的物品加入到資料庫中,在新增完畢後,必須要自動跳回上一頁,因此新增 shouldNavigateBack 來控制是否關閉當前頁。

Swift
@Published var shouldNavigateBack = false
// 略...
func addItemsToInventory() {
    for scannedItem in shoppingItems {
        if categories.isEmpty || locations.isEmpty {
            print("缺少分類或地點,無法儲存商品: \(scannedItem.name)")
            failHandle = (isFail: true, title: "發生錯誤")
            break
        }
        
        let success = dataManager.addItem(
            name: scannedItem.name,
            quantity: scannedItem.quantity,
            price: scannedItem.price,
            dateAdded: scannedItem.dateAdded ?? Date(),
            expiryDate: scannedItem.expiryDate,
            category: scannedItem.category ?? categories[0],
            location: scannedItem.location ?? locations[0]
        )
        
        if success {
            print("成功儲存商品: \(scannedItem.name)")
        } else {
            print("儲存失敗: \(scannedItem.name)")
        }
    }
    
    clearItems()  // 清空已儲存的項目
}

func clearItems() {
    shoppingItems.removeAll()
    shouldNavigateBack = true
}

以下附上完整程式碼:

Swift
class ShoppingListViewModel: ObservableObject {
    @Published var shoppingItems: [ScannedItem] = []
    @Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
    @Published var shouldNavigateBack = false
    
    private let dataManager: DataManager
    var categories: [ItemCategory] = []
    var locations: [Location] = []
    
    init(shoppingItems: [ScannedItem] = [], dataManager: DataManager = DataManager()) {
        self.shoppingItems = shoppingItems
        self.dataManager = dataManager
        fetchItemCategory()
        fetchLocation()
    }
    
    func fetchItemCategory() {
        categories = dataManager.fetchItemCategories()
    }
    
    func fetchLocation() {
        locations = dataManager.fetchLocations()
    }
    
    func updateItem(_ item: ScannedItem) {
        if let index = shoppingItems.firstIndex(where: { $0.id == item.id }) {
            shoppingItems[index] = item
        }
    }
    
    func addItemsToInventory() {
        for scannedItem in shoppingItems {
            if categories.isEmpty || locations.isEmpty {
                print("缺少分類或地點,無法儲存商品: \(scannedItem.name)")
                failHandle = (isFail: true, title: "發生錯誤")
                break
            }
            
            let success = dataManager.addItem(
                name: scannedItem.name,
                quantity: scannedItem.quantity,
                price: scannedItem.price,
                dateAdded: scannedItem.dateAdded ?? Date(),
                expiryDate: scannedItem.expiryDate,
                category: scannedItem.category ?? categories[0],
                location: scannedItem.location ?? locations[0]
            )
            
            if success {
                print("成功儲存商品: \(scannedItem.name)")
            } else {
                print("儲存失敗: \(scannedItem.name)")
            }
        }
        
        clearItems()  // 清空已儲存的項目
    }

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

    func clearItems() {
        shoppingItems.removeAll()
        shouldNavigateBack = true
    }
}

更新 ShoppingListView

最後來更新 ShoppingListView 吧!我們先來更新顯示列表的地方,讓列表的項目被點擊後可以跳到編輯頁面。

可以看到在傳入 ShoppingModifyItemViewModel 的時候,onSave 參數使用剛剛 ViewModel 實作的 updateItem,這樣就可以收到回傳的資料並即時更新 UI。

Swift
List {
    ForEach(viewModel.shoppingItems) { item in
        NavigationLink(
            destination: ShoppingModifyItemView(
                viewModel: ShoppingModifyItemViewModel(item: item, categories: viewModel.categories, locations: viewModel.locations),
                onSave: { updatedItem in
                    viewModel.updateItem(updatedItem)
                }
            )
        ) {
            HStack {
                Image(systemName: "cart.fill")
                    .resizable()
                    .frame(width: 40, height: 40)
                    .foregroundColor(.blue)
                    .padding(.trailing, 10)
                
                VStack(alignment: .leading, spacing: 5) {
                    Text(item.name)
                        .font(.headline)
                        .foregroundColor(.black)
                    
                    HStack {
                        Text("數量: \(item.quantity)")
                            .font(.subheadline)
                            .foregroundColor(.gray)
                        Spacer()
                        Text("價格: \(String(format: "%.2f", item.price)) 元")
                            .font(.subheadline)
                            .foregroundColor(.gray)
                    }
                }
                
                Spacer()
            }
            .padding(.vertical, 10)
        }
    }
    .onDelete(perform: viewModel.deleteItem)
}

接下來要處理點擊返回按鈕,需要提示使用者的功能。由於我們沒辦法攔截到系統返回事件,因此我們隱藏系統預設的返回鍵,換上我們自己實作的按鈕。

Swift
@Environment(\.presentationMode) var presentationMode
@State private var showUnsavedAlert = false
// 略...
.navigationBarBackButtonHidden(true)  // 隱藏系統自帶的返回按鈕
.navigationBarItems(leading: Button(action: {
    if viewModel.shoppingItems.isEmpty {
        presentationMode.wrappedValue.dismiss()  // 如果列表為空,直接返回
    } else {
        showUnsavedAlert = true  // 否則顯示提示
    }
}) {
    HStack {
        Image(systemName: "chevron.left")
    }
})
.alert(isPresented: $showUnsavedAlert) {
    Alert(
        title: Text("尚未儲存資料"),
        message: Text("你還有尚未儲存的清單項目,確定要返回嗎?所有未儲存的項目將會消失。"),
        primaryButton: .destructive(Text("確定")) {
            isNavigatingBack = true
            presentationMode.wrappedValue.dismiss()  // 確定返回
        },
        secondaryButton: .cancel(Text("取消"))
    )
}

最後處理點選按鈕儲存後,要自動退回上一頁。我們監聽 ViewModel 的 shouldNavigateBack,如果 shouldNavigateBack 為 true 的話,就返回。

Swift
.onChange(of: viewModel.shouldNavigateBack) { shouldNavigate in
    if shouldNavigate {
        presentationMode.wrappedValue.dismiss()
    }
}

以下附上完整程式碼:

Swift
import SwiftUI
import AlertToast

struct ShoppingListView: View {
    @ObservedObject var viewModel: ShoppingListViewModel
    @Environment(\.presentationMode) var presentationMode
    @State private var showUnsavedAlert = false  // 控制是否顯示未儲存的提示框
    @State private var isNavigatingBack = false  // 控制是否繼續返回
    
    var body: some View {
        VStack {
            List {
                ForEach(viewModel.shoppingItems) { item in
                    NavigationLink(
                        destination: ShoppingModifyItemView(
                            viewModel: ShoppingModifyItemViewModel(item: item, categories: viewModel.categories, locations: viewModel.locations),
                            onSave: { updatedItem in
                                viewModel.updateItem(updatedItem)
                            }
                        )
                    ) {
                        HStack {
                            Image(systemName: "cart.fill")
                                .resizable()
                                .frame(width: 40, height: 40)
                                .foregroundColor(.blue)
                                .padding(.trailing, 10)
                            
                            VStack(alignment: .leading, spacing: 5) {
                                Text(item.name)
                                    .font(.headline)
                                    .foregroundColor(.black)
                                
                                HStack {
                                    Text("數量: \(item.quantity)")
                                        .font(.subheadline)
                                        .foregroundColor(.gray)
                                    Spacer()
                                    Text("價格: \(String(format: "%.2f", item.price)) 元")
                                        .font(.subheadline)
                                        .foregroundColor(.gray)
                                }
                            }
                            
                            Spacer()
                        }
                        .padding(.vertical, 10)
                    }
                }
                .onDelete(perform: viewModel.deleteItem)
            }
            
            Button(action: {
                viewModel.addItemsToInventory()  // 確認並儲存到資料庫
            }) {
                Text("新增物品到家用品清單")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
            .padding()
        }
        .navigationBarTitle("消費清單", displayMode: .inline)
        .toast(isPresenting: $viewModel.failHandle.isFail, alert: {
            AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
        })
        .navigationBarBackButtonHidden(true)  // 隱藏系統自帶的返回按鈕
        .navigationBarItems(leading: Button(action: {
            if viewModel.shoppingItems.isEmpty {
                presentationMode.wrappedValue.dismiss()  // 如果列表為空,直接返回
            } else {
                showUnsavedAlert = true  // 否則顯示提示
            }
        }) {
            HStack {
                Image(systemName: "chevron.left")
            }
        })
        .alert(isPresented: $showUnsavedAlert) {
            Alert(
                title: Text("尚未儲存資料"),
                message: Text("你還有尚未儲存的清單項目,確定要返回嗎?所有未儲存的項目將會消失。"),
                primaryButton: .destructive(Text("確定")) {
                    isNavigatingBack = true
                    presentationMode.wrappedValue.dismiss()  // 確定返回
                },
                secondaryButton: .cancel(Text("取消"))
            )
        }
        .onChange(of: viewModel.shouldNavigateBack) { shouldNavigate in
            if shouldNavigate {
                presentationMode.wrappedValue.dismiss()
            }
        }
    }
}

#Preview {
    ShoppingListView(viewModel: ShoppingListViewModel(shoppingItems: [ScannedItem(name: "Apple", quantity: 1, price: 100)]))
}

總結

歷經三天!終於完成掃描 QRCode 可以新增家用品的功能!雖然不管是 UI 的呈現還是處理邏輯、架構等,還有很多地方可以優化,不過我們這次就先把整個雛形做出來就好了~

其實大部分預計要實作的功能,都實作完畢了,不過鐵人賽還剩下四天的時間,我們就來看看有哪些地方可優化吧!我們明天再見!

分享這篇文章

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *