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.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,17 +113,11 @@ 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 ->
|
||||
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)
|
||||
@@ -138,20 +132,13 @@ fun AppearanceEditorSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
GridRows(items = curatedColorNames, columns = 6) { colorName ->
|
||||
val c = componentColor(colorName)
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.background(c)
|
||||
@@ -166,11 +153,27 @@ 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user