Polish editors, previews, persistence and docs
Cross-platform refinements to appearance/battery/charger editors, tabs and navigation, plus persistence, screenshot previews and CLAUDE.md docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
CLAUDE.md
23
CLAUDE.md
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Two apps in this repo
|
||||||
|
|
||||||
|
- **iOS** (`Cable/`) — SwiftUI + SwiftData. **This document describes the iOS app unless stated otherwise.**
|
||||||
|
- **Android** (`android/`) — a native Kotlin/Jetpack Compose port that mirrors every iOS feature (package root `app.voltplan.cable`, Room persistence, same Aptabase analytics). See **`android/README.md`** for its architecture and build instructions before working on it.
|
||||||
|
|
||||||
|
Behavior and data shape are meant to stay in sync across both — when changing a user-facing feature on one platform, check the other. See [Export Options](#export-options) for one such cross-platform contract.
|
||||||
|
|
||||||
|
**Apply every instruction to both the iOS and Android versions unless explicitly told otherwise.** Any feature, fix, or change requested without naming a platform must land on both apps and stay behaviorally in sync.
|
||||||
|
|
||||||
## Build & Test Commands
|
## Build & Test Commands
|
||||||
|
|
||||||
Use the **Xcode MCP server** tools instead of `xcodebuild` CLI:
|
Use the **Xcode MCP server** tools instead of `xcodebuild` CLI:
|
||||||
@@ -106,13 +115,25 @@ All list views use consistent styling:
|
|||||||
- Use `String(localized:defaultValue:)` — **not** `NSLocalizedString`. The `defaultValue` serves as English fallback and avoids showing raw keys when a Localizable.strings entry is missing.
|
- Use `String(localized:defaultValue:)` — **not** `NSLocalizedString`. The `defaultValue` serves as English fallback and avoids showing raw keys when a Localizable.strings entry is missing.
|
||||||
- When adding new user-facing strings, add translations to **all 5** Localizable.strings files immediately.
|
- When adding new user-facing strings, add translations to **all 5** Localizable.strings files immediately.
|
||||||
|
|
||||||
## PDF Export Pattern
|
## Export Options
|
||||||
|
|
||||||
|
Three export options, available from the Overview tab's share menu plus the BOM sheet. **Keep iOS and Android (`android/.../pdf/`) in sync — they must offer the same exports.**
|
||||||
|
|
||||||
|
1. **System Overview (PDF)** — summary + a full-page wiring diagram + per-entity tables.
|
||||||
|
2. **Bill of Materials (PDF)** — categorized component list.
|
||||||
|
3. **Wiring Diagram (PNG)** — standalone diagram image.
|
||||||
|
|
||||||
|
The wiring diagram (used both as the standalone PNG and the Overview PDF's diagram page) is fetched from the shared **VoltPlan diagram API** (`POST https://voltplan.app/api/diagram/generate`, JSON payload of system/loads/batteries/chargers, returns PNG). Both platforms send the identical payload shape; falls back gracefully when the API is unreachable (iOS draws a Core Graphics diagram; Android omits the PDF page / shows an error toast for the standalone export).
|
||||||
|
|
||||||
|
### PDF Export Pattern (iOS)
|
||||||
|
|
||||||
PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is:
|
PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is:
|
||||||
- **Exporter struct** (e.g. `SystemOverviewPDFExporter`, `SystemBillOfMaterialsPDFExporter`) with snapshot data types — keeps Core Graphics rendering isolated from SwiftUI/SwiftData.
|
- **Exporter struct** (e.g. `SystemOverviewPDFExporter`, `SystemBillOfMaterialsPDFExporter`) with snapshot data types — keeps Core Graphics rendering isolated from SwiftUI/SwiftData.
|
||||||
- **ShareSheet** triggered via `@State` item binding in the parent view.
|
- **ShareSheet** triggered via `@State` item binding in the parent view.
|
||||||
- **Toolbar button** (not inline content) for the export action.
|
- **Toolbar button** (not inline content) for the export action.
|
||||||
|
|
||||||
|
On Android the equivalents live in `android/app/src/main/java/app/voltplan/cable/pdf/`: `SystemOverviewPdf`, `SystemBomPdf`, `SystemDiagram` (diagram fetch + PNG export), and `PdfShare` (`PdfDocument` + `FileProvider`/`ACTION_SEND`).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`.
|
App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`.
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
var iconName: String
|
var iconName: String
|
||||||
var colorName: String
|
var colorName: String
|
||||||
var system: ElectricalSystem
|
var system: ElectricalSystem
|
||||||
|
var componentID: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
name: String,
|
name: String,
|
||||||
@@ -58,7 +59,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
maximumTemperatureCelsius: Double = 60,
|
maximumTemperatureCelsius: Double = 60,
|
||||||
iconName: String = "battery.100",
|
iconName: String = "battery.100",
|
||||||
colorName: String = "blue",
|
colorName: String = "blue",
|
||||||
system: ElectricalSystem
|
system: ElectricalSystem,
|
||||||
|
componentID: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -73,6 +75,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
self.iconName = iconName
|
self.iconName = iconName
|
||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
|
self.componentID = componentID
|
||||||
}
|
}
|
||||||
|
|
||||||
init(savedBattery: SavedBattery, system: ElectricalSystem) {
|
init(savedBattery: SavedBattery, system: ElectricalSystem) {
|
||||||
@@ -95,6 +98,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
self.iconName = savedBattery.iconName
|
self.iconName = savedBattery.iconName
|
||||||
self.colorName = savedBattery.colorName
|
self.colorName = savedBattery.colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
|
self.componentID = savedBattery.componentID
|
||||||
}
|
}
|
||||||
|
|
||||||
var energyWattHours: Double {
|
var energyWattHours: Double {
|
||||||
@@ -137,6 +141,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
savedBattery.iconName = iconName
|
savedBattery.iconName = iconName
|
||||||
savedBattery.colorName = colorName
|
savedBattery.colorName = colorName
|
||||||
savedBattery.system = system
|
savedBattery.system = system
|
||||||
|
savedBattery.componentID = componentID
|
||||||
savedBattery.timestamp = Date()
|
savedBattery.timestamp = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +159,8 @@ extension BatteryConfiguration {
|
|||||||
lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius &&
|
lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius &&
|
||||||
lhs.chemistry == rhs.chemistry &&
|
lhs.chemistry == rhs.chemistry &&
|
||||||
lhs.iconName == rhs.iconName &&
|
lhs.iconName == rhs.iconName &&
|
||||||
lhs.colorName == rhs.colorName
|
lhs.colorName == rhs.colorName &&
|
||||||
|
lhs.componentID == rhs.componentID
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
@@ -170,5 +176,6 @@ extension BatteryConfiguration {
|
|||||||
hasher.combine(chemistry)
|
hasher.combine(chemistry)
|
||||||
hasher.combine(iconName)
|
hasher.combine(iconName)
|
||||||
hasher.combine(colorName)
|
hasher.combine(colorName)
|
||||||
|
hasher.combine(componentID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
var colorName: String
|
var colorName: String
|
||||||
var system: ElectricalSystem
|
var system: ElectricalSystem
|
||||||
var powerSourceType: SavedCharger.PowerSourceType
|
var powerSourceType: SavedCharger.PowerSourceType
|
||||||
|
var componentID: String?
|
||||||
|
var remoteIconURLString: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -23,7 +25,9 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
iconName: String = "bolt.fill",
|
iconName: String = "bolt.fill",
|
||||||
colorName: String = "orange",
|
colorName: String = "orange",
|
||||||
system: ElectricalSystem,
|
system: ElectricalSystem,
|
||||||
powerSourceType: SavedCharger.PowerSourceType = .shore
|
powerSourceType: SavedCharger.PowerSourceType = .shore,
|
||||||
|
componentID: String? = nil,
|
||||||
|
remoteIconURLString: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -35,6 +39,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
self.powerSourceType = powerSourceType
|
self.powerSourceType = powerSourceType
|
||||||
|
self.componentID = componentID
|
||||||
|
self.remoteIconURLString = remoteIconURLString
|
||||||
}
|
}
|
||||||
|
|
||||||
init(savedCharger: SavedCharger, system: ElectricalSystem) {
|
init(savedCharger: SavedCharger, system: ElectricalSystem) {
|
||||||
@@ -48,6 +54,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
self.colorName = savedCharger.colorName
|
self.colorName = savedCharger.colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
self.powerSourceType = savedCharger.sourceType
|
self.powerSourceType = savedCharger.sourceType
|
||||||
|
self.componentID = savedCharger.componentID
|
||||||
|
self.remoteIconURLString = savedCharger.remoteIconURLString
|
||||||
}
|
}
|
||||||
|
|
||||||
var effectivePowerWatts: Double {
|
var effectivePowerWatts: Double {
|
||||||
@@ -67,6 +75,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
savedCharger.colorName = colorName
|
savedCharger.colorName = colorName
|
||||||
savedCharger.system = system
|
savedCharger.system = system
|
||||||
savedCharger.powerSourceType = powerSourceType.rawValue
|
savedCharger.powerSourceType = powerSourceType.rawValue
|
||||||
|
savedCharger.componentID = componentID
|
||||||
|
savedCharger.remoteIconURLString = remoteIconURLString
|
||||||
savedCharger.timestamp = Date()
|
savedCharger.timestamp = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct ChargersView: View {
|
|||||||
let onAdd: () -> Void
|
let onAdd: () -> Void
|
||||||
let onEdit: (SavedCharger) -> Void
|
let onEdit: (SavedCharger) -> Void
|
||||||
let onDelete: (IndexSet) -> Void
|
let onDelete: (IndexSet) -> Void
|
||||||
|
let onBrowseLibrary: () -> Void
|
||||||
|
|
||||||
private struct SummaryMetric: Identifiable {
|
private struct SummaryMetric: Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
@@ -94,13 +95,15 @@ struct ChargersView: View {
|
|||||||
editMode: Binding<EditMode> = .constant(.inactive),
|
editMode: Binding<EditMode> = .constant(.inactive),
|
||||||
onAdd: @escaping () -> Void = {},
|
onAdd: @escaping () -> Void = {},
|
||||||
onEdit: @escaping (SavedCharger) -> Void = { _ in },
|
onEdit: @escaping (SavedCharger) -> Void = { _ in },
|
||||||
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
onDelete: @escaping (IndexSet) -> Void = { _ in },
|
||||||
|
onBrowseLibrary: @escaping () -> Void = {}
|
||||||
) {
|
) {
|
||||||
self.system = system
|
self.system = system
|
||||||
self.chargers = chargers
|
self.chargers = chargers
|
||||||
self.onAdd = onAdd
|
self.onAdd = onAdd
|
||||||
self.onEdit = onEdit
|
self.onEdit = onEdit
|
||||||
self.onDelete = onDelete
|
self.onDelete = onDelete
|
||||||
|
self.onBrowseLibrary = onBrowseLibrary
|
||||||
_editMode = editMode
|
_editMode = editMode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +247,8 @@ struct ChargersView: View {
|
|||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
OnboardingInfoView(
|
OnboardingInfoView(
|
||||||
configuration: .charger(),
|
configuration: .charger(),
|
||||||
onPrimaryAction: onAdd
|
onPrimaryAction: onAdd,
|
||||||
|
onSecondaryAction: onBrowseLibrary
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 0)
|
.padding(.horizontal, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,56 +221,56 @@ private struct ComponentLibraryScreenshot: View {
|
|||||||
translations: ["de": "Navigationslichter", "es": "Luces de navegación", "fr": "Feux de navigation", "nl": "Navigatieverlichting"],
|
translations: ["de": "Navigationslichter", "es": "Luces de navegación", "fr": "Feux de navigation", "nl": "Navigatieverlichting"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 25,
|
voltageIn: 12.8, voltageOut: nil, watt: 25,
|
||||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 40,
|
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 40,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "2", name: "Refrigerator Compressor",
|
id: "2", name: "Refrigerator Compressor",
|
||||||
translations: ["de": "Kühlschrank-Kompressor", "es": "Compresor de refrigerador", "fr": "Compresseur réfrigérateur", "nl": "Koelkastcompressor"],
|
translations: ["de": "Kühlschrank-Kompressor", "es": "Compresor de refrigerador", "fr": "Compresseur réfrigérateur", "nl": "Koelkastcompressor"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 48,
|
voltageIn: 12.8, voltageOut: nil, watt: 48,
|
||||||
dutyCyclePercent: 40, defaultUtilizationFactorPercent: 100,
|
dutyCyclePercent: 40, defaultUtilizationFactorPercent: 100,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "3", name: "Anchor Windlass",
|
id: "3", name: "Anchor Windlass",
|
||||||
translations: ["de": "Ankerwinde", "es": "Molinete de ancla", "fr": "Guindeau", "nl": "Ankerlier"],
|
translations: ["de": "Ankerwinde", "es": "Molinete de ancla", "fr": "Guindeau", "nl": "Ankerlier"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 960,
|
voltageIn: 12.8, voltageOut: nil, watt: 960,
|
||||||
dutyCyclePercent: 5, defaultUtilizationFactorPercent: 2,
|
dutyCyclePercent: 5, defaultUtilizationFactorPercent: 2,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "4", name: "VHF Radio",
|
id: "4", name: "VHF Radio",
|
||||||
translations: ["de": "UKW Funkgerät", "es": "Radio VHF", "fr": "Radio VHF", "nl": "Marifoon"],
|
translations: ["de": "UKW Funkgerät", "es": "Radio VHF", "fr": "Radio VHF", "nl": "Marifoon"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 72,
|
voltageIn: 12.8, voltageOut: nil, watt: 72,
|
||||||
dutyCyclePercent: 30, defaultUtilizationFactorPercent: 33,
|
dutyCyclePercent: 30, defaultUtilizationFactorPercent: 33,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "5", name: "LED Interior Lights",
|
id: "5", name: "LED Interior Lights",
|
||||||
translations: ["de": "LED Innenbeleuchtung", "es": "Iluminación LED interior", "fr": "Éclairage LED intérieur", "nl": "LED binnenverlichting"],
|
translations: ["de": "LED Innenbeleuchtung", "es": "Iluminación LED interior", "fr": "Éclairage LED intérieur", "nl": "LED binnenverlichting"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 18,
|
voltageIn: 12.8, voltageOut: nil, watt: 18,
|
||||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 25,
|
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 25,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "6", name: "Water Pump",
|
id: "6", name: "Water Pump",
|
||||||
translations: ["de": "Wasserpumpe", "es": "Bomba de agua", "fr": "Pompe à eau", "nl": "Waterpomp"],
|
translations: ["de": "Wasserpumpe", "es": "Bomba de agua", "fr": "Pompe à eau", "nl": "Waterpomp"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 42,
|
voltageIn: 12.8, voltageOut: nil, watt: 42,
|
||||||
dutyCyclePercent: 20, defaultUtilizationFactorPercent: 10,
|
dutyCyclePercent: 20, defaultUtilizationFactorPercent: 10,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "7", name: "Diesel Heater",
|
id: "7", name: "Diesel Heater",
|
||||||
translations: ["de": "Dieselheizung", "es": "Calefactor diésel", "fr": "Chauffage diesel", "nl": "Dieselverwarming"],
|
translations: ["de": "Dieselheizung", "es": "Calefactor diésel", "fr": "Chauffage diesel", "nl": "Dieselverwarming"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 36,
|
voltageIn: 12.8, voltageOut: nil, watt: 36,
|
||||||
dutyCyclePercent: 60, defaultUtilizationFactorPercent: 50,
|
dutyCyclePercent: 60, defaultUtilizationFactorPercent: 50,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "8", name: "USB Charging Station",
|
id: "8", name: "USB Charging Station",
|
||||||
translations: ["de": "USB-Ladestation", "es": "Estación de carga USB", "fr": "Station de charge USB", "nl": "USB-laadstation"],
|
translations: ["de": "USB-Ladestation", "es": "Estación de carga USB", "fr": "Station de charge USB", "nl": "USB-laadstation"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 24,
|
voltageIn: 12.8, voltageOut: nil, watt: 24,
|
||||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 30,
|
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 30,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,87 @@ struct SystemComponentsPersistence {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func makeBatteryDraft(
|
||||||
|
from item: ComponentLibraryItem,
|
||||||
|
for system: ElectricalSystem,
|
||||||
|
existingLoads: [SavedLoad],
|
||||||
|
existingBatteries: [SavedBattery],
|
||||||
|
existingChargers: [SavedCharger]
|
||||||
|
) -> BatteryConfiguration {
|
||||||
|
let localizedName = item.localizedName
|
||||||
|
let baseName = localizedName.isEmpty
|
||||||
|
? String(localized: "battery.editor.default_name", defaultValue: "New Battery")
|
||||||
|
: localizedName
|
||||||
|
let batteryName = uniqueName(
|
||||||
|
startingWith: baseName,
|
||||||
|
loads: existingLoads,
|
||||||
|
batteries: existingBatteries,
|
||||||
|
chargers: existingChargers
|
||||||
|
)
|
||||||
|
let nominalVoltage = item.displayVoltage ?? 12.8
|
||||||
|
let capacity = item.capacityAmpHours ?? 100
|
||||||
|
|
||||||
|
return BatteryConfiguration(
|
||||||
|
name: batteryName,
|
||||||
|
nominalVoltage: nominalVoltage,
|
||||||
|
capacityAmpHours: capacity,
|
||||||
|
chemistry: .lithiumIronPhosphate,
|
||||||
|
iconName: "battery.100",
|
||||||
|
colorName: system.colorName,
|
||||||
|
system: system,
|
||||||
|
componentID: item.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeChargerDraft(
|
||||||
|
from item: ComponentLibraryItem,
|
||||||
|
for system: ElectricalSystem,
|
||||||
|
existingLoads: [SavedLoad],
|
||||||
|
existingBatteries: [SavedBattery],
|
||||||
|
existingChargers: [SavedCharger]
|
||||||
|
) -> ChargerConfiguration {
|
||||||
|
let localizedName = item.localizedName
|
||||||
|
let baseName = localizedName.isEmpty
|
||||||
|
? String(localized: "charger.editor.default_name", defaultValue: "New Charger")
|
||||||
|
: localizedName
|
||||||
|
let chargerName = uniqueName(
|
||||||
|
startingWith: baseName,
|
||||||
|
loads: existingLoads,
|
||||||
|
batteries: existingBatteries,
|
||||||
|
chargers: existingChargers
|
||||||
|
)
|
||||||
|
let inputVoltage = item.voltageIn ?? LocaleDefaults.mainsVoltage
|
||||||
|
let outputVoltage = item.voltageOut ?? 14.2
|
||||||
|
let power = item.watt ?? 0
|
||||||
|
let current = item.outputCurrent ?? (outputVoltage > 0 ? power / outputVoltage : 30)
|
||||||
|
let sourceType = chargerSourceType(forCategory: item.componentCategory)
|
||||||
|
|
||||||
|
return ChargerConfiguration(
|
||||||
|
name: chargerName,
|
||||||
|
inputVoltage: inputVoltage,
|
||||||
|
outputVoltage: outputVoltage,
|
||||||
|
maxCurrentAmps: current,
|
||||||
|
maxPowerWatts: power,
|
||||||
|
iconName: sourceType.iconName,
|
||||||
|
colorName: system.colorName,
|
||||||
|
system: system,
|
||||||
|
powerSourceType: sourceType,
|
||||||
|
componentID: item.id,
|
||||||
|
remoteIconURLString: item.iconURL?.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a PocketBase `component_category` to a charger power source.
|
||||||
|
static func chargerSourceType(forCategory category: String?) -> SavedCharger.PowerSourceType {
|
||||||
|
guard let category = category?.lowercased(), !category.isEmpty else { return .shore }
|
||||||
|
if category.contains("solar") { return .solar }
|
||||||
|
if category.contains("wind") { return .wind }
|
||||||
|
if category.contains("dcdc") || category.contains("alternator") { return .alternator }
|
||||||
|
if category.contains("generator") { return .generator }
|
||||||
|
if category.contains("mains") || category.contains("shore") { return .shore }
|
||||||
|
return .shore
|
||||||
|
}
|
||||||
|
|
||||||
static func makeChargerDraft(
|
static func makeChargerDraft(
|
||||||
for system: ElectricalSystem,
|
for system: ElectricalSystem,
|
||||||
existingLoads: [SavedLoad],
|
existingLoads: [SavedLoad],
|
||||||
@@ -160,7 +241,8 @@ struct SystemComponentsPersistence {
|
|||||||
maximumTemperatureCelsius: configuration.maximumTemperatureCelsius,
|
maximumTemperatureCelsius: configuration.maximumTemperatureCelsius,
|
||||||
iconName: configuration.iconName,
|
iconName: configuration.iconName,
|
||||||
colorName: configuration.colorName,
|
colorName: configuration.colorName,
|
||||||
system: system
|
system: system,
|
||||||
|
componentID: configuration.componentID
|
||||||
)
|
)
|
||||||
context.insert(newBattery)
|
context.insert(newBattery)
|
||||||
}
|
}
|
||||||
@@ -184,7 +266,9 @@ struct SystemComponentsPersistence {
|
|||||||
maxPowerWatts: configuration.maxPowerWatts,
|
maxPowerWatts: configuration.maxPowerWatts,
|
||||||
iconName: configuration.iconName,
|
iconName: configuration.iconName,
|
||||||
colorName: configuration.colorName,
|
colorName: configuration.colorName,
|
||||||
system: system
|
system: system,
|
||||||
|
remoteIconURLString: configuration.remoteIconURLString,
|
||||||
|
componentID: configuration.componentID
|
||||||
)
|
)
|
||||||
context.insert(newCharger)
|
context.insert(newCharger)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import androidx.compose.material.icons.outlined.BatteryChargingFull
|
|||||||
import androidx.compose.material.icons.outlined.BatteryFull
|
import androidx.compose.material.icons.outlined.BatteryFull
|
||||||
import androidx.compose.material.icons.outlined.Bolt
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.icons.outlined.LibraryBooks
|
||||||
import androidx.compose.material.icons.outlined.Speed
|
import androidx.compose.material.icons.outlined.Speed
|
||||||
import androidx.compose.material.icons.outlined.Warning
|
import androidx.compose.material.icons.outlined.Warning
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -63,6 +65,7 @@ fun BatteriesTab(
|
|||||||
state: DetailState,
|
state: DetailState,
|
||||||
onEditBattery: (String) -> Unit,
|
onEditBattery: (String) -> Unit,
|
||||||
onNewBattery: () -> Unit,
|
onNewBattery: () -> Unit,
|
||||||
|
onOpenLibrary: () -> Unit,
|
||||||
onDeleteBattery: (SavedBattery) -> Unit,
|
onDeleteBattery: (SavedBattery) -> Unit,
|
||||||
) {
|
) {
|
||||||
val batteries = state.batteries
|
val batteries = state.batteries
|
||||||
@@ -73,11 +76,15 @@ fun BatteriesTab(
|
|||||||
subtitle = stringResource(R.string.battery_onboarding_subtitle),
|
subtitle = stringResource(R.string.battery_onboarding_subtitle),
|
||||||
primaryLabel = stringResource(R.string.battery_empty_create),
|
primaryLabel = stringResource(R.string.battery_empty_create),
|
||||||
onPrimary = onNewBattery,
|
onPrimary = onNewBattery,
|
||||||
|
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||||
|
onSecondary = onOpenLibrary,
|
||||||
|
images = listOf(R.drawable.onboarding_battery),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val m = state.metrics
|
val m = state.metrics
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
StatsHeader {
|
StatsHeader {
|
||||||
Text(stringResource(R.string.battery_bank_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.battery_bank_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
@@ -101,12 +108,20 @@ fun BatteriesTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp)) {
|
||||||
items(batteries, key = { it.id }) { battery ->
|
items(batteries, key = { it.id }) { battery ->
|
||||||
BatteryRow(battery, onClick = { onEditBattery(battery.id) }, onDelete = { onDeleteBattery(battery) })
|
BatteryRow(battery, onClick = { onEditBattery(battery.id) }, onDelete = { onDeleteBattery(battery) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = onOpenLibrary,
|
||||||
|
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
|
||||||
|
text = { Text(stringResource(R.string.loads_library_button)) },
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -90,6 +91,9 @@ fun BatteryEditorScreen(systemId: String, batteryId: String?, onBack: () -> Unit
|
|||||||
title = {
|
title = {
|
||||||
Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true })
|
Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true })
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ fun ChargerEditorScreen(systemId: String, chargerId: String?, onBack: () -> Unit
|
|||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
|
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
|
||||||
},
|
},
|
||||||
title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) },
|
title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) },
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.voltplan.cable.ui.chargers
|
|||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -15,7 +16,9 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.outlined.Bolt
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
|
import androidx.compose.material.icons.outlined.LibraryBooks
|
||||||
import androidx.compose.material.icons.outlined.Speed
|
import androidx.compose.material.icons.outlined.Speed
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -55,6 +58,7 @@ fun ChargersTab(
|
|||||||
state: DetailState,
|
state: DetailState,
|
||||||
onEditCharger: (String) -> Unit,
|
onEditCharger: (String) -> Unit,
|
||||||
onNewCharger: () -> Unit,
|
onNewCharger: () -> Unit,
|
||||||
|
onOpenLibrary: () -> Unit,
|
||||||
onDeleteCharger: (SavedCharger) -> Unit,
|
onDeleteCharger: (SavedCharger) -> Unit,
|
||||||
) {
|
) {
|
||||||
val chargers = state.chargers
|
val chargers = state.chargers
|
||||||
@@ -65,11 +69,15 @@ fun ChargersTab(
|
|||||||
subtitle = stringResource(R.string.chargers_onboarding_subtitle),
|
subtitle = stringResource(R.string.chargers_onboarding_subtitle),
|
||||||
primaryLabel = stringResource(R.string.chargers_onboarding_primary),
|
primaryLabel = stringResource(R.string.chargers_onboarding_primary),
|
||||||
onPrimary = onNewCharger,
|
onPrimary = onNewCharger,
|
||||||
|
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||||
|
onSecondary = onOpenLibrary,
|
||||||
|
images = listOf(R.drawable.onboarding_charger),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val m = state.metrics
|
val m = state.metrics
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
StatsHeader {
|
StatsHeader {
|
||||||
Text(stringResource(R.string.chargers_summary_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.chargers_summary_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
@@ -85,12 +93,20 @@ fun ChargersTab(
|
|||||||
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalChargerPower)} W", stringResource(R.string.chargers_metric_power), SysPink)
|
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalChargerPower)} W", stringResource(R.string.chargers_metric_power), SysPink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) {
|
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp)) {
|
||||||
items(chargers, key = { it.id }) { charger ->
|
items(chargers, key = { it.id }) { charger ->
|
||||||
ChargerRow(charger, onClick = { onEditCharger(charger.id) }, onDelete = { onDeleteCharger(charger) })
|
ChargerRow(charger, onClick = { onEditCharger(charger.id) }, onDelete = { onDeleteCharger(charger) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = onOpenLibrary,
|
||||||
|
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
|
||||||
|
text = { Text(stringResource(R.string.loads_library_button)) },
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ package app.voltplan.cable.ui.components
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -69,6 +68,7 @@ fun AppearanceEditorSheet(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 20.dp)
|
.padding(horizontal = 20.dp)
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
@@ -113,55 +113,41 @@ fun AppearanceEditorSheet(
|
|||||||
extra?.invoke()
|
extra?.invoke()
|
||||||
|
|
||||||
Text("Icon", style = MaterialTheme.typography.titleSmall)
|
Text("Icon", style = MaterialTheme.typography.titleSmall)
|
||||||
LazyVerticalGrid(
|
GridRows(items = icons, columns = 5) { symbol ->
|
||||||
columns = GridCells.Fixed(5),
|
val selected = symbol == icon
|
||||||
modifier = Modifier.fillMaxWidth().heightForRows(icons.size, 5),
|
Box(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
.weight(1f)
|
||||||
userScrollEnabled = false,
|
.aspectRatio(1f)
|
||||||
) {
|
.clip(RoundedCornerShape(12.dp))
|
||||||
items(icons) { symbol ->
|
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
|
||||||
val selected = symbol == icon
|
.clickable { icon = symbol },
|
||||||
Box(
|
contentAlignment = Alignment.Center,
|
||||||
Modifier
|
) {
|
||||||
.aspectRatio(1f)
|
Icon(
|
||||||
.clip(RoundedCornerShape(12.dp))
|
sfSymbol(symbol),
|
||||||
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
|
contentDescription = symbol,
|
||||||
.clickable { icon = symbol },
|
tint = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
|
||||||
contentAlignment = Alignment.Center,
|
modifier = Modifier.size(24.dp),
|
||||||
) {
|
)
|
||||||
Icon(
|
|
||||||
sfSymbol(symbol),
|
|
||||||
contentDescription = symbol,
|
|
||||||
tint = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Color", style = MaterialTheme.typography.titleSmall)
|
Text("Color", style = MaterialTheme.typography.titleSmall)
|
||||||
LazyVerticalGrid(
|
GridRows(items = curatedColorNames, columns = 6) { colorName ->
|
||||||
columns = GridCells.Fixed(6),
|
val c = componentColor(colorName)
|
||||||
modifier = Modifier.fillMaxWidth().heightForRows(curatedColorNames.size, 6),
|
Box(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
.weight(1f)
|
||||||
userScrollEnabled = false,
|
.aspectRatio(1f)
|
||||||
) {
|
.clip(CircleShape)
|
||||||
items(curatedColorNames) { colorName ->
|
.background(c)
|
||||||
val c = componentColor(colorName)
|
.border(2.dp, if (colorName == color) Color.White else Color.Transparent, CircleShape)
|
||||||
Box(
|
.clickable { color = colorName },
|
||||||
Modifier
|
contentAlignment = Alignment.Center,
|
||||||
.aspectRatio(1f)
|
) {
|
||||||
.clip(CircleShape)
|
if (colorName == color) {
|
||||||
.background(c)
|
Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||||
.border(2.dp, if (colorName == color) Color.White else Color.Transparent, CircleShape)
|
|
||||||
.clickable { color = colorName },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (colorName == color) {
|
|
||||||
Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,8 +155,25 @@ fun AppearanceEditorSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Modifier.heightForRows(itemCount: Int, columns: Int): Modifier {
|
/**
|
||||||
val rows = (itemCount + columns - 1) / columns
|
* Lays out [items] in a non-lazy grid of fixed [columns], sizing to its content so it can live
|
||||||
// ~56dp per cell including spacing; gives a non-scrolling grid inside the sheet.
|
* inside a vertically scrolling container without a fixed height. Empty trailing cells are padded
|
||||||
return this.height((rows * 56).dp)
|
* with spacers so cells keep equal widths on the final row.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <T> GridRows(
|
||||||
|
items: List<T>,
|
||||||
|
columns: Int,
|
||||||
|
cell: @Composable RowScope.(T) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
items.chunked(columns).forEach { rowItems ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
rowItems.forEach { cell(it) }
|
||||||
|
repeat(columns - rowItems.size) {
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ fun CalculatorScreen(systemId: String, loadId: String?, onBack: () -> Unit) {
|
|||||||
Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp))
|
Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ fun ComponentsTab(
|
|||||||
onPrimary = onNewLoad,
|
onPrimary = onNewLoad,
|
||||||
secondaryLabel = stringResource(R.string.loads_empty_library),
|
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||||
onSecondary = onOpenLibrary,
|
onSecondary = onOpenLibrary,
|
||||||
|
images = listOf(R.drawable.onboarding_coffee, R.drawable.onboarding_router, R.drawable.onboarding_charger),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import app.voltplan.cable.ui.batteries.BatteryEditorScreen
|
import app.voltplan.cable.ui.batteries.BatteryEditorScreen
|
||||||
import app.voltplan.cable.ui.bom.BillOfMaterialsScreen
|
import app.voltplan.cable.ui.bom.BillOfMaterialsScreen
|
||||||
|
import app.voltplan.cable.library.ComponentLibraryType
|
||||||
import app.voltplan.cable.ui.chargers.ChargerEditorScreen
|
import app.voltplan.cable.ui.chargers.ChargerEditorScreen
|
||||||
import app.voltplan.cable.ui.library.ComponentLibraryScreen
|
import app.voltplan.cable.ui.library.ComponentLibraryScreen
|
||||||
import app.voltplan.cable.ui.loads.CalculatorScreen
|
import app.voltplan.cable.ui.loads.CalculatorScreen
|
||||||
@@ -22,11 +23,17 @@ object Routes {
|
|||||||
const val BATTERY = "battery/{systemId}?batteryId={batteryId}"
|
const val BATTERY = "battery/{systemId}?batteryId={batteryId}"
|
||||||
const val CHARGER = "charger/{systemId}?chargerId={chargerId}"
|
const val CHARGER = "charger/{systemId}?chargerId={chargerId}"
|
||||||
const val BOM = "bom/{systemId}"
|
const val BOM = "bom/{systemId}"
|
||||||
const val LIBRARY = "library?systemId={systemId}"
|
const val LIBRARY = "library?systemId={systemId}&type={type}"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
|
|
||||||
fun system(id: String) = "system/$id"
|
fun system(id: String) = "system/$id"
|
||||||
fun library(systemId: String? = null) = "library" + (systemId?.let { "?systemId=$it" } ?: "")
|
fun library(systemId: String? = null, type: String = "load"): String {
|
||||||
|
val params = buildList {
|
||||||
|
systemId?.let { add("systemId=$it") }
|
||||||
|
add("type=$type")
|
||||||
|
}
|
||||||
|
return "library?" + params.joinToString("&")
|
||||||
|
}
|
||||||
fun calculator(systemId: String, loadId: String? = null) =
|
fun calculator(systemId: String, loadId: String? = null) =
|
||||||
"calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "")
|
"calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "")
|
||||||
fun battery(systemId: String, batteryId: String? = null) =
|
fun battery(systemId: String, batteryId: String? = null) =
|
||||||
@@ -64,7 +71,7 @@ fun CableNavHost() {
|
|||||||
onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) },
|
onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) },
|
||||||
onNewCharger = { nav.navigate(Routes.charger(systemId)) },
|
onNewCharger = { nav.navigate(Routes.charger(systemId)) },
|
||||||
onOpenBom = { nav.navigate(Routes.bom(systemId)) },
|
onOpenBom = { nav.navigate(Routes.bom(systemId)) },
|
||||||
onOpenLibrary = { nav.navigate(Routes.library(systemId)) },
|
onOpenLibrary = { type -> nav.navigate(Routes.library(systemId, type.typeValue)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,15 +129,27 @@ fun CableNavHost() {
|
|||||||
|
|
||||||
composable(
|
composable(
|
||||||
Routes.LIBRARY,
|
Routes.LIBRARY,
|
||||||
arguments = listOf(navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null }),
|
arguments = listOf(
|
||||||
|
navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||||
|
navArgument("type") { type = NavType.StringType; nullable = true; defaultValue = "load" },
|
||||||
|
),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
ComponentLibraryScreen(
|
ComponentLibraryScreen(
|
||||||
targetSystemId = entry.arguments?.getString("systemId"),
|
targetSystemId = entry.arguments?.getString("systemId"),
|
||||||
|
libraryType = ComponentLibraryType.fromArg(entry.arguments?.getString("type")),
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
onOpenSystem = { systemId ->
|
onOpenSystem = { systemId ->
|
||||||
nav.popBackStack()
|
nav.popBackStack()
|
||||||
nav.navigate(Routes.system(systemId))
|
nav.navigate(Routes.system(systemId))
|
||||||
},
|
},
|
||||||
|
onOpenBatteryEditor = { systemId, batteryId ->
|
||||||
|
nav.popBackStack()
|
||||||
|
nav.navigate(Routes.battery(systemId, batteryId))
|
||||||
|
},
|
||||||
|
onOpenChargerEditor = { systemId, chargerId ->
|
||||||
|
nav.popBackStack()
|
||||||
|
nav.navigate(Routes.charger(systemId, chargerId))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ fun OverviewTab(
|
|||||||
onAddCharger: () -> Unit,
|
onAddCharger: () -> Unit,
|
||||||
onOpenLibrary: () -> Unit,
|
onOpenLibrary: () -> Unit,
|
||||||
onOpenBom: () -> Unit,
|
onOpenBom: () -> Unit,
|
||||||
|
onSelectLoads: () -> Unit,
|
||||||
|
onSelectBatteries: () -> Unit,
|
||||||
|
onSelectChargers: () -> Unit,
|
||||||
onSetRuntimeGoal: (Double?) -> Unit,
|
onSetRuntimeGoal: (Double?) -> Unit,
|
||||||
onSetChargeGoal: (Double?) -> Unit,
|
onSetChargeGoal: (Double?) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -104,9 +107,9 @@ fun OverviewTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadsCard(state, m, onAddLoad, onOpenLibrary)
|
LoadsCard(state, m, onAddLoad, onOpenLibrary, onSelectLoads)
|
||||||
BatteriesCard(state, m, onAddBattery)
|
BatteriesCard(state, m, onAddBattery, onSelectBatteries)
|
||||||
ChargersCard(state, m, onAddCharger)
|
ChargersCard(state, m, onAddCharger, onSelectChargers)
|
||||||
}
|
}
|
||||||
|
|
||||||
goalEditor?.let { kind ->
|
goalEditor?.let { kind ->
|
||||||
@@ -163,9 +166,10 @@ private fun MetricRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverviewCard(title: String, content: @Composable () -> Unit) {
|
private fun OverviewCard(title: String, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||||
) {
|
) {
|
||||||
@@ -177,8 +181,8 @@ private fun OverviewCard(title: String, content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit) {
|
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit, onSelect: () -> Unit) {
|
||||||
OverviewCard(stringResource(R.string.loads_overview_header_title)) {
|
OverviewCard(stringResource(R.string.loads_overview_header_title), onClick = if (state.loads.isEmpty()) null else onSelect) {
|
||||||
if (state.loads.isEmpty()) {
|
if (state.loads.isEmpty()) {
|
||||||
Text(stringResource(R.string.overview_loads_empty_title), fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.overview_loads_empty_title), fontWeight = FontWeight.Medium)
|
||||||
Text(stringResource(R.string.overview_loads_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.overview_loads_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -196,8 +200,8 @@ private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Uni
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit) {
|
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit, onSelect: () -> Unit) {
|
||||||
OverviewCard(stringResource(R.string.battery_bank_header_title)) {
|
OverviewCard(stringResource(R.string.battery_bank_header_title), onClick = if (state.batteries.isEmpty()) null else onSelect) {
|
||||||
if (state.batteries.isEmpty()) {
|
if (state.batteries.isEmpty()) {
|
||||||
Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium)
|
||||||
Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) }
|
Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) }
|
||||||
@@ -212,8 +216,8 @@ private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit) {
|
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit, onSelect: () -> Unit) {
|
||||||
OverviewCard(stringResource(R.string.overview_chargers_header_title)) {
|
OverviewCard(stringResource(R.string.overview_chargers_header_title), onClick = if (state.chargers.isEmpty()) null else onSelect) {
|
||||||
if (state.chargers.isEmpty()) {
|
if (state.chargers.isEmpty()) {
|
||||||
Text(stringResource(R.string.overview_chargers_empty_title), fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.overview_chargers_empty_title), fontWeight = FontWeight.Medium)
|
||||||
Text(stringResource(R.string.overview_chargers_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.overview_chargers_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -16,7 +17,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Add
|
import androidx.compose.material.icons.outlined.Add
|
||||||
import androidx.compose.material.icons.outlined.ChevronRight
|
import androidx.compose.material.icons.outlined.ChevronRight
|
||||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
|
import app.voltplan.cable.ui.components.OnboardingCarousel
|
||||||
import app.voltplan.cable.ui.sfSymbol
|
import app.voltplan.cable.ui.sfSymbol
|
||||||
import app.voltplan.cable.ui.theme.componentColor
|
import app.voltplan.cable.ui.theme.componentColor
|
||||||
|
|
||||||
@@ -174,7 +175,10 @@ private fun SystemsOnboarding(modifier: Modifier = Modifier, onCreate: (String)
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Outlined.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp))
|
OnboardingCarousel(
|
||||||
|
images = listOf(R.drawable.onboarding_van, R.drawable.onboarding_cabin, R.drawable.onboarding_boat),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(220.dp),
|
||||||
|
)
|
||||||
Spacer(Modifier.size(16.dp))
|
Spacer(Modifier.size(16.dp))
|
||||||
Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|||||||
Reference in New Issue
Block a user