From 23b117bfe228f4116548801bd88dcdf55281884c Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Thu, 4 Jun 2026 01:04:47 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 23 +++- Cable/Batteries/BatteryConfiguration.swift | 13 ++- Cable/Chargers/ChargerConfiguration.swift | 12 +- Cable/Chargers/ChargersView.swift | 8 +- Cable/ScreenshotPreviews.swift | 16 +-- .../Systems/SystemComponentsPersistence.swift | 88 +++++++++++++- .../cable/ui/batteries/BatteriesTab.kt | 17 ++- .../cable/ui/batteries/BatteryEditorScreen.kt | 4 + .../cable/ui/chargers/ChargerEditorScreen.kt | 3 + .../voltplan/cable/ui/chargers/ChargersTab.kt | 18 ++- .../cable/ui/components/AppearanceEditor.kt | 109 +++++++++--------- .../cable/ui/loads/CalculatorScreen.kt | 3 + .../voltplan/cable/ui/loads/ComponentsTab.kt | 1 + .../cable/ui/navigation/CableNavHost.kt | 27 ++++- .../voltplan/cable/ui/overview/OverviewTab.kt | 26 +++-- .../cable/ui/systems/SystemsScreen.kt | 8 +- 16 files changed, 287 insertions(+), 89 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1188fc4..bc99215 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,15 @@ 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 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. - 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: - **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. - **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 App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`. diff --git a/Cable/Batteries/BatteryConfiguration.swift b/Cable/Batteries/BatteryConfiguration.swift index a1f86f0..f1c3a74 100644 --- a/Cable/Batteries/BatteryConfiguration.swift +++ b/Cable/Batteries/BatteryConfiguration.swift @@ -44,7 +44,8 @@ struct BatteryConfiguration: Identifiable, Hashable { var iconName: String var colorName: String var system: ElectricalSystem - + var componentID: String? + init( id: UUID = UUID(), name: String, @@ -58,7 +59,8 @@ struct BatteryConfiguration: Identifiable, Hashable { maximumTemperatureCelsius: Double = 60, iconName: String = "battery.100", colorName: String = "blue", - system: ElectricalSystem + system: ElectricalSystem, + componentID: String? = nil ) { self.id = id self.name = name @@ -73,6 +75,7 @@ struct BatteryConfiguration: Identifiable, Hashable { self.iconName = iconName self.colorName = colorName self.system = system + self.componentID = componentID } init(savedBattery: SavedBattery, system: ElectricalSystem) { @@ -95,6 +98,7 @@ struct BatteryConfiguration: Identifiable, Hashable { self.iconName = savedBattery.iconName self.colorName = savedBattery.colorName self.system = system + self.componentID = savedBattery.componentID } var energyWattHours: Double { @@ -137,6 +141,7 @@ struct BatteryConfiguration: Identifiable, Hashable { savedBattery.iconName = iconName savedBattery.colorName = colorName savedBattery.system = system + savedBattery.componentID = componentID savedBattery.timestamp = Date() } } @@ -154,7 +159,8 @@ extension BatteryConfiguration { lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius && lhs.chemistry == rhs.chemistry && lhs.iconName == rhs.iconName && - lhs.colorName == rhs.colorName + lhs.colorName == rhs.colorName && + lhs.componentID == rhs.componentID } func hash(into hasher: inout Hasher) { @@ -170,5 +176,6 @@ extension BatteryConfiguration { hasher.combine(chemistry) hasher.combine(iconName) hasher.combine(colorName) + hasher.combine(componentID) } } diff --git a/Cable/Chargers/ChargerConfiguration.swift b/Cable/Chargers/ChargerConfiguration.swift index d0c9855..7ab8822 100644 --- a/Cable/Chargers/ChargerConfiguration.swift +++ b/Cable/Chargers/ChargerConfiguration.swift @@ -12,6 +12,8 @@ struct ChargerConfiguration: Identifiable, Hashable { var colorName: String var system: ElectricalSystem var powerSourceType: SavedCharger.PowerSourceType + var componentID: String? + var remoteIconURLString: String? init( id: UUID = UUID(), @@ -23,7 +25,9 @@ struct ChargerConfiguration: Identifiable, Hashable { iconName: String = "bolt.fill", colorName: String = "orange", system: ElectricalSystem, - powerSourceType: SavedCharger.PowerSourceType = .shore + powerSourceType: SavedCharger.PowerSourceType = .shore, + componentID: String? = nil, + remoteIconURLString: String? = nil ) { self.id = id self.name = name @@ -35,6 +39,8 @@ struct ChargerConfiguration: Identifiable, Hashable { self.colorName = colorName self.system = system self.powerSourceType = powerSourceType + self.componentID = componentID + self.remoteIconURLString = remoteIconURLString } init(savedCharger: SavedCharger, system: ElectricalSystem) { @@ -48,6 +54,8 @@ struct ChargerConfiguration: Identifiable, Hashable { self.colorName = savedCharger.colorName self.system = system self.powerSourceType = savedCharger.sourceType + self.componentID = savedCharger.componentID + self.remoteIconURLString = savedCharger.remoteIconURLString } var effectivePowerWatts: Double { @@ -67,6 +75,8 @@ struct ChargerConfiguration: Identifiable, Hashable { savedCharger.colorName = colorName savedCharger.system = system savedCharger.powerSourceType = powerSourceType.rawValue + savedCharger.componentID = componentID + savedCharger.remoteIconURLString = remoteIconURLString savedCharger.timestamp = Date() } } diff --git a/Cable/Chargers/ChargersView.swift b/Cable/Chargers/ChargersView.swift index 603aa5c..96fd7ee 100644 --- a/Cable/Chargers/ChargersView.swift +++ b/Cable/Chargers/ChargersView.swift @@ -7,6 +7,7 @@ struct ChargersView: View { let onAdd: () -> Void let onEdit: (SavedCharger) -> Void let onDelete: (IndexSet) -> Void + let onBrowseLibrary: () -> Void private struct SummaryMetric: Identifiable { let id: String @@ -94,13 +95,15 @@ struct ChargersView: View { editMode: Binding = .constant(.inactive), onAdd: @escaping () -> Void = {}, onEdit: @escaping (SavedCharger) -> Void = { _ in }, - onDelete: @escaping (IndexSet) -> Void = { _ in } + onDelete: @escaping (IndexSet) -> Void = { _ in }, + onBrowseLibrary: @escaping () -> Void = {} ) { self.system = system self.chargers = chargers self.onAdd = onAdd self.onEdit = onEdit self.onDelete = onDelete + self.onBrowseLibrary = onBrowseLibrary _editMode = editMode } @@ -244,7 +247,8 @@ struct ChargersView: View { private var emptyState: some View { OnboardingInfoView( configuration: .charger(), - onPrimaryAction: onAdd + onPrimaryAction: onAdd, + onSecondaryAction: onBrowseLibrary ) .padding(.horizontal, 0) } diff --git a/Cable/ScreenshotPreviews.swift b/Cable/ScreenshotPreviews.swift index 51a8d8f..32f3d33 100644 --- a/Cable/ScreenshotPreviews.swift +++ b/Cable/ScreenshotPreviews.swift @@ -221,56 +221,56 @@ private struct ComponentLibraryScreenshot: View { translations: ["de": "Navigationslichter", "es": "Luces de navegación", "fr": "Feux de navigation", "nl": "Navigatieverlichting"], voltageIn: 12.8, voltageOut: nil, watt: 25, dutyCyclePercent: 100, defaultUtilizationFactorPercent: 40, - iconURL: nil + componentCategory: nil, iconURL: nil ), ComponentLibraryItem( id: "2", name: "Refrigerator Compressor", translations: ["de": "Kühlschrank-Kompressor", "es": "Compresor de refrigerador", "fr": "Compresseur réfrigérateur", "nl": "Koelkastcompressor"], voltageIn: 12.8, voltageOut: nil, watt: 48, dutyCyclePercent: 40, defaultUtilizationFactorPercent: 100, - iconURL: nil + componentCategory: nil, iconURL: nil ), ComponentLibraryItem( id: "3", name: "Anchor Windlass", translations: ["de": "Ankerwinde", "es": "Molinete de ancla", "fr": "Guindeau", "nl": "Ankerlier"], voltageIn: 12.8, voltageOut: nil, watt: 960, dutyCyclePercent: 5, defaultUtilizationFactorPercent: 2, - iconURL: nil + componentCategory: nil, iconURL: nil ), ComponentLibraryItem( id: "4", name: "VHF Radio", translations: ["de": "UKW Funkgerät", "es": "Radio VHF", "fr": "Radio VHF", "nl": "Marifoon"], voltageIn: 12.8, voltageOut: nil, watt: 72, dutyCyclePercent: 30, defaultUtilizationFactorPercent: 33, - iconURL: nil + componentCategory: nil, iconURL: nil ), ComponentLibraryItem( id: "5", name: "LED Interior Lights", translations: ["de": "LED Innenbeleuchtung", "es": "Iluminación LED interior", "fr": "Éclairage LED intérieur", "nl": "LED binnenverlichting"], voltageIn: 12.8, voltageOut: nil, watt: 18, dutyCyclePercent: 100, defaultUtilizationFactorPercent: 25, - iconURL: nil + componentCategory: nil, iconURL: nil ), ComponentLibraryItem( id: "6", name: "Water Pump", translations: ["de": "Wasserpumpe", "es": "Bomba de agua", "fr": "Pompe à eau", "nl": "Waterpomp"], voltageIn: 12.8, voltageOut: nil, watt: 42, dutyCyclePercent: 20, defaultUtilizationFactorPercent: 10, - iconURL: nil + componentCategory: nil, iconURL: nil ), ComponentLibraryItem( id: "7", name: "Diesel Heater", translations: ["de": "Dieselheizung", "es": "Calefactor diésel", "fr": "Chauffage diesel", "nl": "Dieselverwarming"], voltageIn: 12.8, voltageOut: nil, watt: 36, dutyCyclePercent: 60, defaultUtilizationFactorPercent: 50, - iconURL: nil + componentCategory: nil, iconURL: nil ), ComponentLibraryItem( id: "8", name: "USB Charging Station", 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, dutyCyclePercent: 100, defaultUtilizationFactorPercent: 30, - iconURL: nil + componentCategory: nil, iconURL: nil ), ] } diff --git a/Cable/Systems/SystemComponentsPersistence.swift b/Cable/Systems/SystemComponentsPersistence.swift index 14dd082..ceed1bc 100644 --- a/Cable/Systems/SystemComponentsPersistence.swift +++ b/Cable/Systems/SystemComponentsPersistence.swift @@ -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( for system: ElectricalSystem, existingLoads: [SavedLoad], @@ -160,7 +241,8 @@ struct SystemComponentsPersistence { maximumTemperatureCelsius: configuration.maximumTemperatureCelsius, iconName: configuration.iconName, colorName: configuration.colorName, - system: system + system: system, + componentID: configuration.componentID ) context.insert(newBattery) } @@ -184,7 +266,9 @@ struct SystemComponentsPersistence { maxPowerWatts: configuration.maxPowerWatts, iconName: configuration.iconName, colorName: configuration.colorName, - system: system + system: system, + remoteIconURLString: configuration.remoteIconURLString, + componentID: configuration.componentID ) context.insert(newCharger) } diff --git a/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteriesTab.kt b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteriesTab.kt index b86d53a..cfbf315 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteriesTab.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteriesTab.kt @@ -19,8 +19,10 @@ import androidx.compose.material.icons.outlined.BatteryChargingFull import androidx.compose.material.icons.outlined.BatteryFull import androidx.compose.material.icons.outlined.Bolt 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.Warning +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -63,6 +65,7 @@ fun BatteriesTab( state: DetailState, onEditBattery: (String) -> Unit, onNewBattery: () -> Unit, + onOpenLibrary: () -> Unit, onDeleteBattery: (SavedBattery) -> Unit, ) { val batteries = state.batteries @@ -73,11 +76,15 @@ fun BatteriesTab( subtitle = stringResource(R.string.battery_onboarding_subtitle), primaryLabel = stringResource(R.string.battery_empty_create), onPrimary = onNewBattery, + secondaryLabel = stringResource(R.string.loads_empty_library), + onSecondary = onOpenLibrary, + images = listOf(R.drawable.onboarding_battery), ) return } val m = state.metrics + Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { StatsHeader { 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 -> 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 diff --git a/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorScreen.kt index 4584449..147abc2 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorScreen.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -90,6 +91,9 @@ fun BatteryEditorScreen(systemId: String, batteryId: String?, onBack: () -> Unit title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) }, + actions = { + TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) } + }, ) }, ) { padding -> diff --git a/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorScreen.kt index 33b59fe..c41a028 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorScreen.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorScreen.kt @@ -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)) } }, title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) }, + actions = { + TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) } + }, ) }, ) { padding -> diff --git a/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargersTab.kt b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargersTab.kt index f8a5aed..1aaabea 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargersTab.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargersTab.kt @@ -2,6 +2,7 @@ package app.voltplan.cable.ui.chargers import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.BatteryChargingFull import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.LibraryBooks import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -55,6 +58,7 @@ fun ChargersTab( state: DetailState, onEditCharger: (String) -> Unit, onNewCharger: () -> Unit, + onOpenLibrary: () -> Unit, onDeleteCharger: (SavedCharger) -> Unit, ) { val chargers = state.chargers @@ -65,11 +69,15 @@ fun ChargersTab( subtitle = stringResource(R.string.chargers_onboarding_subtitle), primaryLabel = stringResource(R.string.chargers_onboarding_primary), onPrimary = onNewCharger, + secondaryLabel = stringResource(R.string.loads_empty_library), + onSecondary = onOpenLibrary, + images = listOf(R.drawable.onboarding_charger), ) return } val m = state.metrics + Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { StatsHeader { 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) } } - 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 -> 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 diff --git a/android/app/src/main/java/app/voltplan/cable/ui/components/AppearanceEditor.kt b/android/app/src/main/java/app/voltplan/cable/ui/components/AppearanceEditor.kt index d34f429..ab8fc92 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/components/AppearanceEditor.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/components/AppearanceEditor.kt @@ -3,18 +3,17 @@ package app.voltplan.cable.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.border 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -69,6 +68,7 @@ fun AppearanceEditorSheet( Column( Modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp) .padding(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -113,55 +113,41 @@ fun AppearanceEditorSheet( extra?.invoke() Text("Icon", style = MaterialTheme.typography.titleSmall) - LazyVerticalGrid( - columns = GridCells.Fixed(5), - modifier = Modifier.fillMaxWidth().heightForRows(icons.size, 5), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - userScrollEnabled = false, - ) { - items(icons) { symbol -> - val selected = symbol == icon - Box( - Modifier - .aspectRatio(1f) - .clip(RoundedCornerShape(12.dp)) - .background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant) - .clickable { icon = symbol }, - contentAlignment = Alignment.Center, - ) { - Icon( - sfSymbol(symbol), - contentDescription = symbol, - tint = if (selected) Color.White else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), - ) - } + GridRows(items = icons, columns = 5) { symbol -> + val selected = symbol == icon + Box( + Modifier + .weight(1f) + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant) + .clickable { icon = symbol }, + contentAlignment = Alignment.Center, + ) { + 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) - LazyVerticalGrid( - columns = GridCells.Fixed(6), - modifier = Modifier.fillMaxWidth().heightForRows(curatedColorNames.size, 6), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - userScrollEnabled = false, - ) { - items(curatedColorNames) { colorName -> - val c = componentColor(colorName) - Box( - Modifier - .aspectRatio(1f) - .clip(CircleShape) - .background(c) - .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)) - } + GridRows(items = curatedColorNames, columns = 6) { colorName -> + val c = componentColor(colorName) + Box( + Modifier + .weight(1f) + .aspectRatio(1f) + .clip(CircleShape) + .background(c) + .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 - // ~56dp per cell including spacing; gives a non-scrolling grid inside the sheet. - return this.height((rows * 56).dp) +/** + * Lays out [items] in a non-lazy grid of fixed [columns], sizing to its content so it can live + * inside a vertically scrolling container without a fixed height. Empty trailing cells are padded + * with spacers so cells keep equal widths on the final row. + */ +@Composable +private fun GridRows( + items: List, + 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)) + } + } + } + } } diff --git a/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorScreen.kt index 02d6306..1d7397a 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorScreen.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorScreen.kt @@ -110,6 +110,9 @@ fun CalculatorScreen(systemId: String, loadId: String?, onBack: () -> Unit) { Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp)) } }, + actions = { + TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) } + }, ) }, ) { padding -> diff --git a/android/app/src/main/java/app/voltplan/cable/ui/loads/ComponentsTab.kt b/android/app/src/main/java/app/voltplan/cable/ui/loads/ComponentsTab.kt index 81027c5..fa6453f 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/loads/ComponentsTab.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/loads/ComponentsTab.kt @@ -69,6 +69,7 @@ fun ComponentsTab( onPrimary = onNewLoad, secondaryLabel = stringResource(R.string.loads_empty_library), onSecondary = onOpenLibrary, + images = listOf(R.drawable.onboarding_coffee, R.drawable.onboarding_router, R.drawable.onboarding_charger), ) return } diff --git a/android/app/src/main/java/app/voltplan/cable/ui/navigation/CableNavHost.kt b/android/app/src/main/java/app/voltplan/cable/ui/navigation/CableNavHost.kt index 35476e0..8efdc76 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/navigation/CableNavHost.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/navigation/CableNavHost.kt @@ -8,6 +8,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import app.voltplan.cable.ui.batteries.BatteryEditorScreen 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.library.ComponentLibraryScreen import app.voltplan.cable.ui.loads.CalculatorScreen @@ -22,11 +23,17 @@ object Routes { const val BATTERY = "battery/{systemId}?batteryId={batteryId}" const val CHARGER = "charger/{systemId}?chargerId={chargerId}" const val BOM = "bom/{systemId}" - const val LIBRARY = "library?systemId={systemId}" + const val LIBRARY = "library?systemId={systemId}&type={type}" const val SETTINGS = "settings" 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) = "calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "") fun battery(systemId: String, batteryId: String? = null) = @@ -64,7 +71,7 @@ fun CableNavHost() { onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) }, onNewCharger = { nav.navigate(Routes.charger(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( 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 -> ComponentLibraryScreen( targetSystemId = entry.arguments?.getString("systemId"), + libraryType = ComponentLibraryType.fromArg(entry.arguments?.getString("type")), onBack = { nav.popBackStack() }, onOpenSystem = { systemId -> nav.popBackStack() 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)) + }, ) } diff --git a/android/app/src/main/java/app/voltplan/cable/ui/overview/OverviewTab.kt b/android/app/src/main/java/app/voltplan/cable/ui/overview/OverviewTab.kt index 15cd882..92891fe 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/overview/OverviewTab.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/overview/OverviewTab.kt @@ -58,6 +58,9 @@ fun OverviewTab( onAddCharger: () -> Unit, onOpenLibrary: () -> Unit, onOpenBom: () -> Unit, + onSelectLoads: () -> Unit, + onSelectBatteries: () -> Unit, + onSelectChargers: () -> Unit, onSetRuntimeGoal: (Double?) -> Unit, onSetChargeGoal: (Double?) -> Unit, ) { @@ -104,9 +107,9 @@ fun OverviewTab( } } - LoadsCard(state, m, onAddLoad, onOpenLibrary) - BatteriesCard(state, m, onAddBattery) - ChargersCard(state, m, onAddCharger) + LoadsCard(state, m, onAddLoad, onOpenLibrary, onSelectLoads) + BatteriesCard(state, m, onAddBattery, onSelectBatteries) + ChargersCard(state, m, onAddCharger, onSelectChargers) } goalEditor?.let { kind -> @@ -163,9 +166,10 @@ private fun MetricRow( } @Composable -private fun OverviewCard(title: String, content: @Composable () -> Unit) { +private fun OverviewCard(title: String, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) { 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), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), ) { @@ -177,8 +181,8 @@ private fun OverviewCard(title: String, content: @Composable () -> Unit) { } @Composable -private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit) { - OverviewCard(stringResource(R.string.loads_overview_header_title)) { +private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit, onSelect: () -> Unit) { + OverviewCard(stringResource(R.string.loads_overview_header_title), onClick = if (state.loads.isEmpty()) null else onSelect) { if (state.loads.isEmpty()) { 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) @@ -196,8 +200,8 @@ private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Uni } @Composable -private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit) { - OverviewCard(stringResource(R.string.battery_bank_header_title)) { +private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit, onSelect: () -> Unit) { + OverviewCard(stringResource(R.string.battery_bank_header_title), onClick = if (state.batteries.isEmpty()) null else onSelect) { if (state.batteries.isEmpty()) { Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium) Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) } @@ -212,8 +216,8 @@ private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () } @Composable -private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit) { - OverviewCard(stringResource(R.string.overview_chargers_header_title)) { +private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit, onSelect: () -> Unit) { + OverviewCard(stringResource(R.string.overview_chargers_header_title), onClick = if (state.chargers.isEmpty()) null else onSelect) { if (state.chargers.isEmpty()) { 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) diff --git a/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsScreen.kt index d5ffe2c..50660ee 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsScreen.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.outlined.Add 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.Settings import androidx.compose.material3.Button @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import app.voltplan.cable.R +import app.voltplan.cable.ui.components.OnboardingCarousel import app.voltplan.cable.ui.sfSymbol import app.voltplan.cable.ui.theme.componentColor @@ -174,7 +175,10 @@ private fun SystemsOnboarding(modifier: Modifier = Modifier, onCreate: (String) horizontalAlignment = Alignment.CenterHorizontally, 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)) Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Spacer(Modifier.size(8.dp))