diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 7070820..2a7614a 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -2,6 +2,57 @@ "affiliate.description.with_link" = "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan."; "affiliate.description.without_link" = "Tapping above shows a full bill of materials with shopping searches to help you source parts."; "affiliate.disclaimer" = "Purchases through affiliate links may support VoltPlan."; +"battery.bank.badge.capacity" = "Capacity"; +"battery.bank.badge.energy" = "Energy"; +"battery.bank.badge.voltage" = "Voltage"; +"battery.bank.banner.capacity" = "Capacity mismatch detected"; +"battery.bank.banner.voltage" = "Voltage mismatch detected"; +"battery.bank.empty.subtitle" = "Tap the plus button to configure a battery for %@."; +"battery.bank.empty.title" = "No Batteries Yet"; +"battery.bank.header.title" = "Battery Bank"; +"battery.bank.metric.capacity" = "Capacity"; +"battery.bank.metric.count" = "Batteries"; +"battery.bank.metric.energy" = "Energy"; +"battery.bank.metric.usable_capacity" = "Usable Capacity"; +"battery.bank.metric.usable_energy" = "Usable Energy"; +"battery.bank.status.capacity.message" = "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear."; +"battery.bank.status.capacity.title" = "Capacity mismatch"; +"battery.bank.status.dismiss" = "Got it"; +"battery.bank.status.multiple.batteries" = "%d batteries"; +"battery.bank.status.single.battery" = "One battery"; +"battery.bank.status.voltage.message" = "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters."; +"battery.bank.status.voltage.title" = "Voltage mismatch"; +"battery.bank.warning.capacity.short" = "Capacity"; +"battery.bank.warning.voltage.short" = "Voltage"; +"battery.editor.advanced.usable_capacity.footer_default" = "Defaults to %@ based on chemistry."; +"battery.editor.advanced.usable_capacity.footer_override" = "Override active. Chemistry default remains %@."; +"battery.editor.alert.cancel" = "Cancel"; +"battery.editor.alert.capacity.message" = "Enter capacity in amp-hours (Ah)"; +"battery.editor.alert.capacity.placeholder" = "Capacity"; +"battery.editor.alert.capacity.title" = "Edit Capacity"; +"battery.editor.alert.save" = "Save"; +"battery.editor.alert.usable_capacity.message" = "Enter usable capacity percentage (%)"; +"battery.editor.alert.usable_capacity.placeholder" = "Usable Capacity (%)"; +"battery.editor.alert.usable_capacity.title" = "Edit Usable Capacity"; +"battery.editor.alert.voltage.message" = "Enter voltage in volts (V)"; +"battery.editor.alert.voltage.placeholder" = "Voltage"; +"battery.editor.alert.voltage.title" = "Edit Nominal Voltage"; +"battery.editor.button.reset_default" = "Reset"; +"battery.editor.cancel" = "Cancel"; +"battery.editor.default_name" = "New Battery"; +"battery.editor.field.chemistry" = "Chemistry"; +"battery.editor.field.name" = "Name"; +"battery.editor.placeholder.name" = "House Bank"; +"battery.editor.save" = "Save"; +"battery.editor.section.advanced" = "Advanced"; +"battery.editor.section.summary" = "Summary"; +"battery.editor.slider.capacity" = "Capacity"; +"battery.editor.slider.usable_capacity" = "Usable Capacity (%)"; +"battery.editor.slider.voltage" = "Nominal Voltage"; +"battery.editor.title" = "Battery Setup"; +"battery.onboarding.subtitle" = "Track your bank's capacity and chemistry to keep runtime expectations in check."; +"battery.onboarding.title" = "Add your first battery"; +"battery.overview.empty.create" = "Add Battery"; "bom.accessibility.mark.complete" = "Mark %@ complete"; "bom.accessibility.mark.incomplete" = "Mark %@ incomplete"; "bom.fuse.detail" = "Inline holder and %dA fuse"; @@ -13,11 +64,66 @@ "bom.navigation.title.system" = "BOM – %@"; "bom.size.unknown" = "Size TBD"; "bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring"; +"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.title" = "Duty Cycle"; +"calculator.advanced.section.title" = "Advanced Settings"; +"calculator.advanced.usage_hours.helper" = "Hours per day the load is turned on."; +"calculator.advanced.usage_hours.title" = "Daily On-Time"; +"calculator.advanced.usage_hours.unit" = "h/day"; +"calculator.alert.duty_cycle.message" = "Enter duty cycle as a percentage (0-100%)."; +"calculator.alert.duty_cycle.placeholder" = "Duty Cycle"; +"calculator.alert.duty_cycle.title" = "Edit Duty Cycle"; +"calculator.alert.usage_hours.message" = "Enter the number of hours per day the load is active."; +"calculator.alert.usage_hours.placeholder" = "Daily On-Time"; +"calculator.alert.usage_hours.title" = "Edit Daily On-Time"; +"charger.default.new" = "New Charger"; +"charger.editor.alert.cancel" = "Cancel"; +"charger.editor.alert.current.message" = "Enter current in amps (A)"; +"charger.editor.alert.current.title" = "Edit Charge Current"; +"charger.editor.alert.input_voltage.title" = "Edit Input Voltage"; +"charger.editor.alert.output_voltage.title" = "Edit Output Voltage"; +"charger.editor.alert.power.message" = "Enter power in watts (W)"; +"charger.editor.alert.power.placeholder" = "Power"; +"charger.editor.alert.power.title" = "Edit Charge Power"; +"charger.editor.alert.save" = "Save"; +"charger.editor.alert.voltage.message" = "Enter voltage in volts (V)"; +"charger.editor.appearance.accessibility" = "Edit charger appearance"; +"charger.editor.appearance.subtitle" = "Customize how this charger shows up"; +"charger.editor.appearance.title" = "Charger Appearance"; +"charger.editor.default_name" = "New Charger"; +"charger.editor.field.current" = "Charge Current"; +"charger.editor.field.input_voltage" = "Input Voltage"; +"charger.editor.field.name" = "Name"; +"charger.editor.field.output_voltage" = "Output Voltage"; +"charger.editor.field.power" = "Charge Power"; +"charger.editor.field.power.footer" = "Leave blank when the rated wattage isn't published. We'll calculate it from voltage and current."; +"charger.editor.placeholder.name" = "Workshop Charger"; +"charger.editor.section.electrical" = "Electrical"; +"charger.editor.section.power" = "Charge Output"; +"charger.editor.title" = "Charger"; +"chargers.badge.current" = "Current"; +"chargers.badge.input" = "Input"; +"chargers.badge.output" = "Output"; +"chargers.badge.power" = "Power"; +"chargers.onboarding.primary" = "Create Charger"; +"chargers.onboarding.subtitle" = "Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity."; +"chargers.onboarding.title" = "Add your chargers"; +"chargers.subtitle" = "Charger components will be available soon."; +"chargers.summary.metric.count" = "Chargers"; +"chargers.summary.metric.current" = "Charge Rate"; +"chargers.summary.metric.output" = "Output Voltage"; +"chargers.summary.metric.power" = "Charge Power"; +"chargers.summary.title" = "Charging Overview"; +"chargers.title" = "Chargers for %@"; "component.fallback.name" = "Component"; "default.load.library" = "Library Load"; "default.load.name" = "My Load"; -"default.load.unnamed" = "Unnamed Load"; "default.load.new" = "New Load"; +"default.load.unnamed" = "Unnamed Load"; "default.system.name" = "My System"; "default.system.new" = "New System"; "editor.load.name_field" = "Load name"; @@ -26,189 +132,124 @@ "editor.system.location.optional" = "Location (optional)"; "editor.system.name_field" = "System name"; "editor.system.title" = "Edit System"; +"loads.library.button" = "Library"; +"loads.metric.cable" = "Cable"; +"loads.metric.fuse" = "Fuse"; +"loads.metric.length" = "Length"; +"loads.onboarding.subtitle" = "Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations."; +"loads.onboarding.title" = "Add your first component"; +"loads.overview.empty.create" = "Add Load"; +"loads.overview.empty.library" = "Browse Library"; +"loads.overview.empty.message" = "Start by adding a load to see system insights."; +"loads.overview.header.title" = "Load Overview"; +"loads.overview.metric.count" = "Loads"; +"loads.overview.metric.current" = "Total Current"; +"loads.overview.metric.power" = "Total Power"; +"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.plural" = "loads"; +"loads.overview.status.missing_details.singular" = "load"; +"loads.overview.status.missing_details.title" = "Missing load details"; +"overview.chargers.empty.create" = "Add Charger"; +"overview.chargers.empty.subtitle" = "Add shore power, DC-DC, or solar chargers to understand your charging capacity."; +"overview.chargers.empty.title" = "No chargers configured yet"; +"overview.chargers.header.title" = "Charger Overview"; +"overview.loads.empty.subtitle" = "Add components to get cable sizing and fuse recommendations tailored to this system."; +"overview.loads.empty.title" = "No loads configured yet"; +"overview.runtime.subtitle" = "At maximum load draw"; +"overview.runtime.title" = "Estimated runtime"; +"overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime."; +"overview.system.header.title" = "System Overview"; +"sample.charger.dcdc.name" = "DC-DC charger"; +"sample.charger.shore.name" = "Shore power charger"; +"sample.charger.workbench.name" = "Workbench charger"; +"sample.load.charger.name" = "Tool charger"; +"sample.load.compressor.name" = "Air compressor"; +"sample.load.fridge.name" = "Compressor fridge"; +"sample.load.lighting.name" = "LED strip lighting"; +"sample.system.rv.location" = "12V living circuit"; +"sample.system.rv.name" = "Adventure Van"; +"sample.system.workshop.location" = "Tool corner"; +"sample.system.workshop.name" = "Workshop Bench"; "slider.button.ampere" = "Ampere"; "slider.button.watt" = "Watt"; "slider.current.title" = "Current"; "slider.length.title" = "Cable Length (%@)"; "slider.power.title" = "Power"; "slider.voltage.title" = "Voltage"; -"calculator.advanced.section.title" = "Advanced Settings"; -"calculator.advanced.duty_cycle.title" = "Duty Cycle"; -"calculator.advanced.duty_cycle.helper" = "Percentage of each active session where the load actually draws power."; -"calculator.advanced.usage_hours.title" = "Daily On-Time"; -"calculator.advanced.usage_hours.helper" = "Hours per day the load is turned on."; -"calculator.advanced.usage_hours.unit" = "h/day"; -"calculator.alert.duty_cycle.title" = "Edit Duty Cycle"; -"calculator.alert.duty_cycle.placeholder" = "Duty Cycle"; -"calculator.alert.duty_cycle.message" = "Enter duty cycle as a percentage (0-100%)."; -"calculator.alert.usage_hours.title" = "Edit Daily On-Time"; -"calculator.alert.usage_hours.placeholder" = "Daily On-Time"; -"calculator.alert.usage_hours.message" = "Enter the number of hours per day the load is active."; -"system.list.no.components" = "No components yet"; -"units.imperial.display" = "Imperial (AWG, ft)"; -"units.metric.display" = "Metric (mm², m)"; -"sample.system.rv.name" = "Adventure Van"; -"sample.system.rv.location" = "12V living circuit"; -"sample.system.workshop.name" = "Workshop Bench"; -"sample.system.workshop.location" = "Tool corner"; -"sample.load.fridge.name" = "Compressor fridge"; -"sample.load.lighting.name" = "LED strip lighting"; -"sample.load.compressor.name" = "Air compressor"; -"sample.load.charger.name" = "Tool charger"; -"system.icon.keywords.rv" = "rv, van, camper, motorhome, coach"; -"system.icon.keywords.truck" = "truck, trailer, rig"; -"system.icon.keywords.boat" = "boat, marine, yacht, sail"; -"system.icon.keywords.plane" = "plane, air, flight"; -"system.icon.keywords.ferry" = "ferry, ship"; -"system.icon.keywords.house" = "house, home, cabin, cottage, lodge"; -"system.icon.keywords.building" = "building, office, warehouse, factory, facility"; -"system.icon.keywords.tent" = "camp, tent, outdoor"; -"system.icon.keywords.solar" = "solar, sun"; "system.icon.keywords.battery" = "battery, storage"; -"system.icon.keywords.server" = "server, data, network, rack"; -"system.icon.keywords.computer" = "computer, electronics, lab, tech"; -"system.icon.keywords.gear" = "gear, mechanic, machine, workshop"; -"system.icon.keywords.tool" = "tool, maintenance, repair, shop"; -"system.icon.keywords.hammer" = "hammer, carpentry"; -"system.icon.keywords.light" = "light, lighting, lamp"; +"system.icon.keywords.boat" = "boat, marine, yacht, sail"; "system.icon.keywords.bolt" = "bolt, power, electric"; -"system.icon.keywords.plug" = "plug"; -"system.icon.keywords.engine" = "engine, generator, motor"; -"system.icon.keywords.fuel" = "fuel, diesel, gas"; -"system.icon.keywords.water" = "water, pump, tank"; -"system.icon.keywords.heat" = "heat, heater, furnace"; -"system.icon.keywords.cold" = "cold, freeze, cool"; +"system.icon.keywords.building" = "building, office, warehouse, factory, facility"; "system.icon.keywords.climate" = "climate, hvac, temperature"; - -"tab.overview" = "Overview"; -"tab.components" = "Components"; +"system.icon.keywords.cold" = "cold, freeze, cool"; +"system.icon.keywords.computer" = "computer, electronics, lab, tech"; +"system.icon.keywords.engine" = "engine, generator, motor"; +"system.icon.keywords.ferry" = "ferry, ship"; +"system.icon.keywords.fuel" = "fuel, diesel, gas"; +"system.icon.keywords.gear" = "gear, mechanic, machine, workshop"; +"system.icon.keywords.hammer" = "hammer, carpentry"; +"system.icon.keywords.heat" = "heat, heater, furnace"; +"system.icon.keywords.house" = "house, home, cabin, cottage, lodge"; +"system.icon.keywords.light" = "light, lighting, lamp"; +"system.icon.keywords.plane" = "plane, air, flight"; +"system.icon.keywords.plug" = "plug"; +"system.icon.keywords.rv" = "rv, van, camper, motorhome, coach"; +"system.icon.keywords.server" = "server, data, network, rack"; +"system.icon.keywords.solar" = "solar, sun"; +"system.icon.keywords.tent" = "camp, tent, outdoor"; +"system.icon.keywords.tool" = "tool, maintenance, repair, shop"; +"system.icon.keywords.truck" = "truck, trailer, rig"; +"system.icon.keywords.water" = "water, pump, tank"; +"system.list.no.components" = "No components yet"; "tab.batteries" = "Batteries"; "tab.chargers" = "Chargers"; - -"loads.overview.header.title" = "Load Overview"; -"loads.overview.metric.count" = "Loads"; -"loads.overview.metric.current" = "Total Current"; -"loads.overview.metric.power" = "Total Power"; -"loads.overview.empty.message" = "Start by adding a load to see system insights."; -"loads.overview.empty.create" = "Add Load"; -"loads.overview.empty.library" = "Browse Library"; -"loads.library.button" = "Library"; -"loads.onboarding.title" = "Add your first component"; -"loads.onboarding.subtitle" = "Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations."; -"loads.overview.status.missing_details.title" = "Missing load details"; -"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations."; -"loads.overview.status.missing_details.singular" = "load"; -"loads.overview.status.missing_details.plural" = "loads"; -"loads.overview.status.missing_details.banner" = "Finish configuring your loads"; -"loads.metric.fuse" = "Fuse"; -"loads.metric.cable" = "Cable"; -"loads.metric.length" = "Length"; -"overview.system.header.title" = "System Overview"; -"overview.loads.empty.title" = "No loads configured yet"; -"overview.loads.empty.subtitle" = "Add components to get cable sizing and fuse recommendations tailored to this system."; -"overview.runtime.title" = "Estimated runtime"; -"overview.runtime.subtitle" = "At maximum load draw"; -"overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime."; -"battery.bank.warning.voltage.short" = "Voltage"; -"battery.bank.warning.capacity.short" = "Capacity"; - -"battery.bank.header.title" = "Battery Bank"; -"battery.bank.metric.count" = "Batteries"; -"battery.bank.metric.capacity" = "Capacity"; -"battery.bank.metric.energy" = "Energy"; -"battery.bank.metric.usable_capacity" = "Usable Capacity"; -"battery.bank.metric.usable_energy" = "Usable Energy"; -"battery.overview.empty.create" = "Add Battery"; -"battery.onboarding.title" = "Add your first battery"; -"battery.onboarding.subtitle" = "Track your bank's capacity and chemistry to keep runtime expectations in check."; -"battery.bank.badge.voltage" = "Voltage"; -"overview.chargers.header.title" = "Charger Overview"; -"overview.chargers.empty.title" = "No chargers configured yet"; -"overview.chargers.empty.subtitle" = "Add shore power, DC-DC, or solar chargers to understand your charging capacity."; -"overview.chargers.empty.create" = "Add Charger"; -"battery.bank.badge.capacity" = "Capacity"; -"battery.bank.badge.energy" = "Energy"; -"battery.bank.banner.voltage" = "Voltage mismatch detected"; -"battery.bank.banner.capacity" = "Capacity mismatch detected"; -"battery.bank.empty.title" = "No Batteries Yet"; -"battery.bank.empty.subtitle" = "Tap the plus button to configure a battery for %@."; -"battery.bank.status.dismiss" = "Got it"; -"battery.bank.status.single.battery" = "One battery"; -"battery.bank.status.multiple.batteries" = "%d batteries"; -"battery.bank.status.voltage.title" = "Voltage mismatch"; -"battery.bank.status.voltage.message" = "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters."; -"battery.bank.status.capacity.title" = "Capacity mismatch"; -"battery.bank.status.capacity.message" = "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear."; - -"battery.editor.title" = "Battery Setup"; -"battery.editor.cancel" = "Cancel"; -"battery.editor.save" = "Save"; -"battery.editor.field.name" = "Name"; -"battery.editor.placeholder.name" = "House Bank"; -"battery.editor.field.chemistry" = "Chemistry"; -"battery.editor.section.summary" = "Summary"; -"battery.editor.slider.voltage" = "Nominal Voltage"; -"battery.editor.slider.capacity" = "Capacity"; -"battery.editor.slider.usable_capacity" = "Usable Capacity (%)"; -"battery.editor.section.advanced" = "Advanced"; -"battery.editor.button.reset_default" = "Reset"; -"battery.editor.advanced.usable_capacity.footer_default" = "Defaults to %@ based on chemistry."; -"battery.editor.advanced.usable_capacity.footer_override" = "Override active. Chemistry default remains %@."; -"battery.editor.alert.voltage.title" = "Edit Nominal Voltage"; -"battery.editor.alert.voltage.placeholder" = "Voltage"; -"battery.editor.alert.voltage.message" = "Enter voltage in volts (V)"; -"battery.editor.alert.capacity.title" = "Edit Capacity"; -"battery.editor.alert.capacity.placeholder" = "Capacity"; -"battery.editor.alert.capacity.message" = "Enter capacity in amp-hours (Ah)"; -"battery.editor.alert.usable_capacity.title" = "Edit Usable Capacity"; -"battery.editor.alert.usable_capacity.placeholder" = "Usable Capacity (%)"; -"battery.editor.alert.usable_capacity.message" = "Enter usable capacity percentage (%)"; -"battery.editor.alert.cancel" = "Cancel"; -"battery.editor.alert.save" = "Save"; -"battery.editor.default_name" = "New Battery"; - -"charger.editor.title" = "Charger"; -"charger.editor.field.name" = "Name"; -"charger.editor.placeholder.name" = "Workshop Charger"; -"charger.editor.section.electrical" = "Electrical"; -"charger.editor.section.power" = "Charge Output"; -"charger.editor.appearance.title" = "Charger Appearance"; -"charger.editor.appearance.subtitle" = "Customize how this charger shows up"; -"charger.editor.appearance.accessibility" = "Edit charger appearance"; -"charger.editor.field.input_voltage" = "Input Voltage"; -"charger.editor.field.output_voltage" = "Output Voltage"; -"charger.editor.field.current" = "Charge Current"; -"charger.editor.field.power" = "Charge Power"; -"charger.editor.field.power.footer" = "Leave blank when the rated wattage isn't published. We'll calculate it from voltage and current."; -"charger.editor.default_name" = "New Charger"; -"charger.editor.alert.input_voltage.title" = "Edit Input Voltage"; -"charger.editor.alert.output_voltage.title" = "Edit Output Voltage"; -"charger.editor.alert.current.title" = "Edit Charge Current"; -"charger.editor.alert.voltage.message" = "Enter voltage in volts (V)"; -"charger.editor.alert.power.title" = "Edit Charge Power"; -"charger.editor.alert.power.placeholder" = "Power"; -"charger.editor.alert.power.message" = "Enter power in watts (W)"; -"charger.editor.alert.current.message" = "Enter current in amps (A)"; -"charger.editor.alert.cancel" = "Cancel"; -"charger.editor.alert.save" = "Save"; -"charger.default.new" = "New Charger"; - -"chargers.summary.title" = "Charging Overview"; -"chargers.summary.metric.count" = "Chargers"; -"chargers.summary.metric.output" = "Output Voltage"; -"chargers.summary.metric.current" = "Charge Rate"; -"chargers.summary.metric.power" = "Charge Power"; -"chargers.badge.input" = "Input"; -"chargers.badge.output" = "Output"; -"chargers.badge.current" = "Current"; -"chargers.badge.power" = "Power"; -"chargers.onboarding.title" = "Add your chargers"; -"chargers.onboarding.subtitle" = "Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity."; -"chargers.onboarding.primary" = "Create Charger"; - -"sample.charger.shore.name" = "Shore power charger"; -"sample.charger.dcdc.name" = "DC-DC charger"; -"sample.charger.workbench.name" = "Workbench charger"; - -"chargers.title" = "Chargers for %@"; -"chargers.subtitle" = "Charger components will be available soon."; +"tab.components" = "Components"; +"tab.overview" = "Overview"; +"units.imperial.display" = "Imperial (AWG, ft)"; +"units.metric.display" = "Metric (mm², m)"; +"settings.pro.cta.description" = "Cable PRO keeps advanced calculations and early tools available."; +"settings.pro.cta.button" = "Get Cable PRO"; +"settings.pro.renewal.date" = "Renews on %@."; +"settings.pro.trial.remaining" = "%@ remaining in free trial."; +"settings.pro.trial.today" = "Free trial renews today."; +"settings.pro.instructions" = "Manage or cancel your subscription in the App Store."; +"settings.pro.manage.button" = "Manage Subscription"; +"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions"; +"settings.pro.day.one" = "%@ day"; +"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.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."; +"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"; diff --git a/Cable/Batteries/BatteryConfiguration.swift b/Cable/Batteries/BatteryConfiguration.swift index edaa3b7..a1f86f0 100644 --- a/Cable/Batteries/BatteryConfiguration.swift +++ b/Cable/Batteries/BatteryConfiguration.swift @@ -36,6 +36,10 @@ struct BatteryConfiguration: Identifiable, Hashable { var nominalVoltage: Double var capacityAmpHours: Double var usableCapacityOverrideFraction: Double? + var chargeVoltage: Double + var cutOffVoltage: Double + var minimumTemperatureCelsius: Double + var maximumTemperatureCelsius: Double var chemistry: Chemistry var iconName: String var colorName: String @@ -48,6 +52,10 @@ struct BatteryConfiguration: Identifiable, Hashable { capacityAmpHours: Double = 100, chemistry: Chemistry = .lithiumIronPhosphate, usableCapacityOverrideFraction: Double? = nil, + chargeVoltage: Double = 14.4, + cutOffVoltage: Double = 10.8, + minimumTemperatureCelsius: Double = -20, + maximumTemperatureCelsius: Double = 60, iconName: String = "battery.100", colorName: String = "blue", system: ElectricalSystem @@ -57,6 +65,10 @@ struct BatteryConfiguration: Identifiable, Hashable { self.nominalVoltage = nominalVoltage self.capacityAmpHours = capacityAmpHours self.usableCapacityOverrideFraction = usableCapacityOverrideFraction + self.chargeVoltage = chargeVoltage + self.cutOffVoltage = cutOffVoltage + self.minimumTemperatureCelsius = minimumTemperatureCelsius + self.maximumTemperatureCelsius = maximumTemperatureCelsius self.chemistry = chemistry self.iconName = iconName self.colorName = colorName @@ -69,6 +81,16 @@ struct BatteryConfiguration: Identifiable, Hashable { self.nominalVoltage = savedBattery.nominalVoltage self.capacityAmpHours = savedBattery.capacityAmpHours self.usableCapacityOverrideFraction = savedBattery.usableCapacityOverrideFraction + self.chargeVoltage = savedBattery.chargeVoltage ?? 14.4 + self.cutOffVoltage = savedBattery.cutOffVoltage ?? 10.8 + self.minimumTemperatureCelsius = savedBattery.minimumTemperatureCelsius ?? -20 + self.maximumTemperatureCelsius = savedBattery.maximumTemperatureCelsius ?? 60 + if self.maximumTemperatureCelsius < self.minimumTemperatureCelsius { + let correctedMin = min(self.minimumTemperatureCelsius, self.maximumTemperatureCelsius) + let correctedMax = max(self.minimumTemperatureCelsius, self.maximumTemperatureCelsius) + self.minimumTemperatureCelsius = correctedMin + self.maximumTemperatureCelsius = correctedMax + } self.chemistry = savedBattery.chemistry self.iconName = savedBattery.iconName self.colorName = savedBattery.colorName @@ -107,6 +129,10 @@ struct BatteryConfiguration: Identifiable, Hashable { savedBattery.nominalVoltage = nominalVoltage savedBattery.capacityAmpHours = capacityAmpHours savedBattery.usableCapacityOverrideFraction = usableCapacityOverrideFraction + savedBattery.chargeVoltage = chargeVoltage + savedBattery.cutOffVoltage = cutOffVoltage + savedBattery.minimumTemperatureCelsius = minimumTemperatureCelsius + savedBattery.maximumTemperatureCelsius = maximumTemperatureCelsius savedBattery.chemistry = chemistry savedBattery.iconName = iconName savedBattery.colorName = colorName @@ -122,6 +148,10 @@ extension BatteryConfiguration { lhs.nominalVoltage == rhs.nominalVoltage && lhs.capacityAmpHours == rhs.capacityAmpHours && lhs.usableCapacityOverrideFraction == rhs.usableCapacityOverrideFraction && + lhs.chargeVoltage == rhs.chargeVoltage && + lhs.cutOffVoltage == rhs.cutOffVoltage && + lhs.minimumTemperatureCelsius == rhs.minimumTemperatureCelsius && + lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius && lhs.chemistry == rhs.chemistry && lhs.iconName == rhs.iconName && lhs.colorName == rhs.colorName @@ -133,6 +163,10 @@ extension BatteryConfiguration { hasher.combine(nominalVoltage) hasher.combine(capacityAmpHours) hasher.combine(usableCapacityOverrideFraction) + hasher.combine(chargeVoltage) + hasher.combine(cutOffVoltage) + hasher.combine(minimumTemperatureCelsius) + hasher.combine(maximumTemperatureCelsius) hasher.combine(chemistry) hasher.combine(iconName) hasher.combine(colorName) diff --git a/Cable/Batteries/BatteryEditorView.swift b/Cable/Batteries/BatteryEditorView.swift index dad035f..3ccb8e4 100644 --- a/Cable/Batteries/BatteryEditorView.swift +++ b/Cable/Batteries/BatteryEditorView.swift @@ -1,16 +1,20 @@ import SwiftUI struct BatteryEditorView: View { + @EnvironmentObject private var unitSettings: UnitSystemSettings @State private var configuration: BatteryConfiguration - @State private var editingField: EditingField? - @State private var voltageInput: String = "" - @State private var capacityInput: String = "" + @State private var temperatureEditingField: TemperatureEditingField? + @State private var isAdvancedExpanded = false + @State private var showingProUpsell = false + @State private var minimumTemperatureInput: String = "" + @State private var maximumTemperatureInput: String = "" @State private var showingAppearanceEditor = false + @State private var hasActiveProSubscription = false let onSave: (BatteryConfiguration) -> Void - private enum EditingField { - case voltage - case capacity + private enum TemperatureEditingField { + case minimumTemperature + case maximumTemperature } private let voltageSnapValues: [Double] = [6, 12, 12.8, 24, 25.6, 36, 48, 51.2] @@ -81,6 +85,286 @@ struct BatteryEditorView: View { ) } + private var advancedSectionTitle: String { + String( + localized: "battery.editor.section.advanced", + bundle: .main, + comment: "Title for the advanced settings section in the battery editor" + ) + } + + private var usableCapacitySliderTitle: String { + String( + localized: "battery.editor.slider.usable_capacity", + bundle: .main, + comment: "Title for the usable capacity slider" + ) + } + + private var resetButtonTitle: String { + String( + localized: "battery.editor.button.reset_default", + bundle: .main, + comment: "Title for the reset button in the advanced section" + ) + } + + private var alertCancelTitle: String { + NSLocalizedString( + "battery.editor.alert.cancel", + bundle: .main, + value: "Cancel", + comment: "Cancel button title for edit alerts" + ) + } + + private var alertSaveTitle: String { + NSLocalizedString( + "battery.editor.alert.save", + bundle: .main, + value: "Save", + comment: "Save button title for edit alerts" + ) + } + + private var voltageAlertTitle: String { + NSLocalizedString( + "battery.editor.alert.voltage.title", + bundle: .main, + value: "Edit Nominal Voltage", + comment: "Title for the voltage edit alert" + ) + } + + private var voltageAlertPlaceholder: String { + NSLocalizedString( + "battery.editor.alert.voltage.placeholder", + bundle: .main, + value: "Voltage", + comment: "Placeholder for voltage text field" + ) + } + + private var voltageAlertMessage: String { + NSLocalizedString( + "battery.editor.alert.voltage.message", + bundle: .main, + value: "Enter voltage in volts (V)", + comment: "Message for the voltage edit alert" + ) + } + + private var capacityAlertTitle: String { + NSLocalizedString( + "battery.editor.alert.capacity.title", + bundle: .main, + value: "Edit Capacity", + comment: "Title for the capacity edit alert" + ) + } + + private var capacityAlertPlaceholder: String { + NSLocalizedString( + "battery.editor.alert.capacity.placeholder", + bundle: .main, + value: "Capacity", + comment: "Placeholder for capacity text field" + ) + } + + private var capacityAlertMessage: String { + NSLocalizedString( + "battery.editor.alert.capacity.message", + bundle: .main, + value: "Enter capacity in amp-hours (Ah)", + comment: "Message for the capacity edit alert" + ) + } + + private var usableCapacityAlertTitle: String { + NSLocalizedString( + "battery.editor.alert.usable_capacity.title", + bundle: .main, + value: "Edit Usable Capacity", + comment: "Title for the usable capacity edit alert" + ) + } + + private var usableCapacityAlertPlaceholder: String { + NSLocalizedString( + "battery.editor.alert.usable_capacity.placeholder", + bundle: .main, + value: "Usable Capacity (%)", + comment: "Placeholder for the usable capacity text field" + ) + } + + private var usableCapacityAlertMessage: String { + NSLocalizedString( + "battery.editor.alert.usable_capacity.message", + bundle: .main, + value: "Enter usable capacity as a percentage (%)", + comment: "Message for the usable capacity edit alert" + ) + } + + private var chargeVoltageAlertTitle: String { + NSLocalizedString( + "battery.editor.alert.charge_voltage.title", + bundle: .main, + value: "Edit Charge Voltage", + comment: "Title for the charge voltage edit alert" + ) + } + + private var chargeVoltageAlertPlaceholder: String { + NSLocalizedString( + "battery.editor.alert.charge_voltage.placeholder", + bundle: .main, + value: "Charge Voltage", + comment: "Placeholder for the charge voltage text field" + ) + } + + private var chargeVoltageAlertMessage: String { + NSLocalizedString( + "battery.editor.alert.charge_voltage.message", + bundle: .main, + value: "Enter charge voltage in volts (V)", + comment: "Message for the charge voltage edit alert" + ) + } + + private var cutOffVoltageAlertTitle: String { + NSLocalizedString( + "battery.editor.alert.cutoff_voltage.title", + bundle: .main, + value: "Edit Cut-off Voltage", + comment: "Title for the cut-off voltage edit alert" + ) + } + + private var cutOffVoltageAlertPlaceholder: String { + NSLocalizedString( + "battery.editor.alert.cutoff_voltage.placeholder", + bundle: .main, + value: "Cut-off Voltage", + comment: "Placeholder for the cut-off voltage text field" + ) + } + + private var cutOffVoltageAlertMessage: String { + NSLocalizedString( + "battery.editor.alert.cutoff_voltage.message", + bundle: .main, + value: "Enter cut-off voltage in volts (V)", + comment: "Message for the cut-off voltage edit alert" + ) + } + + private var chargeVoltageTitle: String { + NSLocalizedString( + "battery.editor.slider.charge_voltage", + bundle: .main, + value: "Charge Voltage", + comment: "Title for the charge voltage slider" + ) + } + + private var cutOffVoltageTitle: String { + NSLocalizedString( + "battery.editor.slider.cutoff_voltage", + bundle: .main, + value: "Cut-off Voltage", + comment: "Title for the cut-off voltage slider" + ) + } + + private var temperatureRangeTitle: String { + NSLocalizedString( + "battery.editor.slider.temperature_range", + bundle: .main, + value: "Temperature Range", + comment: "Title for the temperature range editor" + ) + } + + private var chargeVoltageHelperText: String { + NSLocalizedString( + "battery.editor.advanced.charge_voltage.helper", + bundle: .main, + value: "Set the maximum recommended charging voltage.", + comment: "Helper text explaining charge voltage" + ) + } + + private var cutOffVoltageHelperText: String { + NSLocalizedString( + "battery.editor.advanced.cutoff_voltage.helper", + bundle: .main, + value: "Set the minimum safe discharge voltage.", + comment: "Helper text explaining cut-off voltage" + ) + } + + private var temperatureRangeHelperText: String { + NSLocalizedString( + "battery.editor.advanced.temperature_range.helper", + bundle: .main, + value: "Define the recommended operating temperature range.", + comment: "Helper text explaining temperature range" + ) + } + + private var minimumTemperatureLabel: String { + NSLocalizedString( + "battery.editor.slider.temperature_range.min", + bundle: .main, + value: "Minimum", + comment: "Label for minimum temperature control" + ) + } + + private var maximumTemperatureLabel: String { + NSLocalizedString( + "battery.editor.slider.temperature_range.max", + bundle: .main, + value: "Maximum", + comment: "Label for maximum temperature control" + ) + } + + private var usableCapacityDefaultFooterFormat: String { + NSLocalizedString( + "battery.editor.advanced.usable_capacity.footer_default", + bundle: .main, + value: "Default value %@ based on chemistry.", + comment: "Footer text explaining the default usable capacity value" + ) + } + + private var usableCapacityOverrideFooterFormat: String { + NSLocalizedString( + "battery.editor.advanced.usable_capacity.footer_override", + bundle: .main, + value: "Manual override active. Chemistry default remains %@.", + comment: "Footer text explaining the usable capacity override" + ) + } + + private var usableCapacityFooterText: String { + let defaultPercentage = formattedPercentage(configuration.defaultUsableCapacityFraction) + if configuration.usableCapacityOverrideFraction != nil { + return String.localizedStringWithFormat(usableCapacityOverrideFooterFormat, defaultPercentage) + } else { + return String.localizedStringWithFormat(usableCapacityDefaultFooterFormat, defaultPercentage) + } + } + + private var hasUsableCapacityOverride: Bool { + configuration.usableCapacityOverrideFraction != nil + } + private var summaryVoltageLabel: String { String( localized: "battery.bank.badge.voltage", @@ -152,6 +436,49 @@ struct BatteryEditorView: View { return lowerBound...upperBound } + private var usableCapacitySliderRange: ClosedRange { + 0...100 + } + + private var usableCapacitySnapValues: [Double] { + [configuration.defaultUsableCapacityFraction * 100] + } + + private var chargeVoltageSliderRange: ClosedRange { + let lowerBound = max(0, min(10, configuration.chargeVoltage)) + let upperBound = max(60, configuration.chargeVoltage) + return lowerBound...upperBound + } + + private var cutOffVoltageSliderRange: ClosedRange { + let upperReference = max(configuration.nominalVoltage, configuration.chargeVoltage, 60) + let lowerBound = max(0, min(5, configuration.cutOffVoltage)) + let upperBound = max(lowerBound + 1, upperReference) + return lowerBound...upperBound + } + + private var minimumTemperatureSliderRange: ClosedRange { + let upperBound = max(configuration.maximumTemperatureCelsius, -60) + return -60...min(upperBound, 80) + } + + private var maximumTemperatureSliderRange: ClosedRange { + let lowerBound = min(configuration.minimumTemperatureCelsius, 80) + return max(lowerBound, -60)...80 + } + + private var chargeVoltageSnapValues: [Double] { + [13.8, 14.0, 14.2, 14.4, 14.6, 14.8, 15.0] + } + + private var cutOffVoltageSnapValues: [Double] { + [10.0, 10.5, 11.0, 11.5, 12.0] + } + + private var temperatureSnapValues: [Double] { + [-40, -20, -10, 0, 25, 40, 50, 60] + } + init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void) { _configuration = State(initialValue: configuration) self.onSave = onSave @@ -163,6 +490,7 @@ struct BatteryEditorView: View { List { configurationSection sliderSection + advancedSection } .listStyle(.plain) .scrollIndicators(.hidden) @@ -200,41 +528,47 @@ struct BatteryEditorView: View { ) ) } + .sheet(isPresented: $showingProUpsell) { + CableProPaywallView(isPresented: $showingProUpsell) + } + .task { + hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil + } .alert( NSLocalizedString( - "battery.editor.alert.voltage.title", + "battery.editor.alert.minimum_temperature.title", bundle: .main, - value: "Edit Nominal Voltage", - comment: "Title for the voltage edit alert" + value: "Edit Minimum Temperature", + comment: "Title for the minimum temperature edit alert" ), isPresented: Binding( - get: { editingField == .voltage }, + get: { temperatureEditingField == .minimumTemperature }, set: { isPresented in if !isPresented { - editingField = nil - voltageInput = "" + temperatureEditingField = nil + minimumTemperatureInput = "" } } ) ) { TextField( NSLocalizedString( - "battery.editor.alert.voltage.placeholder", + "battery.editor.alert.minimum_temperature.placeholder", bundle: .main, - value: "Voltage", - comment: "Placeholder for voltage text field" + value: "Minimum Temperature (°C)", + comment: "Placeholder for the minimum temperature text field" ), - text: $voltageInput + text: $minimumTemperatureInput ) .keyboardType(.decimalPad) .onAppear { - if voltageInput.isEmpty { - voltageInput = formattedEditValue(configuration.nominalVoltage) + if minimumTemperatureInput.isEmpty { + minimumTemperatureInput = formattedEditValue(configuration.minimumTemperatureCelsius) } } - .onChange(of: voltageInput) { _, newValue in - guard editingField == .voltage, let parsed = parseInput(newValue) else { return } - configuration.nominalVoltage = roundToTenth(parsed) + .onChange(of: minimumTemperatureInput) { _, newValue in + guard temperatureEditingField == .minimumTemperature, let parsed = parseInput(newValue) else { return } + applyMinimumTemperatureInput(parsed) } Button( @@ -246,8 +580,8 @@ struct BatteryEditorView: View { ), role: .cancel ) { - editingField = nil - voltageInput = "" + temperatureEditingField = nil + minimumTemperatureInput = "" } Button( @@ -258,57 +592,57 @@ struct BatteryEditorView: View { comment: "Save button title for edit alerts" ) ) { - if let parsed = parseInput(voltageInput) { - configuration.nominalVoltage = roundToTenth(parsed) + if let parsed = parseInput(minimumTemperatureInput) { + applyMinimumTemperatureInput(parsed) } - editingField = nil - voltageInput = "" + temperatureEditingField = nil + minimumTemperatureInput = "" } } message: { Text( NSLocalizedString( - "battery.editor.alert.voltage.message", + "battery.editor.alert.minimum_temperature.message", bundle: .main, - value: "Enter voltage in volts (V)", - comment: "Message for the voltage edit alert" + value: "Enter minimum temperature in degrees Celsius (°C)", + comment: "Message for the minimum temperature edit alert" ) ) } .alert( NSLocalizedString( - "battery.editor.alert.capacity.title", + "battery.editor.alert.maximum_temperature.title", bundle: .main, - value: "Edit Capacity", - comment: "Title for the capacity edit alert" + value: "Edit Maximum Temperature", + comment: "Title for the maximum temperature edit alert" ), isPresented: Binding( - get: { editingField == .capacity }, + get: { temperatureEditingField == .maximumTemperature }, set: { isPresented in if !isPresented { - editingField = nil - capacityInput = "" + temperatureEditingField = nil + maximumTemperatureInput = "" } } ) ) { TextField( NSLocalizedString( - "battery.editor.alert.capacity.placeholder", + "battery.editor.alert.maximum_temperature.placeholder", bundle: .main, - value: "Capacity", - comment: "Placeholder for capacity text field" + value: "Maximum Temperature (°C)", + comment: "Placeholder for the maximum temperature text field" ), - text: $capacityInput + text: $maximumTemperatureInput ) .keyboardType(.decimalPad) .onAppear { - if capacityInput.isEmpty { - capacityInput = formattedEditValue(configuration.capacityAmpHours) + if maximumTemperatureInput.isEmpty { + maximumTemperatureInput = formattedEditValue(configuration.maximumTemperatureCelsius) } } - .onChange(of: capacityInput) { _, newValue in - guard editingField == .capacity, let parsed = parseInput(newValue) else { return } - configuration.capacityAmpHours = roundToTenth(parsed) + .onChange(of: maximumTemperatureInput) { _, newValue in + guard temperatureEditingField == .maximumTemperature, let parsed = parseInput(newValue) else { return } + applyMaximumTemperatureInput(parsed) } Button( @@ -320,8 +654,8 @@ struct BatteryEditorView: View { ), role: .cancel ) { - editingField = nil - capacityInput = "" + temperatureEditingField = nil + maximumTemperatureInput = "" } Button( @@ -332,19 +666,19 @@ struct BatteryEditorView: View { comment: "Save button title for edit alerts" ) ) { - if let parsed = parseInput(capacityInput) { - configuration.capacityAmpHours = roundToTenth(parsed) + if let parsed = parseInput(maximumTemperatureInput) { + applyMaximumTemperatureInput(parsed) } - editingField = nil - capacityInput = "" + temperatureEditingField = nil + maximumTemperatureInput = "" } } message: { Text( NSLocalizedString( - "battery.editor.alert.capacity.message", + "battery.editor.alert.maximum_temperature.message", bundle: .main, - value: "Enter capacity in amp-hours (Ah)", - comment: "Message for the capacity edit alert" + value: "Enter maximum temperature in degrees Celsius (°C)", + comment: "Message for the maximum temperature edit alert" ) ) } @@ -462,41 +796,49 @@ struct BatteryEditorView: View { private var sliderSection: some View { Section { - SliderSection( + EditableSliderRow( title: sliderVoltageTitle, + unit: "V", + range: voltageSliderRange, value: Binding( get: { configuration.nominalVoltage }, - set: { newValue in - if editingField == .voltage { - configuration.nominalVoltage = roundToTenth(newValue) - } else { - configuration.nominalVoltage = normalizedVoltage(for: newValue) - } - } + set: { configuration.nominalVoltage = $0 } ), - range: voltageSliderRange, - unit: "V", - tapAction: beginVoltageEditing, - snapValues: editingField == .voltage ? nil : voltageSnapValues + snapValues: voltageSnapValues, + sliderTransform: normalizedVoltage, + alertTransform: roundToTenth, + formatValue: formattedEditValue, + parseInput: parseInput, + alertCopy: EditableSliderRow.AlertCopy( + title: voltageAlertTitle, + placeholder: voltageAlertPlaceholder, + message: voltageAlertMessage, + cancelTitle: alertCancelTitle, + saveTitle: alertSaveTitle + ) ) .listRowSeparator(.hidden) - SliderSection( + EditableSliderRow( title: sliderCapacityTitle, + unit: "Ah", + range: capacitySliderRange, value: Binding( get: { configuration.capacityAmpHours }, - set: { newValue in - if editingField == .capacity { - configuration.capacityAmpHours = roundToTenth(newValue) - } else { - configuration.capacityAmpHours = normalizedCapacity(for: newValue) - } - } + set: { configuration.capacityAmpHours = $0 } ), - range: capacitySliderRange, - unit: "Ah", - tapAction: beginCapacityEditing, - snapValues: editingField == .capacity ? nil : capacitySnapValues + snapValues: capacitySnapValues, + sliderTransform: normalizedCapacity, + alertTransform: roundToTenth, + formatValue: formattedEditValue, + parseInput: parseInput, + alertCopy: EditableSliderRow.AlertCopy( + title: capacityAlertTitle, + placeholder: capacityAlertPlaceholder, + message: capacityAlertMessage, + cancelTitle: alertCancelTitle, + saveTitle: alertSaveTitle + ) ) .listRowSeparator(.hidden) } @@ -504,6 +846,239 @@ struct BatteryEditorView: View { .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18)) } + private var advancedSection: some View { + let advancedEnabled = unitSettings.isProUnlocked || hasActiveProSubscription + + return Section { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isAdvancedExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Text(advancedSectionTitle.uppercased()) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: isAdvancedExpanded ? "chevron.up" : "chevron.down") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 10) + .opacity(advancedEnabled ? 1 : 0.5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(Color(.secondarySystemBackground)) + .listRowSeparator(.hidden) + + if isAdvancedExpanded { + if !advancedEnabled { + upgradeToProCTA + .listRowSeparator(.hidden) + .listRowBackground(Color(.systemBackground)) + } + advancedControls + .opacity(advancedEnabled ? 1 : 0.35) + .allowsHitTesting(advancedEnabled) + .transition(.opacity.combined(with: .move(edge: .top))) + .listRowSeparator(.hidden) + .listRowBackground(Color(.systemBackground)) + } + } footer: { + if isAdvancedExpanded { + VStack(alignment: .leading, spacing: 8) { + Text(usableCapacityFooterText) + Text(chargeVoltageHelperText) + Text(cutOffVoltageHelperText) + Text(temperatureRangeHelperText) + } + .font(.caption) + .foregroundStyle(.secondary) + .opacity(advancedEnabled ? 1 : 0.35) + } + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18)) + .animation(.easeInOut(duration: 0.2), value: isAdvancedExpanded) + } + + private var advancedControls: some View { + VStack(spacing: 16) { + EditableSliderRow( + title: usableCapacitySliderTitle, + unit: "%", + range: usableCapacitySliderRange, + value: Binding( + get: { configuration.usableCapacityFraction * 100 }, + set: { newValue in + updateUsableCapacityPercent(newValue) + } + ), + buttonText: resetButtonTitle, + buttonAction: resetUsableCapacityToDefault, + isButtonVisible: hasUsableCapacityOverride, + snapValues: usableCapacitySnapValues, + sliderTransform: roundToTenth, + alertTransform: roundToTenth, + formatValue: formattedEditValue, + parseInput: parseInput, + alertCopy: EditableSliderRow.AlertCopy( + title: usableCapacityAlertTitle, + placeholder: usableCapacityAlertPlaceholder, + message: usableCapacityAlertMessage, + cancelTitle: alertCancelTitle, + saveTitle: alertSaveTitle + ) + ) + + EditableSliderRow( + title: chargeVoltageTitle, + unit: "V", + range: chargeVoltageSliderRange, + value: Binding( + get: { configuration.chargeVoltage }, + set: { newValue in + let clamped = max(newValue, configuration.cutOffVoltage) + configuration.chargeVoltage = clamped + } + ), + snapValues: chargeVoltageSnapValues, + sliderTransform: normalizedChargeVoltage, + alertTransform: roundToTenth, + formatValue: formattedEditValue, + parseInput: parseInput, + alertCopy: EditableSliderRow.AlertCopy( + title: chargeVoltageAlertTitle, + placeholder: chargeVoltageAlertPlaceholder, + message: chargeVoltageAlertMessage, + cancelTitle: alertCancelTitle, + saveTitle: alertSaveTitle + ) + ) + + EditableSliderRow( + title: cutOffVoltageTitle, + unit: "V", + range: cutOffVoltageSliderRange, + value: Binding( + get: { configuration.cutOffVoltage }, + set: { newValue in + let clamped = min(newValue, configuration.chargeVoltage) + configuration.cutOffVoltage = clamped + } + ), + snapValues: cutOffVoltageSnapValues, + sliderTransform: normalizedCutOffVoltage, + alertTransform: roundToTenth, + formatValue: formattedEditValue, + parseInput: parseInput, + alertCopy: EditableSliderRow.AlertCopy( + title: cutOffVoltageAlertTitle, + placeholder: cutOffVoltageAlertPlaceholder, + message: cutOffVoltageAlertMessage, + cancelTitle: alertCancelTitle, + saveTitle: alertSaveTitle + ) + ) + + temperatureRangeRow + } + .padding(.top, 6) + } + + private var upgradeToProCTA: some View { + Button { + showingProUpsell = true + } label: { + Text("Get Cable Pro") + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + } + + private var temperatureRangeRow: some View { + VStack(alignment: .leading, spacing: 10) { + Text(temperatureRangeTitle) + .font(.headline) + + HStack(spacing: 8) { + Button(action: beginMinimumTemperatureEditing) { + Text(formattedTemperature(configuration.minimumTemperatureCelsius)) + .font(.title) + .fontWeight(.bold) + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + + Text("–") + .font(.title2.weight(.semibold)) + .foregroundStyle(.secondary) + + Button(action: beginMaximumTemperatureEditing) { + Text(formattedTemperature(configuration.maximumTemperatureCelsius)) + .font(.title) + .fontWeight(.bold) + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + } + + VStack(alignment: .leading, spacing: 12) { + Text(minimumTemperatureLabel.uppercased()) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + + HStack { + Text(formattedTemperature(minimumTemperatureSliderRange.lowerBound)) + .font(.caption) + .foregroundStyle(.secondary) + Slider( + value: Binding( + get: { configuration.minimumTemperatureCelsius }, + set: { newValue in + let adjusted = normalizedTemperature(for: newValue) + configuration.minimumTemperatureCelsius = min(adjusted, configuration.maximumTemperatureCelsius) + } + ), + in: minimumTemperatureSliderRange + ) + Text(formattedTemperature(minimumTemperatureSliderRange.upperBound)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(maximumTemperatureLabel.uppercased()) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + + HStack { + Text(formattedTemperature(maximumTemperatureSliderRange.lowerBound)) + .font(.caption) + .foregroundStyle(.secondary) + Slider( + value: Binding( + get: { configuration.maximumTemperatureCelsius }, + set: { newValue in + let adjusted = normalizedTemperature(for: newValue) + configuration.maximumTemperatureCelsius = max(adjusted, configuration.minimumTemperatureCelsius) + } + ), + in: maximumTemperatureSliderRange + ) + Text(formattedTemperature(maximumTemperatureSliderRange.upperBound)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + private var headerInfoBar: some View { HStack(spacing: 12) { overviewChip( @@ -557,22 +1132,6 @@ struct BatteryEditorView: View { ) } - private func normalizedVoltage(for value: Double) -> Double { - let rounded = (value * 10).rounded() / 10 - if let snapped = nearestValue(to: rounded, in: voltageSnapValues, tolerance: voltageSnapTolerance) { - return snapped - } - return rounded - } - - private func normalizedCapacity(for value: Double) -> Double { - let rounded = (value * 10).rounded() / 10 - if let snapped = nearestValue(to: rounded, in: capacitySnapValues, tolerance: capacitySnapTolerance) { - return snapped - } - return rounded - } - private func roundToTenth(_ value: Double) -> Double { max(0, (value * 10).rounded() / 10) } @@ -593,14 +1152,14 @@ struct BatteryEditorView: View { return Self.numberFormatter.number(from: normalized)?.doubleValue } - private func beginVoltageEditing() { - voltageInput = formattedEditValue(configuration.nominalVoltage) - editingField = .voltage + private func beginMinimumTemperatureEditing() { + minimumTemperatureInput = formattedEditValue(configuration.minimumTemperatureCelsius) + temperatureEditingField = .minimumTemperature } - private func beginCapacityEditing() { - capacityInput = formattedEditValue(configuration.capacityAmpHours) - editingField = .capacity + private func beginMaximumTemperatureEditing() { + maximumTemperatureInput = formattedEditValue(configuration.maximumTemperatureCelsius) + temperatureEditingField = .maximumTemperature } private func nearestValue(to value: Double, in options: [Double], tolerance: Double) -> Double? { @@ -608,6 +1167,72 @@ struct BatteryEditorView: View { return abs(closest - value) <= tolerance ? closest : nil } + private func normalizedVoltage(for value: Double) -> Double { + let rounded = (value * 10).rounded() / 10 + if let snapped = nearestValue(to: rounded, in: voltageSnapValues, tolerance: voltageSnapTolerance) { + return snapped + } + return rounded + } + + private func normalizedCapacity(for value: Double) -> Double { + let rounded = (value * 10).rounded() / 10 + if let snapped = nearestValue(to: rounded, in: capacitySnapValues, tolerance: capacitySnapTolerance) { + return snapped + } + return rounded + } + + private func normalizedChargeVoltage(for value: Double) -> Double { + let rounded = (value * 10).rounded() / 10 + if let snapped = nearestValue(to: rounded, in: chargeVoltageSnapValues, tolerance: 0.2) { + return snapped + } + return rounded + } + + private func normalizedCutOffVoltage(for value: Double) -> Double { + let rounded = (value * 10).rounded() / 10 + if let snapped = nearestValue(to: rounded, in: cutOffVoltageSnapValues, tolerance: 0.2) { + return snapped + } + return rounded + } + + private func normalizedTemperature(for value: Double) -> Double { + let rounded = (value * 10).rounded() / 10 + if let snapped = nearestValue(to: rounded, in: temperatureSnapValues, tolerance: 1.5) { + return snapped + } + return rounded + } + + private func updateUsableCapacityPercent(_ percent: Double) { + let clamped = max(0, min(100, percent)) + let rounded = (clamped * 10).rounded() / 10 + let fraction = max(0, min(1, rounded / 100)) + let defaultFraction = configuration.defaultUsableCapacityFraction + if abs(fraction - defaultFraction) < 0.001 { + configuration.usableCapacityOverrideFraction = nil + } else { + configuration.usableCapacityOverrideFraction = fraction + } + } + + private func resetUsableCapacityToDefault() { + configuration.usableCapacityOverrideFraction = nil + } + + private func applyMinimumTemperatureInput(_ value: Double) { + let normalized = normalizedTemperature(for: value) + configuration.minimumTemperatureCelsius = min(normalized, configuration.maximumTemperatureCelsius) + } + + private func applyMaximumTemperatureInput(_ value: Double) { + let normalized = normalizedTemperature(for: value) + configuration.maximumTemperatureCelsius = max(normalized, configuration.minimumTemperatureCelsius) + } + private func summaryBadge(title: String, value: String, symbol: String) -> some View { VStack(spacing: 4) { Image(systemName: symbol) @@ -643,6 +1268,25 @@ struct BatteryEditorView: View { return "\(numberString) \(unit)" } + private func formattedTemperature(_ value: Double) -> String { + let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) + return "\(numberString)°C" + } + + private func formattedPercentage(_ fraction: Double) -> String { + let clamped = max(0, min(1, fraction)) + return Self.percentFormatter.string(from: NSNumber(value: clamped)) ?? String(format: "%.0f%%", clamped * 100) + } + + private static let percentFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.locale = .current + formatter.numberStyle = .percent + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 0 + return formatter + }() + } #Preview { @@ -658,4 +1302,5 @@ struct BatteryEditorView: View { onSave: { _ in } ) } + .environmentObject(UnitSystemSettings()) } diff --git a/Cable/Loads/CalculatorView.swift b/Cable/Loads/CalculatorView.swift index c35b6e1..26880c5 100644 --- a/Cable/Loads/CalculatorView.swift +++ b/Cable/Loads/CalculatorView.swift @@ -25,8 +25,11 @@ struct CalculatorView: View { @State private var dutyCycleInput: String = "" @State private var usageHoursInput: String = "" @State private var showingLoadEditor = false + @State private var showingProUpsell = false @State private var presentedAffiliateLink: AffiliateLinkInfo? @State private var completedItemIDs: Set + @State private var isAdvancedExpanded = false + @State private var hasActiveProSubscription = false let savedLoad: SavedLoad? @@ -76,6 +79,9 @@ struct CalculatorView: View { navigationWrapped(mainLayout) ) ) + .task { + hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil + } } private func attachAlerts(_ view: V) -> some View { @@ -345,6 +351,9 @@ struct CalculatorView: View { .sheet(isPresented: $showingLibrary, content: librarySheet) .sheet(isPresented: $showingLoadEditor, content: loadEditorSheet) .sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:)) + .sheet(isPresented: $showingProUpsell) { + CableProPaywallView(isPresented: $showingProUpsell) + } .onAppear { if let savedLoad = savedLoad { loadConfiguration(from: savedLoad) @@ -980,6 +989,10 @@ struct CalculatorView: View { ) } + private var advancedFeaturesEnabled: Bool { + unitSettings.isProUnlocked || hasActiveProSubscription + } + private var slidersSection: some View { Section { voltageSlider @@ -995,26 +1008,75 @@ struct CalculatorView: View { } private var advancedSettingsSection: some View { - Section( - header: Text(advancedSettingsTitle.uppercased()) - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.secondary), - footer: VStack(alignment: .leading, spacing: 8) { - Text(dutyCycleHelperText) - Text(usageHoursHelperText) + let advancedEnabled = advancedFeaturesEnabled + + return Section { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isAdvancedExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Text(advancedSettingsTitle.uppercased()) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + Spacer() + Image(systemName: isAdvancedExpanded ? "chevron.up" : "chevron.down") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 10) + .opacity(advancedEnabled ? 1 : 0.5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(Color(.secondarySystemBackground)) + .listRowSeparator(.hidden) + + if isAdvancedExpanded { + if !advancedEnabled { + upgradeToProCTA + .listRowSeparator(.hidden) + .listRowBackground(Color(.systemBackground)) + } + dutyCycleSlider + .listRowSeparator(.hidden) + .listRowBackground(Color(.systemBackground)) + .opacity(advancedEnabled ? 1 : 0.35) + .allowsHitTesting(advancedEnabled) + usageHoursSlider + .listRowSeparator(.hidden) + .listRowBackground(Color(.systemBackground)) + .opacity(advancedEnabled ? 1 : 0.35) + .allowsHitTesting(advancedEnabled) + } + } footer: { + if isAdvancedExpanded { + VStack(alignment: .leading, spacing: 8) { + Text(dutyCycleHelperText) + Text(usageHoursHelperText) + } + .font(.caption) + .foregroundStyle(.secondary) + .opacity(advancedEnabled ? 1 : 0.35) } - .font(.caption) - .foregroundStyle(.secondary) - ) { - dutyCycleSlider - .listRowSeparator(.hidden) - usageHoursSlider - .listRowSeparator(.hidden) } - .listRowBackground(Color(.systemBackground)) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18)) + .animation(.easeInOut(duration: 0.2), value: isAdvancedExpanded) + } + + private var upgradeToProCTA: some View { + Button { + showingProUpsell = true + } label: { + Text("Get Cable Pro") + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) } private var voltageSliderRange: ClosedRange { @@ -1524,8 +1586,19 @@ struct SliderSection: View { var buttonAction: (() -> Void)? var tapAction: (() -> Void)? var snapValues: [Double]? + var isButtonVisible: Bool - init(title: String, value: Binding, range: ClosedRange, unit: String, buttonText: String? = nil, buttonAction: (() -> Void)? = nil, tapAction: (() -> Void)? = nil, snapValues: [Double]? = nil) { + init( + title: String, + value: Binding, + range: ClosedRange, + unit: String, + buttonText: String? = nil, + buttonAction: (() -> Void)? = nil, + tapAction: (() -> Void)? = nil, + snapValues: [Double]? = nil, + isButtonVisible: Bool = true + ) { self.title = title self._value = value self.range = range @@ -1534,6 +1607,7 @@ struct SliderSection: View { self.buttonAction = buttonAction self.tapAction = tapAction self.snapValues = snapValues + self.isButtonVisible = isButtonVisible } var body: some View { @@ -1547,6 +1621,10 @@ struct SliderSection: View { buttonAction?() } .buttonStyle(.borderedProminent) + .opacity(isButtonVisible ? 1 : 0) + .allowsHitTesting(isButtonVisible) + .accessibilityHidden(!isButtonVisible) + .disabled(!isButtonVisible || buttonAction == nil) } } @@ -1592,6 +1670,97 @@ struct SliderSection: View { } } +struct EditableSliderRow: View { + struct AlertCopy { + let title: String + let placeholder: String + let message: String + let cancelTitle: String + let saveTitle: String + } + + let title: String + let unit: String + let range: ClosedRange + @Binding var value: Double + var buttonText: String? = nil + var buttonAction: (() -> Void)? = nil + var isButtonVisible: Bool = true + var snapValues: [Double]? = nil + var sliderTransform: (Double) -> Double = { $0 } + var alertTransform: (Double) -> Double = { $0 } + var formatValue: (Double) -> String + var parseInput: (String) -> Double? + var alertCopy: AlertCopy + + @State private var isPresentingAlert = false + @State private var inputText: String = "" + + var body: some View { + SliderSection( + title: title, + value: Binding( + get: { value }, + set: { newValue in + let transformed = sliderTransform(newValue) + let clamped = clampedValue(transformed) + value = clamped + } + ), + range: range, + unit: unit, + buttonText: buttonText, + buttonAction: buttonAction, + tapAction: beginEditing, + snapValues: isPresentingAlert ? nil : snapValues, + isButtonVisible: isButtonVisible + ) + .alert( + alertCopy.title, + isPresented: $isPresentingAlert + ) { + TextField(alertCopy.placeholder, text: $inputText) + .keyboardType(.decimalPad) + .onAppear { + if inputText.isEmpty { + inputText = formatValue(value) + } + } + .onChange(of: inputText) { _, newValue in + guard let parsed = parseInput(newValue) else { return } + value = clampedAlertValue(parsed) + } + + Button(alertCopy.cancelTitle, role: .cancel) { + inputText = "" + } + + Button(alertCopy.saveTitle) { + if let parsed = parseInput(inputText) { + value = clampedAlertValue(parsed) + } + inputText = "" + } + } message: { + Text(alertCopy.message) + } + } + + private func beginEditing() { + inputText = formatValue(value) + isPresentingAlert = true + } + + private func clampedValue(_ newValue: Double) -> Double { + min(max(range.lowerBound, newValue), range.upperBound) + } + + private func clampedAlertValue(_ rawValue: Double) -> Double { + let transformed = alertTransform(rawValue) + return clampedValue(transformed) + } +} + struct LoadLibraryView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext diff --git a/Cable/Paywall/CableProPaywallView.swift b/Cable/Paywall/CableProPaywallView.swift new file mode 100644 index 0000000..e435955 --- /dev/null +++ b/Cable/Paywall/CableProPaywallView.swift @@ -0,0 +1,539 @@ +import SwiftUI +import StoreKit + +@MainActor +final class CableProPaywallViewModel: ObservableObject { + enum LoadingState: Equatable { + case idle + case loading + case loaded + case failed(String) + } + + @Published private(set) var products: [Product] = [] + @Published private(set) var state: LoadingState = .idle + @Published private(set) var purchasingProductID: String? + @Published private(set) var isRestoring = false + @Published private(set) var purchasedProductIDs: Set = [] + @Published var alert: PaywallAlert? + + private let productIdentifiers: [String] + + init(productIdentifiers: [String]) { + self.productIdentifiers = productIdentifiers + Task { + await updateCurrentEntitlements() + } + } + + func loadProducts(force: Bool = false) async { + if state == .loading { return } + if !force, case .loaded = state { return } + + guard !productIdentifiers.isEmpty else { + products = [] + state = .loaded + return + } + + state = .loading + do { + let fetched = try await Product.products(for: productIdentifiers) + products = fetched.sorted { productSortKey(lhs: $0, rhs: $1) } + state = .loaded + await updateCurrentEntitlements() + } catch { + state = .failed(error.localizedDescription) + } + } + + private func productSortKey(lhs: Product, rhs: Product) -> Bool { + sortIndex(for: lhs) < sortIndex(for: rhs) + } + + private func sortIndex(for product: Product) -> Int { + guard let period = product.subscription?.subscriptionPeriod else { return Int.max } + switch period.unit { + case .day: return 0 + case .week: return 1 + case .month: return 2 + case .year: return 3 + @unknown default: return 10 + } + } + + func purchase(_ product: Product) async { + guard purchasingProductID == nil else { return } + + purchasingProductID = product.id + defer { purchasingProductID = nil } + + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + let transaction = try verify(verification) + purchasedProductIDs.insert(transaction.productID) + alert = PaywallAlert(kind: .success, message: localizedString("cable.pro.alert.success.body", defaultValue: "Thanks for supporting Cable PRO!")) + await transaction.finish() + await updateCurrentEntitlements() + case .userCancelled: + break + case .pending: + alert = PaywallAlert(kind: .pending, message: localizedString("cable.pro.alert.pending.body", defaultValue: "Your purchase is awaiting approval.")) + @unknown default: + alert = PaywallAlert(kind: .error, message: localizedString("cable.pro.alert.error.generic", defaultValue: "Something went wrong. Please try again.")) + } + } catch { + alert = PaywallAlert(kind: .error, message: error.localizedDescription) + } + } + + func restorePurchases() async { + guard !isRestoring else { return } + isRestoring = true + defer { isRestoring = false } + + do { + try await AppStore.sync() + await updateCurrentEntitlements() + alert = PaywallAlert(kind: .restored, message: localizedString("cable.pro.alert.restored.body", defaultValue: "Your purchases are available again.")) + } catch { + alert = PaywallAlert(kind: .error, message: error.localizedDescription) + } + } + + private func verify(_ result: VerificationResult) throws -> T { + switch result { + case .verified(let signed): + return signed + case .unverified(_, let error): + throw error + } + } + + private func updateCurrentEntitlements() async { + var unlocked: Set = [] + + for await result in Transaction.currentEntitlements { + switch result { + case .verified(let transaction): + unlocked.insert(transaction.productID) + case .unverified: + continue + } + } + + purchasedProductIDs = unlocked + } +} + +struct CableProPaywallView: View { + @Environment(\.dismiss) private var dismiss + @Binding var isPresented: Bool + @EnvironmentObject private var unitSettings: UnitSystemSettings + + @StateObject private var viewModel: CableProPaywallViewModel + @State private var alertInfo: PaywallAlert? + + private static let defaultProductIds = [ + "app.voltplan.cable.weekly", + "app.voltplan.cable.yearly" + ] + + init(isPresented: Binding, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) { + _isPresented = isPresented + _viewModel = StateObject(wrappedValue: CableProPaywallViewModel(productIdentifiers: productIdentifiers)) + } + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + header + featureList + plansSection + footer + } + .padding(.horizontal, 20) + .padding(.top, 28) + .padding(.bottom, 16) + .navigationTitle("Cable PRO") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + } + } + .task { + await viewModel.loadProducts(force: true) + unitSettings.isProUnlocked = !viewModel.purchasedProductIDs.isEmpty + } + .refreshable { await viewModel.loadProducts(force: true) } + } + .onChange(of: viewModel.alert) { newValue in + alertInfo = newValue + } + .alert(item: $alertInfo) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.messageText), + dismissButton: .default(Text("OK")) { + viewModel.alert = nil + alertInfo = nil + } + ) + } + .onChange(of: viewModel.purchasedProductIDs) { newValue in + unitSettings.isProUnlocked = !newValue.isEmpty + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Unlock Cable PRO") + .font(.largeTitle.bold()) + Text("Keep advanced calculations available and get every new tool the moment it ships.") + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var featureList: some View { + VStack(alignment: .leading, spacing: 10) { + paywallFeature(text: "Duty-cycle aware cable calculators", icon: "bolt.fill") + paywallFeature(text: "Full BOM exports & sourcing help", icon: "list.clipboard") + paywallFeature(text: "Early access to new tools & units", icon: "sparkles") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func paywallFeature(text: String, icon: String) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.headline) + .foregroundStyle(Color.accentColor) + .frame(width: 28, height: 28) + .background( + Circle() + .fill(Color.accentColor.opacity(0.12)) + ) + Text(text) + .font(.callout) + .foregroundStyle(.primary) + } + .padding(.vertical, 4) + } + + @ViewBuilder + private var plansSection: some View { + switch viewModel.state { + case .idle, .loading: + RoundedRectangle(cornerRadius: 16) + .fill(Color(.secondarySystemBackground)) + .frame(height: 140) + .overlay(ProgressView()) + .frame(maxWidth: .infinity) + case .failed(let message): + VStack(spacing: 12) { + Text("We couldn't load Cable PRO at the moment.") + .font(.headline) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + Button(action: { Task { await viewModel.loadProducts(force: true) } }) { + Text("Try Again") + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + } + .padding() + + case .loaded: + if viewModel.products.isEmpty { + VStack(spacing: 12) { + Text("No plans are currently available.") + .font(.headline) + Text("Check back soon—Cable PRO launches in your region shortly.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.secondarySystemBackground)) + ) + } else { + VStack(spacing: 12) { + let hasActiveSubscription = !viewModel.purchasedProductIDs.isEmpty + + ForEach(viewModel.products) { product in + PlanCard( + product: product, + isProcessing: viewModel.purchasingProductID == product.id, + isPurchased: viewModel.purchasedProductIDs.contains(product.id), + isDisabled: hasActiveSubscription && !viewModel.purchasedProductIDs.contains(product.id) + ) { + Task { + await viewModel.purchase(product) + } + } + } + } + } + } + } + + private var footer: some View { + VStack(spacing: 12) { + Button { + Task { + await viewModel.restorePurchases() + } + } label: { + if viewModel.isRestoring { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text(localizedString("cable.pro.restore.button", defaultValue: "Restore Purchases")) + .font(.footnote.weight(.semibold)) + } + } + .buttonStyle(.borderless) + .padding(.top, 8) + .disabled(viewModel.isRestoring) + + HStack(spacing: 16) { + if let termsURL = localizedURL(forKey: "cable.pro.terms.url") { + Link(localizedString("cable.pro.terms.label", defaultValue: "Terms"), destination: termsURL) + } + if let privacyURL = localizedURL(forKey: "cable.pro.privacy.url") { + Link(localizedString("cable.pro.privacy.label", defaultValue: "Privacy"), destination: privacyURL) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + private func localizedURL(forKey key: String) -> URL? { + let raw = localizedString(key, defaultValue: "") + guard let url = URL(string: raw), !raw.isEmpty else { return nil } + return url + } +} + +private func localizedString(_ key: String, defaultValue: String) -> String { + NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "") +} + +private func localizedDurationString(for period: Product.SubscriptionPeriod) -> String { + let locale = Locale.autoupdatingCurrent + let number = localizedNumber(period.value, locale: locale) + + let unitBase: String + switch period.unit { + case .day: unitBase = "day" + case .week: unitBase = "week" + case .month: unitBase = "month" + case .year: unitBase = "year" + @unknown default: unitBase = "day" + } + + if period.value == 1 { + let key = "cable.pro.duration.\(unitBase).singular" + return localizedString(key, defaultValue: singularDurationFallback(for: unitBase)) + } else { + let key = "cable.pro.duration.\(unitBase).plural" + let template = localizedString(key, defaultValue: pluralDurationFallback(for: unitBase)) + return String(format: template, number) + } +} + +private func localizedNumber(_ value: Int, locale: Locale) -> String { + let formatter = NumberFormatter() + formatter.locale = locale + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: value)) ?? String(value) +} + +private func singularDurationFallback(for unit: String) -> String { + switch unit { + case "day": return "every day" + case "week": return "every week" + case "month": return "every month" + case "year": return "every year" + default: return "every day" + } +} + +private func pluralDurationFallback(for unit: String) -> String { + switch unit { + case "day": return "every %@ days" + case "week": return "every %@ weeks" + case "month": return "every %@ months" + case "year": return "every %@ years" + default: return "every %@ days" + } +} + +private func trialDurationString(for period: Product.SubscriptionPeriod) -> String { + let locale = Locale.autoupdatingCurrent + let number = localizedNumber(period.value, locale: locale) + + let unitBase: String + switch period.unit { + case .day: unitBase = "day" + case .week: unitBase = "week" + case .month: unitBase = "month" + case .year: unitBase = "year" + @unknown default: unitBase = "day" + } + + let key = "cable.pro.trial.duration.\(unitBase).\(period.value == 1 ? "singular" : "plural")" + + let fallbackTemplate: String + switch unitBase { + case "day": fallbackTemplate = "%@-day" + case "week": fallbackTemplate = "%@-week" + case "month": fallbackTemplate = "%@-month" + case "year": fallbackTemplate = "%@-year" + default: fallbackTemplate = "%@-day" + } + + let template = localizedString(key, defaultValue: fallbackTemplate) + if template.contains("%@") { + return String(format: template, number) + } else { + return template + } +} + +private struct PlanCard: View { + let product: Product + let isProcessing: Bool + let isPurchased: Bool + let isDisabled: Bool + let action: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text(product.displayName) + .font(.headline) + Spacer() + Text(product.displayPrice) + .font(.headline) + } + + if let info = product.subscription { + VStack(alignment: .leading, spacing: 6) { + if let trial = trialDescription(for: info) { + Text(trial) + .font(.caption.weight(.semibold)) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background( + Capsule() + .fill(Color.accentColor.opacity(0.15)) + ) + .foregroundStyle(Color.accentColor) + } + + Text(subscriptionDescription(for: info)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Button(action: action) { + Group { + if isProcessing { + ProgressView() + } else if isPurchased { + Label(localizedString("cable.pro.button.unlocked", defaultValue: "Unlocked"), systemImage: "checkmark.circle.fill") + .labelStyle(.titleAndIcon) + } else { + let titleKey = product.subscription?.introductoryOffer != nil ? "cable.pro.button.freeTrial" : "cable.pro.button.unlock" + Text(localizedString(titleKey, defaultValue: product.subscription?.introductoryOffer != nil ? "Start Free Trial" : "Unlock Now")) + } + } + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + .disabled(isProcessing || isPurchased || isDisabled) + .opacity((isPurchased || isDisabled) ? 0.6 : 1) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.secondarySystemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isPurchased ? Color.accentColor : Color.clear, lineWidth: isPurchased ? 2 : 0) + ) + } + + private func trialDescription(for info: Product.SubscriptionInfo) -> String? { + guard + let offer = info.introductoryOffer, + offer.paymentMode == .freeTrial + else { return nil } + + let duration = trialDurationString(for: offer.period) + let template = localizedString("cable.pro.trial.badge", defaultValue: "Includes a %@ free trial") + return String(format: template, duration) + } + + private func subscriptionDescription(for info: Product.SubscriptionInfo) -> String { + let quantity = localizedDurationString(for: info.subscriptionPeriod) + + let templateKey: String + if let offer = info.introductoryOffer, + offer.paymentMode == .freeTrial { + templateKey = "cable.pro.subscription.trialThenRenews" + } else { + templateKey = "cable.pro.subscription.renews" + } + let template = localizedString(templateKey, defaultValue: templateKey == "cable.pro.subscription.trialThenRenews" ? "Free trial, then renews every %@." : "Renews every %@.") + return String(format: template, quantity) + } +} + +struct PaywallAlert: Identifiable, Equatable { + enum Kind { case success, pending, restored, error } + + let id = UUID() + let kind: Kind + let message: String + + var title: String { + switch kind { + case .success: + return localizedString("cable.pro.alert.success.title", defaultValue: "Cable PRO Unlocked") + case .pending: + return localizedString("cable.pro.alert.pending.title", defaultValue: "Purchase Pending") + case .restored: + return localizedString("cable.pro.alert.restored.title", defaultValue: "Purchases Restored") + case .error: + return localizedString("cable.pro.alert.error.title", defaultValue: "Purchase Failed") + } + } + + var messageText: String { + message + } +} + +#Preview { + CableProPaywallView(isPresented: .constant(true)) +} diff --git a/Cable/SavedBattery.swift b/Cable/SavedBattery.swift index 2c3e3b7..4a75710 100644 --- a/Cable/SavedBattery.swift +++ b/Cable/SavedBattery.swift @@ -8,6 +8,10 @@ class SavedBattery { var nominalVoltage: Double var capacityAmpHours: Double var usableCapacityOverrideFraction: Double? + var chargeVoltage: Double? + var cutOffVoltage: Double? + var minimumTemperatureCelsius: Double? + var maximumTemperatureCelsius: Double? private var chemistryRawValue: String var iconName: String = "battery.100" var colorName: String = "blue" @@ -21,6 +25,10 @@ class SavedBattery { capacityAmpHours: Double = 100, chemistry: BatteryConfiguration.Chemistry = .lithiumIronPhosphate, usableCapacityOverrideFraction: Double? = nil, + chargeVoltage: Double? = nil, + cutOffVoltage: Double? = nil, + minimumTemperatureCelsius: Double? = nil, + maximumTemperatureCelsius: Double? = nil, iconName: String = "battery.100", colorName: String = "blue", system: ElectricalSystem? = nil, @@ -31,6 +39,10 @@ class SavedBattery { self.nominalVoltage = nominalVoltage self.capacityAmpHours = capacityAmpHours self.usableCapacityOverrideFraction = usableCapacityOverrideFraction + self.chargeVoltage = chargeVoltage + self.cutOffVoltage = cutOffVoltage + self.minimumTemperatureCelsius = minimumTemperatureCelsius + self.maximumTemperatureCelsius = maximumTemperatureCelsius self.chemistryRawValue = chemistry.rawValue self.iconName = iconName self.colorName = colorName diff --git a/Cable/SettingsView.swift b/Cable/SettingsView.swift index 92434f5..419055f 100644 --- a/Cable/SettingsView.swift +++ b/Cable/SettingsView.swift @@ -8,10 +8,16 @@ import SwiftUI import SwiftData +import StoreKit struct SettingsView: View { @EnvironmentObject var unitSettings: UnitSystemSettings @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + + @State private var showingProPaywall = false + @State private var isLoadingProStatus = true + @State private var proStatus: ProSubscriptionStatus? var body: some View { NavigationStack { @@ -24,18 +30,8 @@ struct SettingsView: View { } .pickerStyle(.segmented) } - - Section("Cable Pro") { - Toggle(isOn: $unitSettings.isProUnlocked) { - VStack(alignment: .leading, spacing: 4) { - Text("Early Access Features") - .font(.body.weight(.semibold)) - Text("Enable experimental tools that will require a paid upgrade later on.") - .font(.caption) - .foregroundStyle(.secondary) - } - } + proSectionContent } Section { VStack(alignment: .leading, spacing: 12) { @@ -79,6 +75,167 @@ struct SettingsView: View { } } } + .task { await loadProStatus() } + .sheet(isPresented: $showingProPaywall) { + CableProPaywallView(isPresented: $showingProPaywall) + } + .onChange(of: showingProPaywall) { isPresented in + if !isPresented { + Task { await loadProStatus() } + } + } + .onAppear { + Task { await loadProStatus() } + } + } + + @ViewBuilder + private var proSectionContent: some View { + if isLoadingProStatus { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let status = proStatus { + VStack(alignment: .leading, spacing: 8) { + Label(status.displayName, systemImage: "checkmark.seal.fill") + .font(.headline) + + if let renewalDate = status.renewalDate { + Text(renewalText(for: renewalDate)) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let trialText = trialMessage(for: status) { + Text(trialText) + .font(.footnote) + .foregroundStyle(.secondary) + } + + Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store.")) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.top, 4) + + Button { + openManageSubscriptions() + } label: { + Text(localizedString("settings.pro.manage.button", defaultValue: "Manage Subscription")) + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(6) + } + .buttonStyle(.borderedProminent) + } + .padding(.vertical, 4) + } else { + VStack(alignment: .leading, spacing: 10) { + Text(localizedString("settings.pro.cta.description", defaultValue: "Cable PRO keeps advanced calculations and early tools available.")) + .font(.body) + .foregroundStyle(.secondary) + + Button { + showingProPaywall = true + } label: { + Text(localizedString("settings.pro.cta.button", defaultValue: "Get Cable PRO")) + .font(.callout.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(6) + } + .buttonStyle(.borderedProminent) + } + } + } + + @MainActor + private func loadProStatus() async { + isLoadingProStatus = true + defer { isLoadingProStatus = false } + let status = await SettingsView.fetchProStatus() + proStatus = status + unitSettings.isProUnlocked = status != nil + } + + private func renewalText(for date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + formatter.locale = Locale.autoupdatingCurrent + let dateString = formatter.string(from: date) + let template = localizedString("settings.pro.renewal.date", defaultValue: "Renews on %@.") + return String(format: template, dateString) + } + + private func trialMessage(for status: ProSubscriptionStatus) -> String? { + guard status.isInTrial, let endDate = status.trialEndDate else { return nil } + let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0) + if days > 0 { + let dayText = localizedDayCount(days) + let template = localizedString("settings.pro.trial.remaining", defaultValue: "%@ remaining in free trial.") + return String(format: template, dayText) + } else { + return localizedString("settings.pro.trial.today", defaultValue: "Free trial renews today.") + } + } + + private func localizedDayCount(_ days: Int) -> String { + let number = localizedNumber(days) + let key = days == 1 ? "settings.pro.day.one" : "settings.pro.day.other" + let template = localizedString(key, defaultValue: days == 1 ? "%@ day" : "%@ days") + return String(format: template, number) + } + + private func openManageSubscriptions() { + guard let url = URL(string: localizedString("settings.pro.manage.url", defaultValue: "https://apps.apple.com/account/subscriptions")) else { return } + openURL(url) + } + + private func localizedNumber(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale.autoupdatingCurrent + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } + + static func fetchProStatus() async -> ProSubscriptionStatus? { + let productIDs = Set(["app.voltplan.cable.weekly", "app.voltplan.cable.yearly"]) + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result, + productIDs.contains(transaction.productID) else { continue } + + let product = try? await Product.products(for: [transaction.productID]).first + let displayName = product?.displayName ?? transaction.productID + let renewalDate = transaction.expirationDate + + let hasIntroOffer = transaction.offerType == .introductory + let paymentMode = product?.subscription?.introductoryOffer?.paymentMode + let isInTrial = hasIntroOffer && paymentMode == .freeTrial + let trialEndDate = isInTrial ? transaction.expirationDate : nil + + return ProSubscriptionStatus( + productId: transaction.productID, + displayName: displayName, + renewalDate: renewalDate, + isInTrial: isInTrial, + trialEndDate: trialEndDate + ) + } + + return nil + } + + private func localizedString(_ key: String, defaultValue: String) -> String { + NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "") + } + + struct ProSubscriptionStatus { + let productId: String + let displayName: String + let renewalDate: Date? + let isInTrial: Bool + let trialEndDate: Date? } } diff --git a/Cable/Systems/SystemComponentsPersistence.swift b/Cable/Systems/SystemComponentsPersistence.swift index d664b24..0eb43e2 100644 --- a/Cable/Systems/SystemComponentsPersistence.swift +++ b/Cable/Systems/SystemComponentsPersistence.swift @@ -153,6 +153,11 @@ struct SystemComponentsPersistence { nominalVoltage: configuration.nominalVoltage, capacityAmpHours: configuration.capacityAmpHours, chemistry: configuration.chemistry, + usableCapacityOverrideFraction: configuration.usableCapacityOverrideFraction, + chargeVoltage: configuration.chargeVoltage, + cutOffVoltage: configuration.cutOffVoltage, + minimumTemperatureCelsius: configuration.minimumTemperatureCelsius, + maximumTemperatureCelsius: configuration.maximumTemperatureCelsius, iconName: configuration.iconName, colorName: configuration.colorName, system: system diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index a2831e5..c20ee15 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -1,8 +1,118 @@ -// Keys +"Add your first component" = "Erstelle deine erste Komponente"; +"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Verbraucher sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest."; +"Browse" = "Durchsuchen"; +"Browse Library" = "Bibliothek durchsuchen"; +"Browse electrical components from VoltPlan" = "Elektrische Verbraucher von VoltPlan durchstöbern"; +"Cancel" = "Abbrechen"; +"Changing the unit system will apply to all calculations in the app." = "Die Änderung der Einheiten wirkt sich auf alle Berechnungen der App aus."; +"Check back soon for new loads from VoltPlan." = "Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden."; +"Close" = "Schließen"; +"Color" = "Farbe"; +"Coming soon - manage your electrical systems and panels here." = "Demnächst verfügbar – verwalte hier deine elektrischen Systeme und Verteilungen."; +"Component Library" = "Komponentenbibliothek"; +"Components" = "Verbraucher"; +"Create Component" = "Komponente erstellen"; +"Create System" = "System erstellen"; +"Create your first system" = "Erstelle dein erstes System"; +"Current" = "Strom"; +"Current Units" = "Aktuelle Einheiten"; +"Details" = "Details"; +"Details coming soon" = "Details folgen in Kürze"; +"Edit Current" = "Strom bearbeiten"; +"Edit Length" = "Länge bearbeiten"; +"Edit Power" = "Leistung bearbeiten"; +"Edit Voltage" = "Spannung bearbeiten"; +"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein"; +"Enter length in %@" = "Gib die Länge in %@ ein"; +"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein"; +"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein"; +"FUSE" = "SICHERUNG"; +"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen."; +"Icon" = "Symbol"; +"Important:" = "Wichtig:"; +"Length" = "Länge"; +"Length:" = "Länge:"; +"Load Library" = "Verbraucher-bibliothek"; +"Loading components" = "Komponenten werden geladen"; +"New Load" = "Neuer Verbraucher"; +"No components available" = "Keine Komponenten verfügbar"; +"No loads saved in this system yet." = "Dieses System hat noch keine Verbraucher."; +"No matches" = "Keine Treffer"; +"Power" = "Leistung"; +"Preview" = "Vorschau"; +"Retry" = "Erneut versuchen"; +"Safety Disclaimer" = "Sicherheitshinweis"; +"Save" = "Speichern"; +"Search components" = "Komponenten suchen"; +"Settings" = "Einstellungen"; +"System" = "System"; +"System Name" = "Systemname"; +"System View" = "Systemansicht"; +"Systems" = "Systeme"; +"This application provides electrical calculations for educational and estimation purposes only." = "Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken."; +"Try searching for a different name." = "Versuche, nach einem anderen Namen zu suchen."; +"Unable to load components" = "Komponenten konnten nicht geladen werden"; +"Unit System" = "Einheitensystem"; +"Units" = "Einheiten"; +"VoltPlan Library" = "VoltPlan-Bibliothek"; +"Voltage" = "Spannung"; +"WIRE" = "KABEL"; +"Wire Cross-Section:" = "Kabelquerschnitt:"; "affiliate.button.review_parts" = "Bauteile prüfen"; "affiliate.description.with_link" = "Tippen oben zeigt eine vollständige Stückliste, bevor der Affiliate-Link geöffnet wird. Käufe können VoltPlan unterstützen."; "affiliate.description.without_link" = "Tippen oben zeigt eine vollständige Stückliste mit Einkaufssuchen, die dir bei der Beschaffung helfen."; "affiliate.disclaimer" = "Käufe über Affiliate-Links können VoltPlan unterstützen."; +"battery.bank.badge.capacity" = "Kapazität"; +"battery.bank.badge.energy" = "Energie"; +"battery.bank.badge.voltage" = "Spannung"; +"battery.bank.banner.capacity" = "Kapazitätsabweichung erkannt"; +"battery.bank.banner.voltage" = "Spannungsabweichung erkannt"; +"battery.bank.empty.subtitle" = "Tippe auf Plus, um eine Batterie für %@ zu konfigurieren."; +"battery.bank.empty.title" = "Noch keine Batterien"; +"battery.bank.header.title" = "Batterien"; +"battery.bank.metric.capacity" = "Kapazität"; +"battery.bank.metric.count" = "Batterien"; +"battery.bank.metric.energy" = "Energie"; +"battery.bank.metric.usable_capacity" = "Nutzbare Kapazität"; +"battery.bank.metric.usable_energy" = "Nutzbare Energie"; +"battery.bank.status.capacity.message" = "%@ nutzt eine andere Kapazität als der dominante Bankwert %@. Unterschiedliche Kapazitäten verursachen ungleichmäßige Entladung und vorzeitige Alterung."; +"battery.bank.status.capacity.title" = "Kapazitätsabweichung"; +"battery.bank.status.dismiss" = "Verstanden"; +"battery.bank.status.multiple.batteries" = "%d Batterien"; +"battery.bank.status.single.battery" = "Eine Batterie"; +"battery.bank.status.voltage.message" = "%@ weicht vom Bank-Basiswert %@ ab. Unterschiedliche Nennspannungen führen zu ungleichmäßigem Laden und können Ladegeräte oder Wechselrichter beschädigen."; +"battery.bank.status.voltage.title" = "Spannungsabweichung"; +"battery.bank.warning.capacity.short" = "Kapazität"; +"battery.bank.warning.voltage.short" = "Spannung"; +"battery.editor.advanced.usable_capacity.footer_default" = "Standardwert %@ basierend auf der Chemie."; +"battery.editor.advanced.usable_capacity.footer_override" = "Überschreibung aktiv. Chemie-Standard bleibt %@."; +"battery.editor.alert.cancel" = "Abbrechen"; +"battery.editor.alert.capacity.message" = "Kapazität in Amperestunden (Ah) eingeben"; +"battery.editor.alert.capacity.placeholder" = "Kapazität"; +"battery.editor.alert.capacity.title" = "Kapazität bearbeiten"; +"battery.editor.alert.save" = "Speichern"; +"battery.editor.alert.usable_capacity.message" = "Nutzbare Kapazität in Prozent (%) eingeben"; +"battery.editor.alert.usable_capacity.placeholder" = "Nutzbare Kapazität (%)"; +"battery.editor.alert.usable_capacity.title" = "Nutzbare Kapazität bearbeiten"; +"battery.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben"; +"battery.editor.alert.voltage.placeholder" = "Spannung"; +"battery.editor.alert.voltage.title" = "Nennspannung bearbeiten"; +"battery.editor.button.reset_default" = "Zurücksetzen"; +"battery.editor.cancel" = "Abbrechen"; +"battery.editor.default_name" = "Neue Batterie"; +"battery.editor.field.chemistry" = "Chemie"; +"battery.editor.field.name" = "Name"; +"battery.editor.placeholder.name" = "Hausbank"; +"battery.editor.save" = "Speichern"; +"battery.editor.section.advanced" = "Erweitert"; +"battery.editor.section.summary" = "Übersicht"; +"battery.editor.slider.capacity" = "Kapazität"; +"battery.editor.slider.usable_capacity" = "Nutzbare Kapazität (%)"; +"battery.editor.slider.voltage" = "Nennspannung"; +"battery.editor.title" = "Batterie einrichten"; +"battery.onboarding.subtitle" = "Behalte Kapazität und Chemie deiner Batterien im Blick, um die Laufzeit im Griff zu behalten."; +"battery.onboarding.title" = "Füge deine erste Batterie hinzu"; +"battery.overview.empty.create" = "Batterie hinzufügen"; "bom.accessibility.mark.complete" = "Markiere %@ als erledigt"; "bom.accessibility.mark.incomplete" = "Markiere %@ als unerledigt"; "bom.fuse.detail" = "Inline-Halter und %dA-Sicherung"; @@ -14,11 +124,66 @@ "bom.navigation.title.system" = "Stückliste – %@"; "bom.size.unknown" = "Größe offen"; "bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen"; +"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.title" = "Einschaltdauer"; +"calculator.advanced.section.title" = "Erweitert"; +"calculator.advanced.usage_hours.helper" = "Stunden pro Tag, in denen die Last eingeschaltet ist."; +"calculator.advanced.usage_hours.title" = "Tägliche Laufzeit"; +"calculator.advanced.usage_hours.unit" = "h/Tag"; +"calculator.alert.duty_cycle.message" = "Einschaltdauer als Prozent (0-100 %) eingeben."; +"calculator.alert.duty_cycle.placeholder" = "Einschaltdauer"; +"calculator.alert.duty_cycle.title" = "Einschaltdauer bearbeiten"; +"calculator.alert.usage_hours.message" = "Stunden pro Tag eingeben, in denen die Last aktiv ist."; +"calculator.alert.usage_hours.placeholder" = "Tägliche Laufzeit"; +"calculator.alert.usage_hours.title" = "Tägliche Laufzeit bearbeiten"; +"charger.default.new" = "Neues Ladegerät"; +"charger.editor.alert.cancel" = "Abbrechen"; +"charger.editor.alert.current.message" = "Strom in Ampere (A) eingeben"; +"charger.editor.alert.current.title" = "Ladestrom bearbeiten"; +"charger.editor.alert.input_voltage.title" = "Eingangsspannung bearbeiten"; +"charger.editor.alert.output_voltage.title" = "Ausgangsspannung bearbeiten"; +"charger.editor.alert.power.message" = "Leistung in Watt (W) eingeben"; +"charger.editor.alert.power.placeholder" = "Leistung"; +"charger.editor.alert.power.title" = "Ladeleistung bearbeiten"; +"charger.editor.alert.save" = "Speichern"; +"charger.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben"; +"charger.editor.appearance.accessibility" = "Darstellung des Ladegeräts bearbeiten"; +"charger.editor.appearance.subtitle" = "Passe an, wie dieses Ladegerät angezeigt wird"; +"charger.editor.appearance.title" = "Ladegerät-Darstellung"; +"charger.editor.default_name" = "Neues Ladegerät"; +"charger.editor.field.current" = "Ladestrom"; +"charger.editor.field.input_voltage" = "Eingangsspannung"; +"charger.editor.field.name" = "Name"; +"charger.editor.field.output_voltage" = "Ausgangsspannung"; +"charger.editor.field.power" = "Ladeleistung"; +"charger.editor.field.power.footer" = "Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom."; +"charger.editor.placeholder.name" = "Werkstattladegerät"; +"charger.editor.section.electrical" = "Elektrik"; +"charger.editor.section.power" = "Ladeausgang"; +"charger.editor.title" = "Ladegerät"; +"chargers.badge.current" = "Strom"; +"chargers.badge.input" = "Eingang"; +"chargers.badge.output" = "Ausgang"; +"chargers.badge.power" = "Leistung"; +"chargers.onboarding.primary" = "Ladegerät erstellen"; +"chargers.onboarding.subtitle" = "Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten."; +"chargers.onboarding.title" = "Füge deine Ladegeräte hinzu"; +"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar."; +"chargers.summary.metric.count" = "Ladegeräte"; +"chargers.summary.metric.current" = "Ladestrom"; +"chargers.summary.metric.output" = "Spannung"; +"chargers.summary.metric.power" = "Ladeleistung"; +"chargers.summary.title" = "Ladeübersicht"; +"chargers.title" = "Ladegeräte für %@"; "component.fallback.name" = "Komponente"; "default.load.library" = "Bibliothekslast"; "default.load.name" = "Mein Verbraucher"; -"default.load.unnamed" = "Unbenannter Verbraucher"; "default.load.new" = "Neuer Verbraucher"; +"default.load.unnamed" = "Unbenannter Verbraucher"; "default.system.name" = "Mein System"; "default.system.new" = "Neues System"; "editor.load.name_field" = "Name des Verbrauchers"; @@ -27,256 +192,129 @@ "editor.system.location.optional" = "Standort (optional)"; "editor.system.name_field" = "Name des Systems"; "editor.system.title" = "System bearbeiten"; +"loads.library.button" = "Bibliothek"; +"loads.metric.cable" = "Schnitt"; +"loads.metric.fuse" = "Sicherung"; +"loads.metric.length" = "Länge"; +"loads.onboarding.subtitle" = "Statte dein System mit Verbrauchern aus und lass **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen übernehmen."; +"loads.onboarding.title" = "Füge deinen ersten Verbraucher hinzu"; +"loads.overview.empty.create" = "Verbraucher hinzufügen"; +"loads.overview.empty.library" = "Bibliothek durchsuchen"; +"loads.overview.empty.message" = "Füge einen Verbraucher hinzu, um dein System einzurichten."; +"loads.overview.header.title" = "Verbraucher"; +"loads.overview.metric.count" = "Verbraucher"; +"loads.overview.metric.current" = "Strom"; +"loads.overview.metric.power" = "Leistung"; +"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.plural" = "Verbraucher"; +"loads.overview.status.missing_details.singular" = "Verbraucher"; +"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails"; +"overview.chargers.empty.create" = "Ladegerät hinzufügen"; +"overview.chargers.empty.subtitle" = "Füge Landstrom-, DC-DC- oder Solarladegeräte hinzu, um deine Ladeleistung zu verstehen."; +"overview.chargers.empty.title" = "Noch keine Ladegeräte konfiguriert"; +"overview.chargers.header.title" = "Ladegeräte"; +"overview.loads.empty.subtitle" = "Füge Verbraucher hinzu, um auf dieses System zugeschnittene Kabel- und Sicherungsempfehlungen zu erhalten."; +"overview.loads.empty.title" = "Noch keine Verbraucher eingerichtet"; +"overview.runtime.subtitle" = "Bei dauerhafter Vollast"; +"overview.runtime.title" = "Geschätzte Laufzeit"; +"overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen."; +"overview.system.header.title" = "Systemübersicht"; +"sample.charger.dcdc.name" = "DC-DC-Ladegerät"; +"sample.charger.shore.name" = "Landstrom-Ladegerät"; +"sample.charger.workbench.name" = "Werkbank-Ladegerät"; +"sample.load.charger.name" = "Werkzeugladegerät"; +"sample.load.compressor.name" = "Luftkompressor"; +"sample.load.fridge.name" = "Kompressor-Kühlschrank"; +"sample.load.lighting.name" = "LED-Streifenbeleuchtung"; +"sample.system.rv.location" = "12V Wohnstromkreis"; +"sample.system.rv.name" = "Abenteuer-Van"; +"sample.system.workshop.location" = "Werkzeugecke"; +"sample.system.workshop.name" = "Werkbank"; "slider.button.ampere" = "Ampere"; "slider.button.watt" = "Watt"; "slider.current.title" = "Strom"; "slider.length.title" = "Kabellänge (%@)"; "slider.power.title" = "Leistung"; "slider.voltage.title" = "Spannung"; -"calculator.advanced.section.title" = "Erweitert"; -"calculator.advanced.duty_cycle.title" = "Einschaltdauer"; -"calculator.advanced.duty_cycle.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt."; -"calculator.advanced.usage_hours.title" = "Tägliche Laufzeit"; -"calculator.advanced.usage_hours.helper" = "Stunden pro Tag, in denen die Last eingeschaltet ist."; -"calculator.advanced.usage_hours.unit" = "h/Tag"; -"calculator.alert.duty_cycle.title" = "Einschaltdauer bearbeiten"; -"calculator.alert.duty_cycle.placeholder" = "Einschaltdauer"; -"calculator.alert.duty_cycle.message" = "Einschaltdauer als Prozent (0-100 %) eingeben."; -"calculator.alert.usage_hours.title" = "Tägliche Laufzeit bearbeiten"; -"calculator.alert.usage_hours.placeholder" = "Tägliche Laufzeit"; -"calculator.alert.usage_hours.message" = "Stunden pro Tag eingeben, in denen die Last aktiv ist."; -"system.list.no.components" = "Noch keine Verbraucher"; -"units.imperial.display" = "Imperial (AWG, ft)"; -"units.metric.display" = "Metrisch (mm², m)"; -"sample.system.rv.name" = "Abenteuer-Van"; -"sample.system.rv.location" = "12V Wohnstromkreis"; -"sample.system.workshop.name" = "Werkbank"; -"sample.system.workshop.location" = "Werkzeugecke"; -"sample.load.fridge.name" = "Kompressor-Kühlschrank"; -"sample.load.lighting.name" = "LED-Streifenbeleuchtung"; -"sample.load.compressor.name" = "Luftkompressor"; -"sample.load.charger.name" = "Werkzeugladegerät"; -"system.icon.keywords.rv" = "wohnmobil, camper, campervan, van, reisemobil, campingbus"; -"system.icon.keywords.truck" = "lkw, anhänger, zugmaschine, trailer"; -"system.icon.keywords.boat" = "boot, schiff, yacht, segel, segelboot"; -"system.icon.keywords.plane" = "flugzeug, flug, flieger, luft"; -"system.icon.keywords.ferry" = "fähre, schiff"; -"system.icon.keywords.house" = "haus, zuhause, hütte, ferienhaus, lodge"; -"system.icon.keywords.building" = "gebäude, büro, lagerhalle, fabrik, anlage"; -"system.icon.keywords.tent" = "camp, camping, zelt, outdoor"; -"system.icon.keywords.solar" = "solar, sonne, pv"; "system.icon.keywords.battery" = "batterie, speicher, akku"; -"system.icon.keywords.server" = "server, daten, netzwerk, rack, rechenzentrum"; -"system.icon.keywords.computer" = "computer, elektronik, labor, technik"; -"system.icon.keywords.gear" = "getriebe, mechanik, maschine, werkstatt"; -"system.icon.keywords.tool" = "werkzeug, wartung, reparatur, werkstatt"; -"system.icon.keywords.hammer" = "hammer, tischlerei, zimmerei"; -"system.icon.keywords.light" = "licht, beleuchtung, lampe"; +"system.icon.keywords.boat" = "boot, schiff, yacht, segel, segelboot"; "system.icon.keywords.bolt" = "strom, power, elektrisch, spannung"; -"system.icon.keywords.plug" = "stecker, netzstecker"; -"system.icon.keywords.engine" = "motor, generator, antrieb"; -"system.icon.keywords.fuel" = "kraftstoff, diesel, benzin"; -"system.icon.keywords.water" = "wasser, pumpe, tank"; -"system.icon.keywords.heat" = "heizung, heizer, ofen"; -"system.icon.keywords.cold" = "kalt, kühlen, eis, gefrieren"; +"system.icon.keywords.building" = "gebäude, büro, lagerhalle, fabrik, anlage"; "system.icon.keywords.climate" = "klima, hvac, temperatur"; - -// Direct strings -"Systems" = "Systeme"; -"System" = "System"; -"System View" = "Systemansicht"; -"System Name" = "Systemname"; -"Create System" = "System erstellen"; -"Create your first system" = "Erstelle dein erstes System"; -"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen."; -"Add your first component" = "Erstelle deine erste Komponente"; -"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Verbraucher sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest."; -"Create Component" = "Komponente erstellen"; -"Browse Library" = "Bibliothek durchsuchen"; -"Browse" = "Durchsuchen"; -"Browse electrical components from VoltPlan" = "Elektrische Verbraucher von VoltPlan durchstöbern"; -"Component Library" = "Komponentenbibliothek"; -"Details coming soon" = "Details folgen in Kürze"; -"Components" = "Verbraucher"; -"FUSE" = "SICHERUNG"; -"WIRE" = "KABEL"; -"Current" = "Strom"; -"Power" = "Leistung"; -"Voltage" = "Spannung"; -"Length" = "Länge"; -"Length:" = "Länge:"; -"Wire Cross-Section:" = "Kabelquerschnitt:"; -"Current Units" = "Aktuelle Einheiten"; -"Changing the unit system will apply to all calculations in the app." = "Die Änderung der Einheiten wirkt sich auf alle Berechnungen der App aus."; -"Unit System" = "Einheitensystem"; -"Units" = "Einheiten"; -"Settings" = "Einstellungen"; -"Close" = "Schließen"; -"Cancel" = "Abbrechen"; -"Save" = "Speichern"; -"Retry" = "Erneut versuchen"; -"Loading components" = "Komponenten werden geladen"; -"Unable to load components" = "Komponenten konnten nicht geladen werden"; -"No components available" = "Keine Komponenten verfügbar"; -"No matches" = "Keine Treffer"; -"Check back soon for new loads from VoltPlan." = "Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden."; -"Try searching for a different name." = "Versuche, nach einem anderen Namen zu suchen."; -"Search components" = "Komponenten suchen"; -"No loads saved in this system yet." = "Dieses System hat noch keine Verbraucher."; -"Coming soon - manage your electrical systems and panels here." = "Demnächst verfügbar – verwalte hier deine elektrischen Systeme und Verteilungen."; -"Load Library" = "Verbraucher-bibliothek"; -"Safety Disclaimer" = "Sicherheitshinweis"; -"This application provides electrical calculations for educational and estimation purposes only." = "Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken."; -"Important:" = "Wichtig:"; -"• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu"; -"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen"; -"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden"; -"• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren"; -"• The app developers assume no liability for electrical installations" = "• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen"; -"Enter length in %@" = "Gib die Länge in %@ ein"; -"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein"; -"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein"; -"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein"; -"Edit Length" = "Länge bearbeiten"; -"Edit Voltage" = "Spannung bearbeiten"; -"Edit Current" = "Strom bearbeiten"; -"Edit Power" = "Leistung bearbeiten"; -"Preview" = "Vorschau"; -"Details" = "Details"; -"Icon" = "Symbol"; -"Color" = "Farbe"; -"VoltPlan Library" = "VoltPlan-Bibliothek"; -"New Load" = "Neuer Verbraucher"; - -"tab.overview" = "Übersicht"; -"tab.components" = "Verbraucher"; +"system.icon.keywords.cold" = "kalt, kühlen, eis, gefrieren"; +"system.icon.keywords.computer" = "computer, elektronik, labor, technik"; +"system.icon.keywords.engine" = "motor, generator, antrieb"; +"system.icon.keywords.ferry" = "fähre, schiff"; +"system.icon.keywords.fuel" = "kraftstoff, diesel, benzin"; +"system.icon.keywords.gear" = "getriebe, mechanik, maschine, werkstatt"; +"system.icon.keywords.hammer" = "hammer, tischlerei, zimmerei"; +"system.icon.keywords.heat" = "heizung, heizer, ofen"; +"system.icon.keywords.house" = "haus, zuhause, hütte, ferienhaus, lodge"; +"system.icon.keywords.light" = "licht, beleuchtung, lampe"; +"system.icon.keywords.plane" = "flugzeug, flug, flieger, luft"; +"system.icon.keywords.plug" = "stecker, netzstecker"; +"system.icon.keywords.rv" = "wohnmobil, camper, campervan, van, reisemobil, campingbus"; +"system.icon.keywords.server" = "server, daten, netzwerk, rack, rechenzentrum"; +"system.icon.keywords.solar" = "solar, sonne, pv"; +"system.icon.keywords.tent" = "camp, camping, zelt, outdoor"; +"system.icon.keywords.tool" = "werkzeug, wartung, reparatur, werkstatt"; +"system.icon.keywords.truck" = "lkw, anhänger, zugmaschine, trailer"; +"system.icon.keywords.water" = "wasser, pumpe, tank"; +"system.list.no.components" = "Noch keine Verbraucher"; "tab.batteries" = "Batterien"; "tab.chargers" = "Ladegeräte"; - -"loads.overview.header.title" = "Verbraucher"; -"loads.overview.metric.count" = "Verbraucher"; -"loads.overview.metric.current" = "Strom"; -"loads.overview.metric.power" = "Leistung"; -"loads.overview.empty.message" = "Füge einen Verbraucher hinzu, um dein System einzurichten."; -"loads.overview.empty.create" = "Verbraucher hinzufügen"; -"loads.overview.empty.library" = "Bibliothek durchsuchen"; -"loads.library.button" = "Bibliothek"; -"loads.onboarding.title" = "Füge deinen ersten Verbraucher hinzu"; -"loads.onboarding.subtitle" = "Statte dein System mit Verbrauchern aus und lass **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen übernehmen."; -"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails"; -"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.singular" = "Verbraucher"; -"loads.overview.status.missing_details.plural" = "Verbraucher"; -"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen"; -"loads.metric.fuse" = "Sicherung"; -"loads.metric.cable" = "Schnitt"; -"loads.metric.length" = "Länge"; -"overview.system.header.title" = "Systemübersicht"; -"overview.loads.empty.title" = "Noch keine Verbraucher eingerichtet"; -"overview.loads.empty.subtitle" = "Füge Verbraucher hinzu, um auf dieses System zugeschnittene Kabel- und Sicherungsempfehlungen zu erhalten."; -"overview.runtime.title" = "Geschätzte Laufzeit"; -"overview.runtime.subtitle" = "Bei dauerhafter Vollast"; -"overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen."; -"battery.bank.warning.voltage.short" = "Spannung"; -"battery.bank.warning.capacity.short" = "Kapazität"; - -"battery.bank.header.title" = "Batterien"; -"battery.bank.metric.count" = "Batterien"; -"battery.bank.metric.capacity" = "Kapazität"; -"battery.bank.metric.energy" = "Energie"; -"battery.bank.metric.usable_capacity" = "Nutzbare Kapazität"; -"battery.bank.metric.usable_energy" = "Nutzbare Energie"; -"battery.overview.empty.create" = "Batterie hinzufügen"; -"battery.onboarding.title" = "Füge deine erste Batterie hinzu"; -"battery.onboarding.subtitle" = "Behalte Kapazität und Chemie deiner Batterien im Blick, um die Laufzeit im Griff zu behalten."; -"battery.bank.badge.voltage" = "Spannung"; -"overview.chargers.header.title" = "Ladegeräte"; -"overview.chargers.empty.title" = "Noch keine Ladegeräte konfiguriert"; -"overview.chargers.empty.subtitle" = "Füge Landstrom-, DC-DC- oder Solarladegeräte hinzu, um deine Ladeleistung zu verstehen."; -"overview.chargers.empty.create" = "Ladegerät hinzufügen"; -"battery.bank.badge.capacity" = "Kapazität"; -"battery.bank.badge.energy" = "Energie"; -"battery.bank.banner.voltage" = "Spannungsabweichung erkannt"; -"battery.bank.banner.capacity" = "Kapazitätsabweichung erkannt"; -"battery.bank.empty.title" = "Noch keine Batterien"; -"battery.bank.empty.subtitle" = "Tippe auf Plus, um eine Batterie für %@ zu konfigurieren."; -"battery.bank.status.dismiss" = "Verstanden"; -"battery.bank.status.single.battery" = "Eine Batterie"; -"battery.bank.status.multiple.batteries" = "%d Batterien"; -"battery.bank.status.voltage.title" = "Spannungsabweichung"; -"battery.bank.status.voltage.message" = "%@ weicht vom Bank-Basiswert %@ ab. Unterschiedliche Nennspannungen führen zu ungleichmäßigem Laden und können Ladegeräte oder Wechselrichter beschädigen."; -"battery.bank.status.capacity.title" = "Kapazitätsabweichung"; -"battery.bank.status.capacity.message" = "%@ nutzt eine andere Kapazität als der dominante Bankwert %@. Unterschiedliche Kapazitäten verursachen ungleichmäßige Entladung und vorzeitige Alterung."; - -"battery.editor.title" = "Batterie einrichten"; -"battery.editor.cancel" = "Abbrechen"; -"battery.editor.save" = "Speichern"; -"battery.editor.field.name" = "Name"; -"battery.editor.placeholder.name" = "Hausbank"; -"battery.editor.field.chemistry" = "Chemie"; -"battery.editor.section.summary" = "Übersicht"; -"battery.editor.slider.voltage" = "Nennspannung"; -"battery.editor.slider.capacity" = "Kapazität"; -"battery.editor.slider.usable_capacity" = "Nutzbare Kapazität (%)"; -"battery.editor.section.advanced" = "Erweitert"; -"battery.editor.button.reset_default" = "Zurücksetzen"; -"battery.editor.advanced.usable_capacity.footer_default" = "Standardwert %@ basierend auf der Chemie."; -"battery.editor.advanced.usable_capacity.footer_override" = "Überschreibung aktiv. Chemie-Standard bleibt %@."; -"battery.editor.alert.voltage.title" = "Nennspannung bearbeiten"; -"battery.editor.alert.voltage.placeholder" = "Spannung"; -"battery.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben"; -"battery.editor.alert.capacity.title" = "Kapazität bearbeiten"; -"battery.editor.alert.capacity.placeholder" = "Kapazität"; -"battery.editor.alert.capacity.message" = "Kapazität in Amperestunden (Ah) eingeben"; -"battery.editor.alert.usable_capacity.title" = "Nutzbare Kapazität bearbeiten"; -"battery.editor.alert.usable_capacity.placeholder" = "Nutzbare Kapazität (%)"; -"battery.editor.alert.usable_capacity.message" = "Nutzbare Kapazität in Prozent (%) eingeben"; -"battery.editor.alert.cancel" = "Abbrechen"; -"battery.editor.alert.save" = "Speichern"; -"battery.editor.default_name" = "Neue Batterie"; - -"charger.editor.title" = "Ladegerät"; -"charger.editor.field.name" = "Name"; -"charger.editor.placeholder.name" = "Werkstattladegerät"; -"charger.editor.section.electrical" = "Elektrik"; -"charger.editor.section.power" = "Ladeausgang"; -"charger.editor.appearance.title" = "Ladegerät-Darstellung"; -"charger.editor.appearance.subtitle" = "Passe an, wie dieses Ladegerät angezeigt wird"; -"charger.editor.appearance.accessibility" = "Darstellung des Ladegeräts bearbeiten"; -"charger.editor.field.input_voltage" = "Eingangsspannung"; -"charger.editor.field.output_voltage" = "Ausgangsspannung"; -"charger.editor.field.current" = "Ladestrom"; -"charger.editor.field.power" = "Ladeleistung"; -"charger.editor.field.power.footer" = "Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom."; -"charger.editor.default_name" = "Neues Ladegerät"; -"charger.editor.alert.input_voltage.title" = "Eingangsspannung bearbeiten"; -"charger.editor.alert.output_voltage.title" = "Ausgangsspannung bearbeiten"; -"charger.editor.alert.current.title" = "Ladestrom bearbeiten"; -"charger.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben"; -"charger.editor.alert.power.title" = "Ladeleistung bearbeiten"; -"charger.editor.alert.power.placeholder" = "Leistung"; -"charger.editor.alert.power.message" = "Leistung in Watt (W) eingeben"; -"charger.editor.alert.current.message" = "Strom in Ampere (A) eingeben"; -"charger.editor.alert.cancel" = "Abbrechen"; -"charger.editor.alert.save" = "Speichern"; -"charger.default.new" = "Neues Ladegerät"; - -"chargers.summary.title" = "Ladeübersicht"; -"chargers.summary.metric.count" = "Ladegeräte"; -"chargers.summary.metric.output" = "Spannung"; -"chargers.summary.metric.current" = "Ladestrom"; -"chargers.summary.metric.power" = "Ladeleistung"; -"chargers.badge.input" = "Eingang"; -"chargers.badge.output" = "Ausgang"; -"chargers.badge.current" = "Strom"; -"chargers.badge.power" = "Leistung"; -"chargers.onboarding.title" = "Füge deine Ladegeräte hinzu"; -"chargers.onboarding.subtitle" = "Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten."; -"chargers.onboarding.primary" = "Ladegerät erstellen"; - -"sample.charger.shore.name" = "Landstrom-Ladegerät"; -"sample.charger.dcdc.name" = "DC-DC-Ladegerät"; -"sample.charger.workbench.name" = "Werkbank-Ladegerät"; - -"chargers.title" = "Ladegeräte für %@"; -"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar."; +"tab.components" = "Verbraucher"; +"tab.overview" = "Übersicht"; +"units.imperial.display" = "Imperial (AWG, ft)"; +"units.metric.display" = "Metrisch (mm², m)"; +"settings.pro.cta.description" = "Cable PRO hält erweiterte Berechnungen und neue Werkzeuge verfügbar."; +"settings.pro.cta.button" = "Cable PRO abonnieren"; +"settings.pro.renewal.date" = "Nächste Verlängerung am %@."; +"settings.pro.trial.remaining" = "%@ verbleibend in der Testphase."; +"settings.pro.trial.today" = "Die Testphase endet heute."; +"settings.pro.instructions" = "Verwalte oder kündige dein Abonnement im App Store."; +"settings.pro.manage.button" = "Abonnement verwalten"; +"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions"; +"settings.pro.day.one" = "%@ Tag"; +"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.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."; +"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"; +"• 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"; +"• The app developers assume no liability for electrical installations" = "• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen"; +"• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren";