UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Day 38, iExpense challenge. A few issues remain.

Forums > 100 Days of SwiftUI

This is my take on iExpense, Day 38: the challenge. Mostly I succeeded, I think, but there remain a few issues.

Task 1: Using another currency. I chose Euro by changing the currency code from USD to EUR. Indeed, the currency symbol now is € instead of $. So far, so good. But I noticed a few issues:

  • When I edit the field, I have to remove the € symbol, or else whatever I enter gets converted to €0.00. This is not needed with the $ symbol, that I can leave it in place if I want. I can also enter a $ value in the Euro field, but it gets then converted to €. Not very neat I think. Can it be improved?
  • I have found no way to change the thousands separator to a point, and the decimal point to a comma, as is normal when working in Euro. I can type the Euro way, but upon entering it gets displayed the other way.
  • As an aside, I found that I wanted to disable autocorrection for the name field.

Task 2: Styling. I have chosen a very simple styling, green for small amounts, red for large amounts, and blue in between. That works fine.

Task 3: Sectioning the display according to type. This was the most challenging. The challenge is twofold: first, get separate sections for Personal and Business, and second, deleting the right expense item.

For the first, I created a function that, given a type, returns an array with only those elements that are in that type. In the View I then loop over all types, and create a separate section for each of them with the items in this array. That takes care of the display issue.

I found it convenient to create a separate view "ItemView" for this.

The second I found trickier. The problem is that the onDelete function gives me an index, but that is the index in the reduced array, not in the full array where I need to delete the element.

I have found no built-in way of handling this, so I wrote my own function for it. From the onDelete function I get an indexSet containing one element. This element I use to find the element in the array for the type. This gives me an UUID as id. With this UUID I go into the full expenses array and retrieve the index for the element. Finally, with this index I can remove it with remove(at:).

Along the way I applied a few other changes. I have concentrated the declaration of the type names in one place, so as not to have to enter duplicate information with the risk of mistakes. All other uses of the type names are derived from this array definition. An added bonus is that I can now add extra types, such as Family, just by adding them to the array and all functionality takes that now into account.

Any comments and suggestions will be welcomed. In particular, how about the issues with the currency field? And is there an easier (maybe built-in) way to delete from a sectioned list?

import SwiftUI

let types = ["Personal", "Business"]

struct ExpenseItem: Identifiable, Codable {
    var id = UUID()
    let name: String
    let type: String
    let amount: Double
}

class Expenses: ObservableObject {
    @Published var items = [ExpenseItem]() {
        didSet {
            if let encoded = try? JSONEncoder().encode(items) {
                UserDefaults.standard.set(encoded, forKey: "Items")
            }
        }
    }

    init() {
        if let savedItems = UserDefaults.standard.data(forKey: "Items") {
            if let decodedItems = try? JSONDecoder().decode([ExpenseItem].self, from: savedItems) {
                items = decodedItems
                return
            }
        }
        items = []
    }
}

struct ItemView: View {
    var item: ExpenseItem

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(item.name)
                    .font(.headline)
            }

            Spacer()
            Text(item.amount, format: .currency(code: "EUR"))
                .foregroundColor(item.amount < 10 ? .green : (item.amount < 100) ? .blue : .red)
        }
    }

    init(_ item: ExpenseItem) {
        self.item = item
    }
}

struct ContentView: View {
    @StateObject var expenses = Expenses()
    @State private var showingAddExpense = false

    func removeItems(at offsets: IndexSet, for type: String) {
        let catExpenses = typeExpenses(type: type)
        let chosenElement = catExpenses[offsets.first!]
        let uuid = chosenElement.id
        let index = expenses.items.firstIndex(where: { $0.id == uuid })!
        expenses.items.remove(at: index)
    }

    func typeExpenses(type: String) -> [ExpenseItem] {
        expenses.items.filter {
            $0.type == type
        }
    }

    var body: some View {
        VStack {
            NavigationView {
                List {
                    ForEach(types, id: \.self) { type in
                        Section(header: Text(type)) {
                                ForEach(typeExpenses(type: type)) { item in
                                    VStack {
                                        ItemView(item)
                                    }
                                }
                                .onDelete(perform: { indexSet in removeItems(at: indexSet, for: type) })
                        }
                    }
                    .navigationTitle("iExpense")
                    .toolbar {
                        Button {
                            showingAddExpense = true
                        } label: {
                            Image(systemName: "plus")
                        }
                    }
                    .sheet(isPresented: $showingAddExpense) {
                        AddView(expenses: expenses)
                    }
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

import SwiftUI

struct AddView: View {
    @State private var name = ""
    @State private var type = types[0]
    @State private var amount = 0.0

    @ObservedObject var expenses: Expenses

    @Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationView {
            Form {
                TextField("Name", text: $name)
                    .disableAutocorrection(true)

                Picker("Type", selection: $type) {
                    ForEach(types, id: \.self) { type in
                        Text(type)
                    }
                }

                TextField("Amount", value: $amount, format: .currency(code: "EUR"))
                    .keyboardType(.decimalPad)
            }
            .navigationTitle("Add new expense")
            .toolbar {
                Button("Save") {
                    let item = ExpenseItem(name: name, type: type, amount: amount)
                    expenses.items.append(item)
                    dismiss()
                }
            }
        }
    }
}

struct AddView_Previews: PreviewProvider {
    static var previews: some View {
        AddView(expenses: Expenses())
    }
}

2      

Jero makes great progress but would appreciate some feedback:

The problem is that the onDelete function gives me an index, but that is the index in the reduced array, not in the full array where I need to delete the element. I have found no built-in way of handling this, so I wrote my own function for it.

This is probably the way to do this.

From the onDelete function I get an indexSet containing one element. This element I use to find the element in the array for the type. This gives me an UUID as id. With this UUID I go into the full expenses array and retrieve the index for the element.
Finally, with this index I can remove it with remove(at:). Is there an easier (maybe built-in) way to delete from a sectioned list?

What's NOT the Swifty way is putting this code into a function in your ContentView!

First, ugh, please. Please rename your ContentView to something more descriptive! You may as well call the view FooBarView, or something equally generic and ugly. Pick a name that describes what the view displays. AlbumView? ProduceView? SaleItemView? GameBoardView? You can do way better than the default ContentView.

Second, try to think of views as structs whose main job is to take some data and parameters from a model, and use clever techniques to display that data on the screen somehow. Now the view might have a Button, or TextField. And those actions may change the data, sure. But give that job to someone else to do!

Consider your delete dilemma. It's not the View's job to pick the item out of the array, match its UUID, and delete the element from the array. That's a business rule! Which object in your application is best suited to encapsulate all the business rules around expense elements?

I would say, the Expenses class is best suited to execute business logic surrounding elements. Need to know how many expenses from the last month? Ask the Expenses object. Want to know how many trips to the gas station this quarter? Ask the Expenses object. Want to know how many expenses over 300 pounds sterling? Ask the Expenses object.

If you want to delete an object from the expenses collection, let the user select the object via a View. Capture the UUID() of the selected object in the View. Then, if the user expresses their intent to DELETE the object (pressing a button, or swipe gesture) take that UUID, and execute a method encapsulated in the Expenses class.

What happens next? the Expenses object will happily oblige, and delete the element. This, in turn, publishes a change. Any view observing that object for changes will get notified. Arrays will recalculate their contents. Views will update themselves based on the new arrays. Bingo, your UI is updated.

3      

Jero has some questions about using Euro.

I have found no way to change the thousands separator to a point, and the decimal point to a comma,
as is normal when working in Euro. I can type the Euro way, but upon entering it gets displayed the other way.

Here are some wise words I read in John Sundell's blog.

The task of formatting numbers into human-readable strings is most likely something that we want to delegate to the system as much as possible, especially when we wish to produce descriptions that are localized and otherwise adapted to the current user’s locale.

See-> Formatting Numbers Blog

Maybe you can get some hints from this post. Please come back and let us know how you implemented your solution.

2      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

@Obelix, re: proper place for delete function

Thank you for taking the time for this elaborate answer!

On your remark about renaming ContentView, I agree. I actually considered it for a moment but found it not necessary for the purpose of this challenge, which I took as just amending the given app where needed for the new functionality, and nothing more. But you do have a point. I renamed it to ExpensesView.

On your second remark, about the proper place for model related functionality: I did not think of that, but just amended the existing removal function from the course, which happened to be inside ContentView. But again, you are right in that it does not belong there.

Therefore I moved it. What I actually did is take matters one step further than your suggestion: in my opinion, even the knowledge that there is a UUID as part of ExpenseItem is not, in principle, a matter for ExpensesView. It turns out that it is easily removed. The changes I applied:

  • Moved the function typeExpenses(type:) that yields all items with a certain type out of ContentView (now: ExpensesView) and into the Expenses class (and renamed it to expensesForType(_:)). How to retrieve these items is not a matter for ExpensesView, in my opinion. And moving it helps in removing UUID knowledge from ExpensesView.

  • Moved the removeItems(at:for:) function (my version, with UUID) to Expenses, as you suggested.

  • Applied a few simple changes here and there to reflect the changed location of these functions, and to improve readability.

In this way I was able to keep the .onDelete action, which I liked as it was, only slightly adapted.

The new portions of Expenses:

    func expensesForType(_ type: String) -> [ExpenseItem] {
        items.filter {
            $0.type == type
        }
    }

    func removeItems(givenBy offsets: IndexSet, for type: String) {
        let chosenElement = expensesForType(type)[offsets.first!]
        let uuid = chosenElement.id
        let index = items.firstIndex(where: { $0.id == uuid })!
        items.remove(at: index)
    }

The new version of .onDelete in ExpensesView:

.onDelete { indexSet in expenses.removeItems(givenBy: indexSet, for: type) }

It was simple to do, it works, and it is indeed much cleaner. Thank you for your insights.

2      

Jero updates his code with skill!

It was simple to do, it works, and it is indeed much cleaner. Thank you for your insights.

Well done!

It's not quite apparent in these challenges and exercises. But one of the benefits of having all your business logic in ONE class is your ability to add that functionality to another program! Once you have a fully functional Expenses class, think how easy it will be to move that to a similar application!

If you had the "add expense" logic in one view, the "delete expense" logic in another view, the "tally monthly expenses" logic in a third view, it would be a mess trying to port expense logic to another application.

On your remark about renaming ContentView, I agree. I actually considered it for a moment but found it not necessary for the purpose of this challenge.

For the most part, I agree. View names for homework assignments and challenges isn't the most important topic to bother yourself with. However, I encourage junior developers on my team to always be ready to defend function, variable, and view names. When we discuss cooking on a grill, we don't use code names, or generic descriptions.

Put the reddish hot stuff on the birdPart.

We say chicken, steak, or chops! We use specific names for seasonings and rubs. Use natural language, and your team will understand your approach and intentions.

It's not technically necessary, but it's a good habit to embrace.

2      

@Obelix, re: formatting of currency

Thank you for your pointer. I found that currency formatting is to a large extent governed by locale. In my simulator the locale was set to "en_US". While setting the currency code to "EUR" did fix the currency symbol problem, it did nothing to fix the formatting of the number in terms of thousands and decimal separators.

Once I set the language in the simulator to "Nederlands" (i.e., Dutch), and the region to "Netherlands", all was properly (for me) formatted.

I had a look into achieving this programmatically, and I came up with the following.

Instead of using format: currency(code: "EUR") I used a NumberFormatter which I could set to the proper locale "nl_NL". This was done in the initalizer of ExpensesView. A few other changes to the code were necessary to make it all work. The following are the essential changes.

On the global level a declaration:

let dutchCurrencyFormatter = NumberFormatter()

An init() for ExpensesView:

    init() {
        dutchCurrencyFormatter.numberStyle = .currency
        dutchCurrencyFormatter.locale = Locale(identifier: "nl_NL")
    }

A different line to display the amount in ExpensesView:

Text(dutchCurrencyFormatter.string(from: NSNumber(value: item.amount))!)

and a different line to show it in AddView:

TextField("Amount", value: $amount, formatter: dutchCurrencyFormatter)

That is all. While working on this I found that NumberFormatter is both very complex and extremely powerful.

2      

Nice!

Thanks for posting your solution, it's a tricky topic and your answer will certainly help others in the future! Enjoyed testing the coding tips you posted. Helped clarify the solution.

All this talk about spending Euros in the Nederlands has made think back fondly on the few bottles of Westvleteren I enjoyed on my trip!

2      

This thread was really helpful. I just finished this challenge too, thanks to you both.

I used an enum I defined with String raw values for expense types and iterated on its .allCases in my ForEach for the Sections. I had to make the enum conform to the CaseIterable and Codable protocols.

2      

@rong  

i completed it in an elegant way, here is the main code

Section ("个人") {
    ForEach(expenses.grExpenses) {item in
        HStack {
            VStack (alignment: .leading) {
                Text(item.name)
                    .font(.headline)
                Text(item.type)
                    .foregroundColor(.gray)
            }
            Spacer()
            Text(item.amount, format: .currency(code: "zh-cn"))
        }
    }
    .onDelete { index in
        expenses.grExpenses.remove(atOffsets: index)
    }
}

class Expenses: ObservableObject {

    var gkExpenses: [ExpenseItem] {
        get { items.filter{ $0.type == "公款" } }
        set {
            items = grExpenses + newValue
        }
    }
    var grExpenses: [ExpenseItem] {
        get { items.filter{ $0.type == "个人" } }
        set {
            items = gkExpenses + newValue
        }
    }

    @Published var items = [ExpenseItem]() {
        didSet {
            if let encode = try? JSONEncoder().encode(items) {
                UserDefaults.standard.set(encode, forKey: "expenses")
            }
        }
    }

    init() {
        if let saveData = UserDefaults.standard.data(forKey: "expenses") {
            if let decode = try? JSONDecoder().decode([ExpenseItem].self, from: saveData) {
                items = decode
                return
            }
        }
        items = []
    }
}

3      

Thank for the ideas and pointers to getting these tasks done. Took the adivise to consolidate data and methods to an "Expense" Class, which was a bit of a challenge.

Class:

import Foundation

// An class to hold expense items, add them, delete them.  Independent of how they are displayed in the UI.
class Expenses: ObservableObject {

    enum Types: String, CaseIterable{

        case business = "Business"
        case personal = "Personal"
    }

    enum Currency: String, CaseIterable {
        case usd = "USD"
        case canadian = "CAD"
    }

    struct ExpenseItem: Identifiable, Codable {
        var id = UUID()
        let name: String
        let date: Date
        let type: String
        let client: String
        let currency: String
        let amount: Double
    }

// An array to hold all Expense Items
    @Published var items = [ExpenseItem]() {
        didSet {
            let encoder = JSONEncoder()

            if let encoded = try? encoder.encode(items){
                UserDefaults.standard.set(encoded, forKey: "Items")
            }
        }
    }
    init() {
        //if there is data saved, load the ExpenseItem array
        if let savedItems = UserDefaults.standard.data(forKey: "Items"){
            if let decodedItems = try? JSONDecoder().decode([ExpenseItem].self, from: savedItems) {
                items = decodedItems
                return
            }
        }
        // otherwise, create an empty array
        items = []
    }

    func add (item: ExpenseItem) {
        items.append(item)
    }

Then found this chunk of code that creates the sections, leveraging an enum for Types

struct AllExpenses: View {
    // Watch object for changes, creating a object instance
    @StateObject var expenses = Expenses()
    @State private var showingAddExpense = false

    let date: Date
    let dateFormatter: DateFormatter

    init() {
        date = Date()
        dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .short
        dateFormatter.timeStyle = .none
    }

    var body: some View {

        NavigationView{
            List {
                ForEach(Expenses.Types.allCases, id: \.rawValue) {type in
                    Section(header: Text(type.rawValue))
                    {ForEach(expenses.items.filter {$0.type == type.rawValue}) {
                        (item) in HStack{
                            VStack(alignment: .leading) {
                                HStack {
                                    Text(item.name)
                                    .font(.headline)
                                    Spacer()
                                    Text(item.date.addingTimeInterval(600), formatter: dateFormatter)

                                }
                                HStack {
                                    Text(item.type)
                                    Spacer()
                                    Text(item.client)

                                }
                            }
                            Spacer()
                            Text(item.amount, format: .currency(code: item.currency))
                                .modifier(style(amount: item.amount))

                        }
                    }
                    }
                }
    //            .onTapGesture(.sheet(isPresented: $showingShowExpense))
                .onDelete{ (indexSet) in
                    self.expenses.items.remove(atOffsets: indexSet)
                }

            }
            .navigationTitle("iExpense")
            .toolbar {
                Button {
                    showingAddExpense = true

                } label: {
                    Image(systemName: "plus")
                }
            }
            .sheet(isPresented: $showingAddExpense) {
                AddView(expenses: expenses)
            }
        }
    }

2      

Here is my approch:

  1. add computed property in Expense class

    class Expenses: ObservableObject{
    @Published var items = [ExpenseItem]() //...
    
    //items with catagory "Food"
    var foodItems:[ExpenseItem]{
        items.filter{$0.catagory == "Food"}
    }
    
    //items with catagory "Clothes"
    var clothesItems:[ExpenseItem]{
        items.filter{$0.catagory == "Clothes"}
    }
  2. create 2 different remove functions

    func removeFood(at offsets: IndexSet){
        //get the id of the item to be removed
        let id = expenses.foodItems[offsets.first!].id
    
        //remove the item from the list using id
        expenses.items.removeAll(where: {$0.id == id})
    }
    
    func removeCloth(at offsets: IndexSet){
        //get the id of the item to be removed
        let id = expenses.clothesItems[offsets.first!].id
    
        //remove the item from the list using id
        expenses.items.removeAll(where: {$0.id == id})
    }
  3. call remove accordingly

    List{
                Section(header: Text("Food")){
                    ForEach(expenses.foodItems){ item in
                        showItem(item)
                    }
                    //passing item and food catagoery to remove function
                    .onDelete(perform: removeFood)
    
                }
                Section(header: Text("Clothes")){
                    ForEach(expenses.clothesItems){ item in
                        showItem(item)
                    }
                    .onDelete(perform: removeCloth)
                }

2      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.