Homebrewing beer can be a tricky business. Boiling your wort for the desired amount of time for your hop schedule, and also ending up at the correct specific gravity can be difficult. Most homebrewers don't worry about it, and usually end up compromising on their post-boil gravity. I prefer to be a bit more precise in my brewing.
Brew Kettle tracks your actively boiling wort in real-time. It knows what is going on in your kettle simply by tracking the boil time and getting updated volume measurements from the brewer. If your kettle does not have a sight-glass, a calibrated mash paddle or spoon is enough to get an accurate volume during the boil.
Once you start taking volume measurements, the app is able to tell you valuable information in real-time, like:
Current gravity
Current volume
Expected ETA to reach your desired final gravity
What your final gravity will actually be if you continue boiling for the desired boil time
With this information at hand and updated in real-time, you can decide turn the heat up or down to ensure you boil for the right amount of time, and end up at the correct gravity.
Brew Kettle is written entirely in Swift and SwiftUI. Under the hood, it is really just a stopwatch that knows how to calculate volumes and concentrations. However, as far as the implementation goes, there were a few interesting aspects:
The Apple Units and Measurement APIs are incredible! I wasn't able to find a suitable Dimension to represent sugar concentration (UnitConcentrationMass and UnitDispersion are close, but not quite right) but it was incredibly easy for me to create a new Dimension that I called UnitSugarConcentration. This enabled my code to be incredibly direct and expressive:
func testPredictedFinalGravityOneReading() throws {
let config = BrewSession.BrewSessionFactory(
initialVolume: Volume(value: 10, unit: .gallons),
initialGravity: Gravity(value: 1.048, unit: .specificGravity),
desiredFinalGravity: Gravity(value: 1.070, unit: .specificGravity),
desiredBoilTime: TimeInterval(inMinutes: 60))
var brewSession = try config.buildBrewSession()
// 2.5 gal/30 min -> 5 gal/hour
brewSession.recordFutureVolume(
Volume(value: 7.5, unit: .gallons),
atTime: brewSession.boilStartTime.addingTimeInterval(TimeInterval(inMinutes: 30))
)
// at the end of 60 minutes, we should be at half the original volume, so double the gravity points
Date.mockedNowDate = brewSession.boilStartTime.addingTimeInterval(TimeInterval(inMinutes: 30))
let predictedFinalGravity = try XCTUnwrap(brewSession.predictedFinalGravity)
XCTAssertEqual(Gravity(value: 1.096, unit: .specificGravity), predictedFinalGravity)
}
By using the Units and Measurement API, I set the codebase up to support other sugar concentration units in the future (e.g. Brix and Plato) with very minimal code changes.
The other interesting aspect of the Units and Measurements API is that I found myself creating a new Rate type to express a measurement over a given period of time.
struct Rate<UnitType, UnitTime>: Equatable, Comparable where UnitType: Dimension, UnitTime: UnitDuration {
var measurement: Measurement<UnitType>
var per: UnitTime
...
}
The way the Units and Measurement API expresses rates is with entirely new Dimensions like UnitSpeed, but that approach is limited to the specific units that are created as part of that Dimension. On the other hand, expressing rates as the Rate type above gives the developer freedom to combine any Measurement with any UnitDuration. This allowed me to express and perform calculations with the current boil off rate by expressing it as
typealias BoilRate = Rate<UnitVolume, UnitDuration>
This made it incredibly easy to not only express rates that are interesting to this app, but also to reason about and manipulate them, for example finding an average BoilRate from a collection of BoilRates obtained from volume measurements at certain times.
This app requires the user to enter the volume of their brew kettle at different times in the boiling process. A typical value range for these measurements is between 5-10 gallons and, as far as a homebrewer is concerned, they really don't need any more precision than 1/10th of a gallon. With that in mind, I really didn't like the idea of requiring the user to type in these numbers. Even restricting input to an appropriate keyboard felt too clumsy. What I really wanted was a horizontally sliding ruler with haptic feedback - just like a Picker in the wheel style, but scrolling horizontally.
At the time this was written, the SwiftUI ScrollView did not have a way to monitor the current scroll position (it does now). To get around this, I used a UIScrollView wrapper that itself was a SwiftUI view, but could communicate with UIScrollView to provide the current scroll offset. I found a Swift package doing exactly that. For my part, the ScrollSlider control I wrote sets an acceptable range of values, draws an appropriate ruler, and handles converting scroll position into a corresponding value in the range supplied to the control. Using it in a view is extremely straightforward:
ScrollSlider(value: $newVolume, valueRange: 1.0...brewSession.volumeReadings.last!.value.value
, step: 0.1, markAlignment: .bottom) {
Text(Volume(value: newVolume, unit: .gallons).formatted())
.font(.largeTitle)
.fontDesign(.rounded)
}
The app was officially released to the Apple App Store on July 20, 2024. Go check it out!
Do I think it will become a popular app? Oh heavens no... but that's not the point. I made this app for my own use, and to really start diving into SwitUI. If there are other homebrewers that would appreciate it, then they can enjoy it too.