Repository: dadederk/fromZeroToAccessible
Pull request #8: ability to add drinks from detail screen
Author: login: rwapp
Created: 2025-03-04T19:31:20Z | Merged: 2025-03-05T11:36:13Z

Adds the ability to add a drink to an order from the detail screen - I've been working on this in small parts all day so didn't commit in nice chunks because I kept forgetting where I was.

* Adds the ability to add drinks from the detail screen
* Adds detail of the extras to the basket view
* Makes changes to the extras model - please check
* Groups ordered drinks by drink by extras - please check if this is the behaviour you want
* Adds the 'add' button to the drink detail screen with calculated price

TODO: Add toast on adding a drink to the cart

Known issue: The add button changes shape on push. I haven't done anything to set the shape here, so could be a SwiftUI issue.

https://github.com/user-attachments/assets/39c111e8-6b49-4c1b-908e-e3eaeae6afc9




Diff:
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Localizable.xcstrings b/Z2A SwiftUI/NSCoffee/NSCoffee/Localizable.xcstrings
index 5399341..de85f2b 100644
--- a/Z2A SwiftUI/NSCoffee/NSCoffee/Localizable.xcstrings	
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Localizable.xcstrings	
@@ -9,6 +9,9 @@
     },
     "+ %@" : {
 
+    },
+    "Add" : {
+
     },
     "Add to cart" : {
 
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Basket.swift b/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Basket.swift
index c2b3b2a..517f873 100644
--- a/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Basket.swift	
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Basket.swift	
@@ -22,16 +22,18 @@ class Basket: ObservableObject {
         orders.reduce(into: 0.0) { $0 += $1.totalPrice }
     }
 
-    func add(_ drink: any Drink) {
-        if let orderIndex = orders.firstIndex(where: { $0.drink.name == drink.name }) {
+    func add(_ order: Order) {
+        if let orderIndex = orders.firstIndex(where: {
+            $0.drink.name == order.drink.name &&
+            $0.extras == order.extras
+        }) {
+            let existingOrder = orders[orderIndex]
+            order.quantity = existingOrder.quantity + 1
 
-            let order = orders[orderIndex]
-            let updatedOrder = Order(drink: order.drink, quantity: order.quantity + 1, extras: order.extras)
-
-            orders[orderIndex] = updatedOrder
+            orders[orderIndex] = order
 
         } else {
-            orders.append(Order(drink: drink))
+            orders.append(order)
         }
     }
 }
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Extra.swift b/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Extra.swift
new file mode 100644
index 0000000..50164cd
--- /dev/null
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Extra.swift	
@@ -0,0 +1,26 @@
+//
+//  Extra.swift
+//  NSCoffee
+//
+//  Created by Rob Whitaker on 04/03/2025.
+//
+
+import Foundation
+
+class Extra: Equatable {
+    static func == (lhs: Extra, rhs: Extra) -> Bool {
+        lhs.description == rhs.description &&
+        lhs.price == rhs.price &&
+        lhs.quantity == rhs.quantity
+    }
+
+    let description: String
+    let price: Double
+    let quantity: Int
+
+    init(description: String, price: Double, quantity: Int) {
+        self.description = description
+        self.price = price
+        self.quantity = quantity
+    }
+}
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Order.swift b/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Order.swift
index 4b0737f..10a6933 100644
--- a/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Order.swift	
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Models/Order.swift	
@@ -7,29 +7,32 @@
 
 import Foundation
 
-typealias Extras = [(description: String, price: Double)]
-
-class Order: Hashable {
+class Order: ObservableObject, Hashable {
     static func == (lhs: Order, rhs: Order) -> Bool {
-        lhs.drink.name == rhs.drink.name
+        lhs.drink.name == rhs.drink.name &&
+        lhs.extras == rhs.extras
     }
 
     let drink: any Drink
     var quantity: Int
-    var extras: Extras?
+    var extras: [Extra]?
 
-    init(drink: any Drink, quantity: Int = 1, extras: Extras? = nil) {
+    init(drink: any Drink, quantity: Int = 1, extras: [Extra]? = nil) {
         self.drink = drink
         self.quantity = quantity
         self.extras = extras
     }
 
     var totalPrice: Double {
-        Double(quantity) * drink.basePrice + totalExtrasPrice()
+        Double(quantity) * perDrinkPrice
+    }
+
+    var perDrinkPrice: Double {
+        drink.basePrice + totalExtrasPrice
     }
 
-    func totalExtrasPrice() -> Double {
-        return extras?.reduce(into: 0.0) { $0 += $1.price } ?? 0.0
+    private var totalExtrasPrice: Double {
+        extras?.reduce(into: 0.0) { $0 += ($1.price * Double($1.quantity)) } ?? 0.0
     }
 
     func hash(into hasher: inout Hasher) {
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/BasketRow.swift b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/BasketRow.swift
index a8c2571..b38b488 100644
--- a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/BasketRow.swift	
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/BasketRow.swift	
@@ -11,7 +11,20 @@ struct BasketRow: View {
     let order: Order
 
     private var orderDetail: String {
-        "\(order.quantity) x \(CurrencyFormatter.format(order.drink.basePrice)) = \(CurrencyFormatter.format(order.totalPrice))"
+        let basePrice =  CurrencyFormatter.format(order.drink.basePrice)
+        let totalPrice = CurrencyFormatter.format(order.totalPrice)
+        let quantity = order.quantity
+        let extras = order.extras?
+            .compactMap({ CurrencyFormatter.format($0.price * Double($0.quantity)) })
+            .joined(separator: " + ") ?? "£0.00"
+
+        return "\(quantity) x \(basePrice) + (\(extras)) = \(totalPrice)"
+    }
+
+    private var extras: String {
+        order.extras?
+            .compactMap({ "\($0.quantity) x \($0.description)" })
+            .joined(separator: ", ") ?? ""
     }
 
     var body: some View {
@@ -20,6 +33,11 @@ struct BasketRow: View {
                 Text(order.drink.name)
                     .foregroundStyle(.white)
 
+                if !(order.extras?.isEmpty ?? true) {
+                    Text(extras)
+                        .foregroundStyle(.white)
+                }
+
                 Text(orderDetail)
                     .foregroundStyle(.white.secondary)
             }
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/BasketView.swift b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/BasketView.swift
index 1a57dee..6090c05 100644
--- a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/BasketView.swift	
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/BasketView.swift	
@@ -12,7 +12,7 @@ struct BasketView: View {
     
     var body: some View {
         VStack(alignment: .leading) {
-            Group {
+            ScrollView {
                 VStack {
                     if basket.isEmpty {
                         Text("Basket empty")
@@ -23,11 +23,13 @@ struct BasketView: View {
                         BasketRow(order: $0)
                     }
                 }
+            }
+            .padding(.bottom, 4)
 
                 Spacer()
 
                 Button{
-                    // do something
+                   // TODO: Something
 
                 } label: {
                     Text("\(CurrencyFormatter.format(basket.totalPrice)) Buy")
@@ -35,10 +37,8 @@ struct BasketView: View {
                 }
                 .buttonStyle(.borderedProminent)
                 .disabled(basket.isEmpty)
-
-            }
-            .padding()
         }
+        .padding()
         .containerRelativeFrame(.horizontal, count: 4, span: 3, spacing: 0)
         .containerRelativeFrame(.vertical, count: 3, span: 2, spacing: 0)
         .background(
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkDetail.swift b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkDetail.swift
index 4e182e0..acaaeda 100644
--- a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkDetail.swift	
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkDetail.swift	
@@ -9,6 +9,9 @@ import SwiftUI
 
 struct DrinkDetail: View {
     let drink: any Drink
+    @State var order: Order?
+    @ObservedObject var basket: Basket
+    @State var extras = [Extra]()
 
     var body: some View {
         List {
@@ -24,7 +27,7 @@ struct DrinkDetail: View {
             }
 
             Section("Extra Shots") {
-                ExtraShotsView(shotPrice: drink.shotPrice)
+                ExtraShotsView(shotPrice: drink.shotPrice, extras: $extras)
             }
 
             Section("Rate your drink") {
@@ -34,6 +37,45 @@ struct DrinkDetail: View {
             MilkTypeView()
         }
         .listStyle(.grouped)
+        .onChange(of: extras) { oldValue, newValue in
+            if newValue.count > 0 {
+                order = nil
+                order = Order(drink: drink, extras: extras)
+            } else {
+                order = nil
+            }
+        }
         .navigationTitle(drink.name)
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                Button {
+                    if let order = order {
+                        basket.add(order)
+
+                    } else {
+                        basket.add(Order(drink: drink))
+                    }
+
+                    // TODO: Add toast
+
+                } label: {
+                    HStack {
+                        Image(systemName: "cart.fill.badge.plus")
+
+                        VStack {
+                            Text("Add")
+
+                            if let order = order {
+                                Text(CurrencyFormatter.format(order.perDrinkPrice))
+
+                            } else {
+                                Text(CurrencyFormatter.format(drink.basePrice))
+                            }
+                        }
+                    }
+                }
+                .buttonStyle(.borderedProminent)
+            }
+        }
     }
 }
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkTableRow.swift b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkTableRow.swift
index b4a5719..0d8bbf1 100644
--- a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkTableRow.swift	
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkTableRow.swift	
@@ -27,7 +27,7 @@ struct DrinkTableRow: View {
                         .foregroundStyle(Color(UIColor.darkGray))
 
                     Button {
-                        basket.add(drink)
+                        basket.add(Order(drink: drink))
                     } label: {
                         Text("Add to cart")
                     }
@@ -41,7 +41,7 @@ struct DrinkTableRow: View {
             }
 
             NavigationLink {
-                DrinkDetail(drink: drink)
+                DrinkDetail(drink: drink, basket: basket)
 
             } label: {
                 EmptyView()
diff --git a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/ExtraShotsView.swift b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/ExtraShotsView.swift
index 47362a9..e961c4c 100644
--- a/Z2A SwiftUI/NSCoffee/NSCoffee/Views/ExtraShotsView.swift	
+++ b/Z2A SwiftUI/NSCoffee/NSCoffee/Views/ExtraShotsView.swift	
@@ -10,6 +10,7 @@ import SwiftUI
 struct ExtraShotsView: View {
     let shotPrice: Double
     @State private var shots = 0
+    @Binding var extras: [Extra]
 
     private var extraShotPrice: Double {
         shotPrice * Double(shots)
@@ -20,7 +21,14 @@ struct ExtraShotsView: View {
             Image(systemName: "minus.circle")
                 .onTapGesture {
                     guard shots > 0 else { return }
+
                     shots -= 1
+
+                    if shots == 0 {
+                        extras = []
+                    } else {
+                        extras = [Extra(description: "Extra shot", price: shotPrice, quantity: shots)]
+                    }
                 }
                 .foregroundStyle(.tint)
 
@@ -29,7 +37,9 @@ struct ExtraShotsView: View {
             Image(systemName: "plus.circle")
                 .onTapGesture {
                     guard shots < 4 else { return }
+
                     shots += 1
+                    extras = [Extra(description: "Extra shot", price: shotPrice, quantity: shots)]
                 }
                 .foregroundStyle(.tint)
 


Reviews:
user: login: rwapp
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T19:34:02Z
user: login: dadederk
state: APPROVED
author_association: OWNER
submitted_at: 2025-03-05T10:21:19Z

Review comments:
diff_hunk: @@ -34,6 +37,44 @@ struct DrinkDetail: View {
             MilkTypeView()
         }
         .listStyle(.grouped)
+        .onChange(of: extras) { oldValue, newValue in
+            if newValue.count > 0 {
+                order = nil
path: Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkDetail.swift
user: login: rwapp
body: For some reason the order here will only update if I set it to nil first. Any ideas what I've done wrong?
created_at: 2025-03-04T19:34:02Z
author_association: COLLABORATOR
diff_hunk: @@ -22,16 +22,18 @@ class Basket: ObservableObject {
         orders.reduce(into: 0.0) { $0 += $1.totalPrice }
     }
 
-    func add(_ drink: any Drink) {
-        if let orderIndex = orders.firstIndex(where: { $0.drink.name == drink.name }) {
+    func add(_ order: Order) {
path: Z2A SwiftUI/NSCoffee/NSCoffee/Models/Basket.swift
user: login: dadederk
body: I like that you are treating drinks with different extras as different orders. Will implement that in UIKit too.
created_at: 2025-03-05T10:14:50Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,26 @@
+//
+//  Extra.swift
+//  NSCoffee
+//
+//  Created by Rob Whitaker on 04/03/2025.
+//
+
+import Foundation
+
+class Extra: Equatable {
path: Z2A SwiftUI/NSCoffee/NSCoffee/Models/Extra.swift
user: login: dadederk
body: Nice one. That was on my todo list too. Now we can charge for alternative drinks too 😁
created_at: 2025-03-05T10:15:36Z
author_association: OWNER
diff_hunk: @@ -34,6 +37,44 @@ struct DrinkDetail: View {
             MilkTypeView()
         }
         .listStyle(.grouped)
+        .onChange(of: extras) { oldValue, newValue in
+            if newValue.count > 0 {
+                order = nil
path: Z2A SwiftUI/NSCoffee/NSCoffee/Views/DrinkDetail.swift
user: login: dadederk
body: Oh, not sure. Should actually be nil by default already, shouldn't it?
created_at: 2025-03-05T10:21:10Z
author_association: OWNER

PR comments:
user: login: dadederk
created_at: 2025-03-05T10:23:00Z
body: I'm also having an issue with the button placed in the navigation bar. The text for the price doesn't fit sometimes and it goes into a second line and it doesn't look great. Maybe it wasn't a good idea to put it there. Should we add a toolbar and move it at the bottom or something like that?
author_association: OWNER
user: login: rwapp
created_at: 2025-03-05T11:35:45Z
body: Button at the bottom sounds good to me.
author_association: COLLABORATOR