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:
2026-06-04 01:04:47 +02:00
parent d97e3a2b7c
commit 23b117bfe2
16 changed files with 287 additions and 89 deletions

View File

@@ -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`.

View File

@@ -44,6 +44,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
var iconName: String
var colorName: String
var system: ElectricalSystem
var componentID: String?
init(
id: UUID = UUID(),
@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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<EditMode> = .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)
}

View File

@@ -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
),
]
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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 ->

View File

@@ -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 ->

View File

@@ -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

View File

@@ -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 <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))
}
}
}
}
}

View File

@@ -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 ->

View File

@@ -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
}

View File

@@ -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))
},
)
}

View File

@@ -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)

View File

@@ -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))