diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj index a1ee9e3..9f166be 100644 --- a/Cable.xcodeproj/project.pbxproj +++ b/Cable.xcodeproj/project.pbxproj @@ -123,7 +123,6 @@ 3E5C0BEA2E72C0FE00247EC8 /* CableUITests */, 3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */, 3E5C0BCD2E72C0FD00247EC8 /* Products */, - 57738E9B07763CFA62681EEE /* Pods */, ); sourceTree = ""; }; @@ -138,13 +137,6 @@ name = Products; sourceTree = ""; }; - 57738E9B07763CFA62681EEE /* Pods */ = { - isa = PBXGroup; - children = ( - ); - path = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -415,7 +407,7 @@ CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_TEAM = RE4FXQ754N; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -433,7 +425,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -451,7 +443,7 @@ CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_TEAM = RE4FXQ754N; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -469,7 +461,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Cable/AppIcon copy.icon/Assets/body 3.png b/Cable/AppIcon copy.icon/Assets/body 3.png deleted file mode 100644 index 965b1ca..0000000 Binary files a/Cable/AppIcon copy.icon/Assets/body 3.png and /dev/null differ diff --git a/Cable/AppIcon copy.icon/Assets/fuse-top.png b/Cable/AppIcon copy.icon/Assets/fuse-top.png deleted file mode 100644 index 780bf2c..0000000 Binary files a/Cable/AppIcon copy.icon/Assets/fuse-top.png and /dev/null differ diff --git a/Cable/AppIcon copy.icon/Assets/legs 2.png b/Cable/AppIcon copy.icon/Assets/legs 2.png deleted file mode 100644 index d0f41a6..0000000 Binary files a/Cable/AppIcon copy.icon/Assets/legs 2.png and /dev/null differ diff --git a/Cable/AppIcon copy.icon/icon.json b/Cable/AppIcon copy.icon/icon.json deleted file mode 100644 index afbed23..0000000 --- a/Cable/AppIcon copy.icon/icon.json +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/Cable/AppIcon.icon/icon.json b/Cable/AppIcon.icon/icon.json index b35405b..e8e9c0c 100644 --- a/Cable/AppIcon.icon/icon.json +++ b/Cable/AppIcon.icon/icon.json @@ -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" } }, { diff --git a/Cable/SavedBattery.swift b/Cable/Batteries/SavedBattery.swift similarity index 100% rename from Cable/SavedBattery.swift rename to Cable/Batteries/SavedBattery.swift diff --git a/Cable/Loads/CableCalculator.swift b/Cable/Loads/CableCalculator.swift index 5850053..b33d7bb 100644 --- a/Cable/Loads/CableCalculator.swift +++ b/Cable/Loads/CableCalculator.swift @@ -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, diff --git a/Cable/Loads/ComponentLibraryView.swift b/Cable/Loads/ComponentLibraryView.swift index 390b85c..71907da 100644 --- a/Cable/Loads/ComponentLibraryView.swift +++ b/Cable/Loads/ComponentLibraryView.swift @@ -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 { diff --git a/Cable/Overview/SystemOverviewView.swift b/Cable/Overview/SystemOverviewView.swift index b54f079..7fdb926 100644 --- a/Cable/Overview/SystemOverviewView.swift +++ b/Cable/Overview/SystemOverviewView.swift @@ -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, + 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 { + 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 } } diff --git a/Cable/Systems/SystemComponentsPersistence.swift b/Cable/Systems/SystemComponentsPersistence.swift index 0eb43e2..083003a 100644 --- a/Cable/Systems/SystemComponentsPersistence.swift +++ b/Cable/Systems/SystemComponentsPersistence.swift @@ -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, diff --git a/Cable/Systems/SystemsView.swift b/Cable/Systems/SystemsView.swift index 4e2a58c..630be42 100644 --- a/Cable/Systems/SystemsView.swift +++ b/Cable/Systems/SystemsView.swift @@ -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, diff --git a/Cable/UITestSampleData.swift b/Cable/UITestSampleData.swift index cfab558..ec0c802 100644 --- a/Cable/UITestSampleData.swift +++ b/Cable/UITestSampleData.swift @@ -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) diff --git a/CableTests/ComponentLibraryItemTests.swift b/CableTests/ComponentLibraryItemTests.swift index 42abbbd..2c28460 100644 --- a/CableTests/ComponentLibraryItemTests.swift +++ b/CableTests/ComponentLibraryItemTests.swift @@ -12,6 +12,8 @@ struct ComponentLibraryItemTests { voltageIn: nil, voltageOut: nil, watt: nil, + dutyCyclePercent: nil, + defaultUtilizationFactorPercent: nil, iconURL: nil, affiliateLinks: [] ) @@ -28,6 +30,8 @@ struct ComponentLibraryItemTests { voltageIn: nil, voltageOut: nil, watt: nil, + dutyCyclePercent: nil, + defaultUtilizationFactorPercent: nil, iconURL: nil, affiliateLinks: [] ) @@ -47,6 +51,8 @@ struct ComponentLibraryItemTests { voltageIn: nil, voltageOut: nil, watt: nil, + dutyCyclePercent: nil, + defaultUtilizationFactorPercent: nil, iconURL: nil, affiliateLinks: [] ) @@ -63,6 +69,8 @@ struct ComponentLibraryItemTests { voltageIn: nil, voltageOut: nil, watt: nil, + dutyCyclePercent: nil, + defaultUtilizationFactorPercent: nil, iconURL: nil, affiliateLinks: [] ) @@ -79,6 +87,8 @@ struct ComponentLibraryItemTests { voltageIn: nil, voltageOut: nil, watt: nil, + dutyCyclePercent: nil, + defaultUtilizationFactorPercent: nil, iconURL: nil, affiliateLinks: [] ) @@ -95,6 +105,8 @@ struct ComponentLibraryItemTests { voltageIn: nil, voltageOut: nil, watt: nil, + dutyCyclePercent: nil, + defaultUtilizationFactorPercent: nil, iconURL: nil, affiliateLinks: [] ) @@ -102,4 +114,38 @@ struct ComponentLibraryItemTests { let french = Foundation.Locale(identifier: "fr_FR") #expect(item.localizedName(for: french) == "Guindeau") } + + @Test func normalizedDutyCycleTreatsZeroAsFullUsage() async throws { + let item = ComponentLibraryItem( + id: "component-7", + name: "Compressor", + translations: [:], + voltageIn: nil, + voltageOut: nil, + watt: nil, + dutyCyclePercent: 0, + defaultUtilizationFactorPercent: nil, + iconURL: nil, + affiliateLinks: [] + ) + + #expect(item.normalizedDutyCyclePercent == 100) + } + + @Test func defaultDailyUsageHoursConvertsPercentToHours() async throws { + let item = ComponentLibraryItem( + id: "component-8", + name: "Heater", + translations: [:], + voltageIn: nil, + voltageOut: nil, + watt: nil, + dutyCyclePercent: nil, + defaultUtilizationFactorPercent: 50, + iconURL: nil, + affiliateLinks: [] + ) + + #expect(item.defaultDailyUsageHours == 12) + } } diff --git a/Shots/Titles/de.conf b/Shots/Titles/de.conf index 754c1bb..09063b9 100644 --- a/Shots/Titles/de.conf +++ b/Shots/Titles/de.conf @@ -1,7 +1,6 @@ -OnboardingSystemsView=Lege*einfach*Systeme an\nund vergleiche sie -OnboardingLoadsView=Stelle Geräte*übersichtlich*\ndar und verwalte sie -LoadEditorView=Berechne*zuverlässig*\ndie richtige Sicherung +StartFrameTitle=Damit die*Elektrik*\nnicht*Feuer*fängt +AdventureVanOverview=Behalte den*Überblick*\nüber Deine Elektrik +LoadEditorView=Berechne*zuverlässig*\nKabel und Sicherungen +OnboardingSystemView=Kombiniere*sicher*\nBatterien und Ladegeräte ComponentSelectorView=Finde im*großen*Teilekatalog\nwas du suchst -SystemsWithSampleData=Navigiere*schnell*\ndurch Deine Systeme -AdventureVanLoads=Erstelle*individuelle*\nVerbraucher für Dein System -AdventureVanBillOfMaterials=Behalte den*Überblick*\nwelche Teile du schon hast \ No newline at end of file +BillOfMaterials=Sieh auf einen*Blick*\nwelche Teile du brauchst diff --git a/Shots/Titles/en.conf b/Shots/Titles/en.conf index 9c4fe82..855fa6d 100644 --- a/Shots/Titles/en.conf +++ b/Shots/Titles/en.conf @@ -1,7 +1,6 @@ -OnboardingSystemsView=*Easily*create systems\nand compare them -OnboardingLoadsView=*Clearly*list devices\nand manage them -LoadEditorView=*Reliably*size the fuse\nfor each device +StartFrameTitle=So your*electrics*\ndon't*catch fire* +AdventureVanOverview=Stay in*control*\nof your electrical system +LoadEditorView=*Reliably*size cables\nand fuses for each device +OnboardingSystemView=*Safely*combine\nloads, batteries, and chargers ComponentSelectorView=Find in the*huge*catalog\nwhat you need -SystemsWithSampleData=*Quickly*browse your systems\nat a glance -AdventureVanLoads=*Create*custom loads\nfor your setup -AdventureVanBillOfMaterials=Stay*organized*\nwith your purchases \ No newline at end of file +BillOfMaterials=Know*exactly*\nwhich parts you still need diff --git a/Shots/Titles/es.conf b/Shots/Titles/es.conf index 3bbed88..b6ea42b 100644 --- a/Shots/Titles/es.conf +++ b/Shots/Titles/es.conf @@ -1,7 +1,6 @@ -OnboardingSystemsView=*Fácil*crear sistemas\ny compararlos -OnboardingLoadsView=*Claro*lista equipos\ny gestiona todo -LoadEditorView=*Fiable*calcula el fusible\nadecuado +StartFrameTitle=Para que la*electricidad*\nno*se incendie* +AdventureVanOverview=Mantén el*control*\nde tu sistema eléctrico +LoadEditorView=*Fiable*calcula los cables\ny fusibles adecuados +OnboardingSystemView=*Seguro*combina\nconsumos, baterías y cargadores ComponentSelectorView=Busca en el*amplio*catálogo\nlo que necesitas -SystemsWithSampleData=*Rápido*revisa tus sistemas\nde un vistazo -AdventureVanLoads=*Crea*cargas a medida\npara tu sistema -AdventureVanBillOfMaterials=Lleva*control*\nde lo ya comprado \ No newline at end of file +BillOfMaterials=Ten*claro*\nqué piezas aún te faltan diff --git a/Shots/Titles/fr.conf b/Shots/Titles/fr.conf index 23ebf04..b21aa83 100644 --- a/Shots/Titles/fr.conf +++ b/Shots/Titles/fr.conf @@ -1,7 +1,6 @@ -OnboardingSystemsView=*Facile*créer des systèmes\net les comparer -OnboardingLoadsView=*Clairement*voir les appareils\net les gérer -LoadEditorView=*Fiable*calcule le fusible\nadapté +StartFrameTitle=Pour que l’*électricité*\nne*prenne pas feu* +AdventureVanOverview=Garde le*contrôle*\nsur ton installation électrique +LoadEditorView=*Fiable*calcule les câbles\net le fusible adaptés +OnboardingSystemView=*Sûrement*associe\ncharges, batteries et chargeurs ComponentSelectorView=Trouve dans le*vaste*catalogue\nce que tu cherches -SystemsWithSampleData=*Rapide*parcours tes systèmes\nd'un coup d'œil -AdventureVanLoads=*Crée*des charges sur mesure\npour ton système -AdventureVanBillOfMaterials=Garde*trace* de tes achats\ndéjà faits \ No newline at end of file +BillOfMaterials=Sache*précisément*\nce qu’il te manque diff --git a/Shots/Titles/nl.conf b/Shots/Titles/nl.conf index 112798f..324ec28 100644 --- a/Shots/Titles/nl.conf +++ b/Shots/Titles/nl.conf @@ -1,7 +1,6 @@ -OnboardingSystemsView=*Simpel*systemen aanmaken\nen vergelijken -OnboardingLoadsView=*Duidelijk*apparaten tonen\nen beheren -LoadEditorView=*Betrouwbaar*zekering kiezen\nper apparaat +StartFrameTitle=Zodat de*elektriciteit*\nniet*in brand* vliegt +AdventureVanOverview=Behoud het*overzicht*\nover je elektrische systeem +LoadEditorView=*Betrouwbaar*bereken kabels\nen zekeringen per apparaat +OnboardingSystemView=*Veilig*combineer\nverbruikers, batterijen en laders ComponentSelectorView=Vind in de*grote*catalogus\nwat je zoekt -SystemsWithSampleData=*Snel*door je systemen\nbladeren -AdventureVanLoads=*Maak*aangepaste verbruikers\nvoor je systeem -AdventureVanBillOfMaterials=Houd*overzicht*\nvan wat je al kocht \ No newline at end of file +BillOfMaterials=Weet*precies*\nwat je nog nodig hebt diff --git a/Shots/frame-bg.png b/Shots/frame-bg.png index 1226af6..9017e12 100644 Binary files a/Shots/frame-bg.png and b/Shots/frame-bg.png differ diff --git a/frame_screens.sh b/frame_screens.sh index e71bdcd..b0fd093 100755 --- a/frame_screens.sh +++ b/frame_screens.sh @@ -4,12 +4,14 @@ FONT_COLOR="#3C3C3C" # color for light text FONT_BOLD_COLOR="#B51700" # color for bold text ONLY_IPHONE=false +ONLY_IPAD=false usage() { cat <<'EOF' -Usage: frame_screens.sh [--iphone-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT] +Usage: frame_screens.sh [--iphone-only|--ipad-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT] --iphone-only Only frame screenshots whose device slug is not iPad. + --ipad-only Only frame screenshots whose device slug contains iPad. SRC_ROOT Root folder with device/lang subfolders (default: Shots/Screenshots) BG_IMAGE Background image to use (default: Shots/frame-bg.png) OUT_ROOT Output folder for framed shots (default: Shots/Framed) @@ -23,6 +25,10 @@ while [[ $# -gt 0 ]]; do ONLY_IPHONE=true shift ;; + --ipad-only) + ONLY_IPAD=true + shift + ;; -h|--help) usage exit 0 @@ -44,6 +50,11 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$ONLY_IPHONE" == true && "$ONLY_IPAD" == true ]]; then + echo "Cannot use --iphone-only and --ipad-only together." >&2 + exit 1 +fi + if ((${#POSITIONAL_ARGS[@]})); then set -- "${POSITIONAL_ARGS[@]}" else @@ -52,10 +63,11 @@ fi # Inputs SRC_ROOT="${1:-Shots/Screenshots}" # root folder with device/lang subfolders (iphone-17-pro-max/en/, ipad-pro-13-inch-m4/de/…) -BG_IMAGE="${2:-Shots/frame-bg.png}" # default background image +BG_IMAGE="${2:-Shots/frame-bg-phone.png}" # default background image for iPhone shots OUT_ROOT="${3:-Shots/Framed}" # output folder FONT="./Shots/Fonts/Oswald-Light.ttf" # font for title text FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text +TITLE_POINTSIZE="${TITLE_POINTSIZE:-148}" # baseline point size for titles # Tweakables CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width @@ -67,15 +79,24 @@ SHADOW_OFFSET_Y=40 # px CANVAS_MARGIN=245 # default margin around the device on the background, px TITLE_MARGIN=378 # default margin above the device for title text, px TITLE_LINE_SPACING=220 # vertical distance between lines of the title text, px +START_FRAME_IMAGE="${START_FRAME_IMAGE:-Shots/frame-start-iphone.png}" # static framed asset for the intro screen +START_FRAME_FILENAME="${START_FRAME_FILENAME:-00-StartFrameTitle.png}" # output filename for the intro screen +START_FRAME_TITLE_KEY="${START_FRAME_TITLE_KEY:-StartFrameTitle}" # key in the localization files +START_FRAME_TITLE_Y="${START_FRAME_TITLE_Y:-$((TITLE_MARGIN - 100))}" # Y offset for the intro title text +START_FRAME_POINTSIZE="${START_FRAME_POINTSIZE:-$TITLE_POINTSIZE}" # point size for intro title (defaults to standard) +START_FRAME_RESIZE="${START_FRAME_RESIZE:-true}" # resize intro frame to target dimensions # Device-specific overrides (can be tuned via env vars) TARGET_WIDTH_PHONE="${TARGET_WIDTH_PHONE:-1320}" TARGET_HEIGHT_PHONE="${TARGET_HEIGHT_PHONE:-2868}" TARGET_WIDTH_IPAD="${TARGET_WIDTH_IPAD:-2048}" TARGET_HEIGHT_IPAD="${TARGET_HEIGHT_IPAD:-2732}" -IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-$BG_IMAGE}" +IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-Shots/frame-bg.png}" IPAD_CANVAS_MARGIN="${IPAD_CANVAS_MARGIN:-240}" IPAD_TITLE_MARGIN="${IPAD_TITLE_MARGIN:-180}" +IPAD_FRAME_WIDTH="${IPAD_FRAME_WIDTH:-2200}" +IPAD_FRAME_TOP_OFFSET="${IPAD_FRAME_TOP_OFFSET:-572}" +IPAD_CORNER_RADIUS="${IPAD_CORNER_RADIUS:-60}" mkdir -p "$OUT_ROOT" @@ -85,6 +106,8 @@ render_mixed_font_title() { local title_text="$2" local title_y="$3" local output="$4" + local point_size="${5:-$TITLE_POINTSIZE}" + local line_spacing="${6:-$TITLE_LINE_SPACING}" local expanded_title expanded_title="$(printf '%b' "$title_text")" @@ -110,7 +133,7 @@ render_mixed_font_title() { for idx in "${!lines[@]}"; do local line="${lines[$idx]}" - local current_y=$((title_y + idx * TITLE_LINE_SPACING)) + local current_y=$((title_y + idx * line_spacing)) local -a text_segments=() local -a font_types=() @@ -159,7 +182,7 @@ render_mixed_font_title() { segment_for_measurement="${segment_for_measurement/#/ }" segment_for_measurement="${segment_for_measurement/%/ }" local part_width - part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:) + part_width=$(magick -font "$font_for_measurement" -pointsize "$point_size" -size x label:"$segment_for_measurement" -format "%w" info:) total_width=$((total_width + part_width)) fi done @@ -183,11 +206,11 @@ render_mixed_font_title() { segment_for_rendering="${segment_for_rendering/#/ }" segment_for_rendering="${segment_for_rendering/%/ }" magick "$temp_img" \ - -font "$font_to_use" -pointsize 148 -fill "$color_to_use" \ + -font "$font_to_use" -pointsize "$point_size" -fill "$color_to_use" \ -gravity northwest -annotate "+$((start_x + x_offset))+${current_y}" "$segment_for_rendering" \ "$temp_img" local text_width - text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:) + text_width=$(magick -font "$font_to_use" -pointsize "$point_size" -size x label:"$segment_for_rendering" -format "%w" info:) x_offset=$((x_offset + text_width)) fi done @@ -224,6 +247,93 @@ get_title() { echo "***NOT SET***" } +should_render_start_frame() { + local device_slug="$1" + + if [[ ! -f "$START_FRAME_IMAGE" ]]; then + return 1 + fi + + if [[ -z "$device_slug" ]]; then + return 1 + fi + + local slug_lower + slug_lower="$(printf '%s' "$device_slug" | tr '[:upper:]' '[:lower:]')" + if [[ "$slug_lower" == *"iphone"* ]]; then + return 0 + fi + + return 1 +} + +render_start_frame_for_lang() { + local output_dir="$1" + local lang="$2" + local log_prefix="$3" + local target_width="$4" + local target_height="$5" + local canvas_margin="$6" + local title_margin="$7" + + if [[ ! -f "$START_FRAME_IMAGE" ]]; then + return 0 + fi + + local title_text + title_text="$(get_title "$lang" "$START_FRAME_TITLE_KEY")" + if [[ -z "$title_text" || "$title_text" == "***NOT SET***" ]]; then + echo "Skipped start frame (no title): ${log_prefix}/${START_FRAME_FILENAME}" + return 0 + fi + + mkdir -p "$output_dir" + local dest="$output_dir/$START_FRAME_FILENAME" + rm -f "$dest" + local resize_flag + resize_flag="$(printf '%s' "$START_FRAME_RESIZE" | tr '[:upper:]' '[:lower:]')" + + local working_canvas + working_canvas="$(mktemp /tmp/start_frame_canvas.XXXXXX_$$.png)" + if [[ "$resize_flag" == "true" && -n "${target_width:-}" && -n "${target_height:-}" ]]; then + magick "$START_FRAME_IMAGE" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$working_canvas" + else + cp "$START_FRAME_IMAGE" "$working_canvas" + fi + + local point_size="$START_FRAME_POINTSIZE" + local scaled_title_y="$START_FRAME_TITLE_Y" + local scaled_line_spacing="$TITLE_LINE_SPACING" + if [[ -n "${target_height:-}" && -n "${canvas_margin:-}" && -n "${title_margin:-}" ]]; then + local base_canvas_height=$(( target_height + 2 * canvas_margin + title_margin )) + if (( base_canvas_height > 0 )); then + read -r scaled_title_y scaled_line_spacing <&2 + if [[ "$ONLY_IPHONE" == true ]]; then + if [[ "$skipped_for_iphone_filter" == true ]]; then + echo "No iPhone screenshots found under $SRC_ROOT" >&2 + else + echo "No screenshots found under $SRC_ROOT" >&2 + fi + elif [[ "$ONLY_IPAD" == true ]]; then + if [[ "$skipped_for_ipad_filter" == true ]]; then + echo "No iPad screenshots found under $SRC_ROOT" >&2 + else + echo "No screenshots found under $SRC_ROOT" >&2 + fi else echo "No screenshots found under $SRC_ROOT" >&2 fi diff --git a/shooter.sh b/shooter.sh index 25b2c57..22b5064 100755 --- a/shooter.sh +++ b/shooter.sh @@ -15,7 +15,7 @@ is_truthy() { } DEVICE_MATRIX=( -# "iPhone 17 Pro Max|26.0|iphone-17-pro-max" + "iPhone 17 Pro Max|26.0|iphone-17-pro-max" "iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4" )