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:
@@ -123,7 +123,6 @@
|
||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
||||
57738E9B07763CFA62681EEE /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -138,13 +137,6 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
57738E9B07763CFA62681EEE /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 = "";
|
||||
|
||||
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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
BillOfMaterials=Sieh auf einen*Blick*\nwelche Teile du brauchst
|
||||
|
||||
@@ -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
|
||||
BillOfMaterials=Know*exactly*\nwhich parts you still need
|
||||
|
||||
@@ -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
|
||||
BillOfMaterials=Ten*claro*\nqué piezas aún te faltan
|
||||
|
||||
@@ -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
|
||||
BillOfMaterials=Sache*précisément*\nce qu’il te manque
|
||||
|
||||
@@ -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
|
||||
BillOfMaterials=Weet*precies*\nwat je nog nodig hebt
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 MiB After Width: | Height: | Size: 594 KiB |
221
frame_screens.sh
221
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 <<EOF
|
||||
$(python3 - <<PY
|
||||
target_height=${target_height}
|
||||
base_canvas_height=${base_canvas_height}
|
||||
title_y=${START_FRAME_TITLE_Y}
|
||||
line_spacing=${TITLE_LINE_SPACING}
|
||||
scale = target_height / base_canvas_height if base_canvas_height else 1
|
||||
print(int(round(title_y * scale)), int(round(line_spacing * scale)))
|
||||
PY
|
||||
)
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$scaled_title_y" || "$scaled_title_y" -lt 0 ]]; then
|
||||
scaled_title_y="$START_FRAME_TITLE_Y"
|
||||
fi
|
||||
if [[ -z "$scaled_line_spacing" || "$scaled_line_spacing" -le 0 ]]; then
|
||||
scaled_line_spacing="$TITLE_LINE_SPACING"
|
||||
fi
|
||||
|
||||
render_mixed_font_title "$working_canvas" "$title_text" "$scaled_title_y" "$dest" "$point_size" "$scaled_line_spacing"
|
||||
rm -f "$working_canvas"
|
||||
if [[ -f "$dest" ]]; then
|
||||
echo "Start frame: $log_prefix/$START_FRAME_FILENAME"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to frame one screenshot
|
||||
frame_one () {
|
||||
local in="$1" # input screenshot (e.g., 1320x2868)
|
||||
@@ -235,13 +345,25 @@ frame_one () {
|
||||
local target_height="$7"
|
||||
local canvas_margin="$8"
|
||||
local title_margin="$9"
|
||||
local frame_width="${10:-}"
|
||||
local frame_top_offset="${11:-}"
|
||||
local corner_radius_override="${12:-}"
|
||||
|
||||
local title_text
|
||||
title_text="$(get_title "$lang" "$screenshot_name")"
|
||||
if [[ -z "$title_text" || "$title_text" == "***NOT SET***" ]]; then
|
||||
echo "Skipped (no title): ${lang}/${screenshot_name}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Read sizes
|
||||
read -r W H <<<"$(identify -format "%w %h" "$in")"
|
||||
|
||||
# Determine corner radius
|
||||
local R
|
||||
if [[ "$CORNER_RADIUS" == "auto" ]]; then
|
||||
if [[ -n "$corner_radius_override" ]]; then
|
||||
R="$corner_radius_override"
|
||||
elif [[ "$CORNER_RADIUS" == "auto" ]]; then
|
||||
# Heuristic: ~1/12 of width works well for iPhone 6.9" (≈110px for 1320px width)
|
||||
R=$(( W / 12 ))
|
||||
else
|
||||
@@ -260,6 +382,9 @@ frame_one () {
|
||||
local rounded
|
||||
rounded="$(mktemp /tmp/rounded.XXXXXX_$$.png)"
|
||||
magick "$in" -alpha set "$mask" -compose copyopacity -composite "$rounded"
|
||||
if [[ -n "$frame_width" ]]; then
|
||||
magick "$rounded" -resize "${frame_width}x" "$rounded"
|
||||
fi
|
||||
|
||||
# 2) Shadow from rounded image
|
||||
local shadow
|
||||
@@ -278,7 +403,6 @@ frame_one () {
|
||||
magick "$bg" -resize "${minW}x${minH}^" -gravity center -extent "${minW}x${minH}" "$canvas"
|
||||
|
||||
# Add title text above the screenshot
|
||||
local title_text=$(get_title "$lang" "$screenshot_name")
|
||||
local with_title
|
||||
with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)"
|
||||
|
||||
@@ -291,9 +415,15 @@ frame_one () {
|
||||
# Now place shadow (which already includes the rounded image) positioned below the title
|
||||
# Calculate the vertical offset to center the screenshot in the remaining space below the title
|
||||
local screenshot_offset=$((title_margin*2))
|
||||
local placement_gravity="center"
|
||||
local placement_geometry="+0+${screenshot_offset}"
|
||||
if [[ -n "$frame_top_offset" ]]; then
|
||||
placement_gravity="north"
|
||||
placement_geometry="+0+${frame_top_offset}"
|
||||
fi
|
||||
local temp_result
|
||||
temp_result="$(mktemp /tmp/temp_result.XXXXXX_$$.png)"
|
||||
magick "$with_title" "$shadow" -gravity center -geometry "+0+${screenshot_offset}" -compose over -composite "$temp_result"
|
||||
magick "$with_title" "$shadow" -gravity "$placement_gravity" -geometry "$placement_geometry" -compose over -composite "$temp_result"
|
||||
|
||||
# Final step: scale to exact dimensions 1320 × 2868px
|
||||
magick "$temp_result" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$out"
|
||||
@@ -310,6 +440,9 @@ resolve_device_profile() {
|
||||
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_PHONE"
|
||||
PROFILE_CANVAS_MARGIN="$CANVAS_MARGIN"
|
||||
PROFILE_TITLE_MARGIN="$TITLE_MARGIN"
|
||||
PROFILE_FRAME_WIDTH=""
|
||||
PROFILE_FRAME_TOP_OFFSET=""
|
||||
PROFILE_CORNER_RADIUS=""
|
||||
|
||||
if [[ -n "$device_slug" ]]; then
|
||||
local slug_lower
|
||||
@@ -320,6 +453,9 @@ resolve_device_profile() {
|
||||
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_IPAD"
|
||||
PROFILE_CANVAS_MARGIN="$IPAD_CANVAS_MARGIN"
|
||||
PROFILE_TITLE_MARGIN="$IPAD_TITLE_MARGIN"
|
||||
PROFILE_FRAME_WIDTH="$IPAD_FRAME_WIDTH"
|
||||
PROFILE_FRAME_TOP_OFFSET="$IPAD_FRAME_TOP_OFFSET"
|
||||
PROFILE_CORNER_RADIUS="$IPAD_CORNER_RADIUS"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -345,29 +481,58 @@ process_lang_dir() {
|
||||
shopt -s nullglob
|
||||
for shot in "$lang_path"/*.png; do
|
||||
local base="$(basename "$shot")"
|
||||
local dest="$out_dir/$base"
|
||||
rm -f "$dest"
|
||||
frame_one \
|
||||
"$shot" \
|
||||
"$out_dir/$base" \
|
||||
"$dest" \
|
||||
"$PROFILE_BG" \
|
||||
"$lang" \
|
||||
"$base" \
|
||||
"$PROFILE_TARGET_WIDTH" \
|
||||
"$PROFILE_TARGET_HEIGHT" \
|
||||
"$PROFILE_CANVAS_MARGIN" \
|
||||
"$PROFILE_TITLE_MARGIN"
|
||||
echo "Framed: $log_prefix/$base"
|
||||
"$PROFILE_TITLE_MARGIN" \
|
||||
"$PROFILE_FRAME_WIDTH" \
|
||||
"$PROFILE_FRAME_TOP_OFFSET" \
|
||||
"$PROFILE_CORNER_RADIUS"
|
||||
if [[ -f "$dest" ]]; then
|
||||
echo "Framed: $log_prefix/$base"
|
||||
fi
|
||||
done
|
||||
|
||||
if should_render_start_frame "$device_slug"; then
|
||||
render_start_frame_for_lang \
|
||||
"$out_dir" \
|
||||
"$lang" \
|
||||
"$log_prefix" \
|
||||
"$PROFILE_TARGET_WIDTH" \
|
||||
"$PROFILE_TARGET_HEIGHT" \
|
||||
"$PROFILE_CANVAS_MARGIN" \
|
||||
"$PROFILE_TITLE_MARGIN"
|
||||
fi
|
||||
}
|
||||
|
||||
shopt -s nullglob
|
||||
found_any=false
|
||||
skipped_for_device=false
|
||||
skipped_for_iphone_filter=false
|
||||
skipped_for_ipad_filter=false
|
||||
for entry in "$SRC_ROOT"/*; do
|
||||
[[ -d "$entry" ]] || continue
|
||||
entry_basename="$(basename "$entry")"
|
||||
entry_lower="$(printf '%s' "$entry_basename" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$ONLY_IPHONE" == true && "$entry_lower" == *"ipad"* ]]; then
|
||||
skipped_for_device=true
|
||||
is_ipad_entry=false
|
||||
if [[ "$entry_lower" == *"ipad"* ]]; then
|
||||
is_ipad_entry=true
|
||||
fi
|
||||
|
||||
if [[ "$ONLY_IPHONE" == true && "$is_ipad_entry" == true ]]; then
|
||||
skipped_for_iphone_filter=true
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$ONLY_IPAD" == true && "$is_ipad_entry" == false ]]; then
|
||||
skipped_for_ipad_filter=true
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -384,7 +549,15 @@ for entry in "$SRC_ROOT"/*; do
|
||||
lang_device_slug="$(basename "$entry")"
|
||||
lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$lang_slug_lower" == *"ipad"* ]]; then
|
||||
skipped_for_device=true
|
||||
skipped_for_iphone_filter=true
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
if [[ "$ONLY_IPAD" == true ]]; then
|
||||
lang_device_slug="$(basename "$entry")"
|
||||
lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$lang_slug_lower" != *"ipad"* ]]; then
|
||||
skipped_for_ipad_filter=true
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
@@ -397,8 +570,18 @@ for entry in "$SRC_ROOT"/*; do
|
||||
done
|
||||
|
||||
if [[ "$found_any" == false ]]; then
|
||||
if [[ "$ONLY_IPHONE" == true && "$skipped_for_device" == true ]]; then
|
||||
echo "No iPhone screenshots found under $SRC_ROOT" >&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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user