Angelo Stavrow [dot] Blog

Missives and musings on a variety of topics.

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Sorting order

In the original version of Per, you entered the details for two products, and hit a big red “Compare” button to see which option gave you the most value for your money. That isn't going to be a great way to deal with more than two products — imagine having to initiate a comparison every time you add something?

Instead, the app can simply float the best option(s) to the top of the list every time you add a new product! That makes feedback really fast and really easy.

Turns out, it's really easy to implement this, because of a couple of things working together.

First, because I made the Product protocol conform to Comparable based on the pricePerUnit computed property on day 3, all I really need to do is call _products.sort() whenever I add a new product to the ProductList. Remember that sort() sorts an array in place, whereas sorted returns a sorted array.

But I can't just add _product.sort() at the end of the add(item: ProductItem) method I wrote on day 5 without creating a bug: if there's already something in the _products array, I (unnecessarily) return after appending a new ProductItem, so those early returns have to be removed.

Furthermore, I'd be changing the semantics of add(item: ProductItem) as well — this method no longer just adds an item to the product list, it adds an item and then sorts the list. I am the only person working on this, so I could leave it as is, but I would rather be kind to my future self and create a new method that I call instead:

mutating func add(_ item: ProductItem, sort: Bool = false) {
    
    // The original add(item:) method implementation goes here

    if (sort) {
        _products.sort()
    }
}

Adding this new sort parameter with a default value of false to the add(item:) method means I don't have to change calls to the method unless I want the list sorted.

I only call it in one place right now —the handleAddProductBarButtonItemTapped action in the product list context view controller, from yesterday— and it should sort the list when called there, because we want to sort the list before updating the product list table view:

self.contentViewController.productList.add(createRandomProduct(), sort: true)

And that's it! We can now add (randomly-generated) items to the product list and they'll sort themselves automatically.

Tomorrow feels like a good day to start working on the UI for adding an actual product's details, so that I can get the app doing what it's supposed to do — compare products.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Where's UINavigationItem?

On day 1, we created a hierarchy of view controllers — coordinators, context, content, containers. In retrospect, this may have overcomplicated how I reason about where things go. Something like adding a UIBarButtonItem should be fairly straightforward:

addBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add,
                                                  target: self,
                                                  action: #selector(handleAddTapped(sender:)))
navigationItem.rightBarButtonItem = addBarButtonItem

(Remember, I'm using stock UI components and functionality for now.)

So I added that to viewDidLoad() in my ProductListCoordinatorViewController, since its root view controller is a UINavigationController. But when I ran the app... no button.

I could change the navigation bar's color without issue, but adding a button to it? Nope. Turns out, I had to drop down a level, to my ProductListContextViewController and add the button there. Why? We can always turn to Hacking With Swift for an answer:

Note: usually bar button items don't belong to the UINavigationBar directly. Instead, they belong to a UINavigationItem that is currently active on the navigation bar, which in turn is usually owned by the view controller that is currently active on the screen.

[source]

This makes sense, because you'll want the view controller that's active to make decisions about what navigation items are relevant. Could I have gone deeper still into the embedded ProductListContentViewController, which holds our table view? Probably! Does it make more sense? Maybe! I'm going to explore this when I implement a detail view controller for adding new products.

Right now, when you tap the ➕ button in the navigation bar, the app runs the following action:

@objc func handleAddProductBarButtonItemTapped(sender: UIBarButtonItem) {
    self.contentViewController.productList.add(createRandomProduct())
    self.contentViewController.loadView()
}

All this does is add a product to the table view's data source, and then call loadView() on the content view controller to update the table view with the new entries. And we're just creating a simple, unitless random product for now:

// MARK: - Temporary methods for testing
private func createRandomProduct() -> ProductItem {
    let dollars = Double(Int.random(in: 1...5))
    let cents = Double(Int.random(in: 0..<100)) / 100
    let price = dollars + cents
    let quantity = Double(Int.random(in: 25...50))
    
    return ProductItem(price: price, quantity: quantity)
}

And so we can now add products to the product list, and see them appear in the app's table view UI. Neat!

But... this app is about comparing price per unit, not just listing products. So, tomorrow, I'm going to add a sorting feature to the ProductList model that gets called whenever you add a new item.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Building the UITableView in code

If you try to simply add var productTableView: UITableView = UITableView() as a property and then set its delegate and dataSource as self without touching the other boilerplate methods, you'll crash the app with this error:

*** Assertion failure in -[UITableView _dequeueReusableCellWithIdentifier:forIndexPath:usingPresentationValues:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKitCore_Sim/UIKit-3901.4.2/UITableView.m:8624
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'unable to dequeue a cell with identifier customcell - must register a nib or a class for the identifier or connect a prototype cell in a storyboard'

This makes sense! The commented-out tableView(cellForRowAt:) method needs to be uncommented, and I need to give this table view a UITableViewCell to work with. And it's not too hard, I just need to add this to the table view controller's viewDidLoad method:

productTableView.register(UITableViewCell.self, forCellReuseIdentifier: "productListCell")

Adding the ProductList data

Now I can create a ProductList in the table view controller, populate it with some fake data, and start working with it!

iOS Simulator showing three of rows of ProductItem data

Getting that to display just requires three lines in tableView(cellForRowAt:) :

let productItem = productList.getItems()[indexPath[1]]
let productUnits = productItem.units?.symbol ?? "units"
cell.textLabel?.text = "\(productItem.quantity) \(productUnits) for \(productItem.price) costs \(productItem.pricePerUnit) per unit"

Remember that the indexPath is collection of two values ([section, row]), so we want to specify its row value (indexPath[1]) as our index for the array returned by productList.getItems().

There's no formatting, and it's a static view of data that we can't add to, but we're slowly getting there! Tomorrow, I'll start working on a way to add product items to the table view.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Start with a Spike

I'm trying to spend less than an hour a day working on this rewrite.

One thing I've found is that trying to think through an elegant solution, write the code, and write up these little diary posts is... ambitious, especially on a daily cadence.

What does seem to work well is thinking about the problem I'm trying to solve, and then spiking a solution for it:

Create spike solutions to figure out answers to tough technical or design problems. A spike solution is a very simple program to explore potential solutions. Build the spike to only addresses the problem under examination and ignore all other concerns. Most spikes are not good enough to keep, so expect to throw it away.

(Emphasis mine.)

I like iteratively solving little problems to maintain forward momentum. It lets me easily scale back the scope of what I'm trying to achieve if the problem feels too hairy to solve in an hour. And if I keep in mind that every line of code I write is equally likely to be thrown away, I don't have to feel attached to the way I'm tackling the problem.

Of course, the point of this rewrite is to remove technical debt, so if at the end of my hour I don't feel great about my solution, I can always choose to revisit it again tomorrow.

So, with that, on to today's problem.

The Product List

The table view controller from day 1 needs a data source, and that data source should contain a collection of the Product items I've been working on the last few days.

But we can't just add any ol' object that conforms to Product to an array and call it a day. Once we add the first such object to this collection —call it a ProductList (because... that's what I called it)— we're defining what type of product it will hold, based on the type of units it uses: either something based on UnitMass for items sold by weight, UnitVolume for items sold by volume, or nil (Product.units is an optional, after all) for items that are sold by the unit.

So, if the first item I add has a UnitMass type, and then I try to add something without units (i.e., nil), the ProductList should reject it. That means it needs to remember the first type of Product added.

Here's what I came up with as today's spike. It's a struct that uses private properties, and a mutating function to add ProductItems (which conform to Product) to an internal array as well as set and check the unit type integrity. It doesn't throw an error yet, it just prints a message to console and skips adding the item.

It also adds a function to return the internal array. So it's basically a public-get, private-set value type.

struct ProductList {
    private var _products: [ProductItem]
    private var _unitType: Unit?
    
    init() {
        _products = [ProductItem]()
    }
    
    mutating func add(_ item: ProductItem) {
        // We can't just add ProductItems willy-nilly because they have to have matching units.
        // So, we need to check two things:
        //
        // 1. Is there already a ProductItem in the _products array?
        // 2. If so, what is its unit type (nil, UnitMass, or UnitVolume)?
        let incomingUnitType = item.units ?? nil
        
        if (_products.count > 0) {
            // There's stuff already in the _products array, so check the unit type.
            if (incomingUnitType == nil && _unitType == nil) {
                _products.append(item)
                return
            } else if (incomingUnitType != nil && incomingUnitType!.isKind(of: type(of: _unitType!))) {
                _products.append(item)
                return
            } else {
                print("Can't add an item with type \(String(describing: incomingUnitType)) to a list of \(String(describing: _unitType)) items")
            }
        } else {
            _unitType = incomingUnitType
            _products.append(item)
        }
    }
    
    func getItems() -> [ProductItem] {
        return _products
    }
}

I don't love the if/else if/else stuff going on here and can probably make it more elegant, but I can reason about this type of logic really easily, so it makes it very fast, if somewhat ugly, to write.

The add(item:) function should also be marked as throws, and that logged message about mismatched types should throw an error for the client to catch. I'll add that tomorrow and see what I can do about cleaning up the logic. I feel like I'm not using a lot of the really beautiful bits of Swift —like guard and nil coalescing— to their full potential here, and that's due to a lack of familiarity.

If you've got feedback, it's always welcome!

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Funny subtitle goes here

On day 2 of the rewrite, I put together a computed property for pricePerUnit in an extension to the Product protocol that looked like this:

var pricePerUnit: Double {
    get {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        let formattedPricePerUnit = formatter.string(for: NSNumber(value: price / quantity))
        return Double(truncating: formatter.number(from: formattedPricePerUnit!)!)
    }
}

As I mentioned at the time, I still don't like the way I'm doing the necessary work of rounding digits using the NumberFormatter conversion to a String and back, but it works as a spike solution for now — we can come back to it another day.

I also added two static functions to the extension yesterday so that the protocol better conforms to Comparable — not only do we want to compare the pricePerUnit property, but we also want to compare the types of Units and make sure they're comparable.

Here's where I need to be careful. I can use quantity and units properties to initialize a Measurement, which gives me the ability to convert this to base units via the appropriately-named baseUnits() method on Measurement — this gives me a common unit to compare all items of the same unit type (UnitMass and UnitVolume are the two we care about). I can add the following logic to the pricePerUnit computed property getter and we're good to go:

var pricePerUnit: Double {
    get {
        var amount: Double = 0
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        
        if let unitType = units {
            if (unitType.isKind(of: UnitMass.self)) {
                // This product is sold by weight.
                let measurement = Measurement(value: quantity, unit: unitType as! UnitMass).converted(to: UnitMass.baseUnit())
                amount = measurement.value
            } else if (unitType.isKind(of: UnitVolume.self)) {
                // This product is sold by volumne.
                let measurement = Measurement(value: quantity, unit: unitType as! UnitVolume).converted(to: UnitVolume.baseUnit())
                amount = measurement.value
            }
        } else {
            amount = quantity
        }
        // We're force-unwrapping here but these variables should never be nil. 😬
        return Double(truncating: formatter.number(from: formatter.string(for: NSNumber(value: price / amount))!)!)
    }
}

With that, we can set up a simple ProductList model and get it hooked up as a UITableViewController data source tomorrow!

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

What's that smell?

So yesterday, I posted about making Per's Product model conform to Comparable, so that I can compare between Product objects — something that's pretty important in a price-comparison app.

Comparing the actual price-per-unit is pretty straightforward:

static func <(lhs: Self, rhs: Self) -> Bool {
    return lhs.pricePerUnit < rhs.pricePerUnit
}

static func ==(lhs: Self, rhs: Self) -> Bool {
    return lhs.pricePerUnit == rhs.pricePerUnit
}

We can't just leave it at that, though, because of Per's built-in unit conversion — you can input one product's quantity in pounds and another product's quantity in grams, and Per will figure out the best-value option automatically. This means that Per's Product model needs to understand how units compare; you can't compare the price per unit between one thing sold by the kilogram, and something else sold by the quart.

The Product model stores units as an optional Unit, and we're going to create the initializer such that it'll set that property as either some flavour of UnitMass (for items sold by weight) or UnitVolume (for items sold by volume), or otherwise nil (for dimensionless units). I started with an ugly nested-conditional mess for figuring out whether lhs.units and rhs.units can be compared. It works, but it feels like a code smell:

static func <(lhs: Self, rhs: Self) -> Bool {
    if let lhsUnitType = lhs.units {
        // lhs.units is not nil
        if let rhsUnitType = rhs.units {
            // rhs.units is not nil
            if (type(of: lhsUnitType) == type(of: rhsUnitType)) {
                return lhs.pricePerUnit < rhs.pricePerUnit
            } else {
                return false
            }
        } else {
            // rhs.units is nil
            return false
        }
    } else {
        // lhs.units is nil
        if rhs.units != nil {
            // rhs.units is not nil
            return false
        } else {
            // rhs.units is nil
            return lhs.pricePerUnit < rhs.pricePerUnit
        }
    }
}

We can make this much, much cleaner:

static func ==(lhs: Self, rhs: self) -> Bool {
    // If both lhs and rhs units nil, then evaluate the boolean expression:
    if (lhs.units == nil && rhs.units == nil) { return lhs.pricePerUnit == rhs.pricePerUnit }

    // If they're not BOTH nil, but any ONE is nil, return false:
    guard let lhsUnitType = lhs.units else { return false }
    guard let rhsUnitType = rhs.units else { return false }

    // If neither is nil, but they're of the same type, evaluate the boolean expression:
    if (type(of: lhsUnitType) == type(of: rhsUnitType)) { return lhs.pricePerUnit == rhs.pricePerUnit }

    // If we get to this point, they're not of the same type, so return false:
    return false
}

That feels much cleaner. Tomorrow, let's explore how that initializer reasons about unit types!

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

I'm a model, you know what I mean

View controllers are fun to work on and all, but at the end of the day, they're only meant to mediate between the user and some data model. In Per, we've got two models that I can think of right now. Today, we focus on one.

The Product

Start with a protocol, say Apple. The Product model in Per is fairly straightforward, but sure, let's do the protocol-oriented programming thing. Here's what our protocol looks like:

protocol Product: Comparable {
	var quantity: Double { get set }
	var units: Unit? { get set }
	var price: Double { get set }
	var pricePerUnit: Double { get }

Why is units an optional? Because we only care if the units are of type UnitMass or UnitVolume right now — otherwise we treat the product as dimensionless, i.e., plain ol' units.

The Product has to conform to Comparable so that we can, uh, compare multiple Products — and here, we're starting with a simplistic implementation in an extension:

extension Product {
    var pricePerUnit: Double {
        get {
            let formatter = NumberFormatter()
            formatter.numberStyle = .currency
            let formattedPricePerUnit = formatter.string(for: NSNumber(value: price / quantity))
            return Double(truncating: formatter.number(from: formattedPricePerUnit!)!)
        }
    }
    
    static func <(lhs: Self, rhs: Self) -> Bool {
        // Naïve implementation, doesn't account for unit type
        return lhs.pricePerUnit < rhs.pricePerUnit
    }
    
    static func ==(lhs: Self, rhs: Self) -> Bool {
        // Naïve implementation, doesn't account for unit type
        return lhs.pricePerUnit == rhs.pricePerUnit
    }
}

This doesn't account for the fact that if a client tries to compare something of UnitLength.meters against UnitType.pounds, it should fail. Instead, I'm simply looking at comparing the price per unit, which is a computed property in the Product extension. I don't love the way I'm rounding the value of pricePerUnit here using a NumberFormatter, so if you have any suggestions, do let me know!

Tomorrow, I'll work on an initializer for the actual struct that implements the protocol, and aim to better handle the unit comparison.

#per #perRewriteDiary #ios

Discuss...

This post is part of a series about rewriting my iOS app, Per. Per is a price per unit comparison app with a bunch of neat convenience figures, but it hasn't been updated in years, so I'm rewriting it from scratch to eliminate a bunch of technical debt. Just because it's not an open-source app doesn't mean I can't share what I learn as I go!

See the rest of the series here.

Tabula Rasa

Rewriting Per from the ground up is going to be a learning experience, and I'm going to take this opportunity to write about the process as I go. I'm aiming to make one (relatively) small change per day, with the goal of having a functional MVP by the end of February 2020 that's not using any custom design work — i.e., using built-in animations, colour palettes, fonts, and icons.

Working this way —without custom design work— means that I can focus on design patterns, unit testing, and so on, moving quickly without getting blocked on UI decisions. It also de-couples the development work (which I'm doing) from the design work (which someone else is doing), so that we can move through this iteratively, but without deep dependency on each other's progress.

I should pause here to define the functional MVP. By the end of the month, I want to replicate the functionality of Per in its current iteration, plus one more feature, which means it should:

  • Provide a quick, easy way to compare price per unit (existing feature)
  • Handle unit conversion automatically (existing feature)
  • Allow simple arithmetic when entering price or quantity (existing feature)
  • Expand the number of compared products from two to “unlimited” (new feature)

So, I started with a single-view iOS app, added the bundle identifier, and then started ripping out Main.storyboard from everywhere. Thankfully, my podcast co-host Frank Courville posted a great article on the topic last month to follow.

View controllers all the way down

I also started implementing a “layered view controller” concept presented in another article of Frank's — this uses coordinator view controllers, that manage container view controllers or wrap context view controllers that are composed of content view controllers. Read Frank's article, and be sure to download the sample app. You'll need to sign up for his newsletter, but I've been subscribed for a couple of years now and it's all been high quality articles on iOS development.

Per isn't an especially complex app, so this may seem like overkill. I'm going to be building this iteratively, though, and while this does mean I'll be creating several view controllers, this method will keep things small and loosely coupled.

Today was spent setting up this hierarchy.

Specifically, I created a simple top-level Product List coordinator view controller that sets a UINavigationController as its root view controller, which in turn embeds a Product List context view controller.

That context view controller wrap a Product List content view controller that implements UITableViewController. Why a table view controller? That feels like the fastest way to add a list of several products for comparison. I didn't yet set up any delegates or data sources for that table view, nore did I create a Product detail view controller or any way to navigate to it.

I'll kick off work on that tomorrow by creating some very basic models.

#per #perRewriteDiary #ios

Discuss...

One of my goals for 2020 is to share more, and one of the ways I intend on doing this is to take photos more often and share them on Flickr. There's even a daily-photo challenge on Micro.blog to help kickstart that challenge. And since I can just plug any RSS feed into Micro.blog to pull in content, and I can generate Flickr RSS feeds from a given tag, we're golden! Just add the Flickr feed to Micro.blog, post one photo to Flickr daily with the tag photoblog, and it'll get shared to Micro.blog (which then cross-posts to other platforms, like Twitter). Great!

Except, right now, Flickr search is broken, so getting a feed of my photos filtered by tag returns… nothing.

But! I can create a Glitch app that uses the Flickr API to pull in my photos, filter them by tag, and generate an RSS feed of them. Yeah, that duplicates the functionality of Flickr's own built-in feed services, but hey, they're broken, and I can make this pretty easily, right? And once search is fixed, I can just turn off the app. It'll be a fun learning experience.

I'm in the middle of working on some other stuff right now. Those projects are far more important and/or time-sensitive, so I add this Flickr feed-generator app to my Someday/Maybe list in OmniFocus; it's neither important nor time-sensitive. Someday/Maybe lists are a collection of “nice to haves” — a backlog of project that you maybe want to do, someday.

A better turn of phrase is the side-project graveyard, as we call these lists on the show.

Inflating the numbers

But the nature of this list, for me, is a little bit more insidious than that. As part of the weekly retrospective that I do, I go over my Someday/Maybe list, automatically bringing every one of those projects to the front of my attention. This routine signal boost makes sure these backlogged projects are kept on the radar.

I have never actually promoted anything from my Someday/Maybe list to an actual project that I tackle.

I have also almost never deleted an idea from my Someday/Maybe list, except during my annual purge.

I have demoted actual projects to my Someday/Maybe list.

I've been thinking about what this does to me. Here's this list of projects that aren't important or urgent enough to take on, but I'm reminded of them week after week. Hey, my system says, don't forget about these things. And so, as I go through my week, my planning is distracted by these ideas. I could be more ruthless about deleting them from the list, but, the fact is: I'm a digital hoarder.

So, today, I deleted the Someday/Maybe list in OmniFocus.

Organic Growth

For myself, it's probably better to ignore a new idea. I'm too easily pulled off course by the promise of something new and shiny. Going forward, if I think of something interesting to make or do, I'm going to push it aside.

If it eventually comes back, that'll be interesting. But that's not good enough — maybe I turn the idea over in my head a bit more, and write it down in my journal where I'll only see it maybe another couple of times in the year, and then I'm going to push it away again.

But if it comes back to me a third time? That's probably a sign. That's maybe where you decide to create a project for it, either to tackle right away or sometime in the future. It's planned, and it's considered.

The ideas that pester you, that won't leave you alone, those are probably the ones you want to pay attention to. And you can't tell which ones they are, if you're pestering yourself with every idea you have.

More TK.


Interesting reads this week

  • Tuning the distraction machine — Tanya Reilly: Our smartphones default to Do Not Disturb being off. Peel away the double negative and consider what this actually means: our smartphones default to Disturb being on. As I'll ask anyone who'll listen, what do you think that does to our psyche? (Yeah, I'm real popular at parties.)
  • Why Software is Slow and Shitty — Robin Rendle: “You don't need a plan to make a beautiful thing” is something I need to remind myself of again, and again, and again.

#projects #focus

Discuss...

From Intelligencer:

If you let products spoil, or you decide you don’t like them anymore halfway through the box, or if you forget what drawer your huge package of batteries is in, then you’re not getting as much value out of your bulk purchase as you had planned. Your effective investment return is likely to be negative; you would have been better off paying more per unit to buy less.

— Source: Buying in Bulk: When Is It a Mistake?

It's this philosophy of “cheaper isn't necessarily saving you money” that drove the development of Per, my little price-per-unit utility calculator. From the marketing copy:

Just because you’re getting more for your money, doesn’t mean that it’s a good buy. Maybe you’re not sure you’ve got the space to store eighty rolls of toilet paper. Or that you can get through sixteen heads of lettuce before they spoil.

Per tells you just how much more you’re getting for your money, so you can make those decisions confidently.

(I'm still planning updates for the app. Stay tuned for V2.)

#behaviour #per

Discuss...

Enter your email to subscribe to updates.