Compare commits
18 Commits
022e309873
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5476209c50 | |||
| 61e0cb061f | |||
| 9673bde107 | |||
| 165827c4d4 | |||
| af0687c362 | |||
| eb9efee9af | |||
| 38183f5282 | |||
| 88e79d79bf | |||
| 8541130fa3 | |||
| 4c0524618d | |||
| 40c887a61a | |||
| 695d2ccd75 | |||
| a714a0d0d5 | |||
| ab50728f07 | |||
| 345c8b3ac7 | |||
| 5cf4a2e5b9 | |||
| 9257da046a | |||
| ff955b35fe |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -4,4 +4,15 @@ xcshareddata
|
|||||||
Vendor
|
Vendor
|
||||||
Shots/Framed
|
Shots/Framed
|
||||||
Shots/Screenshots
|
Shots/Screenshots
|
||||||
*.xcresult
|
*.xcresult
|
||||||
|
|
||||||
|
# Build products
|
||||||
|
DerivedData*/
|
||||||
|
build/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcuserdatad
|
||||||
|
|
||||||
|
# Credentials
|
||||||
|
PocketBase-LLM/pb-credentials.json
|
||||||
|
|||||||
@@ -418,7 +418,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 85;
|
CURRENT_PROJECT_VERSION = 88;
|
||||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.7.0;
|
MARKETING_VERSION = 1.8.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -454,7 +454,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 85;
|
CURRENT_PROJECT_VERSION = 88;
|
||||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -472,7 +472,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.7.0;
|
MARKETING_VERSION = 1.8.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
let isFirstLaunch = !UserDefaults.standard.bool(forKey: "hasLaunchedBefore")
|
let isFirstLaunch = !UserDefaults.standard.bool(forKey: "hasLaunchedBefore")
|
||||||
if isFirstLaunch {
|
if isFirstLaunch {
|
||||||
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
||||||
|
}
|
||||||
|
// Before the first log call: every event carries this launch's tenure counters.
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: isFirstLaunch)
|
||||||
|
if isFirstLaunch {
|
||||||
AnalyticsTracker.log("First Launch")
|
AnalyticsTracker.log("First Launch")
|
||||||
}
|
}
|
||||||
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
|
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
|
||||||
@@ -31,8 +35,12 @@ enum AnalyticsTracker {
|
|||||||
static func configure() {}
|
static func configure() {}
|
||||||
|
|
||||||
static func log(_ event: String, properties: [String: Any] = [:]) {
|
static func log(_ event: String, properties: [String: Any] = [:]) {
|
||||||
|
// Tenure counters first so an explicit property of the same name would win.
|
||||||
|
var merged = UsageMetrics.eventProps
|
||||||
|
for (key, value) in properties { merged[key] = value }
|
||||||
|
|
||||||
var converted: [String: Any] = [:]
|
var converted: [String: Any] = [:]
|
||||||
for (key, value) in properties {
|
for (key, value) in merged {
|
||||||
switch value {
|
switch value {
|
||||||
case let s as String: converted[key] = s
|
case let s as String: converted[key] = s
|
||||||
case let i as Int: converted[key] = i
|
case let i as Int: converted[key] = i
|
||||||
@@ -44,10 +52,10 @@ enum AnalyticsTracker {
|
|||||||
}
|
}
|
||||||
Aptabase.shared.trackEvent(event, with: converted)
|
Aptabase.shared.trackEvent(event, with: converted)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if properties.isEmpty {
|
if converted.isEmpty {
|
||||||
NSLog("Analytics: %@", event)
|
NSLog("Analytics: %@", event)
|
||||||
} else {
|
} else {
|
||||||
let formatted = properties
|
let formatted = converted
|
||||||
.map { "\($0.key)=\($0.value)" }
|
.map { "\($0.key)=\($0.value)" }
|
||||||
.sorted()
|
.sorted()
|
||||||
.joined(separator: ", ")
|
.joined(separator: ", ")
|
||||||
|
|||||||
@@ -117,10 +117,6 @@
|
|||||||
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
||||||
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
||||||
"bom.quantity.single.badge" = "1× • %@";
|
"bom.quantity.single.badge" = "1× • %@";
|
||||||
"cable.pro.privacy.label" = "Privacy";
|
|
||||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
|
||||||
"cable.pro.terms.label" = "Terms";
|
|
||||||
"cable.pro.terms.url" = "https://voltplan.app/terms";
|
|
||||||
"calculator.advanced.duty_cycle.helper" = "Percentage of each active session where the load actually draws power.";
|
"calculator.advanced.duty_cycle.helper" = "Percentage of each active session where the load actually draws power.";
|
||||||
"calculator.advanced.duty_cycle.title" = "Duty Cycle";
|
"calculator.advanced.duty_cycle.title" = "Duty Cycle";
|
||||||
"calculator.advanced.section.title" = "Advanced Settings";
|
"calculator.advanced.section.title" = "Advanced Settings";
|
||||||
@@ -199,7 +195,7 @@
|
|||||||
"loads.overview.metric.current" = "Total Current";
|
"loads.overview.metric.current" = "Total Current";
|
||||||
"loads.overview.metric.power" = "Total Power";
|
"loads.overview.metric.power" = "Total Power";
|
||||||
"loads.overview.status.missing_details.banner" = "Finish configuring your loads";
|
"loads.overview.status.missing_details.banner" = "Finish configuring your loads";
|
||||||
"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations.";
|
"loads.overview.status.missing_details.message" = "Enter cable length and current draw for %d %@ so Cable can size the wiring.";
|
||||||
"loads.overview.status.missing_details.plural" = "loads";
|
"loads.overview.status.missing_details.plural" = "loads";
|
||||||
"loads.overview.status.missing_details.singular" = "load";
|
"loads.overview.status.missing_details.singular" = "load";
|
||||||
"loads.overview.status.missing_details.title" = "Missing load details";
|
"loads.overview.status.missing_details.title" = "Missing load details";
|
||||||
@@ -289,47 +285,7 @@
|
|||||||
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
|
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
|
||||||
"settings.pro.day.one" = "%@ day";
|
"settings.pro.day.one" = "%@ day";
|
||||||
"settings.pro.day.other" = "%@ days";
|
"settings.pro.day.other" = "%@ days";
|
||||||
"cable.pro.terms.label" = "Terms";
|
|
||||||
"cable.pro.privacy.label" = "Privacy";
|
|
||||||
"cable.pro.terms.url" = "https://voltplan.app/terms";
|
|
||||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
|
||||||
"cable.pro.paywall.title" = "Cable PRO";
|
|
||||||
"cable.pro.paywall.subtitle" = "Cable PRO enables more configuration options for loads, batteries and chargers.";
|
|
||||||
"cable.pro.feature.dutyCycle" = "Duty-cycle aware cable calculators";
|
|
||||||
"cable.pro.feature.batteryCapacity" = "Configure usable battery capacity";
|
|
||||||
"cable.pro.feature.usageBased" = "Usage based calculations";
|
|
||||||
"cable.pro.button.unlock" = "Unlock Now";
|
|
||||||
"cable.pro.button.freeTrial" = "Start Free Trial";
|
|
||||||
"cable.pro.button.unlocked" = "Unlocked";
|
|
||||||
"cable.pro.restore.button" = "Restore Purchases";
|
|
||||||
"cable.pro.alert.success.title" = "Cable PRO Unlocked";
|
|
||||||
"cable.pro.alert.success.body" = "Thanks for supporting Cable PRO!";
|
|
||||||
"cable.pro.alert.pending.title" = "Purchase Pending";
|
|
||||||
"cable.pro.alert.pending.body" = "Your purchase is awaiting approval.";
|
|
||||||
"cable.pro.alert.restored.title" = "Purchases Restored";
|
|
||||||
"cable.pro.alert.restored.body" = "Your purchases are available again.";
|
|
||||||
"cable.pro.alert.error.title" = "Purchase Failed";
|
|
||||||
"cable.pro.alert.error.generic" = "Something went wrong. Please try again.";
|
|
||||||
"generic.ok" = "OK";
|
"generic.ok" = "OK";
|
||||||
"cable.pro.trial.badge" = "Includes a %@ free trial";
|
|
||||||
"cable.pro.subscription.renews" = "Renews %@.";
|
|
||||||
"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@.";
|
|
||||||
"cable.pro.duration.day.singular" = "every day";
|
|
||||||
"cable.pro.duration.day.plural" = "every %@ days";
|
|
||||||
"cable.pro.duration.week.singular" = "every week";
|
|
||||||
"cable.pro.duration.week.plural" = "every %@ weeks";
|
|
||||||
"cable.pro.duration.month.singular" = "every month";
|
|
||||||
"cable.pro.duration.month.plural" = "every %@ months";
|
|
||||||
"cable.pro.duration.year.singular" = "every year";
|
|
||||||
"cable.pro.duration.year.plural" = "every %@ years";
|
|
||||||
"cable.pro.trial.duration.day.singular" = "%@-day";
|
|
||||||
"cable.pro.trial.duration.day.plural" = "%@-day";
|
|
||||||
"cable.pro.trial.duration.week.singular" = "%@-week";
|
|
||||||
"cable.pro.trial.duration.week.plural" = "%@-week";
|
|
||||||
"cable.pro.trial.duration.month.singular" = "%@-month";
|
|
||||||
"cable.pro.trial.duration.month.plural" = "%@-month";
|
|
||||||
"cable.pro.trial.duration.year.singular" = "%@-year";
|
|
||||||
"cable.pro.trial.duration.year.plural" = "%@-year";
|
|
||||||
|
|
||||||
// MARK: - PDF Overview Export
|
// MARK: - PDF Overview Export
|
||||||
"overview.pdf.loads.section" = "Loads";
|
"overview.pdf.loads.section" = "Loads";
|
||||||
@@ -388,3 +344,12 @@
|
|||||||
"overview.share.diagram" = "Wiring Diagram";
|
"overview.share.diagram" = "Wiring Diagram";
|
||||||
"overview.share.pdf" = "Full Report (PDF)";
|
"overview.share.pdf" = "Full Report (PDF)";
|
||||||
"overview.share.diagram.error" = "Could not generate diagram. Check your internet connection.";
|
"overview.share.diagram.error" = "Could not generate diagram. Check your internet connection.";
|
||||||
|
|
||||||
|
"editor.system.voltage_drop.label" = "Voltage drop budget";
|
||||||
|
"editor.system.voltage_drop.hint.critical" = "3 % — for critical circuits like navigation, bilge pumps and electronics.";
|
||||||
|
"editor.system.voltage_drop.hint.standard" = "5 % — balanced default for mixed installations.";
|
||||||
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — for non-critical loads such as cabin lighting.";
|
||||||
|
"settings.voltage_drop.label" = "Voltage drop budget";
|
||||||
|
"settings.voltage_drop.footnote" = "Used by the calculator and for new systems. Existing systems keep their own budget.";
|
||||||
|
"settings.rate.title" = "Rate Cable";
|
||||||
|
"settings.rate.footnote" = "Ratings are how other installers find Cable in the App Store.";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class CableCalculator: ObservableObject {
|
|||||||
@Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
|
@Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
|
||||||
@Published var dutyCyclePercent: Double = 100.0
|
@Published var dutyCyclePercent: Double = 100.0
|
||||||
@Published var dailyUsageHours: Double = 24.0
|
@Published var dailyUsageHours: Double = 24.0
|
||||||
|
@Published var maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent
|
||||||
|
|
||||||
var calculatedPower: Double {
|
var calculatedPower: Double {
|
||||||
voltage * current
|
voltage * current
|
||||||
@@ -38,38 +39,42 @@ class CableCalculator: ObservableObject {
|
|||||||
length: length,
|
length: length,
|
||||||
current: current,
|
current: current,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
unitSystem: unitSystem
|
unitSystem: unitSystem,
|
||||||
|
maxVoltageDropPercent: maxVoltageDropPercent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func crossSection(for unitSystem: UnitSystem) -> Double {
|
func crossSection(for unitSystem: UnitSystem) -> Double {
|
||||||
recommendedCrossSection(for: unitSystem)
|
recommendedCrossSection(for: unitSystem)
|
||||||
}
|
}
|
||||||
|
|
||||||
func voltageDrop(for unitSystem: UnitSystem) -> Double {
|
func voltageDrop(for unitSystem: UnitSystem) -> Double {
|
||||||
ElectricalCalculations.voltageDrop(
|
ElectricalCalculations.voltageDrop(
|
||||||
length: length,
|
length: length,
|
||||||
current: current,
|
current: current,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
unitSystem: unitSystem
|
unitSystem: unitSystem,
|
||||||
|
maxVoltageDropPercent: maxVoltageDropPercent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
|
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
|
||||||
ElectricalCalculations.voltageDropPercentage(
|
ElectricalCalculations.voltageDropPercentage(
|
||||||
length: length,
|
length: length,
|
||||||
current: current,
|
current: current,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
unitSystem: unitSystem
|
unitSystem: unitSystem,
|
||||||
|
maxVoltageDropPercent: maxVoltageDropPercent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func powerLoss(for unitSystem: UnitSystem) -> Double {
|
func powerLoss(for unitSystem: UnitSystem) -> Double {
|
||||||
ElectricalCalculations.powerLoss(
|
ElectricalCalculations.powerLoss(
|
||||||
length: length,
|
length: length,
|
||||||
current: current,
|
current: current,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
unitSystem: unitSystem
|
unitSystem: unitSystem,
|
||||||
|
maxVoltageDropPercent: maxVoltageDropPercent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +97,9 @@ class ElectricalSystem {
|
|||||||
var colorName: String = "blue"
|
var colorName: String = "blue"
|
||||||
var targetRuntimeHours: Double?
|
var targetRuntimeHours: Double?
|
||||||
var targetChargeTimeHours: Double?
|
var targetChargeTimeHours: Double?
|
||||||
|
/// Share of system voltage that may be lost in a cable. Stored per system so a critical
|
||||||
|
/// 12 V circuit can be planned at 3 % while a cabin light stays at 10 %.
|
||||||
|
var maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent
|
||||||
|
|
||||||
init(
|
init(
|
||||||
name: String,
|
name: String,
|
||||||
@@ -99,7 +107,8 @@ class ElectricalSystem {
|
|||||||
iconName: String = "building.2",
|
iconName: String = "building.2",
|
||||||
colorName: String = "blue",
|
colorName: String = "blue",
|
||||||
targetRuntimeHours: Double? = nil,
|
targetRuntimeHours: Double? = nil,
|
||||||
targetChargeTimeHours: Double? = nil
|
targetChargeTimeHours: Double? = nil,
|
||||||
|
maxVoltageDropPercent: Double = ElectricalCalculations.defaultMaxVoltageDropPercent
|
||||||
) {
|
) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.location = location
|
self.location = location
|
||||||
@@ -108,6 +117,7 @@ class ElectricalSystem {
|
|||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.targetRuntimeHours = targetRuntimeHours
|
self.targetRuntimeHours = targetRuntimeHours
|
||||||
self.targetChargeTimeHours = targetChargeTimeHours
|
self.targetChargeTimeHours = targetChargeTimeHours
|
||||||
|
self.maxVoltageDropPercent = maxVoltageDropPercent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -345,10 +345,14 @@ struct CalculatorView: View {
|
|||||||
.sheet(isPresented: $showingLoadEditor, content: loadEditorSheet)
|
.sheet(isPresented: $showingLoadEditor, content: loadEditorSheet)
|
||||||
.sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:))
|
.sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
calculator.maxVoltageDropPercent = unitSettings.voltageDropTargetPercent
|
||||||
if let savedLoad = savedLoad {
|
if let savedLoad = savedLoad {
|
||||||
loadConfiguration(from: savedLoad)
|
loadConfiguration(from: savedLoad)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: unitSettings.voltageDropTargetPercent) { _, newValue in
|
||||||
|
calculator.maxVoltageDropPercent = newValue
|
||||||
|
}
|
||||||
.onChange(of: completedItemIDs) { _, _ in
|
.onChange(of: completedItemIDs) { _, _ in
|
||||||
persistCompletedItems()
|
persistCompletedItems()
|
||||||
}
|
}
|
||||||
@@ -701,7 +705,7 @@ struct CalculatorView: View {
|
|||||||
Button(action: {}) {
|
Button(action: {}) {
|
||||||
Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem)))
|
Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem)))
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > 5 ? .orange : .primary)
|
.foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > calculator.maxVoltageDropPercent ? .orange : .primary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
|||||||
let iconURL: URL?
|
let iconURL: URL?
|
||||||
|
|
||||||
var displayVoltage: Double? {
|
var displayVoltage: Double? {
|
||||||
voltageIn ?? voltageOut
|
[voltageIn, voltageOut].compactMap({ $0 }).first(where: { $0 > 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
var current: Double? {
|
var current: Double? {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct ElectricalCalculations {
|
struct ElectricalCalculations {
|
||||||
private static let maxVoltageDropFraction = 0.05
|
/// Default share of system voltage that may be lost in the cable. ABYC E-11 works with
|
||||||
|
/// 3 % for critical circuits and 10 % for non-critical ones; 5 % is the middle ground.
|
||||||
|
static let defaultMaxVoltageDropPercent = 5.0
|
||||||
private static let copperResistivity = 0.017 // Ω⋅mm²/m
|
private static let copperResistivity = 0.017 // Ω⋅mm²/m
|
||||||
private static let feetToMeters = 0.3048
|
private static let feetToMeters = 0.3048
|
||||||
|
|
||||||
@@ -29,27 +31,87 @@ struct ElectricalCalculations {
|
|||||||
300, 350, 400, 450, 500, 600, 700, 800,
|
300, 350, 400, 450, 500, 600, 700, 800,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// Ampacity of the AWG ladder in amps. Source: ABYC E-11 Table 6A, 105 °C insulation
|
||||||
|
/// rating, outside engine spaces, single conductors not bundled. Index-aligned with
|
||||||
|
/// `standardAWG`. AWG 20 is not covered by the table, so it is never selected on
|
||||||
|
/// ampacity grounds.
|
||||||
|
private static let awgAmpacity: [Double] = [
|
||||||
|
0, 20, 25, 35, 45, 60, 80, 120, 160, 210, 245, 285, 330, 385, 445,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Ampacity of the metric ladder in amps. Source: ISO 13297 Table A1, 105 °C insulation
|
||||||
|
/// rating, bundles up to three conductors. The standard tabulates 0.75–150 mm²; larger
|
||||||
|
/// cross-sections inherit the 150 mm² value as a conservative lower bound. Index-aligned
|
||||||
|
/// with `standardMetricCrossSections`.
|
||||||
|
private static let metricAmpacity: [Double] = [
|
||||||
|
16, 20, 25, 35, 45, 60, 90, 130, 170, 210, 270, 330, 390, 450, 475,
|
||||||
|
475, 475, 475, 475, 475, 475,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Current-carrying capacity of a cable in amps. `crossSection` is mm² for `.metric`
|
||||||
|
/// and an AWG value (see `standardAWG`) for `.imperial`. Cross-sections between two
|
||||||
|
/// standard sizes are rated with the smaller neighbour.
|
||||||
|
static func ampacity(forCrossSection crossSection: Double, unitSystem: UnitSystem) -> Double {
|
||||||
|
if unitSystem == .imperial {
|
||||||
|
guard let index = standardAWG.firstIndex(of: Int(crossSection)) else { return 0 }
|
||||||
|
return awgAmpacity[index]
|
||||||
|
}
|
||||||
|
var rating = 0.0
|
||||||
|
for (index, area) in standardMetricCrossSections.enumerated() where area <= crossSection {
|
||||||
|
rating = metricAmpacity[index]
|
||||||
|
}
|
||||||
|
return rating
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nearest AWG size for a stored metric cross-section. Used to display mm² values in
|
||||||
|
/// AWG; picks the closest ladder entry, not the next larger one.
|
||||||
|
static func nearestAWG(forCrossSectionMM2 crossSection: Double) -> Double {
|
||||||
|
guard crossSection > 0 else { return 0 }
|
||||||
|
var bestAWG = standardAWG.first ?? 20
|
||||||
|
var bestDelta = Double.greatestFiniteMagnitude
|
||||||
|
for (index, area) in awgCrossSections.enumerated() {
|
||||||
|
let delta = abs(area - crossSection)
|
||||||
|
if delta < bestDelta {
|
||||||
|
bestDelta = delta
|
||||||
|
bestAWG = standardAWG[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Double(bestAWG)
|
||||||
|
}
|
||||||
|
|
||||||
/// Length must always be in meters. unitSystem controls the output format (mm² vs AWG).
|
/// Length must always be in meters. unitSystem controls the output format (mm² vs AWG).
|
||||||
|
///
|
||||||
|
/// The recommendation satisfies two independent criteria, whichever one demands more
|
||||||
|
/// copper: the voltage-drop budget, and an ampacity at least as high as the fuse that
|
||||||
|
/// protects the circuit. The latter follows ABYC E-11, which requires the rating of the
|
||||||
|
/// overcurrent protection device to not exceed the ampacity of the conductor it protects.
|
||||||
static func recommendedCrossSection(
|
static func recommendedCrossSection(
|
||||||
length: Double,
|
length: Double,
|
||||||
current: Double,
|
current: Double,
|
||||||
voltage: Double,
|
voltage: Double,
|
||||||
unitSystem: UnitSystem
|
unitSystem: UnitSystem,
|
||||||
|
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
|
||||||
) -> Double {
|
) -> Double {
|
||||||
let lengthInMeters = length
|
let lengthInMeters = length
|
||||||
let maxVoltageDrop = voltage * maxVoltageDropFraction
|
let maxVoltageDrop = voltage * (maxVoltageDropPercent / 100)
|
||||||
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
|
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
|
||||||
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
|
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
|
||||||
}
|
}
|
||||||
|
let requiredAmpacity = recommendedFuse(forCurrent: current)
|
||||||
|
|
||||||
if unitSystem == .imperial {
|
if unitSystem == .imperial {
|
||||||
for (index, crossSection) in awgCrossSections.enumerated() where crossSection >= minimumCrossSection {
|
for (index, crossSection) in awgCrossSections.enumerated()
|
||||||
|
where crossSection >= minimumCrossSection && awgAmpacity[index] >= requiredAmpacity {
|
||||||
return Double(standardAWG[index])
|
return Double(standardAWG[index])
|
||||||
}
|
}
|
||||||
return Double(standardAWG.last ?? 0)
|
return Double(standardAWG.last ?? 0)
|
||||||
} else {
|
} else {
|
||||||
return standardMetricCrossSections.first { $0 >= max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) }
|
let minimumArea = max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection)
|
||||||
?? standardMetricCrossSections.last ?? 0.75
|
for (index, crossSection) in standardMetricCrossSections.enumerated()
|
||||||
|
where crossSection >= minimumArea && metricAmpacity[index] >= requiredAmpacity {
|
||||||
|
return crossSection
|
||||||
|
}
|
||||||
|
return standardMetricCrossSections.last ?? 0.75
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,13 +120,15 @@ struct ElectricalCalculations {
|
|||||||
current: Double,
|
current: Double,
|
||||||
voltage: Double,
|
voltage: Double,
|
||||||
unitSystem: UnitSystem,
|
unitSystem: UnitSystem,
|
||||||
crossSection: Double? = nil
|
crossSection: Double? = nil,
|
||||||
|
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
|
||||||
) -> Double {
|
) -> Double {
|
||||||
let selectedCrossSection = crossSection ?? recommendedCrossSection(
|
let selectedCrossSection = crossSection ?? recommendedCrossSection(
|
||||||
length: length,
|
length: length,
|
||||||
current: current,
|
current: current,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
unitSystem: unitSystem
|
unitSystem: unitSystem,
|
||||||
|
maxVoltageDropPercent: maxVoltageDropPercent
|
||||||
)
|
)
|
||||||
|
|
||||||
let lengthInMeters = length
|
let lengthInMeters = length
|
||||||
@@ -84,7 +148,8 @@ struct ElectricalCalculations {
|
|||||||
current: Double,
|
current: Double,
|
||||||
voltage: Double,
|
voltage: Double,
|
||||||
unitSystem: UnitSystem,
|
unitSystem: UnitSystem,
|
||||||
crossSection: Double? = nil
|
crossSection: Double? = nil,
|
||||||
|
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
|
||||||
) -> Double {
|
) -> Double {
|
||||||
guard voltage != 0 else { return 0 }
|
guard voltage != 0 else { return 0 }
|
||||||
let drop = voltageDrop(
|
let drop = voltageDrop(
|
||||||
@@ -92,7 +157,8 @@ struct ElectricalCalculations {
|
|||||||
current: current,
|
current: current,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
unitSystem: unitSystem,
|
unitSystem: unitSystem,
|
||||||
crossSection: crossSection
|
crossSection: crossSection,
|
||||||
|
maxVoltageDropPercent: maxVoltageDropPercent
|
||||||
)
|
)
|
||||||
return (drop / voltage) * 100
|
return (drop / voltage) * 100
|
||||||
}
|
}
|
||||||
@@ -102,14 +168,16 @@ struct ElectricalCalculations {
|
|||||||
current: Double,
|
current: Double,
|
||||||
voltage: Double,
|
voltage: Double,
|
||||||
unitSystem: UnitSystem,
|
unitSystem: UnitSystem,
|
||||||
crossSection: Double? = nil
|
crossSection: Double? = nil,
|
||||||
|
maxVoltageDropPercent: Double = defaultMaxVoltageDropPercent
|
||||||
) -> Double {
|
) -> Double {
|
||||||
let drop = voltageDrop(
|
let drop = voltageDrop(
|
||||||
length: length,
|
length: length,
|
||||||
current: current,
|
current: current,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
unitSystem: unitSystem,
|
unitSystem: unitSystem,
|
||||||
crossSection: crossSection
|
crossSection: crossSection,
|
||||||
|
maxVoltageDropPercent: maxVoltageDropPercent
|
||||||
)
|
)
|
||||||
return current * drop
|
return current * drop
|
||||||
}
|
}
|
||||||
|
|||||||
32
Cable/Loads/LoadCableSync.swift
Normal file
32
Cable/Loads/LoadCableSync.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The cross-section of a load is derived, never entered by the user: it follows from length,
|
||||||
|
/// current, voltage and the system's voltage-drop budget. Whenever one of those inputs changes,
|
||||||
|
/// the stored value has to follow — otherwise the app displays a size it no longer stands behind
|
||||||
|
/// and would have to ask the user to fix something only the app can compute.
|
||||||
|
enum LoadCableSync {
|
||||||
|
/// Sets every load's cross-section to the size the app would recommend right now.
|
||||||
|
/// Returns how many loads changed, so callers can log or react without diffing themselves.
|
||||||
|
@discardableResult
|
||||||
|
static func synchronize(loads: [SavedLoad], maxVoltageDropPercent: Double) -> Int {
|
||||||
|
var changed = 0
|
||||||
|
for load in loads where load.length > 0 && load.current > 0 {
|
||||||
|
let recommended = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: load.length,
|
||||||
|
current: load.current,
|
||||||
|
voltage: load.voltage,
|
||||||
|
unitSystem: .metric,
|
||||||
|
maxVoltageDropPercent: maxVoltageDropPercent
|
||||||
|
)
|
||||||
|
guard abs(load.crossSection - recommended) > 0.0001 else { continue }
|
||||||
|
load.crossSection = recommended
|
||||||
|
changed += 1
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func synchronize(loads: [SavedLoad], for system: ElectricalSystem) -> Int {
|
||||||
|
synchronize(loads: loads, maxVoltageDropPercent: system.maxVoltageDropPercent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The only load problem the app cannot solve on its own: missing length or current.
|
||||||
|
/// Cross-sections are derived and kept in sync by `LoadCableSync`, so an undersized cable
|
||||||
|
/// can no longer occur — there is nothing to warn about that the user would have to fix.
|
||||||
enum LoadConfigurationStatus: Identifiable, Equatable {
|
enum LoadConfigurationStatus: Identifiable, Equatable {
|
||||||
case missingDetails(count: Int)
|
case missingDetails(count: Int)
|
||||||
|
|
||||||
|
static func evaluate(loads: [SavedLoad]) -> LoadConfigurationStatus? {
|
||||||
|
guard !loads.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let incomplete = loads.filter { load in
|
||||||
|
load.length <= 0 || load.current <= 0 || load.crossSection <= 0
|
||||||
|
}
|
||||||
|
guard !incomplete.isEmpty else { return nil }
|
||||||
|
return .missingDetails(count: incomplete.count)
|
||||||
|
}
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .missingDetails(let count):
|
case .missingDetails(let count):
|
||||||
@@ -43,7 +56,7 @@ enum LoadConfigurationStatus: Identifiable, Equatable {
|
|||||||
)
|
)
|
||||||
let format = String(
|
let format = String(
|
||||||
localized: "loads.overview.status.missing_details.message",
|
localized: "loads.overview.status.missing_details.message",
|
||||||
defaultValue: "Enter cable length and wire size for %d %@ to see accurate recommendations."
|
defaultValue: "Enter cable length and current draw for %d %@ so Cable can size the wiring."
|
||||||
)
|
)
|
||||||
let loadWord = count == 1
|
let loadWord = count == 1
|
||||||
? String(
|
? String(
|
||||||
@@ -54,8 +67,7 @@ enum LoadConfigurationStatus: Identifiable, Equatable {
|
|||||||
localized: "loads.overview.status.missing_details.plural",
|
localized: "loads.overview.status.missing_details.plural",
|
||||||
defaultValue: "loads"
|
defaultValue: "loads"
|
||||||
)
|
)
|
||||||
let message = String(format: format, count, loadWord)
|
return LoadStatusDetail(title: title, message: String(format: format, count, loadWord))
|
||||||
return LoadStatusDetail(title: title, message: message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ struct LoadsView: View {
|
|||||||
@State private var overviewExportRequested = false
|
@State private var overviewExportRequested = false
|
||||||
@State private var diagramExportRequested = false
|
@State private var diagramExportRequested = false
|
||||||
@State private var isExportingOverview = false
|
@State private var isExportingOverview = false
|
||||||
@State private var overviewShareItem: OverviewShareItem?
|
@State private var previewURL: URL?
|
||||||
|
@State private var previewTempURL: URL?
|
||||||
@State private var overviewExportError: OverviewExportError?
|
@State private var overviewExportError: OverviewExportError?
|
||||||
|
|
||||||
let system: ElectricalSystem
|
let system: ElectricalSystem
|
||||||
@@ -55,6 +56,10 @@ struct LoadsView: View {
|
|||||||
allChargers.filter { $0.system == system }
|
allChargers.filter { $0.system == system }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var hasComponents: Bool {
|
||||||
|
!savedLoads.isEmpty || !savedBatteries.isEmpty || !savedChargers.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
TabView(selection: $selectedComponentTab) {
|
TabView(selection: $selectedComponentTab) {
|
||||||
@@ -140,6 +145,7 @@ struct LoadsView: View {
|
|||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("system-title-button")
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
@@ -173,6 +179,7 @@ struct LoadsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
|
.disabled(!hasComponents)
|
||||||
.accessibilityIdentifier("system-overview-share-button")
|
.accessibilityIdentifier("system-overview-share-button")
|
||||||
}
|
}
|
||||||
} else if showPrimary || showEditLoads || showEditBatteries || showEditChargers {
|
} else if showPrimary || showEditLoads || showEditBatteries || showEditChargers {
|
||||||
@@ -219,11 +226,26 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sheet(item: $overviewShareItem, onDismiss: {
|
.sheet(
|
||||||
cleanupOverviewShareItem()
|
isPresented: Binding(
|
||||||
ReviewPrompt.registerSuccessfulExport()
|
get: { previewURL != nil },
|
||||||
}) { item in
|
set: { if !$0 { previewURL = nil } }
|
||||||
ShareSheet(items: item.shareItems)
|
)
|
||||||
|
) {
|
||||||
|
if let previewURL {
|
||||||
|
QuickLookPreview(url: previewURL) {
|
||||||
|
self.previewURL = nil
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: previewURL) { _, newValue in
|
||||||
|
if newValue == nil {
|
||||||
|
cleanupPreview()
|
||||||
|
ReviewPrompt.record(.exported)
|
||||||
|
// The QuickLook sheet is still animating away; `promptIfEligible` waits it out.
|
||||||
|
ReviewPrompt.promptIfEligible()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
String(localized: "overview.share.error.title", defaultValue: "Export Failed"),
|
String(localized: "overview.share.error.title", defaultValue: "Export Failed"),
|
||||||
@@ -283,6 +305,18 @@ struct LoadsView: View {
|
|||||||
colorName: Binding(
|
colorName: Binding(
|
||||||
get: { system.colorName },
|
get: { system.colorName },
|
||||||
set: { system.colorName = $0 }
|
set: { system.colorName = $0 }
|
||||||
|
),
|
||||||
|
maxVoltageDropPercent: Binding(
|
||||||
|
get: { system.maxVoltageDropPercent },
|
||||||
|
set: { newValue in
|
||||||
|
system.maxVoltageDropPercent = newValue
|
||||||
|
// The budget is an input to every cable size, so the stored sizes follow.
|
||||||
|
let resized = LoadCableSync.synchronize(loads: savedLoads, for: system)
|
||||||
|
AnalyticsTracker.log("Voltage Drop Budget Changed", properties: [
|
||||||
|
"percent": newValue,
|
||||||
|
"resized_loads": resized,
|
||||||
|
])
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -299,6 +333,13 @@ struct LoadsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
LoadCableSync.synchronize(loads: savedLoads, for: system)
|
||||||
|
|
||||||
|
if !savedLoads.isEmpty && loadStatus == nil {
|
||||||
|
ReviewPrompt.record(.systemPlanned)
|
||||||
|
}
|
||||||
|
ReviewPrompt.promptIfEligible()
|
||||||
|
|
||||||
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
||||||
hasPresentedSystemEditorOnAppear = true
|
hasPresentedSystemEditorOnAppear = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -742,14 +783,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var loadStatus: LoadConfigurationStatus? {
|
private var loadStatus: LoadConfigurationStatus? {
|
||||||
guard !savedLoads.isEmpty else { return nil }
|
LoadConfigurationStatus.evaluate(loads: savedLoads)
|
||||||
let incompleteLoads = savedLoads.filter { load in
|
|
||||||
load.length <= 0 || load.crossSection <= 0
|
|
||||||
}
|
|
||||||
if !incompleteLoads.isEmpty {
|
|
||||||
return .missingDetails(count: incompleteLoads.count)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
|
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
|
||||||
@@ -895,6 +929,7 @@ struct LoadsView: View {
|
|||||||
"system": system.name
|
"system": system.name
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
ReviewPrompt.record(.billOfMaterials)
|
||||||
showingSystemBOM = true
|
showingSystemBOM = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1083,13 +1118,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||||
let awgSizes: [(Int, Double)] = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31),
|
ElectricalCalculations.nearestAWG(forCrossSectionMM2: crossSectionMM2)
|
||||||
(10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6),
|
|
||||||
(1, 42.4), (-1, 53.5), (-2, 67.4), (-3, 85.0), (-4, 107.0)]
|
|
||||||
|
|
||||||
// Find the closest AWG size
|
|
||||||
let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) }
|
|
||||||
return Double(closest?.0 ?? 20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func recommendedFuse(for load: SavedLoad) -> String {
|
private func recommendedFuse(for load: SavedLoad) -> String {
|
||||||
@@ -1106,12 +1135,6 @@ struct LoadsView: View {
|
|||||||
|
|
||||||
// MARK: - PDF Export
|
// MARK: - PDF Export
|
||||||
|
|
||||||
private struct OverviewShareItem: Identifiable {
|
|
||||||
let id = UUID()
|
|
||||||
let shareItems: [Any]
|
|
||||||
let tempURL: URL?
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct OverviewExportError: Identifiable {
|
private struct OverviewExportError: Identifiable {
|
||||||
let message: String
|
let message: String
|
||||||
var id: String { message }
|
var id: String { message }
|
||||||
@@ -1134,7 +1157,8 @@ struct LoadsView: View {
|
|||||||
"system": snapshot.systemName,
|
"system": snapshot.systemName,
|
||||||
])
|
])
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
previewURL = url
|
||||||
|
previewTempURL = url
|
||||||
isExportingOverview = false
|
isExportingOverview = false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1164,7 +1188,8 @@ struct LoadsView: View {
|
|||||||
AnalyticsTracker.log("Diagram Image Shared", properties: [
|
AnalyticsTracker.log("Diagram Image Shared", properties: [
|
||||||
"system": snapshot.systemName,
|
"system": snapshot.systemName,
|
||||||
])
|
])
|
||||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
previewURL = url
|
||||||
|
previewTempURL = url
|
||||||
} else {
|
} else {
|
||||||
overviewExportError = OverviewExportError(
|
overviewExportError = OverviewExportError(
|
||||||
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")
|
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")
|
||||||
@@ -1203,16 +1228,20 @@ struct LoadsView: View {
|
|||||||
dutyCyclePercent: load.dutyCyclePercent,
|
dutyCyclePercent: load.dutyCyclePercent,
|
||||||
dailyUsageHours: load.dailyUsageHours,
|
dailyUsageHours: load.dailyUsageHours,
|
||||||
recommendedCrossSection: ElectricalCalculations.recommendedCrossSection(
|
recommendedCrossSection: ElectricalCalculations.recommendedCrossSection(
|
||||||
length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem
|
length: load.length, current: load.current, voltage: load.voltage,
|
||||||
|
unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||||
),
|
),
|
||||||
voltageDrop: ElectricalCalculations.voltageDrop(
|
voltageDrop: ElectricalCalculations.voltageDrop(
|
||||||
length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem
|
length: load.length, current: load.current, voltage: load.voltage,
|
||||||
|
unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||||
),
|
),
|
||||||
voltageDropPercent: ElectricalCalculations.voltageDropPercentage(
|
voltageDropPercent: ElectricalCalculations.voltageDropPercentage(
|
||||||
length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem
|
length: load.length, current: load.current, voltage: load.voltage,
|
||||||
|
unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||||
),
|
),
|
||||||
powerLoss: ElectricalCalculations.powerLoss(
|
powerLoss: ElectricalCalculations.powerLoss(
|
||||||
length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem
|
length: load.length, current: load.current, voltage: load.voltage,
|
||||||
|
unitSystem: currentUnitSystem, maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||||
),
|
),
|
||||||
recommendedFuse: ElectricalCalculations.recommendedFuse(forCurrent: load.current),
|
recommendedFuse: ElectricalCalculations.recommendedFuse(forCurrent: load.current),
|
||||||
iconUrl: load.remoteIconURLString
|
iconUrl: load.remoteIconURLString
|
||||||
@@ -1284,11 +1313,10 @@ struct LoadsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupOverviewShareItem() {
|
private func cleanupPreview() {
|
||||||
guard let item = overviewShareItem else { return }
|
if let url = previewTempURL {
|
||||||
overviewShareItem = nil
|
|
||||||
if let url = item.tempURL {
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
previewTempURL = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
Cable/Loads/QuickLookPreview.swift
Normal file
67
Cable/Loads/QuickLookPreview.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// QuickLookPreview.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
|
||||||
|
import QuickLook
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Presents a single exported file (PDF or PNG) in Quick Look so the user can
|
||||||
|
/// inspect it before sharing.
|
||||||
|
///
|
||||||
|
/// SwiftUI's `quickLookPreview(_:)` modifier is macOS-only, so iOS wraps
|
||||||
|
/// `QLPreviewController` directly. The controller only shows its action
|
||||||
|
/// buttons — share, print, open in another app — when it sits inside a
|
||||||
|
/// navigation controller, so it is wrapped in one and gets an explicit Done
|
||||||
|
/// button to close the sheet.
|
||||||
|
struct QuickLookPreview: UIViewControllerRepresentable {
|
||||||
|
let url: URL
|
||||||
|
var onDone: () -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UINavigationController {
|
||||||
|
let preview = QLPreviewController()
|
||||||
|
preview.dataSource = context.coordinator
|
||||||
|
preview.navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
barButtonSystemItem: .done,
|
||||||
|
target: context.coordinator,
|
||||||
|
action: #selector(Coordinator.done)
|
||||||
|
)
|
||||||
|
preview.navigationItem.leftBarButtonItem?.accessibilityIdentifier = "quick-look-done-button"
|
||||||
|
return UINavigationController(rootViewController: preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ controller: UINavigationController, context: Context) {
|
||||||
|
context.coordinator.onDone = onDone
|
||||||
|
|
||||||
|
guard context.coordinator.url != url as NSURL else { return }
|
||||||
|
context.coordinator.url = url as NSURL
|
||||||
|
(controller.viewControllers.first as? QLPreviewController)?.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(url: url as NSURL, onDone: onDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinator
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, QLPreviewControllerDataSource {
|
||||||
|
var url: NSURL
|
||||||
|
var onDone: () -> Void
|
||||||
|
|
||||||
|
init(url: NSURL, onDone: @escaping () -> Void) {
|
||||||
|
self.url = url
|
||||||
|
self.onDone = onDone
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
|
||||||
|
|
||||||
|
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func done() {
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import SwiftData
|
|||||||
struct SystemOverviewView: View {
|
struct SystemOverviewView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
@State private var activeStatus: LoadConfigurationStatus?
|
@State private var activeStatus: LoadConfigurationStatus?
|
||||||
@State private var suppressLoadNavigation = false
|
@State private var suppressLoadNavigation = false
|
||||||
@State private var showingRuntimeGoalEditor = false
|
@State private var showingRuntimeGoalEditor = false
|
||||||
@@ -390,7 +391,6 @@ struct SystemOverviewView: View {
|
|||||||
.fill(Color(.tertiarySystemBackground))
|
.fill(Color(.tertiarySystemBackground))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.alert(item: $activeStatus) { status in
|
.alert(item: $activeStatus) { status in
|
||||||
let detail = status.detailInfo()
|
let detail = status.detailInfo()
|
||||||
return Alert(
|
return Alert(
|
||||||
@@ -588,14 +588,7 @@ struct SystemOverviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var loadStatus: LoadConfigurationStatus? {
|
private var loadStatus: LoadConfigurationStatus? {
|
||||||
guard !loads.isEmpty else { return nil }
|
LoadConfigurationStatus.evaluate(loads: loads)
|
||||||
let incomplete = loads.filter { load in
|
|
||||||
load.length <= 0 || load.crossSection <= 0
|
|
||||||
}
|
|
||||||
if !incomplete.isEmpty {
|
|
||||||
return .missingDetails(count: incomplete.count)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var totalCurrent: Double {
|
private var totalCurrent: Double {
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
// Decides when to ask the user for an App Store rating via StoreKit's
|
// Decides when to ask the user for an App Store rating via StoreKit's
|
||||||
// `AppStore.requestReview(in:)`. The OS throttles the actual dialog (max ~3×/year and
|
// `AppStore.requestReview(in:)`. The OS throttles the actual dialog (max ~3×/year and
|
||||||
// may show nothing at all), so this gate keeps requests rare and tied to genuine success
|
// may show nothing at all), so this gate keeps requests rare and tied to genuine success
|
||||||
// moments — a completed export/share. Mirrors the Android `ReviewPrompt` object.
|
// moments. Recording a milestone and asking for the rating are deliberately separate:
|
||||||
|
// milestones are reached in the middle of a task (a sheet opens, a share sheet closes),
|
||||||
|
// which is exactly when StoreKit drops the request. `promptIfEligible()` is therefore only
|
||||||
|
// called from calm screens. Mirrors the Android `ReviewPrompt` object.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -13,97 +16,125 @@ import StoreKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
enum ReviewPrompt {
|
enum ReviewPrompt {
|
||||||
|
/// Distinct proofs that the user got real value out of the app. Each one counts at most
|
||||||
|
/// once per install, so navigating in circles cannot inflate the gate.
|
||||||
|
enum Milestone: String {
|
||||||
|
/// A system whose loads are all fully configured — the app's own definition of a finished plan.
|
||||||
|
case systemPlanned
|
||||||
|
/// Opened a system's bill of materials.
|
||||||
|
case billOfMaterials
|
||||||
|
/// Completed an export/share (Overview PDF, BOM PDF, diagram image).
|
||||||
|
case exported
|
||||||
|
}
|
||||||
|
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let migrationDone = "review.migrationDone"
|
static let gateVersion = "review.gateVersion"
|
||||||
static let firstLaunchDate = "review.firstLaunchDate"
|
static let milestones = "review.milestones"
|
||||||
static let exportCount = "review.successfulExportCount"
|
|
||||||
static let lastPromptDate = "review.lastPromptDate"
|
static let lastPromptDate = "review.lastPromptDate"
|
||||||
static let lastPromptedVersion = "review.lastPromptedVersion"
|
static let lastPromptedVersion = "review.lastPromptedVersion"
|
||||||
static let userType = "review.userType"
|
static let userType = "review.userType"
|
||||||
|
static let legacyExportCount = "review.successfulExportCount"
|
||||||
|
static let legacyFirstLaunchDate = "review.firstLaunchDate"
|
||||||
|
static let legacyMigrationDone = "review.migrationDone"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gate thresholds — see CLAUDE-discussed spec.
|
/// Two *different* milestones — a single one (only opening the parts list, say) is not enough.
|
||||||
private static let minExports = 2
|
private static let minMilestones = 2
|
||||||
private static let minDaysSinceInstall: TimeInterval = 3
|
private static let minDaysBetweenPrompts: TimeInterval = 90
|
||||||
private static let minDaysBetweenPrompts: TimeInterval = 120
|
|
||||||
private static let day: TimeInterval = 86_400
|
private static let day: TimeInterval = 86_400
|
||||||
|
/// Bump when the milestone semantics change so `migrateIfNeeded` runs again.
|
||||||
|
private static let gateVersion = 2
|
||||||
|
/// Lets a sheet dismissal finish before StoreKit tries to present on the same scene.
|
||||||
|
private static let presentationDelay: TimeInterval = 0.7
|
||||||
|
|
||||||
private static var defaults: UserDefaults { .standard }
|
/// Deep link for the manual entry point in Settings. Unlike `AppStore.requestReview`, this
|
||||||
|
/// is never throttled or suppressed, so it is the only path a willing user can always take.
|
||||||
|
static let writeReviewURL = URL(string: "https://apps.apple.com/app/id6752443870?action=write-review")!
|
||||||
|
|
||||||
/// One-time setup distinguishing fresh installs from users updating into this feature.
|
/// Injection seam for tests; production always uses `.standard`.
|
||||||
/// Existing users are backdated and pre-seeded so the prompt can fire on their *first*
|
static var store: UserDefaults = .standard
|
||||||
/// successful export after updating. Pass the `isFirstLaunch` value already computed in
|
|
||||||
/// `AppDelegate` (the existing `hasLaunchedBefore` flag).
|
/// One-time setup, called on every launch. Existing installs keep the credit they earned
|
||||||
|
/// under the previous export-only gate: the legacy counter was pre-seeded on update, so any
|
||||||
|
/// non-zero value means "knows the app already" and counts as the export milestone. They
|
||||||
|
/// still need a second, real milestone before we ask.
|
||||||
static func migrateIfNeeded(isFirstLaunch: Bool) {
|
static func migrateIfNeeded(isFirstLaunch: Bool) {
|
||||||
guard !defaults.bool(forKey: Key.migrationDone) else { return }
|
guard store.integer(forKey: Key.gateVersion) < gateVersion else { return }
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
if isFirstLaunch {
|
if store.object(forKey: Key.userType) == nil {
|
||||||
// Genuine new install: normal flow — needs 2 exports and 3 days.
|
store.set(isFirstLaunch ? "new" : "existing", forKey: Key.userType)
|
||||||
defaults.set(now, forKey: Key.firstLaunchDate)
|
|
||||||
defaults.set(0, forKey: Key.exportCount)
|
|
||||||
defaults.set("new", forKey: Key.userType)
|
|
||||||
} else {
|
|
||||||
// Existing user updating in: backdate install past the age gate and pre-seed the
|
|
||||||
// counter so the very next successful export satisfies the gate.
|
|
||||||
defaults.set(now - minDaysSinceInstall * day, forKey: Key.firstLaunchDate)
|
|
||||||
defaults.set(minExports - 1, forKey: Key.exportCount)
|
|
||||||
defaults.set("existing", forKey: Key.userType)
|
|
||||||
}
|
}
|
||||||
defaults.set(true, forKey: Key.migrationDone)
|
if store.integer(forKey: Key.legacyExportCount) > 0 {
|
||||||
|
record(.exported)
|
||||||
|
}
|
||||||
|
for key in [Key.legacyExportCount, Key.legacyFirstLaunchDate, Key.legacyMigrationDone] {
|
||||||
|
store.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
store.set(gateVersion, forKey: Key.gateVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
|
/// Books a success moment. Never presents anything — safe to call from anywhere.
|
||||||
/// Increments the shared counter, then requests a review if every gate condition holds.
|
static func record(_ milestone: Milestone) {
|
||||||
|
var reached = Set(store.stringArray(forKey: Key.milestones) ?? [])
|
||||||
|
guard reached.insert(milestone.rawValue).inserted else { return }
|
||||||
|
store.set(reached.sorted(), forKey: Key.milestones)
|
||||||
|
|
||||||
|
AnalyticsTracker.log("Review Milestone Reached", properties: [
|
||||||
|
"milestone": milestone.rawValue,
|
||||||
|
"reached": reached.count,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for a rating if the gate allows it. Call only from a screen at rest — never while a
|
||||||
|
/// sheet is being presented or dismissed.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func registerSuccessfulExport() {
|
static func promptIfEligible() {
|
||||||
// Guard against an export that races ahead of migration.
|
guard isEligible else { return }
|
||||||
if defaults.object(forKey: Key.firstLaunchDate) == nil {
|
DispatchQueue.main.asyncAfter(deadline: .now() + presentationDelay) { present() }
|
||||||
defaults.set(Date().timeIntervalSince1970, forKey: Key.firstLaunchDate)
|
|
||||||
}
|
|
||||||
let count = defaults.integer(forKey: Key.exportCount) + 1
|
|
||||||
defaults.set(count, forKey: Key.exportCount)
|
|
||||||
|
|
||||||
guard shouldRequest(exportCount: count) else { return }
|
|
||||||
request()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func shouldRequest(exportCount: Int) -> Bool {
|
/// Internal rather than private so the gate can be tested without a foreground scene.
|
||||||
// A: enough successful exports
|
static var isEligible: Bool {
|
||||||
guard exportCount >= minExports else { return false }
|
// A: enough distinct milestones
|
||||||
|
let reached = store.stringArray(forKey: Key.milestones) ?? []
|
||||||
|
guard reached.count >= minMilestones else { return false }
|
||||||
|
|
||||||
|
// B: not prompted too recently
|
||||||
let now = Date().timeIntervalSince1970
|
let now = Date().timeIntervalSince1970
|
||||||
|
let lastPrompt = store.double(forKey: Key.lastPromptDate)
|
||||||
// B: installed long enough
|
|
||||||
let firstLaunch = defaults.double(forKey: Key.firstLaunchDate)
|
|
||||||
guard now - firstLaunch >= minDaysSinceInstall * day else { return false }
|
|
||||||
|
|
||||||
// C: not prompted too recently
|
|
||||||
let lastPrompt = defaults.double(forKey: Key.lastPromptDate)
|
|
||||||
if lastPrompt > 0, now - lastPrompt < minDaysBetweenPrompts * day { return false }
|
if lastPrompt > 0, now - lastPrompt < minDaysBetweenPrompts * day { return false }
|
||||||
|
|
||||||
// D: at most once per app version
|
// C: at most once per app version
|
||||||
if defaults.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
|
if store.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private static func request() {
|
private static func present() {
|
||||||
// Mark as requested up front — the OS may suppress the dialog, but we still
|
// Re-check after the delay: the app may have been backgrounded, or another trigger
|
||||||
// count it against our own throttle so we don't ask again immediately.
|
// may have gotten there first. Only mark the throttle once we really can present —
|
||||||
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
|
// otherwise a suppressed request would burn this version's single slot.
|
||||||
defaults.set(currentVersion, forKey: Key.lastPromptedVersion)
|
guard isEligible, let scene = activeScene else { return }
|
||||||
|
|
||||||
|
store.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
|
||||||
|
store.set(currentVersion, forKey: Key.lastPromptedVersion)
|
||||||
|
|
||||||
AnalyticsTracker.log("Review Prompt Requested", properties: [
|
AnalyticsTracker.log("Review Prompt Requested", properties: [
|
||||||
"version": currentVersion,
|
"version": currentVersion,
|
||||||
"userType": defaults.string(forKey: Key.userType) ?? "unknown",
|
"userType": store.string(forKey: Key.userType) ?? "unknown",
|
||||||
|
"milestones": (store.stringArray(forKey: Key.milestones) ?? []).joined(separator: ","),
|
||||||
])
|
])
|
||||||
|
|
||||||
guard let scene = UIApplication.shared.connectedScenes
|
|
||||||
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { return }
|
|
||||||
AppStore.requestReview(in: scene)
|
AppStore.requestReview(in: scene)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static var activeScene: UIWindowScene? {
|
||||||
|
UIApplication.shared.connectedScenes
|
||||||
|
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
|
||||||
|
}
|
||||||
|
|
||||||
private static var currentVersion: String {
|
private static var currentVersion: String {
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,54 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
|
Section {
|
||||||
|
Picker(
|
||||||
|
String(
|
||||||
|
localized: "settings.voltage_drop.label",
|
||||||
|
defaultValue: "Voltage drop budget"
|
||||||
|
),
|
||||||
|
selection: $unitSettings.voltageDropTargetPercent
|
||||||
|
) {
|
||||||
|
ForEach([3.0, 5.0, 10.0], id: \.self) { target in
|
||||||
|
Text(String(format: "%.0f %%", target)).tag(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
localized: "settings.voltage_drop.footnote",
|
||||||
|
defaultValue: "Used by the calculator and for new systems. Existing systems keep their own budget."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
AnalyticsTracker.log("Rate App Tapped")
|
||||||
|
openURL(ReviewPrompt.writeReviewURL)
|
||||||
|
} label: {
|
||||||
|
Label {
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
localized: "settings.rate.title",
|
||||||
|
defaultValue: "Rate Cable"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings-rate-app")
|
||||||
|
} footer: {
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
localized: "settings.rate.footnote",
|
||||||
|
defaultValue: "Ratings are how other installers find Cable in the App Store."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
|||||||
@@ -303,7 +303,8 @@ struct SystemBillOfMaterialsView: View {
|
|||||||
.accessibilityIdentifier("system-bom-view")
|
.accessibilityIdentifier("system-bom-view")
|
||||||
.sheet(item: $activeShareItem, onDismiss: {
|
.sheet(item: $activeShareItem, onDismiss: {
|
||||||
cleanupShareItem()
|
cleanupShareItem()
|
||||||
ReviewPrompt.registerSuccessfulExport()
|
ReviewPrompt.record(.exported)
|
||||||
|
ReviewPrompt.promptIfEligible()
|
||||||
}) { item in
|
}) { item in
|
||||||
ShareSheet(items: [item.url])
|
ShareSheet(items: [item.url])
|
||||||
}
|
}
|
||||||
@@ -852,19 +853,7 @@ struct SystemBillOfMaterialsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||||
let mapping: [(awg: Double, area: Double)] = [
|
ElectricalCalculations.nearestAWG(forCrossSectionMM2: crossSectionMM2)
|
||||||
(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26),
|
|
||||||
(8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (-1, 53.5),
|
|
||||||
(-2, 67.4), (-3, 85.0), (-4, 107.0)
|
|
||||||
]
|
|
||||||
|
|
||||||
guard crossSectionMM2 > 0 else { return 0 }
|
|
||||||
|
|
||||||
let closest = mapping.min { lhs, rhs in
|
|
||||||
abs(lhs.area - crossSectionMM2) < abs(rhs.area - crossSectionMM2)
|
|
||||||
}
|
|
||||||
|
|
||||||
return closest?.awg ?? 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var footerMessage: String {
|
private var footerMessage: String {
|
||||||
|
|||||||
@@ -20,13 +20,22 @@ struct SystemComponentsPersistence {
|
|||||||
batteries: existingBatteries,
|
batteries: existingBatteries,
|
||||||
chargers: existingChargers
|
chargers: existingChargers
|
||||||
)
|
)
|
||||||
|
let defaultLength = 10.0
|
||||||
|
let defaultVoltage = 12.0
|
||||||
|
let defaultCurrent = 5.0
|
||||||
let newLoad = SavedLoad(
|
let newLoad = SavedLoad(
|
||||||
name: loadName,
|
name: loadName,
|
||||||
voltage: 12.0,
|
voltage: defaultVoltage,
|
||||||
current: 5.0,
|
current: defaultCurrent,
|
||||||
power: 60.0,
|
power: defaultVoltage * defaultCurrent,
|
||||||
length: 10.0,
|
length: defaultLength,
|
||||||
crossSection: 1.0,
|
crossSection: ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: defaultLength,
|
||||||
|
current: defaultCurrent,
|
||||||
|
voltage: defaultVoltage,
|
||||||
|
unitSystem: .metric,
|
||||||
|
maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||||
|
),
|
||||||
iconName: "lightbulb",
|
iconName: "lightbulb",
|
||||||
colorName: "blue",
|
colorName: "blue",
|
||||||
isWattMode: false,
|
isWattMode: false,
|
||||||
@@ -69,13 +78,20 @@ struct SystemComponentsPersistence {
|
|||||||
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
|
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
|
||||||
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
|
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
|
||||||
|
|
||||||
|
let length = 10.0
|
||||||
let newLoad = SavedLoad(
|
let newLoad = SavedLoad(
|
||||||
name: loadName,
|
name: loadName,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
current: current,
|
current: current,
|
||||||
power: power,
|
power: power,
|
||||||
length: 10.0,
|
length: length,
|
||||||
crossSection: 1.0,
|
crossSection: ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: .metric,
|
||||||
|
maxVoltageDropPercent: system.maxVoltageDropPercent
|
||||||
|
),
|
||||||
iconName: "lightbulb",
|
iconName: "lightbulb",
|
||||||
colorName: "blue",
|
colorName: "blue",
|
||||||
isWattMode: item.watt != nil,
|
isWattMode: item.watt != nil,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct SystemEditorView: View {
|
|||||||
@Binding var location: String
|
@Binding var location: String
|
||||||
@Binding var iconName: String
|
@Binding var iconName: String
|
||||||
@Binding var colorName: String
|
@Binding var colorName: String
|
||||||
|
@Binding var maxVoltageDropPercent: Double
|
||||||
|
|
||||||
@State private var tempLocation: String
|
@State private var tempLocation: String
|
||||||
|
|
||||||
@@ -23,11 +24,22 @@ struct SystemEditorView: View {
|
|||||||
"engine.combustion", "fuelpump", "drop", "flame", "snowflake", "thermometer"
|
"engine.combustion", "fuelpump", "drop", "flame", "snowflake", "thermometer"
|
||||||
]
|
]
|
||||||
|
|
||||||
init(systemName: Binding<String>, location: Binding<String>, iconName: Binding<String>, colorName: Binding<String>) {
|
/// Targets offered as presets: ABYC E-11 uses 3 % for critical circuits and 10 % for
|
||||||
|
/// non-critical ones, 5 % is the default middle ground.
|
||||||
|
private let voltageDropTargets: [Double] = [3, 5, 10]
|
||||||
|
|
||||||
|
init(
|
||||||
|
systemName: Binding<String>,
|
||||||
|
location: Binding<String>,
|
||||||
|
iconName: Binding<String>,
|
||||||
|
colorName: Binding<String>,
|
||||||
|
maxVoltageDropPercent: Binding<Double>
|
||||||
|
) {
|
||||||
self._systemName = systemName
|
self._systemName = systemName
|
||||||
self._location = location
|
self._location = location
|
||||||
self._iconName = iconName
|
self._iconName = iconName
|
||||||
self._colorName = colorName
|
self._colorName = colorName
|
||||||
|
self._maxVoltageDropPercent = maxVoltageDropPercent
|
||||||
self._tempLocation = State(initialValue: location.wrappedValue)
|
self._tempLocation = State(initialValue: location.wrappedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +58,41 @@ struct SystemEditorView: View {
|
|||||||
colorName: $colorName,
|
colorName: $colorName,
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
AnyView(
|
AnyView(
|
||||||
TextField(locationPlaceholder, text: $tempLocation)
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.autocapitalization(.words)
|
TextField(locationPlaceholder, text: $tempLocation)
|
||||||
.onChange(of: tempLocation) { _, newValue in
|
.autocapitalization(.words)
|
||||||
location = newValue
|
.onChange(of: tempLocation) { _, newValue in
|
||||||
|
location = newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
localized: "editor.system.voltage_drop.label",
|
||||||
|
defaultValue: "Voltage drop budget"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
|
Picker(
|
||||||
|
String(
|
||||||
|
localized: "editor.system.voltage_drop.label",
|
||||||
|
defaultValue: "Voltage drop budget"
|
||||||
|
),
|
||||||
|
selection: $maxVoltageDropPercent
|
||||||
|
) {
|
||||||
|
ForEach(voltageDropTargets, id: \.self) { target in
|
||||||
|
Text(String(format: "%.0f %%", target)).tag(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.accessibilityIdentifier("system-voltage-drop-picker")
|
||||||
|
|
||||||
|
Text(voltageDropHint)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -58,6 +100,26 @@ struct SystemEditorView: View {
|
|||||||
tempLocation = location
|
tempLocation = location
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var voltageDropHint: String {
|
||||||
|
switch maxVoltageDropPercent {
|
||||||
|
case ..<4:
|
||||||
|
return String(
|
||||||
|
localized: "editor.system.voltage_drop.hint.critical",
|
||||||
|
defaultValue: "3 % — for critical circuits like navigation, bilge pumps and electronics."
|
||||||
|
)
|
||||||
|
case ..<6:
|
||||||
|
return String(
|
||||||
|
localized: "editor.system.voltage_drop.hint.standard",
|
||||||
|
defaultValue: "5 % — balanced default for mixed installations."
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return String(
|
||||||
|
localized: "editor.system.voltage_drop.hint.noncritical",
|
||||||
|
defaultValue: "10 % — for non-critical loads such as cabin lighting."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@@ -65,6 +127,13 @@ struct SystemEditorView: View {
|
|||||||
@Previewable @State var location = "Main Building"
|
@Previewable @State var location = "Main Building"
|
||||||
@Previewable @State var icon = "building.2"
|
@Previewable @State var icon = "building.2"
|
||||||
@Previewable @State var color = "blue"
|
@Previewable @State var color = "blue"
|
||||||
|
@Previewable @State var dropTarget = 5.0
|
||||||
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
|
|
||||||
|
return SystemEditorView(
|
||||||
|
systemName: $name,
|
||||||
|
location: $location,
|
||||||
|
iconName: $icon,
|
||||||
|
colorName: $color,
|
||||||
|
maxVoltageDropPercent: $dropTarget
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ struct SystemsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("systems-settings")
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -324,7 +325,8 @@ struct SystemsView: View {
|
|||||||
name: systemName,
|
name: systemName,
|
||||||
location: "",
|
location: "",
|
||||||
iconName: resolvedIconName,
|
iconName: resolvedIconName,
|
||||||
colorName: resolvedColorName
|
colorName: resolvedColorName,
|
||||||
|
maxVoltageDropPercent: unitSettings.voltageDropTargetPercent
|
||||||
)
|
)
|
||||||
modelContext.insert(newSystem)
|
modelContext.insert(newSystem)
|
||||||
return newSystem
|
return newSystem
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ class UnitSystemSettings: ObservableObject {
|
|||||||
AnalyticsTracker.log("Unit System Changed", properties: ["system": unitSystem.rawValue])
|
AnalyticsTracker.log("Unit System Changed", properties: ["system": unitSystem.rawValue])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Applies to the standalone calculator and seeds newly created systems.
|
||||||
|
@Published var voltageDropTargetPercent: Double {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(voltageDropTargetPercent, forKey: "voltageDropTargetPercent")
|
||||||
|
AnalyticsTracker.log("Voltage Drop Target Changed", properties: ["percent": voltageDropTargetPercent])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if let saved = UserDefaults.standard.string(forKey: "unitSystem"),
|
if let saved = UserDefaults.standard.string(forKey: "unitSystem"),
|
||||||
@@ -66,5 +73,9 @@ class UnitSystemSettings: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.unitSystem = Locale.current.measurementSystem == .us ? .imperial : .metric
|
self.unitSystem = Locale.current.measurementSystem == .us ? .imperial : .metric
|
||||||
}
|
}
|
||||||
|
let savedTarget = UserDefaults.standard.double(forKey: "voltageDropTargetPercent")
|
||||||
|
self.voltageDropTargetPercent = savedTarget > 0
|
||||||
|
? savedTarget
|
||||||
|
: ElectricalCalculations.defaultMaxVoltageDropPercent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
80
Cable/UsageMetrics.swift
Normal file
80
Cable/UsageMetrics.swift
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// UsageMetrics.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Makes retention measurable although the analytics backend cannot identify a device across
|
||||||
|
// days: Aptabase derives its `user_id` from a hash of IP address + user agent plus a salt that
|
||||||
|
// rotates every 24 h, so events can never be linked to the previous day's events. Sessions
|
||||||
|
// expire after an hour of inactivity, so `session_id` cannot bridge days either.
|
||||||
|
//
|
||||||
|
// Instead of an identity, every tracked event carries this install's own tenure counters, which
|
||||||
|
// never leave the device in raw form — only the derived day counts are sent. Exact retention
|
||||||
|
// curves can then be reconstructed by *counting events* in the export:
|
||||||
|
//
|
||||||
|
// installs on a given day launch_no == 1 && tenure_days == 0
|
||||||
|
// installs active on day k dormant_days >= 1 && tenure_days == k
|
||||||
|
// D_k retention the latter / the former, k days earlier
|
||||||
|
//
|
||||||
|
// `dormant_days >= 1` holds for exactly one launch per calendar day, which is what makes the
|
||||||
|
// second line count installs rather than launches.
|
||||||
|
//
|
||||||
|
// Days are UTC day indices so they line up with the timestamps in the analytics export.
|
||||||
|
// Mirrors the Android `UsageMetrics` object, which reports into the same Aptabase project.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum UsageMetrics {
|
||||||
|
private enum Key {
|
||||||
|
static let installDay = "usage.installDay"
|
||||||
|
static let launchCount = "usage.launchCount"
|
||||||
|
static let activeDays = "usage.activeDays"
|
||||||
|
static let lastActiveDay = "usage.lastActiveDay"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Injection seams for tests; production always uses `.standard` and the real clock.
|
||||||
|
static var store: UserDefaults = .standard
|
||||||
|
static var clock: () -> Date = Date.init
|
||||||
|
|
||||||
|
/// Merged into every event by `AnalyticsTracker.log`. Empty until `beginLaunch` has run.
|
||||||
|
private(set) static var eventProps: [String: Any] = [:]
|
||||||
|
|
||||||
|
/// Advances the counters once per process start and freezes this launch's props.
|
||||||
|
///
|
||||||
|
/// `isFirstLaunch` is the app's own install marker (`hasLaunchedBefore`). Installs that
|
||||||
|
/// predate these counters have no known install date and report `tenure_days == -1` for the
|
||||||
|
/// rest of their life, so cohort analysis can exclude them instead of mistaking their first
|
||||||
|
/// instrumented launch for a fresh install.
|
||||||
|
static func beginLaunch(isFirstLaunch: Bool) {
|
||||||
|
let today = dayIndex(clock())
|
||||||
|
|
||||||
|
if isFirstLaunch, store.object(forKey: Key.installDay) == nil {
|
||||||
|
store.set(today, forKey: Key.installDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
let launchCount = store.integer(forKey: Key.launchCount) + 1
|
||||||
|
store.set(launchCount, forKey: Key.launchCount)
|
||||||
|
|
||||||
|
// nil on the very first instrumented launch — reported as -1 ("no previous use"), which
|
||||||
|
// keeps it out of the `dormant_days >= 1` day-boundary count.
|
||||||
|
let lastActiveDay = store.object(forKey: Key.lastActiveDay) as? Int
|
||||||
|
let dormantDays = lastActiveDay.map { max(0, today - $0) } ?? -1
|
||||||
|
if lastActiveDay != today {
|
||||||
|
store.set(today, forKey: Key.lastActiveDay)
|
||||||
|
store.set(store.integer(forKey: Key.activeDays) + 1, forKey: Key.activeDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
let installDay = store.object(forKey: Key.installDay) as? Int
|
||||||
|
eventProps = [
|
||||||
|
"tenure_days": installDay.map { max(0, today - $0) } ?? -1,
|
||||||
|
"launch_no": launchCount,
|
||||||
|
"active_days": store.integer(forKey: Key.activeDays),
|
||||||
|
"dormant_days": dormantDays,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whole days since the Unix epoch, in UTC.
|
||||||
|
static func dayIndex(_ date: Date) -> Int {
|
||||||
|
Int(floor(date.timeIntervalSince1970 / 86_400))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,10 +177,6 @@
|
|||||||
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
||||||
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
||||||
"bom.quantity.single.badge" = "1× • %@";
|
"bom.quantity.single.badge" = "1× • %@";
|
||||||
"cable.pro.privacy.label" = "Datenschutz";
|
|
||||||
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
|
|
||||||
"cable.pro.terms.label" = "Nutzungsbedingungen";
|
|
||||||
"cable.pro.terms.url" = "https://voltplan.app/de/agb";
|
|
||||||
"calculator.advanced.duty_cycle.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt.";
|
"calculator.advanced.duty_cycle.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt.";
|
||||||
"calculator.advanced.duty_cycle.title" = "Einschaltdauer";
|
"calculator.advanced.duty_cycle.title" = "Einschaltdauer";
|
||||||
"calculator.advanced.section.title" = "Erweitert";
|
"calculator.advanced.section.title" = "Erweitert";
|
||||||
@@ -259,7 +255,7 @@
|
|||||||
"loads.overview.metric.current" = "Strom";
|
"loads.overview.metric.current" = "Strom";
|
||||||
"loads.overview.metric.power" = "Leistung";
|
"loads.overview.metric.power" = "Leistung";
|
||||||
"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen";
|
"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen";
|
||||||
"loads.overview.status.missing_details.message" = "Gib Kabellänge und Leitungsquerschnitt für %d %@ ein, um genaue Empfehlungen zu erhalten.";
|
"loads.overview.status.missing_details.message" = "Gib Kabellänge und Stromaufnahme für %d %@ ein, damit Cable die Leitung dimensionieren kann.";
|
||||||
"loads.overview.status.missing_details.plural" = "Verbraucher";
|
"loads.overview.status.missing_details.plural" = "Verbraucher";
|
||||||
"loads.overview.status.missing_details.singular" = "Verbraucher";
|
"loads.overview.status.missing_details.singular" = "Verbraucher";
|
||||||
"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails";
|
"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails";
|
||||||
@@ -349,47 +345,7 @@
|
|||||||
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
|
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
|
||||||
"settings.pro.day.one" = "%@ Tag";
|
"settings.pro.day.one" = "%@ Tag";
|
||||||
"settings.pro.day.other" = "%@ Tage";
|
"settings.pro.day.other" = "%@ Tage";
|
||||||
"cable.pro.terms.label" = "AGB";
|
|
||||||
"cable.pro.privacy.label" = "Datenschutz";
|
|
||||||
"cable.pro.terms.url" = "https://voltplan.app/terms";
|
|
||||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
|
||||||
"cable.pro.paywall.title" = "Cable PRO";
|
|
||||||
"cable.pro.paywall.subtitle" = "Cable PRO bietet mehr Konfigurationsoptionen für Verbraucher, Batterien und Ladegeräte.";
|
|
||||||
"cable.pro.feature.dutyCycle" = "Kabelberechnungen mit Einschaltdauer";
|
|
||||||
"cable.pro.feature.batteryCapacity" = "Verfügbare Batteriekapazität konfigurieren";
|
|
||||||
"cable.pro.feature.usageBased" = "Nutzungsbasierte Berechnungen";
|
|
||||||
"cable.pro.button.unlock" = "Jetzt freischalten";
|
|
||||||
"cable.pro.button.freeTrial" = "Kostenlose Testphase starten";
|
|
||||||
"cable.pro.button.unlocked" = "Bereits aktiviert";
|
|
||||||
"cable.pro.restore.button" = "Käufe wiederherstellen";
|
|
||||||
"cable.pro.alert.success.title" = "Cable PRO aktiviert";
|
|
||||||
"cable.pro.alert.success.body" = "Danke für deine Unterstützung!";
|
|
||||||
"cable.pro.alert.pending.title" = "Kauf ausstehend";
|
|
||||||
"cable.pro.alert.pending.body" = "Dein Kauf wartet auf Bestätigung.";
|
|
||||||
"cable.pro.alert.restored.title" = "Käufe wiederhergestellt";
|
|
||||||
"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar.";
|
|
||||||
"cable.pro.alert.error.title" = "Kauf fehlgeschlagen";
|
|
||||||
"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut.";
|
|
||||||
"generic.ok" = "OK";
|
"generic.ok" = "OK";
|
||||||
"cable.pro.trial.badge" = "Enthält eine %@ Testphase";
|
|
||||||
"cable.pro.subscription.renews" = "Verlängert sich %@.";
|
|
||||||
"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@.";
|
|
||||||
"cable.pro.duration.day.singular" = "jeden Tag";
|
|
||||||
"cable.pro.duration.day.plural" = "alle %@ Tage";
|
|
||||||
"cable.pro.duration.week.singular" = "jede Woche";
|
|
||||||
"cable.pro.duration.week.plural" = "alle %@ Wochen";
|
|
||||||
"cable.pro.duration.month.singular" = "monatlich";
|
|
||||||
"cable.pro.duration.month.plural" = "alle %@ Monate";
|
|
||||||
"cable.pro.duration.year.singular" = "jährlich";
|
|
||||||
"cable.pro.duration.year.plural" = "alle %@ Jahre";
|
|
||||||
"cable.pro.trial.duration.day.singular" = "%@-tägige";
|
|
||||||
"cable.pro.trial.duration.day.plural" = "%@-tägige";
|
|
||||||
"cable.pro.trial.duration.week.singular" = "%@-wöchige";
|
|
||||||
"cable.pro.trial.duration.week.plural" = "%@-wöchige";
|
|
||||||
"cable.pro.trial.duration.month.singular" = "%@-monatige";
|
|
||||||
"cable.pro.trial.duration.month.plural" = "%@-monatige";
|
|
||||||
"cable.pro.trial.duration.year.singular" = "%@-jährige";
|
|
||||||
"cable.pro.trial.duration.year.plural" = "%@-jährige";
|
|
||||||
"• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu";
|
"• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu";
|
||||||
"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden";
|
"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden";
|
||||||
"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
|
"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
|
||||||
@@ -453,3 +409,12 @@
|
|||||||
"overview.share.diagram" = "Schaltplan";
|
"overview.share.diagram" = "Schaltplan";
|
||||||
"overview.share.pdf" = "Vollständiger Bericht (PDF)";
|
"overview.share.pdf" = "Vollständiger Bericht (PDF)";
|
||||||
"overview.share.diagram.error" = "Schaltplan konnte nicht erstellt werden. Überprüfe deine Internetverbindung.";
|
"overview.share.diagram.error" = "Schaltplan konnte nicht erstellt werden. Überprüfe deine Internetverbindung.";
|
||||||
|
|
||||||
|
"editor.system.voltage_drop.label" = "Spannungsfall-Budget";
|
||||||
|
"editor.system.voltage_drop.hint.critical" = "3 % — für kritische Stromkreise wie Navigation, Bilgepumpe und Elektronik.";
|
||||||
|
"editor.system.voltage_drop.hint.standard" = "5 % — ausgewogener Standard für gemischte Installationen.";
|
||||||
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — für unkritische Verbraucher wie Innenbeleuchtung.";
|
||||||
|
"settings.voltage_drop.label" = "Spannungsfall-Budget";
|
||||||
|
"settings.voltage_drop.footnote" = "Gilt für den Rechner und neue Systeme. Bestehende Systeme behalten ihr eigenes Budget.";
|
||||||
|
"settings.rate.title" = "Cable bewerten";
|
||||||
|
"settings.rate.footnote" = "Bewertungen helfen anderen Monteuren, Cable im App Store zu finden.";
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
"loads.onboarding.title" = "Añade tu primer consumidor";
|
"loads.onboarding.title" = "Añade tu primer consumidor";
|
||||||
"loads.onboarding.subtitle" = "Completa tu sistema con consumidores y deja que **Cable by VoltPlan** calcule cables y fusibles por ti.";
|
"loads.onboarding.subtitle" = "Completa tu sistema con consumidores y deja que **Cable by VoltPlan** calcule cables y fusibles por ti.";
|
||||||
"loads.overview.status.missing_details.title" = "Faltan detalles de la carga";
|
"loads.overview.status.missing_details.title" = "Faltan detalles de la carga";
|
||||||
"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y el calibre del conductor para %d %@ para obtener recomendaciones precisas.";
|
"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y la corriente de %d %@ para que Cable dimensione el cableado.";
|
||||||
"loads.overview.status.missing_details.singular" = "carga";
|
"loads.overview.status.missing_details.singular" = "carga";
|
||||||
"loads.overview.status.missing_details.plural" = "cargas";
|
"loads.overview.status.missing_details.plural" = "cargas";
|
||||||
"loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas";
|
"loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas";
|
||||||
@@ -351,11 +351,6 @@
|
|||||||
"chargers.title" = "Cargadores para %@";
|
"chargers.title" = "Cargadores para %@";
|
||||||
"chargers.subtitle" = "Los componentes de carga estarán disponibles pronto.";
|
"chargers.subtitle" = "Los componentes de carga estarán disponibles pronto.";
|
||||||
|
|
||||||
"cable.pro.paywall.title" = "Cable PRO";
|
|
||||||
"cable.pro.paywall.subtitle" = "Cable PRO permite más opciones de configuración para cargas, baterías y cargadores.";
|
|
||||||
"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo";
|
|
||||||
"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería";
|
|
||||||
"cable.pro.feature.usageBased" = "Cálculos basados en el uso";
|
|
||||||
"generic.ok" = "Aceptar";
|
"generic.ok" = "Aceptar";
|
||||||
|
|
||||||
// MARK: - PDF Overview Export
|
// MARK: - PDF Overview Export
|
||||||
@@ -415,3 +410,12 @@
|
|||||||
"overview.share.diagram" = "Diagrama de cableado";
|
"overview.share.diagram" = "Diagrama de cableado";
|
||||||
"overview.share.pdf" = "Informe completo (PDF)";
|
"overview.share.pdf" = "Informe completo (PDF)";
|
||||||
"overview.share.diagram.error" = "No se pudo generar el diagrama. Comprueba tu conexión a Internet.";
|
"overview.share.diagram.error" = "No se pudo generar el diagrama. Comprueba tu conexión a Internet.";
|
||||||
|
|
||||||
|
"editor.system.voltage_drop.label" = "Caída de tensión admisible";
|
||||||
|
"editor.system.voltage_drop.hint.critical" = "3 % — para circuitos críticos como navegación, bombas de sentina y electrónica.";
|
||||||
|
"editor.system.voltage_drop.hint.standard" = "5 % — valor equilibrado para instalaciones mixtas.";
|
||||||
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — para consumos no críticos como la iluminación interior.";
|
||||||
|
"settings.voltage_drop.label" = "Caída de tensión admisible";
|
||||||
|
"settings.voltage_drop.footnote" = "Se aplica a la calculadora y a los sistemas nuevos. Los sistemas existentes conservan su valor.";
|
||||||
|
"settings.rate.title" = "Valorar Cable";
|
||||||
|
"settings.rate.footnote" = "Las valoraciones ayudan a que otros instaladores encuentren Cable en el App Store.";
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
"loads.onboarding.title" = "Ajoutez votre premier consommateur";
|
"loads.onboarding.title" = "Ajoutez votre premier consommateur";
|
||||||
"loads.onboarding.subtitle" = "Complétez votre système avec des équipements et laissez **Cable by VoltPlan** proposer les câbles et fusibles adaptés.";
|
"loads.onboarding.subtitle" = "Complétez votre système avec des équipements et laissez **Cable by VoltPlan** proposer les câbles et fusibles adaptés.";
|
||||||
"loads.overview.status.missing_details.title" = "Détails de charge manquants";
|
"loads.overview.status.missing_details.title" = "Détails de charge manquants";
|
||||||
"loads.overview.status.missing_details.message" = "Saisissez la longueur de câble et la section du conducteur pour %d %@ afin d'obtenir des recommandations précises.";
|
"loads.overview.status.missing_details.message" = "Indiquez la longueur de câble et le courant de %d %@ pour que Cable dimensionne le câblage.";
|
||||||
"loads.overview.status.missing_details.singular" = "charge";
|
"loads.overview.status.missing_details.singular" = "charge";
|
||||||
"loads.overview.status.missing_details.plural" = "charges";
|
"loads.overview.status.missing_details.plural" = "charges";
|
||||||
"loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges";
|
"loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges";
|
||||||
@@ -351,11 +351,6 @@
|
|||||||
"chargers.title" = "Chargeurs pour %@";
|
"chargers.title" = "Chargeurs pour %@";
|
||||||
"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici.";
|
"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici.";
|
||||||
|
|
||||||
"cable.pro.paywall.title" = "Cable PRO";
|
|
||||||
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
|
|
||||||
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
|
|
||||||
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
|
|
||||||
"cable.pro.feature.usageBased" = "Calculs basés sur l’utilisation";
|
|
||||||
"generic.ok" = "OK";
|
"generic.ok" = "OK";
|
||||||
|
|
||||||
// MARK: - PDF Overview Export
|
// MARK: - PDF Overview Export
|
||||||
@@ -415,3 +410,12 @@
|
|||||||
"overview.share.diagram" = "Schéma de câblage";
|
"overview.share.diagram" = "Schéma de câblage";
|
||||||
"overview.share.pdf" = "Rapport complet (PDF)";
|
"overview.share.pdf" = "Rapport complet (PDF)";
|
||||||
"overview.share.diagram.error" = "Impossible de générer le schéma. Vérifiez votre connexion Internet.";
|
"overview.share.diagram.error" = "Impossible de générer le schéma. Vérifiez votre connexion Internet.";
|
||||||
|
|
||||||
|
"editor.system.voltage_drop.label" = "Chute de tension admissible";
|
||||||
|
"editor.system.voltage_drop.hint.critical" = "3 % — pour les circuits critiques : navigation, pompe de cale, électronique.";
|
||||||
|
"editor.system.voltage_drop.hint.standard" = "5 % — valeur équilibrée pour les installations mixtes.";
|
||||||
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — pour les charges non critiques comme l’éclairage intérieur.";
|
||||||
|
"settings.voltage_drop.label" = "Chute de tension admissible";
|
||||||
|
"settings.voltage_drop.footnote" = "S’applique au calculateur et aux nouvelles installations. Les installations existantes gardent leur valeur.";
|
||||||
|
"settings.rate.title" = "Noter Cable";
|
||||||
|
"settings.rate.footnote" = "Les avis aident les autres installateurs à trouver Cable sur l’App Store.";
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
"loads.onboarding.title" = "Voeg je eerste verbruiker toe";
|
"loads.onboarding.title" = "Voeg je eerste verbruiker toe";
|
||||||
"loads.onboarding.subtitle" = "Bouw je systeem uit met verbruikers en laat **Cable by VoltPlan** de kabel- en zekeringadviezen verzorgen.";
|
"loads.onboarding.subtitle" = "Bouw je systeem uit met verbruikers en laat **Cable by VoltPlan** de kabel- en zekeringadviezen verzorgen.";
|
||||||
"loads.overview.status.missing_details.title" = "Ontbrekende lastdetails";
|
"loads.overview.status.missing_details.title" = "Ontbrekende lastdetails";
|
||||||
"loads.overview.status.missing_details.message" = "Voer kabellengte en kabeldoorsnede in voor %d %@ om nauwkeurige aanbevelingen te krijgen.";
|
"loads.overview.status.missing_details.message" = "Vul kabellengte en stroom van %d %@ in zodat Cable de kabel kan dimensioneren.";
|
||||||
"loads.overview.status.missing_details.singular" = "last";
|
"loads.overview.status.missing_details.singular" = "last";
|
||||||
"loads.overview.status.missing_details.plural" = "lasten";
|
"loads.overview.status.missing_details.plural" = "lasten";
|
||||||
"loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af";
|
"loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af";
|
||||||
@@ -351,11 +351,6 @@
|
|||||||
"chargers.title" = "Laders voor %@";
|
"chargers.title" = "Laders voor %@";
|
||||||
"chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar.";
|
"chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar.";
|
||||||
|
|
||||||
"cable.pro.paywall.title" = "Cable PRO";
|
|
||||||
"cable.pro.paywall.subtitle" = "Cable PRO biedt meer configuratie-opties voor verbruikers, batterijen en laders.";
|
|
||||||
"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur";
|
|
||||||
"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit";
|
|
||||||
"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen";
|
|
||||||
"generic.ok" = "OK";
|
"generic.ok" = "OK";
|
||||||
|
|
||||||
// MARK: - PDF Overview Export
|
// MARK: - PDF Overview Export
|
||||||
@@ -415,3 +410,12 @@
|
|||||||
"overview.share.diagram" = "Bedradingsschema";
|
"overview.share.diagram" = "Bedradingsschema";
|
||||||
"overview.share.pdf" = "Volledig rapport (PDF)";
|
"overview.share.pdf" = "Volledig rapport (PDF)";
|
||||||
"overview.share.diagram.error" = "Diagram kon niet worden gegenereerd. Controleer je internetverbinding.";
|
"overview.share.diagram.error" = "Diagram kon niet worden gegenereerd. Controleer je internetverbinding.";
|
||||||
|
|
||||||
|
"editor.system.voltage_drop.label" = "Toegestane spanningsval";
|
||||||
|
"editor.system.voltage_drop.hint.critical" = "3 % — voor kritische circuits zoals navigatie, bilgepomp en elektronica.";
|
||||||
|
"editor.system.voltage_drop.hint.standard" = "5 % — evenwichtige standaard voor gemengde installaties.";
|
||||||
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — voor niet-kritische verbruikers zoals binnenverlichting.";
|
||||||
|
"settings.voltage_drop.label" = "Toegestane spanningsval";
|
||||||
|
"settings.voltage_drop.footnote" = "Geldt voor de rekenhulp en nieuwe systemen. Bestaande systemen houden hun eigen waarde.";
|
||||||
|
"settings.rate.title" = "Cable beoordelen";
|
||||||
|
"settings.rate.footnote" = "Beoordelingen helpen andere installateurs om Cable in de App Store te vinden.";
|
||||||
|
|||||||
@@ -354,8 +354,9 @@ struct CableTests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Minimum raw cross-section: (2×15×7.62×0.017)/(120×0.05) = 0.648mm²
|
// Minimum raw cross-section: (2×15×7.62×0.017)/(120×0.05) = 0.648mm²
|
||||||
// Metric: rounds to 0.75mm² (smallest standard ≥ 0.648)
|
// Metric: 0.75mm² covers the voltage drop but only carries 16A (ISO 13297, 105 °C),
|
||||||
#expect(metricMinCS == 0.75)
|
// which is below the 20A fuse for a 15A load, so ampacity forces 1.0mm² (20A).
|
||||||
|
#expect(metricMinCS == 1.0)
|
||||||
// Imperial: AWG 18 (0.823mm² ≥ 0.648)
|
// Imperial: AWG 18 (0.823mm² ≥ 0.648)
|
||||||
#expect(imperialAWG == 18.0)
|
#expect(imperialAWG == 18.0)
|
||||||
}
|
}
|
||||||
@@ -412,4 +413,208 @@ struct CableTests {
|
|||||||
#expect(abs(actualDrop - expectedDrop) < 0.001)
|
#expect(abs(actualDrop - expectedDrop) < 0.001)
|
||||||
#expect(abs(actualLoss - expectedPowerLoss) < 0.001)
|
#expect(abs(actualLoss - expectedPowerLoss) < 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Ampacity
|
||||||
|
|
||||||
|
/// A short run makes voltage drop irrelevant, so before ampacity was considered the
|
||||||
|
/// recommendation was 1.5mm² / AWG 16 next to a 50A fuse — a cable the fuse cannot protect.
|
||||||
|
@Test func shortHighCurrentRunIsSizedForAmpacityNotVoltageDrop() async throws {
|
||||||
|
// 0.5m, 40A, 12V → voltage-drop minimum is only 1.13mm², fuse is 50A
|
||||||
|
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 40) == 50.0)
|
||||||
|
|
||||||
|
let metric = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 0.5, current: 40, voltage: 12, unitSystem: .metric
|
||||||
|
)
|
||||||
|
// 4.0mm² carries 45A < 50A, so 6.0mm² (60A) is the smallest protected size
|
||||||
|
#expect(metric == 6.0)
|
||||||
|
|
||||||
|
let imperial = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 0.5, current: 40, voltage: 12, unitSystem: .imperial
|
||||||
|
)
|
||||||
|
// AWG 12 carries 45A < 50A, so AWG 10 (60A) is the smallest protected size
|
||||||
|
#expect(imperial == 10.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func fuseNeverExceedsCableAmpacity() async throws {
|
||||||
|
let scenarios: [(length: Double, current: Double, voltage: Double)] = [
|
||||||
|
(0.3, 15, 12), (0.5, 40, 12), (1, 30, 12), (1, 60, 12), (2, 25, 12),
|
||||||
|
(3, 10, 12), (5, 5, 12), (8, 20, 24), (12, 50, 24), (10, 100, 48),
|
||||||
|
]
|
||||||
|
|
||||||
|
for scenario in scenarios {
|
||||||
|
let fuse = ElectricalCalculations.recommendedFuse(forCurrent: scenario.current)
|
||||||
|
|
||||||
|
for unitSystem in [UnitSystem.metric, UnitSystem.imperial] {
|
||||||
|
let crossSection = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: scenario.length,
|
||||||
|
current: scenario.current,
|
||||||
|
voltage: scenario.voltage,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
|
let ampacity = ElectricalCalculations.ampacity(
|
||||||
|
forCrossSection: crossSection, unitSystem: unitSystem
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
ampacity >= fuse,
|
||||||
|
"\(scenario.current)A over \(scenario.length)m: \(fuse)A fuse on a cable rated \(ampacity)A"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func longRunsStayVoltageDropDriven() async throws {
|
||||||
|
// 3m, 10A, 12V → voltage drop needs 1.7mm², fuse is only 15A, so the drop still wins
|
||||||
|
let metric = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 3, current: 10, voltage: 12, unitSystem: .metric
|
||||||
|
)
|
||||||
|
#expect(metric == 2.5)
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 2.5, unitSystem: .metric) == 35.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ampacityMatchesPublishedTables() async throws {
|
||||||
|
// ISO 13297 Table A1, 105 °C
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 0.75, unitSystem: .metric) == 16.0)
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 2.5, unitSystem: .metric) == 35.0)
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 16.0, unitSystem: .metric) == 130.0)
|
||||||
|
// Between two standard sizes the smaller neighbour rates the cable
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 3.0, unitSystem: .metric) == 35.0)
|
||||||
|
// ABYC E-11 Table 6A, 105 °C, outside engine spaces
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 14, unitSystem: .imperial) == 35.0)
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 6, unitSystem: .imperial) == 120.0)
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: -4, unitSystem: .imperial) == 445.0)
|
||||||
|
// AWG 20 is not covered by the table
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 20, unitSystem: .imperial) == 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voltage Drop Target
|
||||||
|
|
||||||
|
@Test func tighterTargetDemandsMoreCopper() async throws {
|
||||||
|
// 5m, 10A, 12V: 5% needs 2.833mm² → 4.0mm², 3% needs 4.722mm² → 6.0mm²
|
||||||
|
let standard = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 5, current: 10, voltage: 12, unitSystem: .metric
|
||||||
|
)
|
||||||
|
let critical = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 3
|
||||||
|
)
|
||||||
|
#expect(standard == 4.0)
|
||||||
|
#expect(critical == 6.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func looserTargetAllowsThinnerCableUntilAmpacityStops() async throws {
|
||||||
|
// 5m, 10A, 12V at 10%: drop needs only 1.417mm², but the 15A fuse still requires
|
||||||
|
// a cable rated for it, so 1.5mm² (25A per ISO 13297) is the floor.
|
||||||
|
let nonCritical = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 10
|
||||||
|
)
|
||||||
|
#expect(nonCritical == 1.5)
|
||||||
|
#expect(ElectricalCalculations.ampacity(forCrossSection: 1.5, unitSystem: .metric) >= 15.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func targetIsRespectedByResultingDrop() async throws {
|
||||||
|
for target in [3.0, 5.0, 10.0] {
|
||||||
|
let crossSection = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 8, current: 20, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: target
|
||||||
|
)
|
||||||
|
let dropPercent = ElectricalCalculations.voltageDropPercentage(
|
||||||
|
length: 8, current: 20, voltage: 12, unitSystem: .metric, crossSection: crossSection
|
||||||
|
)
|
||||||
|
#expect(dropPercent <= target, "target \(target)% produced \(dropPercent)%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultTargetStaysAtFivePercent() async throws {
|
||||||
|
#expect(ElectricalCalculations.defaultMaxVoltageDropPercent == 5.0)
|
||||||
|
let explicit = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 5, current: 10, voltage: 12, unitSystem: .metric, maxVoltageDropPercent: 5
|
||||||
|
)
|
||||||
|
let implicit = ElectricalCalculations.recommendedCrossSection(
|
||||||
|
length: 5, current: 10, voltage: 12, unitSystem: .metric
|
||||||
|
)
|
||||||
|
#expect(explicit == implicit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func systemStoresItsOwnTarget() async throws {
|
||||||
|
let system = ElectricalSystem(name: "Nav circuit", maxVoltageDropPercent: 3)
|
||||||
|
#expect(system.maxVoltageDropPercent == 3.0)
|
||||||
|
let defaultSystem = ElectricalSystem(name: "Cabin")
|
||||||
|
#expect(defaultSystem.maxVoltageDropPercent == ElectricalCalculations.defaultMaxVoltageDropPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Derived Cable Sizes
|
||||||
|
|
||||||
|
/// `SavedLoad.crossSection` is always stored in mm², independent of the display unit.
|
||||||
|
private func makeLoad(current: Double, length: Double, crossSection: Double, voltage: Double = 12) -> SavedLoad {
|
||||||
|
SavedLoad(
|
||||||
|
name: "Load",
|
||||||
|
voltage: voltage,
|
||||||
|
current: current,
|
||||||
|
power: voltage * current,
|
||||||
|
length: length,
|
||||||
|
crossSection: crossSection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func syncRepairsLibraryPlaceholderSizes() async throws {
|
||||||
|
// Library loads used to be stored with a hardcoded 1.0mm² at 10m
|
||||||
|
let load = makeLoad(current: 5, length: 10, crossSection: 1.0)
|
||||||
|
let changed = LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5)
|
||||||
|
// 5 % needs (2×5×10×0.017)/(12×0.05) = 2.833mm² → next standard size is 4.0mm²
|
||||||
|
#expect(changed == 1)
|
||||||
|
#expect(load.crossSection == 4.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func syncRepairsCablesThatCannotCarryTheirFuse() async throws {
|
||||||
|
// 40A load → 50A fuse; 1.5mm² carries 25A (ISO 13297), so it has to grow to 6.0mm² (60A)
|
||||||
|
let load = makeLoad(current: 40, length: 0.5, crossSection: 1.5)
|
||||||
|
LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5)
|
||||||
|
#expect(load.crossSection == 6.0)
|
||||||
|
#expect(
|
||||||
|
ElectricalCalculations.ampacity(forCrossSection: load.crossSection, unitSystem: .metric)
|
||||||
|
>= ElectricalCalculations.recommendedFuse(forCurrent: load.current)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func syncFollowsTheBudgetInBothDirections() async throws {
|
||||||
|
let load = makeLoad(current: 5, length: 10, crossSection: 1.0)
|
||||||
|
|
||||||
|
LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 3)
|
||||||
|
// 3 % needs 4.72mm² → 6.0mm²
|
||||||
|
#expect(load.crossSection == 6.0)
|
||||||
|
|
||||||
|
LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 10)
|
||||||
|
// 10 % needs 1.42mm², but the 7.5A fuse still needs a cable rated for it → 1.5mm²
|
||||||
|
#expect(load.crossSection == 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func syncIsIdempotent() async throws {
|
||||||
|
let load = makeLoad(current: 10, length: 5, crossSection: 1.0)
|
||||||
|
#expect(LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5) == 1)
|
||||||
|
#expect(LoadCableSync.synchronize(loads: [load], maxVoltageDropPercent: 5) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func syncLeavesIncompleteLoadsAlone() async throws {
|
||||||
|
let noLength = makeLoad(current: 5, length: 0, crossSection: 0)
|
||||||
|
let noCurrent = makeLoad(current: 0, length: 5, crossSection: 0)
|
||||||
|
#expect(LoadCableSync.synchronize(loads: [noLength, noCurrent], maxVoltageDropPercent: 5) == 0)
|
||||||
|
#expect(noLength.crossSection == 0)
|
||||||
|
#expect(noCurrent.crossSection == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func syncedSystemHasNothingLeftToReport() async throws {
|
||||||
|
let loads = [
|
||||||
|
makeLoad(current: 5, length: 10, crossSection: 1.0),
|
||||||
|
makeLoad(current: 40, length: 0.5, crossSection: 1.5),
|
||||||
|
]
|
||||||
|
LoadCableSync.synchronize(loads: loads, maxVoltageDropPercent: 5)
|
||||||
|
#expect(LoadConfigurationStatus.evaluate(loads: loads) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func onlyMissingInputsAreStillReported() async throws {
|
||||||
|
let loads = [
|
||||||
|
makeLoad(current: 0, length: 0, crossSection: 0),
|
||||||
|
makeLoad(current: 5, length: 2, crossSection: 2.5),
|
||||||
|
]
|
||||||
|
#expect(LoadConfigurationStatus.evaluate(loads: loads) == .missingDetails(count: 1))
|
||||||
|
#expect(LoadConfigurationStatus.evaluate(loads: []) == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct ComponentLibraryItemTests {
|
|||||||
watt: nil,
|
watt: nil,
|
||||||
dutyCyclePercent: nil,
|
dutyCyclePercent: nil,
|
||||||
defaultUtilizationFactorPercent: nil,
|
defaultUtilizationFactorPercent: nil,
|
||||||
|
componentCategory: nil,
|
||||||
iconURL: nil,
|
iconURL: nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ struct ComponentLibraryItemTests {
|
|||||||
watt: nil,
|
watt: nil,
|
||||||
dutyCyclePercent: nil,
|
dutyCyclePercent: nil,
|
||||||
defaultUtilizationFactorPercent: nil,
|
defaultUtilizationFactorPercent: nil,
|
||||||
|
componentCategory: nil,
|
||||||
iconURL: nil,
|
iconURL: nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ struct ComponentLibraryItemTests {
|
|||||||
watt: nil,
|
watt: nil,
|
||||||
dutyCyclePercent: nil,
|
dutyCyclePercent: nil,
|
||||||
defaultUtilizationFactorPercent: nil,
|
defaultUtilizationFactorPercent: nil,
|
||||||
|
componentCategory: nil,
|
||||||
iconURL: nil,
|
iconURL: nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ struct ComponentLibraryItemTests {
|
|||||||
watt: nil,
|
watt: nil,
|
||||||
dutyCyclePercent: nil,
|
dutyCyclePercent: nil,
|
||||||
defaultUtilizationFactorPercent: nil,
|
defaultUtilizationFactorPercent: nil,
|
||||||
|
componentCategory: nil,
|
||||||
iconURL: nil,
|
iconURL: nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ struct ComponentLibraryItemTests {
|
|||||||
watt: nil,
|
watt: nil,
|
||||||
dutyCyclePercent: nil,
|
dutyCyclePercent: nil,
|
||||||
defaultUtilizationFactorPercent: nil,
|
defaultUtilizationFactorPercent: nil,
|
||||||
|
componentCategory: nil,
|
||||||
iconURL: nil,
|
iconURL: nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,6 +107,7 @@ struct ComponentLibraryItemTests {
|
|||||||
watt: nil,
|
watt: nil,
|
||||||
dutyCyclePercent: nil,
|
dutyCyclePercent: nil,
|
||||||
defaultUtilizationFactorPercent: nil,
|
defaultUtilizationFactorPercent: nil,
|
||||||
|
componentCategory: nil,
|
||||||
iconURL: nil,
|
iconURL: nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,6 +125,7 @@ struct ComponentLibraryItemTests {
|
|||||||
watt: nil,
|
watt: nil,
|
||||||
dutyCyclePercent: 0,
|
dutyCyclePercent: 0,
|
||||||
defaultUtilizationFactorPercent: nil,
|
defaultUtilizationFactorPercent: nil,
|
||||||
|
componentCategory: nil,
|
||||||
iconURL: nil,
|
iconURL: nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,9 +142,27 @@ struct ComponentLibraryItemTests {
|
|||||||
watt: nil,
|
watt: nil,
|
||||||
dutyCyclePercent: nil,
|
dutyCyclePercent: nil,
|
||||||
defaultUtilizationFactorPercent: 50,
|
defaultUtilizationFactorPercent: 50,
|
||||||
|
componentCategory: nil,
|
||||||
iconURL: nil,
|
iconURL: nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(item.defaultDailyUsageHours == 12)
|
#expect(item.defaultDailyUsageHours == 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func displayVoltageIgnoresZeroInputVoltage() async throws {
|
||||||
|
let item = ComponentLibraryItem(
|
||||||
|
id: "component-9",
|
||||||
|
name: "Library Battery",
|
||||||
|
translations: [:],
|
||||||
|
voltageIn: 0,
|
||||||
|
voltageOut: 12.8,
|
||||||
|
watt: nil,
|
||||||
|
dutyCyclePercent: nil,
|
||||||
|
defaultUtilizationFactorPercent: nil,
|
||||||
|
componentCategory: nil,
|
||||||
|
iconURL: nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(item.displayVoltage == 12.8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
CableTests/LocalizationIntegrityTests.swift
Normal file
75
CableTests/LocalizationIntegrityTests.swift
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Cable
|
||||||
|
|
||||||
|
/// Guards the string tables themselves. A malformed `.strings` file still parses — a value
|
||||||
|
/// spanning several lines silently swallows the entries behind it — and the app then renders
|
||||||
|
/// raw keys like "tab.overview". These tests fail instead.
|
||||||
|
struct LocalizationIntegrityTests {
|
||||||
|
|
||||||
|
private static let locales = ["Base", "de", "es", "fr", "nl"]
|
||||||
|
|
||||||
|
/// Keys rendered without a `defaultValue:`, so a missing entry is visible to users.
|
||||||
|
private static let keysWithoutFallback = [
|
||||||
|
"tab.overview",
|
||||||
|
"tab.components",
|
||||||
|
"tab.batteries",
|
||||||
|
"tab.chargers",
|
||||||
|
]
|
||||||
|
|
||||||
|
private func table(for locale: String) throws -> [String: String] {
|
||||||
|
let bundle = Bundle.main
|
||||||
|
guard let url = bundle.url(
|
||||||
|
forResource: "Localizable",
|
||||||
|
withExtension: "strings",
|
||||||
|
subdirectory: nil,
|
||||||
|
localization: locale
|
||||||
|
) else {
|
||||||
|
throw LocalizationTestError.tableMissing(locale)
|
||||||
|
}
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let parsed = try PropertyListSerialization.propertyList(from: data, format: nil)
|
||||||
|
guard let table = parsed as? [String: String] else {
|
||||||
|
throw LocalizationTestError.tableMalformed(locale)
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func everyLocaleProvidesTheKeysThatHaveNoFallback() async throws {
|
||||||
|
for locale in Self.locales {
|
||||||
|
let table = try table(for: locale)
|
||||||
|
for key in Self.keysWithoutFallback {
|
||||||
|
#expect(table[key] != nil, "\(locale): missing \(key), the UI would show the raw key")
|
||||||
|
#expect(
|
||||||
|
table[key] != key,
|
||||||
|
"\(locale): \(key) is not translated"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func noValueSpansMultipleLines() async throws {
|
||||||
|
for locale in Self.locales {
|
||||||
|
let table = try table(for: locale)
|
||||||
|
for (key, value) in table {
|
||||||
|
#expect(
|
||||||
|
!value.contains("\n"),
|
||||||
|
"\(locale): \(key) contains a real newline; removing such an entry line by line corrupts the table"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func retiredProKeysAreGone() async throws {
|
||||||
|
for locale in Self.locales {
|
||||||
|
let table = try table(for: locale)
|
||||||
|
let leftovers = table.keys.filter { $0.hasPrefix("cable.pro.") }
|
||||||
|
#expect(leftovers.isEmpty, "\(locale): dead PRO keys \(leftovers.sorted())")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum LocalizationTestError: Error {
|
||||||
|
case tableMissing(String)
|
||||||
|
case tableMalformed(String)
|
||||||
|
}
|
||||||
114
CableTests/ReviewPromptTests.swift
Normal file
114
CableTests/ReviewPromptTests.swift
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Cable
|
||||||
|
|
||||||
|
/// The rating gate is invisible in the app — the OS may swallow the dialog — so its rules are
|
||||||
|
/// only observable here. Serialized because `ReviewPrompt.store` is process-wide state.
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct ReviewPromptTests {
|
||||||
|
|
||||||
|
private func withFreshStore(_ body: (UserDefaults) -> Void) {
|
||||||
|
let name = "review.tests.\(UUID().uuidString)"
|
||||||
|
guard let defaults = UserDefaults(suiteName: name) else {
|
||||||
|
Issue.record("could not create a test defaults suite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let previous = ReviewPrompt.store
|
||||||
|
ReviewPrompt.store = defaults
|
||||||
|
defer {
|
||||||
|
ReviewPrompt.store = previous
|
||||||
|
defaults.removePersistentDomain(forName: name)
|
||||||
|
}
|
||||||
|
body(defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var appVersion: String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func oneMilestoneIsNotEnough() {
|
||||||
|
withFreshStore { _ in
|
||||||
|
ReviewPrompt.record(.billOfMaterials)
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func twoDistinctMilestonesOpenTheGate() {
|
||||||
|
withFreshStore { _ in
|
||||||
|
ReviewPrompt.record(.systemPlanned)
|
||||||
|
ReviewPrompt.record(.billOfMaterials)
|
||||||
|
#expect(ReviewPrompt.isEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func repeatingOneMilestoneNeverOpensTheGate() {
|
||||||
|
withFreshStore { store in
|
||||||
|
for _ in 0..<5 { ReviewPrompt.record(.systemPlanned) }
|
||||||
|
#expect(store.stringArray(forKey: "review.milestones") == ["systemPlanned"])
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aRecentPromptBlocksTheGate() {
|
||||||
|
withFreshStore { store in
|
||||||
|
ReviewPrompt.record(.systemPlanned)
|
||||||
|
ReviewPrompt.record(.exported)
|
||||||
|
store.set(Date().timeIntervalSince1970 - 10 * 86_400, forKey: "review.lastPromptDate")
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
|
||||||
|
store.set(Date().timeIntervalSince1970 - 91 * 86_400, forKey: "review.lastPromptDate")
|
||||||
|
#expect(ReviewPrompt.isEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func theCurrentVersionIsOnlyAskedOnce() {
|
||||||
|
withFreshStore { store in
|
||||||
|
ReviewPrompt.record(.systemPlanned)
|
||||||
|
ReviewPrompt.record(.exported)
|
||||||
|
store.set(appVersion, forKey: "review.lastPromptedVersion")
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
|
||||||
|
store.set("0.0.0-old", forKey: "review.lastPromptedVersion")
|
||||||
|
#expect(ReviewPrompt.isEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs that already exported under the previous gate keep that credit, but still need a
|
||||||
|
/// second, genuine milestone before we ask.
|
||||||
|
@Test func migrationCarriesLegacyExportCreditAndDropsOldKeys() {
|
||||||
|
withFreshStore { store in
|
||||||
|
store.set(1, forKey: "review.successfulExportCount")
|
||||||
|
store.set(true, forKey: "review.migrationDone")
|
||||||
|
store.set(Date().timeIntervalSince1970, forKey: "review.firstLaunchDate")
|
||||||
|
|
||||||
|
ReviewPrompt.migrateIfNeeded(isFirstLaunch: false)
|
||||||
|
|
||||||
|
#expect(store.stringArray(forKey: "review.milestones") == ["exported"])
|
||||||
|
#expect(store.string(forKey: "review.userType") == "existing")
|
||||||
|
#expect(store.object(forKey: "review.successfulExportCount") == nil)
|
||||||
|
#expect(store.object(forKey: "review.migrationDone") == nil)
|
||||||
|
#expect(store.object(forKey: "review.firstLaunchDate") == nil)
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
|
||||||
|
ReviewPrompt.record(.billOfMaterials)
|
||||||
|
#expect(ReviewPrompt.isEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aFreshInstallStartsWithNoCredit() {
|
||||||
|
withFreshStore { store in
|
||||||
|
ReviewPrompt.migrateIfNeeded(isFirstLaunch: true)
|
||||||
|
|
||||||
|
#expect(store.stringArray(forKey: "review.milestones") == nil)
|
||||||
|
#expect(store.string(forKey: "review.userType") == "new")
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func theManualReviewLinkTargetsTheStoreListing() {
|
||||||
|
#expect(
|
||||||
|
ReviewPrompt.writeReviewURL.absoluteString
|
||||||
|
== "https://apps.apple.com/app/id6752443870?action=write-review"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
128
CableTests/UsageMetricsTests.swift
Normal file
128
CableTests/UsageMetricsTests.swift
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Cable
|
||||||
|
|
||||||
|
/// The retention counters are the only way to reconstruct D_k curves from an analytics backend
|
||||||
|
/// that cannot identify a device across days, so their arithmetic is verified here rather than
|
||||||
|
/// trusted in production. Serialized because `UsageMetrics.store` is process-wide state.
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct UsageMetricsTests {
|
||||||
|
|
||||||
|
/// Runs `body` against an isolated defaults suite and a clock the test drives itself.
|
||||||
|
private func withFreshStore(_ body: (UserDefaults, _ setDay: (Int) -> Void) -> Void) {
|
||||||
|
let name = "usage.tests.\(UUID().uuidString)"
|
||||||
|
guard let defaults = UserDefaults(suiteName: name) else {
|
||||||
|
Issue.record("could not create a test defaults suite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let previousStore = UsageMetrics.store
|
||||||
|
let previousClock = UsageMetrics.clock
|
||||||
|
var day = 20_000
|
||||||
|
UsageMetrics.store = defaults
|
||||||
|
UsageMetrics.clock = { Date(timeIntervalSince1970: Double(day) * 86_400 + 3_600) }
|
||||||
|
defer {
|
||||||
|
UsageMetrics.store = previousStore
|
||||||
|
UsageMetrics.clock = previousClock
|
||||||
|
defaults.removePersistentDomain(forName: name)
|
||||||
|
}
|
||||||
|
body(defaults, { day = $0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func props() -> [String: Int] {
|
||||||
|
UsageMetrics.eventProps.compactMapValues { $0 as? Int }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func firstLaunchOfANewInstallStartsTheCounters() {
|
||||||
|
withFreshStore { _, _ in
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: true)
|
||||||
|
#expect(props() == [
|
||||||
|
"tenure_days": 0, "launch_no": 1, "active_days": 1, "dormant_days": -1,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() {
|
||||||
|
withFreshStore { _, _ in
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: true)
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: false)
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: false)
|
||||||
|
#expect(props() == [
|
||||||
|
"tenure_days": 0, "launch_no": 3, "active_days": 1, "dormant_days": 0,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tenureAndActiveDaysAdvanceAcrossDays() {
|
||||||
|
withFreshStore { _, setDay in
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: true)
|
||||||
|
setDay(20_001)
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: false)
|
||||||
|
#expect(props() == [
|
||||||
|
"tenure_days": 1, "launch_no": 2, "active_days": 2, "dormant_days": 1,
|
||||||
|
])
|
||||||
|
setDay(20_007)
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: false)
|
||||||
|
#expect(props() == [
|
||||||
|
"tenure_days": 7, "launch_no": 3, "active_days": 3, "dormant_days": 6,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The D_k denominator: exactly one launch per install carries `launch_no == 1`, and exactly
|
||||||
|
/// one launch per calendar day carries `dormant_days >= 1`. Both must hold or the counts in
|
||||||
|
/// the export measure launches instead of installs.
|
||||||
|
@Test func exactlyOneLaunchPerDayMarksTheDayBoundary() {
|
||||||
|
withFreshStore { _, setDay in
|
||||||
|
var installMarkers = 0
|
||||||
|
var dayMarkers = 0
|
||||||
|
var isFirst = true
|
||||||
|
for day in 20_000...20_004 {
|
||||||
|
setDay(day)
|
||||||
|
for _ in 0..<3 {
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: isFirst)
|
||||||
|
isFirst = false
|
||||||
|
if props()["launch_no"] == 1 { installMarkers += 1 }
|
||||||
|
if props()["dormant_days", default: 0] >= 1 { dayMarkers += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(installMarkers == 1)
|
||||||
|
// Day 0 is counted by the install marker, so the boundary marks days 1...4.
|
||||||
|
#expect(dayMarkers == 4)
|
||||||
|
#expect(props()["active_days"] == 5)
|
||||||
|
#expect(props()["launch_no"] == 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs that predate the counters have no install date. They must stay distinguishable
|
||||||
|
/// from fresh installs forever, otherwise the update inflates the new-install cohort.
|
||||||
|
@Test func installsPredatingTheCountersReportUnknownTenure() {
|
||||||
|
withFreshStore { _, setDay in
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: false)
|
||||||
|
#expect(props() == [
|
||||||
|
"tenure_days": -1, "launch_no": 1, "active_days": 1, "dormant_days": -1,
|
||||||
|
])
|
||||||
|
setDay(20_003)
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: false)
|
||||||
|
#expect(props() == [
|
||||||
|
"tenure_days": -1, "launch_no": 2, "active_days": 2, "dormant_days": 3,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aBackwardsClockNeverProducesNegativeCounts() {
|
||||||
|
withFreshStore { _, setDay in
|
||||||
|
setDay(20_010)
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: true)
|
||||||
|
setDay(20_002)
|
||||||
|
UsageMetrics.beginLaunch(isFirstLaunch: false)
|
||||||
|
#expect(props()["tenure_days"] == 0)
|
||||||
|
#expect(props()["dormant_days"] == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dayIndexIsAUTCDayCount() {
|
||||||
|
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 0)) == 0)
|
||||||
|
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_399)) == 0)
|
||||||
|
#expect(UsageMetrics.dayIndex(Date(timeIntervalSince1970: 86_400)) == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
CableUITestsScreenshot/LocalizedTabBarUITests.swift
Normal file
62
CableUITestsScreenshot/LocalizedTabBarUITests.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Reproduces the regression where the tab bar rendered raw keys ("tab.overview") because a
|
||||||
|
/// malformed entry in Localizable.strings hid every key behind it. The tab titles are looked up
|
||||||
|
/// without a `defaultValue:`, so a broken table is immediately visible here.
|
||||||
|
final class LocalizedTabBarUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
try super.setUpWithError()
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testTabBarIsLocalizedInGerman() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = [
|
||||||
|
"--uitest-reset-data",
|
||||||
|
"--uitest-sample-data",
|
||||||
|
"-AppleLanguages", "(de)",
|
||||||
|
"-AppleLocale", "de_DE",
|
||||||
|
]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
openFirstSystem(in: app)
|
||||||
|
|
||||||
|
let expected = ["Übersicht", "Verbraucher", "Batterien", "Ladegeräte"]
|
||||||
|
for title in expected {
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.buttons[title].waitForExistence(timeout: 15),
|
||||||
|
"Tab \"\(title)\" is missing — the German string table is not being read"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for rawKey in ["tab.overview", "tab.components", "tab.batteries", "tab.chargers"] {
|
||||||
|
XCTAssertFalse(
|
||||||
|
app.buttons[rawKey].exists,
|
||||||
|
"Tab bar shows the raw key \(rawKey) instead of a translation"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openFirstSystem(in app: XCUIApplication) {
|
||||||
|
let list: XCUIElement
|
||||||
|
if app.collectionViews["systems-list"].waitForExistence(timeout: 15) {
|
||||||
|
list = app.collectionViews["systems-list"]
|
||||||
|
} else {
|
||||||
|
list = app.collectionViews.firstMatch
|
||||||
|
}
|
||||||
|
XCTAssertTrue(list.waitForExistence(timeout: 15))
|
||||||
|
|
||||||
|
let firstCell = list.cells.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(firstCell.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
let cellButton = firstCell.buttons.firstMatch
|
||||||
|
if cellButton.exists {
|
||||||
|
cellButton.tap()
|
||||||
|
} else {
|
||||||
|
firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
CableUITestsScreenshot/SettingsRateAppUITests.swift
Normal file
37
CableUITestsScreenshot/SettingsRateAppUITests.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// The manual rating entry point is the only path a willing user can always take — StoreKit's
|
||||||
|
/// dialog may be suppressed by the OS. If this row disappears, ratings stop entirely.
|
||||||
|
final class SettingsRateAppUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
try super.setUpWithError()
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSettingsOffersARateAppRow() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = [
|
||||||
|
"--uitest-reset-data",
|
||||||
|
"--uitest-sample-data",
|
||||||
|
"-AppleLanguages", "(en)",
|
||||||
|
"-AppleLocale", "en_US",
|
||||||
|
]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
let settingsButton = app.buttons["systems-settings"]
|
||||||
|
XCTAssertTrue(settingsButton.waitForExistence(timeout: 15))
|
||||||
|
settingsButton.tap()
|
||||||
|
|
||||||
|
let rateRow = app.buttons["settings-rate-app"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
rateRow.waitForExistence(timeout: 10),
|
||||||
|
"Settings no longer offers a way to rate the app"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
rateRow.label.contains("Rate Cable"),
|
||||||
|
"Rate row shows \"\(rateRow.label)\" instead of the localized title"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
CableUITestsScreenshot/SystemExportButtonUITests.swift
Normal file
115
CableUITestsScreenshot/SystemExportButtonUITests.swift
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Verifies that the Overview export/share menu is only available once the
|
||||||
|
/// system actually contains something worth exporting.
|
||||||
|
final class SystemExportButtonUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
try super.setUpWithError()
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testExportIsDisabledForSystemWithoutComponents() throws {
|
||||||
|
let app = launch(arguments: ["--uitest-reset-data"])
|
||||||
|
|
||||||
|
let createSystemButton = app.buttons["create-system-button"]
|
||||||
|
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 15))
|
||||||
|
createSystemButton.tap()
|
||||||
|
|
||||||
|
let shareButton = app.buttons["system-overview-share-button"]
|
||||||
|
XCTAssertTrue(shareButton.waitForExistence(timeout: 15))
|
||||||
|
XCTAssertFalse(
|
||||||
|
shareButton.isEnabled,
|
||||||
|
"Export must stay disabled while the system has no loads, batteries or chargers"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testExportIsEnabledOnceComponentsExist() throws {
|
||||||
|
let app = launch(arguments: ["--uitest-reset-data", "--uitest-sample-data"])
|
||||||
|
|
||||||
|
openFirstSystem(in: app)
|
||||||
|
|
||||||
|
let shareButton = app.buttons["system-overview-share-button"]
|
||||||
|
XCTAssertTrue(shareButton.waitForExistence(timeout: 15))
|
||||||
|
XCTAssertTrue(
|
||||||
|
shareButton.isEnabled,
|
||||||
|
"Export must be available for a system that has components"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testReportPreviewOffersActionButtons() throws {
|
||||||
|
let app = launch(arguments: ["--uitest-reset-data", "--uitest-sample-data"])
|
||||||
|
|
||||||
|
openFirstSystem(in: app)
|
||||||
|
|
||||||
|
let shareButton = app.buttons["system-overview-share-button"]
|
||||||
|
XCTAssertTrue(shareButton.waitForExistence(timeout: 15))
|
||||||
|
shareButton.tap()
|
||||||
|
|
||||||
|
let reportItem = app.buttons.matching(
|
||||||
|
NSPredicate(format: "label CONTAINS[c] 'PDF'")
|
||||||
|
).firstMatch
|
||||||
|
XCTAssertTrue(reportItem.waitForExistence(timeout: 10))
|
||||||
|
reportItem.tap()
|
||||||
|
|
||||||
|
// Quick Look lives in its own navigation stack; the Done button proves
|
||||||
|
// the navigation bar exists, the share item proves the file can leave
|
||||||
|
// the preview.
|
||||||
|
let doneButton = app.buttons["quick-look-done-button"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
doneButton.waitForExistence(timeout: 60),
|
||||||
|
"Quick Look preview must be embedded in a navigation bar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope the action lookup to the preview's own navigation bar so the
|
||||||
|
// Overview share button underneath can never satisfy it.
|
||||||
|
let previewBar = app.navigationBars.containing(
|
||||||
|
.button,
|
||||||
|
identifier: "quick-look-done-button"
|
||||||
|
).firstMatch
|
||||||
|
XCTAssertTrue(previewBar.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
let actionButtons = previewBar.buttons.allElementsBoundByIndex
|
||||||
|
.filter { $0.identifier != "quick-look-done-button" }
|
||||||
|
XCTAssertFalse(
|
||||||
|
actionButtons.isEmpty,
|
||||||
|
"Quick Look preview must offer an action button to share or open the file"
|
||||||
|
)
|
||||||
|
|
||||||
|
doneButton.tap()
|
||||||
|
XCTAssertTrue(shareButton.waitForExistence(timeout: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func launch(arguments: [String]) -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = arguments
|
||||||
|
app.launch()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openFirstSystem(in app: XCUIApplication) {
|
||||||
|
let list: XCUIElement
|
||||||
|
if app.collectionViews["systems-list"].waitForExistence(timeout: 15) {
|
||||||
|
list = app.collectionViews["systems-list"]
|
||||||
|
} else {
|
||||||
|
list = app.collectionViews.firstMatch
|
||||||
|
}
|
||||||
|
XCTAssertTrue(list.waitForExistence(timeout: 15))
|
||||||
|
|
||||||
|
let firstCell = list.cells.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(firstCell.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
let cellButton = firstCell.buttons.firstMatch
|
||||||
|
if cellButton.exists {
|
||||||
|
cellButton.tap()
|
||||||
|
} else {
|
||||||
|
firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
CableUITestsScreenshot/SystemVoltageDropTargetUITests.swift
Normal file
64
CableUITestsScreenshot/SystemVoltageDropTargetUITests.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Verifies that a system exposes its voltage-drop budget and that picking a tighter
|
||||||
|
/// target sticks, which is what drives cable sizing for that system.
|
||||||
|
final class SystemVoltageDropTargetUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
try super.setUpWithError()
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testVoltageDropBudgetIsSelectableInSystemEditor() throws {
|
||||||
|
let app = launch(arguments: ["--uitest-reset-data", "--uitest-sample-data"])
|
||||||
|
|
||||||
|
openFirstSystem(in: app)
|
||||||
|
|
||||||
|
let systemTitle = app.buttons["system-title-button"]
|
||||||
|
XCTAssertTrue(systemTitle.waitForExistence(timeout: 15))
|
||||||
|
systemTitle.tap()
|
||||||
|
|
||||||
|
let picker = app.segmentedControls["system-voltage-drop-picker"]
|
||||||
|
XCTAssertTrue(picker.waitForExistence(timeout: 15), "System editor must offer a voltage drop budget")
|
||||||
|
|
||||||
|
let standard = picker.buttons["5 %"]
|
||||||
|
XCTAssertTrue(standard.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(standard.isSelected, "New systems start at the 5 % default")
|
||||||
|
|
||||||
|
let critical = picker.buttons["3 %"]
|
||||||
|
XCTAssertTrue(critical.exists)
|
||||||
|
critical.tap()
|
||||||
|
XCTAssertTrue(critical.isSelected, "Picking 3 % must stick")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func launch(arguments: [String]) -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = arguments
|
||||||
|
app.launch()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openFirstSystem(in app: XCUIApplication) {
|
||||||
|
let list: XCUIElement
|
||||||
|
if app.collectionViews["systems-list"].waitForExistence(timeout: 15) {
|
||||||
|
list = app.collectionViews["systems-list"]
|
||||||
|
} else {
|
||||||
|
list = app.collectionViews.firstMatch
|
||||||
|
}
|
||||||
|
XCTAssertTrue(list.waitForExistence(timeout: 15))
|
||||||
|
|
||||||
|
let firstCell = list.cells.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(firstCell.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
let cellButton = firstCell.buttons.firstMatch
|
||||||
|
if cellButton.exists {
|
||||||
|
cellButton.tap()
|
||||||
|
} else {
|
||||||
|
firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
android/.gitignore
vendored
7
android/.gitignore
vendored
@@ -16,3 +16,10 @@
|
|||||||
keystore.properties
|
keystore.properties
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
|
||||||
|
# Gradle build scratch
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# Play service account (never commit)
|
||||||
|
fastlane/play-service-account.json
|
||||||
|
fastlane/report.xml
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.baselineprofile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release signing credentials, loaded from android/keystore.properties (gitignored).
|
// Release signing credentials, loaded from android/keystore.properties (gitignored).
|
||||||
@@ -19,14 +20,14 @@ val hasReleaseSigning = keystoreProps.getProperty("storeFile")?.let { file(it).e
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "app.voltplan.cable"
|
namespace = "app.voltplan.cable"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.voltplan.cable"
|
applicationId = "app.voltplan.cable"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = 85
|
versionCode = 88
|
||||||
versionName = "1.7.0"
|
versionName = "1.8.1"
|
||||||
|
|
||||||
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
|
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
|
||||||
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
|
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
|
||||||
@@ -117,4 +118,12 @@ dependencies {
|
|||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
implementation(libs.play.review.ktx)
|
implementation(libs.play.review.ktx)
|
||||||
|
|
||||||
|
// Installs the bundled baseline profile on devices that do not get it from Play.
|
||||||
|
implementation(libs.androidx.profileinstaller)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
|
||||||
|
// Consumes the profile produced by :baselineprofile.
|
||||||
|
baselineProfile(project(":baselineprofile"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.voltplan.cable
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
|
import app.voltplan.cable.analytics.UsageMetrics
|
||||||
import app.voltplan.cable.data.CableRepository
|
import app.voltplan.cable.data.CableRepository
|
||||||
import app.voltplan.cable.data.ReviewPrompt
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.data.UnitSystemSettings
|
import app.voltplan.cable.data.UnitSystemSettings
|
||||||
@@ -26,6 +27,8 @@ class CableApplication : Application() {
|
|||||||
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
val isFirstLaunch = settings.consumeFirstLaunch()
|
val isFirstLaunch = settings.consumeFirstLaunch()
|
||||||
|
// Before the first log call: every event carries this launch's tenure counters.
|
||||||
|
UsageMetrics.beginLaunch(this@CableApplication, isFirstLaunch)
|
||||||
if (isFirstLaunch) {
|
if (isFirstLaunch) {
|
||||||
Analytics.log("First Launch")
|
Analytics.log("First Launch")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,17 +78,19 @@ object Analytics {
|
|||||||
|
|
||||||
/** Tracks an event. [properties] values are coerced to String/Number/Boolean like the iOS tracker. */
|
/** Tracks an event. [properties] values are coerced to String/Number/Boolean like the iOS tracker. */
|
||||||
fun log(event: String, properties: Map<String, Any?> = emptyMap()) {
|
fun log(event: String, properties: Map<String, Any?> = emptyMap()) {
|
||||||
|
// Tenure counters first so an explicit property of the same name would win.
|
||||||
|
val merged = UsageMetrics.eventProps + properties
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
if (properties.isEmpty()) {
|
if (merged.isEmpty()) {
|
||||||
Log.d(TAG, "Analytics: $event")
|
Log.d(TAG, "Analytics: $event")
|
||||||
} else {
|
} else {
|
||||||
val formatted = properties.entries.sortedBy { it.key }
|
val formatted = merged.entries.sortedBy { it.key }
|
||||||
.joinToString(", ") { "${it.key}=${it.value}" }
|
.joinToString(", ") { "${it.key}=${it.value}" }
|
||||||
Log.d(TAG, "Analytics: $event { $formatted }")
|
Log.d(TAG, "Analytics: $event { $formatted }")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val props = buildJsonObject {
|
val props = buildJsonObject {
|
||||||
for ((key, value) in properties) {
|
for ((key, value) in merged) {
|
||||||
when (value) {
|
when (value) {
|
||||||
null -> {}
|
null -> {}
|
||||||
is String -> put(key, value)
|
is String -> put(key, value)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package app.voltplan.cable.analytics
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import app.voltplan.cable.data.dataStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes retention measurable although the analytics backend cannot identify a device across days:
|
||||||
|
* Aptabase derives its `user_id` from a hash of IP address + user agent plus a salt that rotates
|
||||||
|
* every 24 h, so events can never be linked to the previous day's events. Sessions expire after an
|
||||||
|
* hour of inactivity, so `sessionId` cannot bridge days either.
|
||||||
|
*
|
||||||
|
* Instead of an identity, every tracked event carries this install's own tenure counters, which
|
||||||
|
* never leave the device in raw form — only the derived day counts are sent. Exact retention
|
||||||
|
* curves can then be reconstructed by *counting events* in the export:
|
||||||
|
*
|
||||||
|
* installs on a given day launch_no == 1 && tenure_days == 0
|
||||||
|
* installs active on day k dormant_days >= 1 && tenure_days == k
|
||||||
|
* D_k retention the latter / the former, k days earlier
|
||||||
|
*
|
||||||
|
* `dormant_days >= 1` holds for exactly one launch per calendar day, which is what makes the
|
||||||
|
* second line count installs rather than launches.
|
||||||
|
*
|
||||||
|
* Days are UTC day indices so they line up with the timestamps in the analytics export.
|
||||||
|
* Mirrors the iOS `UsageMetrics` enum, which reports into the same Aptabase project. The counter
|
||||||
|
* arithmetic lives in the pure [advance] so it can be tested without an Android context; iOS tests
|
||||||
|
* the same rules through its injectable `UserDefaults`.
|
||||||
|
*/
|
||||||
|
object UsageMetrics {
|
||||||
|
private val INSTALL_DAY = intPreferencesKey("usage.installDay")
|
||||||
|
private val LAUNCH_COUNT = intPreferencesKey("usage.launchCount")
|
||||||
|
private val ACTIVE_DAYS = intPreferencesKey("usage.activeDays")
|
||||||
|
private val LAST_ACTIVE_DAY = intPreferencesKey("usage.lastActiveDay")
|
||||||
|
|
||||||
|
private const val DAY_MS = 86_400_000L
|
||||||
|
|
||||||
|
/** Persisted counters. `installDay` and `lastActiveDay` are null until the first launch. */
|
||||||
|
internal data class State(
|
||||||
|
val installDay: Int? = null,
|
||||||
|
val launchCount: Int = 0,
|
||||||
|
val activeDays: Int = 0,
|
||||||
|
val lastActiveDay: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class Launch(val state: State, val props: Map<String, Any>)
|
||||||
|
|
||||||
|
/** Merged into every event by [Analytics.log]. Empty until [beginLaunch] has run. */
|
||||||
|
@Volatile
|
||||||
|
var eventProps: Map<String, Any> = emptyMap()
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the counters once per process start and freezes this launch's props.
|
||||||
|
*
|
||||||
|
* [isFirstLaunch] is the app's own install marker (`hasLaunchedBefore`, consumed by
|
||||||
|
* `UnitSystemSettings.consumeFirstLaunch`). Installs that predate these counters have no known
|
||||||
|
* install date and report `tenure_days == -1` for the rest of their life, so cohort analysis
|
||||||
|
* can exclude them instead of mistaking their first instrumented launch for a fresh install.
|
||||||
|
*/
|
||||||
|
suspend fun beginLaunch(
|
||||||
|
context: Context,
|
||||||
|
isFirstLaunch: Boolean,
|
||||||
|
nowMillis: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
val today = dayIndex(nowMillis)
|
||||||
|
var props: Map<String, Any> = emptyMap()
|
||||||
|
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val launch = advance(
|
||||||
|
State(
|
||||||
|
installDay = prefs[INSTALL_DAY],
|
||||||
|
launchCount = prefs[LAUNCH_COUNT] ?: 0,
|
||||||
|
activeDays = prefs[ACTIVE_DAYS] ?: 0,
|
||||||
|
lastActiveDay = prefs[LAST_ACTIVE_DAY],
|
||||||
|
),
|
||||||
|
isFirstLaunch,
|
||||||
|
today,
|
||||||
|
)
|
||||||
|
launch.state.installDay?.let { prefs[INSTALL_DAY] = it }
|
||||||
|
prefs[LAUNCH_COUNT] = launch.state.launchCount
|
||||||
|
prefs[ACTIVE_DAYS] = launch.state.activeDays
|
||||||
|
launch.state.lastActiveDay?.let { prefs[LAST_ACTIVE_DAY] = it }
|
||||||
|
props = launch.props
|
||||||
|
}
|
||||||
|
|
||||||
|
eventProps = props
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure counter arithmetic: the new state plus the props this launch reports. */
|
||||||
|
internal fun advance(state: State, isFirstLaunch: Boolean, today: Int): Launch {
|
||||||
|
val installDay = state.installDay ?: today.takeIf { isFirstLaunch }
|
||||||
|
val launchCount = state.launchCount + 1
|
||||||
|
|
||||||
|
// null on the very first instrumented launch — reported as -1 ("no previous use"), which
|
||||||
|
// keeps it out of the `dormant_days >= 1` day-boundary count.
|
||||||
|
val dormantDays = state.lastActiveDay?.let { maxOf(0, today - it) } ?: -1
|
||||||
|
val isNewDay = state.lastActiveDay != today
|
||||||
|
val activeDays = if (isNewDay) state.activeDays + 1 else state.activeDays
|
||||||
|
|
||||||
|
return Launch(
|
||||||
|
State(installDay, launchCount, activeDays, if (isNewDay) today else state.lastActiveDay),
|
||||||
|
mapOf(
|
||||||
|
"tenure_days" to (installDay?.let { maxOf(0, today - it) } ?: -1),
|
||||||
|
"launch_no" to launchCount,
|
||||||
|
"active_days" to activeDays,
|
||||||
|
"dormant_days" to dormantDays,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole days since the Unix epoch, in UTC. */
|
||||||
|
fun dayIndex(epochMillis: Long): Int = Math.floorDiv(epochMillis, DAY_MS).toInt()
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import app.voltplan.cable.BuildConfig
|
import app.voltplan.cable.BuildConfig
|
||||||
@@ -20,109 +21,144 @@ import kotlinx.coroutines.flow.first
|
|||||||
/**
|
/**
|
||||||
* Decides when to ask the user for a Play Store rating via the Play In-App Review API.
|
* Decides when to ask the user for a Play Store rating via the Play In-App Review API.
|
||||||
* Google throttles the actual dialog (and shows nothing in debug/sideload builds), so this gate
|
* Google throttles the actual dialog (and shows nothing in debug/sideload builds), so this gate
|
||||||
* keeps requests rare and tied to genuine success moments — a completed export/share.
|
* keeps requests rare and tied to genuine success moments. Booking a milestone and asking for the
|
||||||
* Mirrors the iOS `ReviewPrompt` enum, sharing the same gate thresholds and the `cable_settings`
|
* rating are deliberately separate: milestones are reached mid-task (a screen opens, a share
|
||||||
* DataStore so both platforms behave identically.
|
* intent fires), which is exactly when the review flow gets dropped. [promptIfEligible] is
|
||||||
|
* therefore only called from calm screens.
|
||||||
|
* Mirrors the iOS `ReviewPrompt` enum, sharing the same milestones and thresholds and the
|
||||||
|
* `cable_settings` DataStore so both platforms behave identically.
|
||||||
*/
|
*/
|
||||||
object ReviewPrompt {
|
object ReviewPrompt {
|
||||||
private val MIGRATION_DONE = stringPreferencesKey("review.migrationDone")
|
/**
|
||||||
private val FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate")
|
* Distinct proofs that the user got real value out of the app. Each one counts at most once
|
||||||
private val EXPORT_COUNT = intPreferencesKey("review.successfulExportCount")
|
* per install, so navigating in circles cannot inflate the gate.
|
||||||
|
*/
|
||||||
|
enum class Milestone(val key: String) {
|
||||||
|
/** A system whose loads are all fully configured — the app's own definition of a finished plan. */
|
||||||
|
SYSTEM_PLANNED("systemPlanned"),
|
||||||
|
|
||||||
|
/** Opened a system's bill of materials. */
|
||||||
|
BILL_OF_MATERIALS("billOfMaterials"),
|
||||||
|
|
||||||
|
/** Completed an export/share (Overview PDF, BOM PDF, diagram image). */
|
||||||
|
EXPORTED("exported"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private val GATE_VERSION = intPreferencesKey("review.gateVersion")
|
||||||
|
private val MILESTONES = stringSetPreferencesKey("review.milestones")
|
||||||
private val LAST_PROMPT_DATE = longPreferencesKey("review.lastPromptDate")
|
private val LAST_PROMPT_DATE = longPreferencesKey("review.lastPromptDate")
|
||||||
private val LAST_PROMPTED_VERSION = stringPreferencesKey("review.lastPromptedVersion")
|
private val LAST_PROMPTED_VERSION = stringPreferencesKey("review.lastPromptedVersion")
|
||||||
private val USER_TYPE = stringPreferencesKey("review.userType")
|
private val USER_TYPE = stringPreferencesKey("review.userType")
|
||||||
|
private val LEGACY_EXPORT_COUNT = intPreferencesKey("review.successfulExportCount")
|
||||||
|
private val LEGACY_FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate")
|
||||||
|
private val LEGACY_MIGRATION_DONE = stringPreferencesKey("review.migrationDone")
|
||||||
|
|
||||||
private const val MIN_EXPORTS = 2
|
/** Two *different* milestones — a single one (only opening the parts list, say) is not enough. */
|
||||||
private const val MIN_DAYS_SINCE_INSTALL = 3L
|
private const val MIN_MILESTONES = 2
|
||||||
private const val MIN_DAYS_BETWEEN_PROMPTS = 120L
|
private const val MIN_DAYS_BETWEEN_PROMPTS = 90L
|
||||||
private const val DAY_MS = 24L * 60 * 60 * 1000
|
private const val DAY_MS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Bump when the milestone semantics change so [migrateIfNeeded] runs again. */
|
||||||
|
private const val CURRENT_GATE_VERSION = 2
|
||||||
|
|
||||||
|
/** Play listing, used by the manual entry point in Settings. Unlike the in-app review flow this
|
||||||
|
* is never throttled or suppressed, so it is the only path a willing user can always take. */
|
||||||
|
const val PLAY_STORE_URI = "market://details?id=app.voltplan.cable"
|
||||||
|
const val PLAY_STORE_WEB_URL = "https://play.google.com/store/apps/details?id=app.voltplan.cable"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time setup distinguishing fresh installs from users updating into this feature.
|
* One-time setup, called on every launch. Existing installs keep the credit they earned under
|
||||||
* Existing users are backdated and pre-seeded so the prompt can fire on their *first*
|
* the previous export-only gate: the legacy counter was pre-seeded on update, so any non-zero
|
||||||
* successful export after updating. Pass the value returned by [UnitSystemSettings.consumeFirstLaunch].
|
* value means "knows the app already" and counts as the export milestone. They still need a
|
||||||
|
* second, real milestone before we ask. Pass the value returned by
|
||||||
|
* [UnitSystemSettings.consumeFirstLaunch].
|
||||||
*/
|
*/
|
||||||
suspend fun migrateIfNeeded(context: Context, isFirstLaunch: Boolean) {
|
suspend fun migrateIfNeeded(context: Context, isFirstLaunch: Boolean) {
|
||||||
if (context.dataStore.data.first()[MIGRATION_DONE] != null) return
|
val prefs = context.dataStore.data.first()
|
||||||
val now = System.currentTimeMillis()
|
if ((prefs[GATE_VERSION] ?: 0) >= CURRENT_GATE_VERSION) return
|
||||||
context.dataStore.edit {
|
|
||||||
if (isFirstLaunch) {
|
if ((prefs[LEGACY_EXPORT_COUNT] ?: 0) > 0) {
|
||||||
// Genuine new install: normal flow — needs 2 exports and 3 days.
|
record(context, Milestone.EXPORTED)
|
||||||
it[FIRST_LAUNCH_DATE] = now
|
|
||||||
it[EXPORT_COUNT] = 0
|
|
||||||
it[USER_TYPE] = "new"
|
|
||||||
} else {
|
|
||||||
// Existing user updating in: backdate install past the age gate and pre-seed the
|
|
||||||
// counter so the very next successful export satisfies the gate.
|
|
||||||
it[FIRST_LAUNCH_DATE] = now - MIN_DAYS_SINCE_INSTALL * DAY_MS
|
|
||||||
it[EXPORT_COUNT] = MIN_EXPORTS - 1
|
|
||||||
it[USER_TYPE] = "existing"
|
|
||||||
}
|
|
||||||
it[MIGRATION_DONE] = "true"
|
|
||||||
}
|
}
|
||||||
|
context.dataStore.edit {
|
||||||
|
if (it[USER_TYPE] == null) it[USER_TYPE] = if (isFirstLaunch) "new" else "existing"
|
||||||
|
it.remove(LEGACY_EXPORT_COUNT)
|
||||||
|
it.remove(LEGACY_FIRST_LAUNCH_DATE)
|
||||||
|
it.remove(LEGACY_MIGRATION_DONE)
|
||||||
|
it[GATE_VERSION] = CURRENT_GATE_VERSION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Books a success moment. Never shows anything — safe to call from anywhere. */
|
||||||
|
suspend fun record(context: Context, milestone: Milestone) {
|
||||||
|
var reached = emptySet<String>()
|
||||||
|
var added = false
|
||||||
|
context.dataStore.edit {
|
||||||
|
val current = it[MILESTONES] ?: emptySet()
|
||||||
|
added = milestone.key !in current
|
||||||
|
reached = current + milestone.key
|
||||||
|
if (added) it[MILESTONES] = reached
|
||||||
|
}
|
||||||
|
if (!added) return
|
||||||
|
|
||||||
|
Analytics.log(
|
||||||
|
"Review Milestone Reached",
|
||||||
|
mapOf("milestone" to milestone.key, "reached" to reached.size),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
|
* Asks for a rating if the gate allows it. Call only from a screen at rest — never right
|
||||||
* Increments the shared counter, then requests a review if every gate condition holds.
|
* before starting an activity.
|
||||||
*/
|
*/
|
||||||
suspend fun registerSuccessfulExport(context: Context) {
|
suspend fun promptIfEligible(context: Context) {
|
||||||
var count = 0
|
if (!isEligible(context)) return
|
||||||
var firstLaunch = 0L
|
|
||||||
context.dataStore.edit {
|
|
||||||
if (it[FIRST_LAUNCH_DATE] == null) it[FIRST_LAUNCH_DATE] = System.currentTimeMillis()
|
|
||||||
count = (it[EXPORT_COUNT] ?: 0) + 1
|
|
||||||
it[EXPORT_COUNT] = count
|
|
||||||
firstLaunch = it[FIRST_LAUNCH_DATE] ?: 0L
|
|
||||||
}
|
|
||||||
if (shouldRequest(context, count, firstLaunch)) {
|
|
||||||
requestReview(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun shouldRequest(context: Context, exportCount: Int, firstLaunch: Long): Boolean {
|
val activity = context.findActivity() ?: return
|
||||||
// A: enough successful exports
|
val manager = ReviewManagerFactory.create(context)
|
||||||
if (exportCount < MIN_EXPORTS) return false
|
// Play decides whether there is a flow to show at all; asking first means a suppressed
|
||||||
|
// request never burns this version's single slot.
|
||||||
|
val reviewInfo = runCatching { manager.requestReview() }.getOrNull() ?: return
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
// Wait until the activity is resumed so the dialog doesn't overlap a share sheet that was
|
||||||
// B: installed long enough
|
// just launched (startActivity returns immediately, so we may still be paused).
|
||||||
if (now - firstLaunch < MIN_DAYS_SINCE_INSTALL * DAY_MS) return false
|
(activity as? LifecycleOwner)?.lifecycle?.currentStateFlow
|
||||||
|
?.filter { state: Lifecycle.State -> state.isAtLeast(Lifecycle.State.RESUMED) }
|
||||||
|
?.first()
|
||||||
|
|
||||||
val prefs = context.dataStore.data.first()
|
val prefs = context.dataStore.data.first()
|
||||||
// C: not prompted too recently
|
|
||||||
val lastPrompt = prefs[LAST_PROMPT_DATE] ?: 0L
|
|
||||||
if (lastPrompt > 0 && now - lastPrompt < MIN_DAYS_BETWEEN_PROMPTS * DAY_MS) return false
|
|
||||||
|
|
||||||
// D: at most once per app version
|
|
||||||
if (prefs[LAST_PROMPTED_VERSION] == BuildConfig.VERSION_NAME) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun requestReview(context: Context) {
|
|
||||||
// Mark as requested up front — Google may suppress the dialog, but we still count it
|
|
||||||
// against our own throttle so we don't ask again immediately.
|
|
||||||
context.dataStore.edit {
|
context.dataStore.edit {
|
||||||
it[LAST_PROMPT_DATE] = System.currentTimeMillis()
|
it[LAST_PROMPT_DATE] = System.currentTimeMillis()
|
||||||
it[LAST_PROMPTED_VERSION] = BuildConfig.VERSION_NAME
|
it[LAST_PROMPTED_VERSION] = BuildConfig.VERSION_NAME
|
||||||
}
|
}
|
||||||
val userType = context.dataStore.data.first()[USER_TYPE] ?: "unknown"
|
|
||||||
Analytics.log(
|
Analytics.log(
|
||||||
"Review Prompt Requested",
|
"Review Prompt Requested",
|
||||||
mapOf("version" to BuildConfig.VERSION_NAME, "userType" to userType),
|
mapOf(
|
||||||
|
"version" to BuildConfig.VERSION_NAME,
|
||||||
|
"userType" to (prefs[USER_TYPE] ?: "unknown"),
|
||||||
|
"milestones" to (prefs[MILESTONES] ?: emptySet()).sorted().joinToString(","),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val activity = context.findActivity() ?: return
|
runCatching { manager.launchReview(activity, reviewInfo) }
|
||||||
runCatching {
|
}
|
||||||
val manager = ReviewManagerFactory.create(context)
|
|
||||||
val reviewInfo = manager.requestReview()
|
/** Internal rather than private so the gate can be tested without an Activity. */
|
||||||
// Wait until the activity is resumed so the dialog doesn't overlap a share sheet
|
internal suspend fun isEligible(context: Context): Boolean {
|
||||||
// that was just launched (startActivity returns immediately, so we may still be paused).
|
val prefs = context.dataStore.data.first()
|
||||||
(activity as? LifecycleOwner)?.lifecycle?.currentStateFlow
|
|
||||||
?.filter { state: Lifecycle.State -> state.isAtLeast(Lifecycle.State.RESUMED) }
|
// A: enough distinct milestones
|
||||||
?.first()
|
if ((prefs[MILESTONES] ?: emptySet()).size < MIN_MILESTONES) return false
|
||||||
manager.launchReview(activity, reviewInfo)
|
|
||||||
}
|
// B: not prompted too recently
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastPrompt = prefs[LAST_PROMPT_DATE] ?: 0L
|
||||||
|
if (lastPrompt > 0 && now - lastPrompt < MIN_DAYS_BETWEEN_PROMPTS * DAY_MS) return false
|
||||||
|
|
||||||
|
// C: at most once per app version
|
||||||
|
if (prefs[LAST_PROMPTED_VERSION] == BuildConfig.VERSION_NAME) return false
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Context.findActivity(): Activity? {
|
private fun Context.findActivity(): Activity? {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ data class ComponentLibraryItem(
|
|||||||
val iconURL: String?,
|
val iconURL: String?,
|
||||||
val affiliateLinks: List<AffiliateLink>,
|
val affiliateLinks: List<AffiliateLink>,
|
||||||
) {
|
) {
|
||||||
val displayVoltage: Double? get() = voltageIn ?: voltageOut
|
val displayVoltage: Double? get() = voltageIn?.takeIf { it > 0 } ?: voltageOut?.takeIf { it > 0 }
|
||||||
|
|
||||||
val current: Double?
|
val current: Double?
|
||||||
get() {
|
get() {
|
||||||
|
|||||||
@@ -87,16 +87,20 @@ object SystemDiagram {
|
|||||||
onError: () -> Unit,
|
onError: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val bitmap = fetchOrFallback(context, state, unit)
|
val bitmap = fetchOrFallback(context, state, unit)
|
||||||
|
share(context, bitmap, state.system?.name ?: "System")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun share(context: Context, bitmap: Bitmap, systemName: String) {
|
||||||
val file = withContext(Dispatchers.IO) {
|
val file = withContext(Dispatchers.IO) {
|
||||||
val opaque = flattenOnWhite(bitmap)
|
val opaque = flattenOnWhite(bitmap)
|
||||||
val name = state.system?.name?.takeIf { it.isNotBlank() } ?: "System"
|
val name = systemName.takeIf { it.isNotBlank() } ?: "System"
|
||||||
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
|
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
|
||||||
val out = File(dir, "${name.replace(Regex("[^A-Za-z0-9-_]"), "_")}-Diagram.png")
|
val out = File(dir, "${name.replace(Regex("[^A-Za-z0-9-_]"), "_")}-Diagram.png")
|
||||||
out.outputStream().use { opaque.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
out.outputStream().use { opaque.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Analytics.log("Diagram Image Shared", mapOf("system" to (state.system?.name ?: "")))
|
Analytics.log("Diagram Image Shared", mapOf("system" to systemName))
|
||||||
PdfShare.shareFile(context, file, "image/png")
|
PdfShare.shareFile(context, file, "image/png")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) {
|
|||||||
vm.logPdfExported()
|
vm.logPdfExported()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
SystemBomPdf.exportAndShare(context, state, unit)
|
SystemBomPdf.exportAndShare(context, state, unit)
|
||||||
ReviewPrompt.registerSuccessfulExport(context)
|
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
|
||||||
|
ReviewPrompt.promptIfEligible(context)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }
|
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package app.voltplan.cable.ui.settings
|
package app.voltplan.cable.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -9,9 +13,11 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.outlined.Warning
|
import androidx.compose.material.icons.outlined.Warning
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
@@ -25,18 +31,24 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
|
import app.voltplan.cable.analytics.Analytics
|
||||||
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.data.UnitSystem
|
import app.voltplan.cable.data.UnitSystem
|
||||||
import app.voltplan.cable.ui.LocalUnitSettings
|
import app.voltplan.cable.ui.LocalUnitSettings
|
||||||
import app.voltplan.cable.ui.theme.SysOrange
|
import app.voltplan.cable.ui.theme.SysOrange
|
||||||
|
import app.voltplan.cable.ui.theme.SysYellow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBack: () -> Unit) {
|
fun SettingsScreen(onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
val settings = LocalUnitSettings.current
|
val settings = LocalUnitSettings.current
|
||||||
val unit by settings.unitSystem.collectAsStateWithLifecycle()
|
val unit by settings.unitSystem.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -68,6 +80,33 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable {
|
||||||
|
Analytics.log("Rate App Tapped")
|
||||||
|
openPlayStoreListing(context)
|
||||||
|
}
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Star, contentDescription = null, tint = SysYellow, modifier = Modifier.size(20.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_rate_title),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_rate_footnote),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Icon(Icons.Outlined.Warning, contentDescription = null, tint = SysOrange, modifier = Modifier.size(18.dp))
|
Icon(Icons.Outlined.Warning, contentDescription = null, tint = SysOrange, modifier = Modifier.size(18.dp))
|
||||||
Text(stringResource(R.string.settings_disclaimer_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.settings_disclaimer_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
@@ -77,3 +116,12 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the Play listing, falling back to the web listing on devices without the Play app. */
|
||||||
|
private fun openPlayStoreListing(context: Context) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ReviewPrompt.PLAY_STORE_URI)))
|
||||||
|
}.recoverCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ReviewPrompt.PLAY_STORE_WEB_URL)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package app.voltplan.cable.ui.system
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.gestures.rememberTransformableState
|
||||||
|
import androidx.compose.foundation.gestures.transformable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.outlined.IosShare
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import app.voltplan.cable.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DiagramPreviewDialog(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.overview_share_diagram)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onShare) {
|
||||||
|
Icon(Icons.Outlined.IosShare, contentDescription = stringResource(R.string.overview_share_diagram))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
val transformState = rememberTransformableState { zoomChange, panChange, _ ->
|
||||||
|
scale = (scale * zoomChange).coerceIn(1f, 8f)
|
||||||
|
offset = if (scale == 1f) Offset.Zero else offset + panChange
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.transformable(transformState),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap.asImageBitmap(),
|
||||||
|
contentDescription = stringResource(R.string.overview_share_diagram),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = scale,
|
||||||
|
scaleY = scale,
|
||||||
|
translationX = offset.x,
|
||||||
|
translationY = offset.y,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.Bolt
|
|||||||
import androidx.compose.material.icons.outlined.IosShare
|
import androidx.compose.material.icons.outlined.IosShare
|
||||||
import androidx.compose.material.icons.outlined.PictureAsPdf
|
import androidx.compose.material.icons.outlined.PictureAsPdf
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -54,6 +55,7 @@ import app.voltplan.cable.ui.theme.componentColor
|
|||||||
import app.voltplan.cable.data.ReviewPrompt
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.pdf.SystemDiagram
|
import app.voltplan.cable.pdf.SystemDiagram
|
||||||
import app.voltplan.cable.pdf.SystemOverviewPdf
|
import app.voltplan.cable.pdf.SystemOverviewPdf
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
@@ -106,8 +108,18 @@ fun SystemDetailScreen(
|
|||||||
var showSystemEditor by remember { mutableStateOf(false) }
|
var showSystemEditor by remember { mutableStateOf(false) }
|
||||||
var showOverviewMenu by remember { mutableStateOf(false) }
|
var showOverviewMenu by remember { mutableStateOf(false) }
|
||||||
var exporting by remember { mutableStateOf(false) }
|
var exporting by remember { mutableStateOf(false) }
|
||||||
|
var diagramBitmapPreview by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
val system = state.system
|
val system = state.system
|
||||||
|
|
||||||
|
// A finished plan is the app's own definition of success, and the overview is the calm moment
|
||||||
|
// where a rating request can actually be presented.
|
||||||
|
val systemPlanned = state.loads.isNotEmpty() &&
|
||||||
|
state.loads.all { it.length > 0 && it.current > 0 && it.crossSection > 0 }
|
||||||
|
LaunchedEffect(systemPlanned) {
|
||||||
|
if (systemPlanned) ReviewPrompt.record(context, ReviewPrompt.Milestone.SYSTEM_PLANNED)
|
||||||
|
ReviewPrompt.promptIfEligible(context)
|
||||||
|
}
|
||||||
|
|
||||||
// Switch to the matching tab before opening an editor, so returning from the
|
// Switch to the matching tab before opening an editor, so returning from the
|
||||||
// editor lands on that tab with the newly created component visible.
|
// editor lands on that tab with the newly created component visible.
|
||||||
val newLoad = { tab = ComponentTab.COMPONENTS; onNewLoad() }
|
val newLoad = { tab = ComponentTab.COMPONENTS; onNewLoad() }
|
||||||
@@ -155,7 +167,7 @@ fun SystemDetailScreen(
|
|||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
IconButton(onClick = { showOverviewMenu = true }) {
|
IconButton(onClick = { showOverviewMenu = true }, enabled = state.hasComponents) {
|
||||||
Icon(Icons.Outlined.IosShare, contentDescription = stringResource(R.string.overview_share_pdf))
|
Icon(Icons.Outlined.IosShare, contentDescription = stringResource(R.string.overview_share_pdf))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,13 +179,9 @@ fun SystemDetailScreen(
|
|||||||
showOverviewMenu = false
|
showOverviewMenu = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
exporting = true
|
exporting = true
|
||||||
var failed = false
|
val bitmap = SystemDiagram.fetchOrFallback(context, state, unitSystem)
|
||||||
SystemDiagram.exportAndShare(context, state, unitSystem) {
|
|
||||||
failed = true
|
|
||||||
Toast.makeText(context, R.string.overview_share_diagram_error, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
exporting = false
|
exporting = false
|
||||||
if (!failed) ReviewPrompt.registerSuccessfulExport(context)
|
diagramBitmapPreview = bitmap
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -186,7 +194,8 @@ fun SystemDetailScreen(
|
|||||||
exporting = true
|
exporting = true
|
||||||
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
|
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
|
||||||
exporting = false
|
exporting = false
|
||||||
ReviewPrompt.registerSuccessfulExport(context)
|
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
|
||||||
|
ReviewPrompt.promptIfEligible(context)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -223,7 +232,11 @@ fun SystemDetailScreen(
|
|||||||
onAddBattery = newBattery,
|
onAddBattery = newBattery,
|
||||||
onAddCharger = newCharger,
|
onAddCharger = newCharger,
|
||||||
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
|
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
|
||||||
onOpenBom = { vm.logBomOpened(); onOpenBom() },
|
onOpenBom = {
|
||||||
|
vm.logBomOpened()
|
||||||
|
scope.launch { ReviewPrompt.record(context, ReviewPrompt.Milestone.BILL_OF_MATERIALS) }
|
||||||
|
onOpenBom()
|
||||||
|
},
|
||||||
onSelectLoads = { tab = ComponentTab.COMPONENTS; vm.logTabChange(ComponentTab.COMPONENTS.analytics) },
|
onSelectLoads = { tab = ComponentTab.COMPONENTS; vm.logTabChange(ComponentTab.COMPONENTS.analytics) },
|
||||||
onSelectBatteries = { tab = ComponentTab.BATTERIES; vm.logTabChange(ComponentTab.BATTERIES.analytics) },
|
onSelectBatteries = { tab = ComponentTab.BATTERIES; vm.logTabChange(ComponentTab.BATTERIES.analytics) },
|
||||||
onSelectChargers = { tab = ComponentTab.CHARGERS; vm.logTabChange(ComponentTab.CHARGERS.analytics) },
|
onSelectChargers = { tab = ComponentTab.CHARGERS; vm.logTabChange(ComponentTab.CHARGERS.analytics) },
|
||||||
@@ -256,6 +269,21 @@ fun SystemDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diagramBitmapPreview?.let { bmp ->
|
||||||
|
DiagramPreviewDialog(
|
||||||
|
bitmap = bmp,
|
||||||
|
onShare = {
|
||||||
|
scope.launch {
|
||||||
|
SystemDiagram.share(context, bmp, state.system?.name ?: "System")
|
||||||
|
diagramBitmapPreview = null
|
||||||
|
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
|
||||||
|
ReviewPrompt.promptIfEligible(context)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { diagramBitmapPreview = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showSystemEditor && system != null) {
|
if (showSystemEditor && system != null) {
|
||||||
var location by remember { mutableStateOf(system.location) }
|
var location by remember { mutableStateOf(system.location) }
|
||||||
AppearanceEditorSheet(
|
AppearanceEditorSheet(
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ data class DetailState(
|
|||||||
val chargers: List<SavedCharger> = emptyList(),
|
val chargers: List<SavedCharger> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val metrics: SystemMetrics get() = SystemMetrics(loads, batteries, chargers)
|
val metrics: SystemMetrics get() = SystemMetrics(loads, batteries, chargers)
|
||||||
|
|
||||||
|
val hasComponents: Boolean
|
||||||
|
get() = loads.isNotEmpty() || batteries.isNotEmpty() || chargers.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
class SystemDetailViewModel(
|
class SystemDetailViewModel(
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Einheiten</string>
|
<string name="settings_units_section">Einheiten</string>
|
||||||
<string name="units_metric_display">Metrisch (mm², m)</string>
|
<string name="units_metric_display">Metrisch (mm², m)</string>
|
||||||
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Cable bewerten</string>
|
||||||
|
<string name="settings_rate_footnote">Bewertungen helfen anderen Monteuren, Cable im Play Store zu finden.</string>
|
||||||
<string name="settings_disclaimer_title">Sicherheitshinweis</string>
|
<string name="settings_disclaimer_title">Sicherheitshinweis</string>
|
||||||
<string name="settings_disclaimer_body">Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.</string>
|
<string name="settings_disclaimer_body">Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.</string>
|
||||||
<string name="settings_disclaimer_points">• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu\n• Beachte alle örtlichen Vorschriften und Normen\n• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden\n• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren\n• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen</string>
|
<string name="settings_disclaimer_points">• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu\n• Beachte alle örtlichen Vorschriften und Normen\n• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden\n• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren\n• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen</string>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Unidades</string>
|
<string name="settings_units_section">Unidades</string>
|
||||||
<string name="units_metric_display">Métrico (mm², m)</string>
|
<string name="units_metric_display">Métrico (mm², m)</string>
|
||||||
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Valorar Cable</string>
|
||||||
|
<string name="settings_rate_footnote">Las valoraciones ayudan a que otros instaladores encuentren Cable en Play Store.</string>
|
||||||
<string name="settings_disclaimer_title">Aviso de seguridad</string>
|
<string name="settings_disclaimer_title">Aviso de seguridad</string>
|
||||||
<string name="settings_disclaimer_body">Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.</string>
|
<string name="settings_disclaimer_body">Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.</string>
|
||||||
<string name="settings_disclaimer_points">• Consulta siempre a electricistas calificados para las instalaciones reales\n• Cumple todas las normativas y códigos eléctricos locales\n• Los trabajos eléctricos solo deben realizarlos profesionales autorizados\n• Estos cálculos pueden no tener en cuenta todos los factores ambientales\n• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas</string>
|
<string name="settings_disclaimer_points">• Consulta siempre a electricistas calificados para las instalaciones reales\n• Cumple todas las normativas y códigos eléctricos locales\n• Los trabajos eléctricos solo deben realizarlos profesionales autorizados\n• Estos cálculos pueden no tener en cuenta todos los factores ambientales\n• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas</string>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Unités</string>
|
<string name="settings_units_section">Unités</string>
|
||||||
<string name="units_metric_display">Métrique (mm², m)</string>
|
<string name="units_metric_display">Métrique (mm², m)</string>
|
||||||
<string name="units_imperial_display">Impérial (AWG, ft)</string>
|
<string name="units_imperial_display">Impérial (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Noter Cable</string>
|
||||||
|
<string name="settings_rate_footnote">Les avis aident les autres installateurs à trouver Cable sur le Play Store.</string>
|
||||||
<string name="settings_disclaimer_title">Avertissement de sécurité</string>
|
<string name="settings_disclaimer_title">Avertissement de sécurité</string>
|
||||||
<string name="settings_disclaimer_body">Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d\'estimation.</string>
|
<string name="settings_disclaimer_body">Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d\'estimation.</string>
|
||||||
<string name="settings_disclaimer_points">• Faites toujours appel à des électriciens qualifiés pour les installations réelles\n• Respectez toutes les normes et réglementations électriques locales\n• Les travaux électriques doivent être réalisés uniquement par des professionnels certifiés\n• Ces calculs peuvent ne pas prendre en compte tous les facteurs environnementaux\n• Les développeurs de l\'application déclinent toute responsabilité quant aux installations électriques</string>
|
<string name="settings_disclaimer_points">• Faites toujours appel à des électriciens qualifiés pour les installations réelles\n• Respectez toutes les normes et réglementations électriques locales\n• Les travaux électriques doivent être réalisés uniquement par des professionnels certifiés\n• Ces calculs peuvent ne pas prendre en compte tous les facteurs environnementaux\n• Les développeurs de l\'application déclinent toute responsabilité quant aux installations électriques</string>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Eenheden</string>
|
<string name="settings_units_section">Eenheden</string>
|
||||||
<string name="units_metric_display">Metrisch (mm², m)</string>
|
<string name="units_metric_display">Metrisch (mm², m)</string>
|
||||||
<string name="units_imperial_display">Imperiaal (AWG, ft)</string>
|
<string name="units_imperial_display">Imperiaal (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Cable beoordelen</string>
|
||||||
|
<string name="settings_rate_footnote">Beoordelingen helpen andere installateurs om Cable in de Play Store te vinden.</string>
|
||||||
<string name="settings_disclaimer_title">Veiligheidswaarschuwing</string>
|
<string name="settings_disclaimer_title">Veiligheidswaarschuwing</string>
|
||||||
<string name="settings_disclaimer_body">Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen.</string>
|
<string name="settings_disclaimer_body">Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen.</string>
|
||||||
<string name="settings_disclaimer_points">• Raadpleeg voor echte installaties altijd een gekwalificeerde elektricien\n• Volg alle lokale elektrische voorschriften en regels\n• Elektrisch werk mag alleen worden uitgevoerd door bevoegde professionals\n• Deze berekeningen houden mogelijk niet met alle omgevingsfactoren rekening\n• De ontwikkelaars van de app aanvaarden geen aansprakelijkheid voor elektrische installaties</string>
|
<string name="settings_disclaimer_points">• Raadpleeg voor echte installaties altijd een gekwalificeerde elektricien\n• Volg alle lokale elektrische voorschriften en regels\n• Elektrisch werk mag alleen worden uitgevoerd door bevoegde professionals\n• Deze berekeningen houden mogelijk niet met alle omgevingsfactoren rekening\n• De ontwikkelaars van de app aanvaarden geen aansprakelijkheid voor elektrische installaties</string>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Units</string>
|
<string name="settings_units_section">Units</string>
|
||||||
<string name="units_metric_display">Metric (mm², m)</string>
|
<string name="units_metric_display">Metric (mm², m)</string>
|
||||||
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Rate Cable</string>
|
||||||
|
<string name="settings_rate_footnote">Ratings are how other installers find Cable in the Play Store.</string>
|
||||||
<string name="settings_disclaimer_title">Safety Disclaimer</string>
|
<string name="settings_disclaimer_title">Safety Disclaimer</string>
|
||||||
<string name="settings_disclaimer_body">This application provides electrical calculations for educational and estimation purposes only.</string>
|
<string name="settings_disclaimer_body">This application provides electrical calculations for educational and estimation purposes only.</string>
|
||||||
<string name="settings_disclaimer_points">• Always consult qualified electricians for actual installations\n• Follow all local electrical codes and regulations\n• Electrical work should only be performed by licensed professionals\n• These calculations may not account for all environmental factors\n• The app developers assume no liability for electrical installations</string>
|
<string name="settings_disclaimer_points">• Always consult qualified electricians for actual installations\n• Follow all local electrical codes and regulations\n• Electrical work should only be performed by licensed professionals\n• These calculations may not account for all environmental factors\n• The app developers assume no liability for electrical installations</string>
|
||||||
|
|||||||
21943
android/app/src/release/generated/baselineProfiles/baseline-prof.txt
Normal file
21943
android/app/src/release/generated/baselineProfiles/baseline-prof.txt
Normal file
File diff suppressed because it is too large
Load Diff
18842
android/app/src/release/generated/baselineProfiles/startup-prof.txt
Normal file
18842
android/app/src/release/generated/baselineProfiles/startup-prof.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
package app.voltplan.cable.analytics
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The retention counters are the only way to reconstruct D_k curves from an analytics backend that
|
||||||
|
* cannot identify a device across days, so their arithmetic is verified here rather than trusted in
|
||||||
|
* production. Mirrors `CableTests/UsageMetricsTests.swift`.
|
||||||
|
*/
|
||||||
|
class UsageMetricsTest {
|
||||||
|
|
||||||
|
private fun props(vararg pairs: Pair<String, Any>) = mapOf(*pairs)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun firstLaunchOfANewInstallStartsTheCounters() {
|
||||||
|
val launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_000)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to 0, "launch_no" to 1, "active_days" to 1, "dormant_days" to -1),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
assertEquals(UsageMetrics.State(20_000, 1, 1, 20_000), launch.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun furtherLaunchesOnTheSameDayDoNotCountAsANewActiveDay() {
|
||||||
|
var state = UsageMetrics.State()
|
||||||
|
var props: Map<String, Any> = emptyMap()
|
||||||
|
repeat(3) { index ->
|
||||||
|
val launch = UsageMetrics.advance(state, isFirstLaunch = index == 0, today = 20_000)
|
||||||
|
state = launch.state
|
||||||
|
props = launch.props
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to 0, "launch_no" to 3, "active_days" to 1, "dormant_days" to 0),
|
||||||
|
props,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tenureAndActiveDaysAdvanceAcrossDays() {
|
||||||
|
var launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_000)
|
||||||
|
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_001)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to 1, "launch_no" to 2, "active_days" to 2, "dormant_days" to 1),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_007)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to 7, "launch_no" to 3, "active_days" to 3, "dormant_days" to 6),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The D_k denominator: exactly one launch per install carries `launch_no == 1`, and exactly one
|
||||||
|
* launch per calendar day carries `dormant_days >= 1`. Both must hold or the counts in the
|
||||||
|
* export measure launches instead of installs.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun exactlyOneLaunchPerDayMarksTheDayBoundary() {
|
||||||
|
var state = UsageMetrics.State()
|
||||||
|
var installMarkers = 0
|
||||||
|
var dayMarkers = 0
|
||||||
|
var isFirst = true
|
||||||
|
var props: Map<String, Any> = emptyMap()
|
||||||
|
for (day in 20_000..20_004) {
|
||||||
|
repeat(3) {
|
||||||
|
val launch = UsageMetrics.advance(state, isFirst, day)
|
||||||
|
isFirst = false
|
||||||
|
state = launch.state
|
||||||
|
props = launch.props
|
||||||
|
if (props["launch_no"] == 1) installMarkers++
|
||||||
|
if ((props["dormant_days"] as Int) >= 1) dayMarkers++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(1, installMarkers)
|
||||||
|
// Day 0 is counted by the install marker, so the boundary marks days 1..4.
|
||||||
|
assertEquals(4, dayMarkers)
|
||||||
|
assertEquals(5, props["active_days"])
|
||||||
|
assertEquals(15, props["launch_no"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs that predate the counters have no install date. They must stay distinguishable from
|
||||||
|
* fresh installs forever, otherwise the update inflates the new-install cohort.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun installsPredatingTheCountersReportUnknownTenure() {
|
||||||
|
var launch = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = false, today = 20_000)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to -1, "launch_no" to 1, "active_days" to 1, "dormant_days" to -1),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
launch = UsageMetrics.advance(launch.state, isFirstLaunch = false, today = 20_003)
|
||||||
|
assertEquals(
|
||||||
|
props("tenure_days" to -1, "launch_no" to 2, "active_days" to 2, "dormant_days" to 3),
|
||||||
|
launch.props,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBackwardsClockNeverProducesNegativeCounts() {
|
||||||
|
val first = UsageMetrics.advance(UsageMetrics.State(), isFirstLaunch = true, today = 20_010)
|
||||||
|
val second = UsageMetrics.advance(first.state, isFirstLaunch = false, today = 20_002)
|
||||||
|
assertEquals(0, second.props["tenure_days"])
|
||||||
|
assertEquals(0, second.props["dormant_days"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayIndexIsAUtcDayCount() {
|
||||||
|
assertEquals(0, UsageMetrics.dayIndex(0L))
|
||||||
|
assertEquals(0, UsageMetrics.dayIndex(86_399_000L))
|
||||||
|
assertEquals(1, UsageMetrics.dayIndex(86_400_000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
48
android/baselineprofile/build.gradle.kts
Normal file
48
android/baselineprofile/build.gradle.kts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.test)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.baselineprofile)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.voltplan.cable.baselineprofile"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// Baseline profile capture needs API 33+ on an unrooted device.
|
||||||
|
minSdk = 33
|
||||||
|
targetSdk = 36
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
targetProjectPath = ":app"
|
||||||
|
|
||||||
|
// Profiles must be generated on an AOSP image: Play Store images are user
|
||||||
|
// builds where the benchmark cannot reset the compilation state.
|
||||||
|
testOptions.managedDevices.allDevices {
|
||||||
|
create<com.android.build.api.dsl.ManagedVirtualDevice>("pixel6Api34") {
|
||||||
|
device = "Pixel 6"
|
||||||
|
apiLevel = 34
|
||||||
|
systemImageSource = "aosp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baselineProfile {
|
||||||
|
managedDevices += "pixel6Api34"
|
||||||
|
useConnectedDevices = false
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.test.ext.junit)
|
||||||
|
implementation(libs.androidx.uiautomator)
|
||||||
|
implementation(libs.androidx.benchmark.macro.junit4)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#- File Locator -
|
||||||
|
listingFile=../../../../outputs/apk/nonMinifiedRelease/output-metadata.json
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
8
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<merger version="3"><dataSet config="androidx.benchmark:benchmark-macro:1.4.1" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets"><file name="trace_processor_shell_x86" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets/trace_processor_shell_x86"/><file name="trace_processor_shell_arm" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets/trace_processor_shell_arm"/><file name="trace_processor_shell_aarch64" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets/trace_processor_shell_aarch64"/><file name="trace_processor_shell_x86_64" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets/trace_processor_shell_x86_64"/></source></dataSet><dataSet config="androidx.benchmark:benchmark-common:1.4.1" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets"><file name="tracebox_x86" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets/tracebox_x86"/><file name="tracebox_arm" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets/tracebox_arm"/><file name="tracebox_x86_64" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets/tracebox_x86_64"/><file name="tracebox_aarch64" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets/tracebox_aarch64"/></source></dataSet><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/main/assets"/></dataSet><dataSet config="nonMinifiedRelease" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/nonMinifiedRelease/assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/build/intermediates/shader_assets/nonMinifiedRelease/compileNonMinifiedReleaseShaders/out"/></dataSet></merger>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/main/jniLibs"/></dataSet><dataSet config="nonMinifiedRelease" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/nonMinifiedRelease/jniLibs"/></dataSet></merger>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user