Add duty cycle/utilization fields, wheel picker for goals, and updated screenshots

- Add dutyCyclePercent and defaultUtilizationFactorPercent to ComponentLibraryItem
  with normalization logic and backend field fetching
- Change default dailyUsageHours from 1h to 24h
- Replace goal editor stepper with day/hour/minute wheel pickers
- Update app icon colors and remove duplicate icon assets
- Move SavedBattery.swift into Batteries/ directory, remove Pods group
- Add iPad-only flag and start frame support to screenshot framing scripts
- Rework localized App Store screenshot titles across all languages
- Add runtime goals and BOM completed items to sample data
- Bump version to 1.5.1 (build 41)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stefan Lange-Hegermann
2026-02-17 21:49:21 +01:00
parent 8da6987f32
commit 34e8c0f74b
22 changed files with 571 additions and 371 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,295 +0,0 @@
{
"fill" : {
"automatic-gradient" : "extended-gray:1.00000,1.00000"
},
"groups" : [
{
"blur-material" : null,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.31812,0.56494,0.59766,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
}
}
],
"image-name" : "fuse-top.png",
"name" : "fuse-top",
"opacity" : 1,
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-225.390625
]
}
},
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.31812,0.56494,0.59766,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"glass" : true,
"hidden" : false,
"image-name" : "body 3.png",
"name" : "body 3",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-74.9921875
]
}
}
],
"lighting" : "individual",
"name" : "Group",
"opacity" : 0.8,
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.8
}
},
{
"hidden" : false,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "srgb:0.37056,0.37056,0.37056,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
}
],
"glass-specializations" : [
{
"appearance" : "tinted",
"value" : true
}
],
"image-name" : "legs 2.png",
"name" : "legs 2",
"opacity-specializations" : [
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
100
]
}
}
],
"lighting" : "combined",
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
},
{
"blur-material" : null,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"solid" : "srgb:1.00000,0.57811,0.00000,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
}
}
],
"image-name" : "fuse-top.png",
"name" : "fuse-top",
"opacity" : 1,
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-225.390625
]
}
},
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "srgb:1.00000,0.57811,0.00000,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"glass" : true,
"hidden" : false,
"image-name" : "body 3.png",
"name" : "body 3",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-74.9921875
]
}
}
],
"lighting" : "individual",
"name" : "Group",
"opacity" : 0.8,
"position" : {
"scale" : 1,
"translation-in-points" : [
65.078125,
-49.375
]
},
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.8
}
},
{
"hidden" : false,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "srgb:0.37056,0.37056,0.37056,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
}
],
"glass-specializations" : [
{
"appearance" : "tinted",
"value" : true
}
],
"image-name" : "legs 2.png",
"name" : "legs 2",
"opacity-specializations" : [
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
-14.34375,
117.640625
]
}
}
],
"lighting" : "combined",
"position" : {
"scale" : 1,
"translation-in-points" : [
73.96875,
-62.640625
]
},
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -11,18 +11,18 @@
"layers" : [
{
"fill" : {
"solid" : "srgb:1.00000,0.57811,0.00000,1.00000"
"solid" : "display-p3:0.94269,0.45676,0.15045,1.00000"
},
"glass" : true,
"hidden" : true,
"hidden" : false,
"image-name" : "flash.png",
"name" : "flash",
"opacity" : 1,
"position" : {
"scale" : 0.8,
"scale" : 2.03,
"translation-in-points" : [
7.4921875,
-218.84375
0,
-74.15562499999982
]
}
}
@@ -44,7 +44,7 @@
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.31765,0.56471,0.59608,1.00000"
"solid" : "display-p3:0.00000,0.08076,0.34088,1.00000"
}
},
{
@@ -69,7 +69,7 @@
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.31812,0.56494,0.59766,1.00000"
"automatic-gradient" : "display-p3:0.00000,0.08076,0.34088,1.00000"
}
},
{

View File

@@ -15,7 +15,7 @@ class CableCalculator: ObservableObject {
@Published var length: Double = 10.0
@Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
@Published var dutyCyclePercent: Double = 100.0
@Published var dailyUsageHours: Double = 1.0
@Published var dailyUsageHours: Double = 24.0
var calculatedPower: Double {
voltage * current
@@ -119,7 +119,7 @@ class SavedLoad {
var colorName: String = "blue"
var isWattMode: Bool = false
var dutyCyclePercent: Double = 100.0
var dailyUsageHours: Double = 1.0
var dailyUsageHours: Double = 24.0
var system: ElectricalSystem?
var remoteIconURLString: String? = nil
var affiliateURLString: String? = nil
@@ -138,7 +138,7 @@ class SavedLoad {
colorName: String = "blue",
isWattMode: Bool = false,
dutyCyclePercent: Double = 100.0,
dailyUsageHours: Double = 1.0,
dailyUsageHours: Double = 24.0,
system: ElectricalSystem? = nil,
remoteIconURLString: String? = nil,
affiliateURLString: String? = nil,

View File

@@ -13,6 +13,8 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let voltageIn: Double?
let voltageOut: Double?
let watt: Double?
let dutyCyclePercent: Double?
let defaultUtilizationFactorPercent: Double?
let iconURL: URL?
let affiliateLinks: [AffiliateLink]
@@ -40,6 +42,19 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return String(format: "%.1fA", current)
}
var normalizedDutyCyclePercent: Double? {
Self.normalizePercentValue(dutyCyclePercent)
}
var normalizedUtilizationFactorPercent: Double? {
Self.normalizePercentValue(defaultUtilizationFactorPercent)
}
var defaultDailyUsageHours: Double? {
guard let percent = normalizedUtilizationFactorPercent else { return nil }
return (percent / 100) * 24
}
var localizedName: String {
localizedName(usingPreferredLanguages: Locale.preferredLanguages) ?? name
}
@@ -172,6 +187,15 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return collected
}
private static func normalizePercentValue(_ value: Double?) -> Double? {
guard var percent = value else { return nil }
if percent <= 0 {
// Backend sends 0 to represent 100% utilization.
percent = 100
}
return min(max(percent, 0), 100)
}
}
@MainActor
@@ -221,7 +245,10 @@ final class ComponentLibraryViewModel: ObservableObject {
components?.queryItems = [
URLQueryItem(name: "filter", value: "(type='load')"),
URLQueryItem(name: "sort", value: "+name"),
URLQueryItem(name: "fields", value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt"),
URLQueryItem(
name: "fields",
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor"
),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "perPage", value: "\(perPage)")
]
@@ -265,6 +292,8 @@ final class ComponentLibraryViewModel: ObservableObject {
voltageIn: record.voltageIn,
voltageOut: record.voltageOut,
watt: record.watt,
dutyCyclePercent: record.dutyCycle,
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
iconURL: iconURL(for: record),
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
)
@@ -414,6 +443,8 @@ final class ComponentLibraryViewModel: ObservableObject {
let voltageIn: Double?
let voltageOut: Double?
let watt: Double?
let dutyCycle: Double?
let defaultUtilizationFactor: Double?
enum CodingKeys: String, CodingKey {
case id
@@ -424,6 +455,8 @@ final class ComponentLibraryViewModel: ObservableObject {
case voltageIn = "voltage_in"
case voltageOut = "voltage_out"
case watt
case dutyCycle = "duty_cycle"
case defaultUtilizationFactor = "default_utilization_factor"
}
struct TranslationsContainer: Decodable {

View File

@@ -1437,13 +1437,59 @@ private struct GoalEditorSheet: View {
let onSave: (Double) -> Void
let onClear: (() -> Void)?
@State private var days: Int
@State private var hours: Int
@State private var minutes: Int
private let minuteStepValues: [Int]
@Environment(\.dismiss) private var dismiss
init(
title: String,
tint: Color,
value: Binding<Double>,
minimum: Double,
maximum: Double,
step: Double,
cancelTitle: String,
saveTitle: String,
clearTitle: String,
showsClear: Bool,
formattedDurationProvider: @escaping (Double) -> String,
onSave: @escaping (Double) -> Void,
onClear: (() -> Void)?
) {
self.title = title
self.tint = tint
self._value = value
self.minimum = minimum
self.maximum = maximum
self.step = step
self.cancelTitle = cancelTitle
self.saveTitle = saveTitle
self.clearTitle = clearTitle
self.showsClear = showsClear
self.formattedDurationProvider = formattedDurationProvider
self.onSave = onSave
self.onClear = onClear
self.minuteStepValues = GoalEditorSheet.minuteValues(forStep: step)
let initialComponents = GoalEditorSheet.components(
for: value.wrappedValue,
minimum: minimum,
maximum: maximum,
minuteValues: self.minuteStepValues
)
_days = State(initialValue: initialComponents.days)
_hours = State(initialValue: initialComponents.hours)
_minutes = State(initialValue: initialComponents.minutes)
}
var body: some View {
NavigationStack {
Form {
Section {
Stepper(value: $value, in: minimum...maximum, step: step) {
VStack(alignment: .leading, spacing: 16) {
Label {
Text(formattedDurationProvider(value))
.font(.title3.weight(.semibold))
@@ -1452,6 +1498,36 @@ private struct GoalEditorSheet: View {
.symbolRenderingMode(.hierarchical)
.foregroundStyle(tint)
}
HStack {
Picker("Days", selection: $days) {
ForEach(0...maxDays, id: \.self) { day in
Text("\(day) day\(day == 1 ? "" : "s")")
.tag(day)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
Picker("Hours", selection: $hours) {
ForEach(hourRange, id: \.self) { hour in
Text("\(hour) hr\(hour == 1 ? "" : "s")")
.tag(hour)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
Picker("Minutes", selection: $minutes) {
ForEach(minuteOptionsForSelection, id: \.self) { minute in
Text("\(minute) min\(minute == 1 ? "" : "s")")
.tag(minute)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
}
.frame(height: 140)
}
}
@@ -1484,12 +1560,166 @@ private struct GoalEditorSheet: View {
}
}
}
.onChange(of: days) { _ in
let cappedHours = min(hours, maxHours(for: days))
if cappedHours != hours {
hours = cappedHours
}
let allowedMinutes = minuteOptions(for: days, hours: hours)
if !allowedMinutes.contains(minutes) {
minutes = allowedMinutes.last ?? 0
}
updateValueFromSelection()
}
.onChange(of: hours) { _ in
let allowedMinutes = minuteOptions(for: days, hours: hours)
if !allowedMinutes.contains(minutes) {
minutes = allowedMinutes.last ?? 0
}
updateValueFromSelection()
}
.onChange(of: minutes) { _ in
updateValueFromSelection()
}
.onChange(of: value) { newValue in
syncPickers(with: newValue)
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
private var maxDays: Int {
max(0, Int(floor(maximum / 24)))
}
private var hourRange: ClosedRange<Int> {
0...maxHours(for: days)
}
private var minuteOptionsForSelection: [Int] {
minuteOptions(for: days, hours: hours)
}
private var clampedValue: Double {
min(max(value, minimum), maximum)
clamp(value)
}
private func maxHours(for days: Int) -> Int {
GoalEditorSheet.maxHours(forDays: days, maximum: maximum)
}
private func minuteOptions(for days: Int, hours: Int) -> [Int] {
GoalEditorSheet.allowedMinutes(
forDays: days,
hours: hours,
maximum: maximum,
minuteValues: minuteStepValues
)
}
private func updateValueFromSelection() {
let totalHours = Double(days * 24 + hours) + Double(minutes) / 60
let clamped = clamp(totalHours)
if abs(value - clamped) > .ulpOfOne {
value = clamped
}
}
private func syncPickers(with newValue: Double) {
let clamped = clamp(newValue)
let components = GoalEditorSheet.components(
for: clamped,
minimum: minimum,
maximum: maximum,
minuteValues: minuteStepValues
)
if components.days != days {
days = components.days
}
if components.hours != hours {
hours = components.hours
}
if components.minutes != minutes {
minutes = components.minutes
}
}
private func clamp(_ candidate: Double) -> Double {
min(max(candidate, minimum), maximum)
}
private static func components(
for value: Double,
minimum: Double,
maximum: Double,
minuteValues: [Int]
) -> (days: Int, hours: Int, minutes: Int) {
let clamped = min(max(value, minimum), maximum)
let totalMinutes = Int((clamped * 60).rounded())
let minutesPerDay = 24 * 60
let maxDays = max(0, Int(floor(maximum / 24)))
let rawDays = totalMinutes / minutesPerDay
let days = min(rawDays, maxDays)
let remainingAfterDays = totalMinutes - (days * minutesPerDay)
let rawHours = remainingAfterDays / 60
let maxHours = maxHours(forDays: days, maximum: maximum)
let hours = min(rawHours, maxHours)
let minuteRemainder = remainingAfterDays - hours * 60
let allowedMinutes = allowedMinutes(
forDays: days,
hours: hours,
maximum: maximum,
minuteValues: minuteValues
)
let minutes = closestMinuteValue(
target: minuteRemainder,
allowedMinutes: allowedMinutes
)
return (days, hours, minutes)
}
private static func maxHours(forDays days: Int, maximum: Double) -> Int {
let remaining = maximum - Double(days * 24)
guard remaining > 0 else { return 0 }
return min(23, max(0, Int(floor(remaining))))
}
private static func allowedMinutes(
forDays days: Int,
hours: Int,
maximum: Double,
minuteValues: [Int]
) -> [Int] {
guard maximum > 0 else { return [0] }
let usedHours = Double(days * 24 + hours)
let remaining = maximum - usedHours
guard remaining > 0 else { return [0] }
let allowed = minuteValues.filter { minute in
let additional = Double(minute) / 60
return usedHours + additional <= maximum + 1e-6
}
return allowed.isEmpty ? [0] : allowed
}
private static func closestMinuteValue(target: Int, allowedMinutes: [Int]) -> Int {
guard !allowedMinutes.isEmpty else { return 0 }
let clampedTarget = max(0, min(59, target))
return allowedMinutes.min(by: { abs($0 - clampedTarget) < abs($1 - clampedTarget) }) ?? 0
}
private static func minuteValues(forStep step: Double) -> [Int] {
guard step > 0 else { return [0, 15, 30, 45] }
let increment = max(1, Int(round(step * 60)))
guard increment < 60 else { return [0] }
var values: [Int] = []
var current = 0
while current < 60 {
values.append(current)
current += increment
}
return values.isEmpty ? [0] : values
}
}

View File

@@ -30,6 +30,8 @@ struct SystemComponentsPersistence {
iconName: "lightbulb",
colorName: "blue",
isWattMode: false,
dutyCyclePercent: 100.0,
dailyUsageHours: 24.0,
system: system,
remoteIconURLString: nil
)
@@ -65,6 +67,8 @@ struct SystemComponentsPersistence {
}
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
let newLoad = SavedLoad(
name: loadName,
@@ -76,6 +80,8 @@ struct SystemComponentsPersistence {
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
dutyCyclePercent: dutyCyclePercent,
dailyUsageHours: dailyUsageHours,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,

View File

@@ -373,6 +373,8 @@ struct SystemsView: View {
}
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
let newLoad = SavedLoad(
name: loadName,
@@ -384,6 +386,8 @@ struct SystemsView: View {
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
dutyCyclePercent: dutyCyclePercent,
dailyUsageHours: dailyUsageHours,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,

View File

@@ -73,6 +73,8 @@ extension UITestSampleData {
colorName: "orange"
)
adventureVan.timestamp = Date(timeIntervalSinceReferenceDate: 3000)
adventureVan.targetRuntimeHours = 24
adventureVan.targetChargeTimeHours = 3
let workshopBench = ElectricalSystem(
name: String(localized: "sample.system.workshop.name", comment: "Sample data name for the workshop system"),
@@ -96,6 +98,7 @@ extension UITestSampleData {
colorName: "blue",
isWattMode: true,
system: adventureVan,
bomCompletedItemIDs: ["component", "cable-red", "fuse"],
identifier: "sample.load.fridge"
)
vanFridge.timestamp = Date(timeIntervalSinceReferenceDate: 1100)
@@ -111,6 +114,7 @@ extension UITestSampleData {
colorName: "yellow",
isWattMode: false,
system: adventureVan,
bomCompletedItemIDs: ["component", "cable-black"],
identifier: "sample.load.lighting"
)
vanLighting.timestamp = Date(timeIntervalSinceReferenceDate: 1200)
@@ -158,7 +162,8 @@ extension UITestSampleData {
maximumTemperatureCelsius: 60,
iconName: "battery.100.bolt",
colorName: "purple",
system: adventureVan
system: adventureVan,
bomCompletedItemIDs: ["battery"]
)
vanHouseBattery.timestamp = Date(timeIntervalSinceReferenceDate: 1250)
@@ -188,6 +193,7 @@ extension UITestSampleData {
iconName: "powerplug",
colorName: "orange",
system: adventureVan,
bomCompletedItemIDs: ["charger"],
identifier: "sample.charger.shore"
)
shoreCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1300)