Compare commits

...

7 Commits

Author SHA1 Message Date
89ee36c1a4 Add in-app rating prompt (iOS + Android)
Request an App Store / Play Store review after a successful export
(Overview PDF, BOM PDF, or wiring diagram). A shared gate keeps prompts
rare: >=2 successful exports, >=3 days since install, >=120 days since the
last prompt, and at most once per app version. A one-time migration
backdates existing users so the prompt can fire on their first export
after updating. Logs a "Review Prompt Requested" analytics event.

iOS uses StoreKit's AppStore.requestReview(in:) with UserDefaults state;
Android uses the Play In-App Review API with DataStore state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:05:24 +02:00
23b117bfe2 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>
2026-06-04 01:04:47 +02:00
d97e3a2b7c Add standalone wiring diagram PNG export
Fetch the wiring diagram from the VoltPlan API and share it as a
standalone PNG from the Overview share menu on both platforms, with a
localized error when the diagram cannot be generated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:04:11 +02:00
67ec44e60a Refine component library (iOS + Android)
Tweaks to the PocketBase-backed component library: item parsing,
repository/view-model fetching, and the library screen UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:04:00 +02:00
0aa3184406 Add onboarding image carousel
Bundle the rotating onboarding illustrations (light/dark) and wire them
into the Android onboarding info component, matching the iOS carousel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:03:53 +02:00
38118ebc36 android: refresh adaptive launcher icon
Add density-specific launcher PNGs with a new foreground, a monochrome
layer for themed icons, and a light background; drop the old vector
foreground.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:03:45 +02:00
b1fbac3ec1 android: load release signing from keystore.properties
Read upload-keystore credentials from a gitignored keystore.properties
(falling back to unsigned release when absent), bundle full native debug
symbols for Play crash symbolication, and ignore keystore secrets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:03:38 +02:00
83 changed files with 1273 additions and 205 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

@@ -21,6 +21,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
AnalyticsTracker.log("First Launch")
}
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
AnalyticsTracker.log("App Launched")
return true
}

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

@@ -1,5 +1,16 @@
import SwiftUI
enum ComponentLibraryType: String, Identifiable, CaseIterable {
case load
case battery
case charger
var id: String { rawValue }
/// PocketBase filter expression selecting this type.
var filterValue: String { "type='\(rawValue)'" }
}
struct ComponentLibraryItem: Identifiable, Equatable {
let id: String
let name: String
@@ -9,6 +20,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let watt: Double?
let dutyCyclePercent: Double?
let defaultUtilizationFactorPercent: Double?
let componentCategory: String?
let iconURL: URL?
var displayVoltage: Double? {
@@ -20,6 +32,52 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return power / voltage
}
/// Battery capacity derived from stored energy (Wh) and nominal voltage.
var capacityAmpHours: Double? {
guard let energy = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
return energy / voltage
}
/// Charger output current derived from rated power and output voltage.
var outputCurrent: Double? {
guard let power = watt, let voltage = voltageOut ?? displayVoltage, voltage > 0 else { return nil }
return power / voltage
}
var capacityLabel: String? {
guard let capacity = capacityAmpHours else { return nil }
return String(format: "%.0fAh", capacity)
}
var energyLabel: String? {
guard let energy = watt else { return nil }
return String(format: "%.0fWh", energy)
}
var voltageRangeLabel: String? {
if let input = voltageIn, let output = voltageOut {
return String(format: "%.0fV → %.0fV", input, output)
}
return voltageLabel
}
var outputCurrentLabel: String? {
guard let current = outputCurrent else { return nil }
return String(format: "%.1fA", current)
}
/// Detail metrics shown in a library row, tailored to the component type.
func detailLabels(for type: ComponentLibraryType) -> [String] {
switch type {
case .load:
return [voltageLabel, powerLabel, currentLabel].compactMap { $0 }
case .battery:
return [voltageLabel, capacityLabel, energyLabel].compactMap { $0 }
case .charger:
return [voltageRangeLabel, outputCurrentLabel, powerLabel].compactMap { $0 }
}
}
var voltageLabel: String? {
guard let voltage = displayVoltage else { return nil }
return String(format: "%.1fV", voltage)
@@ -173,12 +231,15 @@ final class ComponentLibraryViewModel: ObservableObject {
private let baseURL = URL(string: "https://base.voltplan.app")!
private let urlSession: URLSession
let libraryType: ComponentLibraryType
init(urlSession: URLSession = .shared) {
init(libraryType: ComponentLibraryType = .load, urlSession: URLSession = .shared) {
self.libraryType = libraryType
self.urlSession = urlSession
}
init(previewItems: [ComponentLibraryItem]) {
init(previewItems: [ComponentLibraryItem], libraryType: ComponentLibraryType = .load) {
self.libraryType = libraryType
self.urlSession = .shared
self.items = previewItems
self.isLoading = false
@@ -216,11 +277,11 @@ final class ComponentLibraryViewModel: ObservableObject {
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "filter", value: "(type='load')"),
URLQueryItem(name: "filter", value: "(\(libraryType.filterValue))"),
URLQueryItem(name: "sort", value: "+name"),
URLQueryItem(
name: "fields",
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor"
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor,component_category"
),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "perPage", value: "\(perPage)")
@@ -266,6 +327,7 @@ final class ComponentLibraryViewModel: ObservableObject {
watt: record.watt,
dutyCyclePercent: record.dutyCycle,
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
componentCategory: record.componentCategory,
iconURL: iconURL(for: record)
)
}
@@ -312,6 +374,7 @@ final class ComponentLibraryViewModel: ObservableObject {
let watt: Double?
let dutyCycle: Double?
let defaultUtilizationFactor: Double?
let componentCategory: String?
enum CodingKeys: String, CodingKey {
case id
@@ -324,6 +387,7 @@ final class ComponentLibraryViewModel: ObservableObject {
case watt
case dutyCycle = "duty_cycle"
case defaultUtilizationFactor = "default_utilization_factor"
case componentCategory = "component_category"
}
struct TranslationsContainer: Decodable {
@@ -385,14 +449,17 @@ struct ComponentLibraryView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ComponentLibraryViewModel
@State private var searchText: String = ""
private let libraryType: ComponentLibraryType
let onSelect: (ComponentLibraryItem) -> Void
init(onSelect: @escaping (ComponentLibraryItem) -> Void) {
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel())
init(libraryType: ComponentLibraryType = .load, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self.libraryType = libraryType
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel(libraryType: libraryType))
self.onSelect = onSelect
}
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self.libraryType = viewModel.libraryType
self._viewModel = StateObject(wrappedValue: viewModel)
self.onSelect = onSelect
}
@@ -463,7 +530,7 @@ struct ComponentLibraryView: View {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
ComponentRow(item: item, libraryType: libraryType)
}
.buttonStyle(.plain)
}
@@ -508,6 +575,7 @@ struct ComponentLibraryView: View {
private struct ComponentRow: View {
let item: ComponentLibraryItem
let libraryType: ComponentLibraryType
var body: some View {
HStack(spacing: 12) {
@@ -529,15 +597,23 @@ private struct ComponentRow: View {
private var iconView: some View {
LoadIconView(
remoteIconURLString: item.iconURL?.absoluteString,
fallbackSystemName: "bolt",
fallbackSystemName: fallbackIcon,
fallbackColor: Color.blue.opacity(0.15),
size: 44
)
}
private var fallbackIcon: String {
switch libraryType {
case .load: return "bolt"
case .battery: return "battery.100"
case .charger: return "bolt.fill"
}
}
@ViewBuilder
private var detailLine: some View {
let labels = [item.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 }
let labels = item.detailLabels(for: libraryType)
if labels.isEmpty {
Text("Details coming soon")

View File

@@ -19,7 +19,7 @@ struct LoadsView: View {
@State private var showingSystemEditor = false
@State private var hasPresentedSystemEditorOnAppear = false
@State private var hasOpenedLoadOnAppear = false
@State private var showingComponentLibrary = false
@State private var activeLibrary: ComponentLibraryType?
@State private var showingSystemBOM = false
@State private var selectedComponentTab: ComponentTab
@State private var batteryDraft: BatteryConfiguration?
@@ -86,23 +86,7 @@ struct LoadsView: View {
.accessibilityIdentifier("components-tab")
}
Group {
if savedBatteries.isEmpty {
OnboardingInfoView(
configuration: .battery(),
onPrimaryAction: { startBatteryConfiguration() }
)
} else {
BatteriesView(
system: system,
batteries: savedBatteries,
editMode: $editMode,
onEdit: { editBattery($0) },
onDelete: deleteBatteries
)
.environment(\.editMode, $editMode)
}
}
batteriesTab
.tag(ComponentTab.batteries)
.tabItem {
Label(
@@ -117,14 +101,7 @@ struct LoadsView: View {
}
.environment(\.editMode, $editMode)
ChargersView(
system: system,
chargers: savedChargers,
editMode: $editMode,
onAdd: { startChargerConfiguration() },
onEdit: { editCharger($0) },
onDelete: deleteChargers
)
chargersTab
.tag(ComponentTab.chargers)
.tabItem {
Label(
@@ -272,9 +249,9 @@ struct LoadsView: View {
exportDiagramImage()
}
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponent(item)
.sheet(item: $activeLibrary) { type in
ComponentLibraryView(libraryType: type) { item in
handleLibrarySelection(item, for: type)
}
}
.sheet(isPresented: $showingSystemBOM) {
@@ -439,9 +416,9 @@ struct LoadsView: View {
}
}
private var libraryButton: some View {
private func libraryButton(type: ComponentLibraryType) -> some View {
Button {
openComponentLibrary(source: "library-button")
openComponentLibrary(source: "library-button", type: type)
} label: {
Group {
if #available(iOS 26.0, *) {
@@ -490,6 +467,53 @@ struct LoadsView: View {
.background(Color(.systemGroupedBackground))
}
private var batteriesTab: some View {
Group {
if savedBatteries.isEmpty {
OnboardingInfoView(
configuration: .battery(),
onPrimaryAction: { startBatteryConfiguration() },
onSecondaryAction: { openComponentLibrary(source: "batteries-onboarding", type: .battery) }
)
} else {
BatteriesView(
system: system,
batteries: savedBatteries,
editMode: $editMode,
onEdit: { editBattery($0) },
onDelete: deleteBatteries
)
.environment(\.editMode, $editMode)
}
}
.overlay(alignment: .bottomTrailing) {
if !savedBatteries.isEmpty {
libraryButton(type: .battery)
.padding(.trailing, 24)
.padding(.bottom, 24)
}
}
}
private var chargersTab: some View {
ChargersView(
system: system,
chargers: savedChargers,
editMode: $editMode,
onAdd: { startChargerConfiguration() },
onEdit: { editCharger($0) },
onDelete: deleteChargers,
onBrowseLibrary: { openComponentLibrary(source: "chargers-onboarding", type: .charger) }
)
.overlay(alignment: .bottomTrailing) {
if !savedChargers.isEmpty {
libraryButton(type: .charger)
.padding(.trailing, 24)
.padding(.bottom, 24)
}
}
}
@ViewBuilder
private var loadsListWithHeader: some View {
Group {
@@ -507,7 +531,7 @@ struct LoadsView: View {
}
}
.overlay(alignment: .bottomTrailing) {
libraryButton
libraryButton(type: .load)
.padding(.trailing, 24)
.padding(.bottom, 24)
}
@@ -802,15 +826,63 @@ struct LoadsView: View {
showingSystemEditor = true
}
private func openComponentLibrary(source: String) {
private func openComponentLibrary(source: String, type: ComponentLibraryType = .load) {
AnalyticsTracker.log(
"Component Library Opened",
properties: [
"source": source,
"type": type.rawValue,
"system": system.name
]
)
showingComponentLibrary = true
activeLibrary = type
}
private func handleLibrarySelection(_ item: ComponentLibraryItem, for type: ComponentLibraryType) {
switch type {
case .load:
addComponent(item)
case .battery:
addBatteryFromLibrary(item)
case .charger:
addChargerFromLibrary(item)
}
}
private func addBatteryFromLibrary(_ item: ComponentLibraryItem) {
AnalyticsTracker.log(
"Library Battery Added",
properties: [
"id": item.id,
"name": item.localizedName,
"system": system.name
]
)
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
from: item,
for: system,
existingLoads: savedLoads,
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
}
private func addChargerFromLibrary(_ item: ComponentLibraryItem) {
AnalyticsTracker.log(
"Library Charger Added",
properties: [
"id": item.id,
"name": item.localizedName,
"system": system.name
]
)
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
from: item,
for: system,
existingLoads: savedLoads,
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
}
private func openBillOfMaterials() {
@@ -1061,6 +1133,7 @@ struct LoadsView: View {
await MainActor.run {
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
isExportingOverview = false
ReviewPrompt.registerSuccessfulExport()
}
} catch {
await MainActor.run {
@@ -1090,6 +1163,7 @@ struct LoadsView: View {
"system": snapshot.systemName,
])
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
ReviewPrompt.registerSuccessfulExport()
} else {
overviewExportError = OverviewExportError(
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")

View File

@@ -141,8 +141,8 @@ extension OnboardingInfoView.Configuration {
subtitle: LocalizedStringKey("battery.onboarding.subtitle"),
primaryActionTitle: LocalizedStringKey("battery.overview.empty.create"),
primaryActionIcon: "plus",
secondaryActionTitle: nil,
secondaryActionIcon: nil,
secondaryActionTitle: LocalizedStringKey("loads.overview.empty.library"),
secondaryActionIcon: "books.vertical",
imageNames: [
"battery-onboarding"
]
@@ -155,8 +155,8 @@ extension OnboardingInfoView.Configuration {
subtitle: LocalizedStringKey("chargers.onboarding.subtitle"),
primaryActionTitle: LocalizedStringKey("chargers.onboarding.primary"),
primaryActionIcon: "plus",
secondaryActionTitle: nil,
secondaryActionIcon: nil,
secondaryActionTitle: LocalizedStringKey("loads.overview.empty.library"),
secondaryActionIcon: "books.vertical",
imageNames: [
"charger-onboarding"
]

110
Cable/ReviewPrompt.swift Normal file
View File

@@ -0,0 +1,110 @@
//
// ReviewPrompt.swift
// Cable
//
// Decides when to ask the user for an App Store rating via StoreKit's
// `AppStore.requestReview(in:)`. The OS throttles the actual dialog (max ~3×/year and
// may show nothing at all), so this gate keeps requests rare and tied to genuine success
// moments a completed export/share. Mirrors the Android `ReviewPrompt` object.
//
import Foundation
import StoreKit
import UIKit
enum ReviewPrompt {
private enum Key {
static let migrationDone = "review.migrationDone"
static let firstLaunchDate = "review.firstLaunchDate"
static let exportCount = "review.successfulExportCount"
static let lastPromptDate = "review.lastPromptDate"
static let lastPromptedVersion = "review.lastPromptedVersion"
static let userType = "review.userType"
}
/// Gate thresholds see CLAUDE-discussed spec.
private static let minExports = 2
private static let minDaysSinceInstall: TimeInterval = 3
private static let minDaysBetweenPrompts: TimeInterval = 120
private static let day: TimeInterval = 86_400
private static var defaults: UserDefaults { .standard }
/// One-time setup distinguishing fresh installs from users updating into this feature.
/// Existing users are backdated and pre-seeded so the prompt can fire on their *first*
/// successful export after updating. Pass the `isFirstLaunch` value already computed in
/// `AppDelegate` (the existing `hasLaunchedBefore` flag).
static func migrateIfNeeded(isFirstLaunch: Bool) {
guard !defaults.bool(forKey: Key.migrationDone) else { return }
let now = Date().timeIntervalSince1970
if isFirstLaunch {
// Genuine new install: normal flow needs 2 exports and 3 days.
defaults.set(now, forKey: Key.firstLaunchDate)
defaults.set(0, forKey: Key.exportCount)
defaults.set("new", forKey: Key.userType)
} else {
// Existing user updating in: backdate install past the age gate and pre-seed the
// counter so the very next successful export satisfies the gate.
defaults.set(now - minDaysSinceInstall * day, forKey: Key.firstLaunchDate)
defaults.set(minExports - 1, forKey: Key.exportCount)
defaults.set("existing", forKey: Key.userType)
}
defaults.set(true, forKey: Key.migrationDone)
}
/// Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
/// Increments the shared counter, then requests a review if every gate condition holds.
@MainActor
static func registerSuccessfulExport() {
// Guard against an export that races ahead of migration.
if defaults.object(forKey: Key.firstLaunchDate) == nil {
defaults.set(Date().timeIntervalSince1970, forKey: Key.firstLaunchDate)
}
let count = defaults.integer(forKey: Key.exportCount) + 1
defaults.set(count, forKey: Key.exportCount)
guard shouldRequest(exportCount: count) else { return }
request()
}
private static func shouldRequest(exportCount: Int) -> Bool {
// A: enough successful exports
guard exportCount >= minExports else { return false }
let now = Date().timeIntervalSince1970
// B: installed long enough
let firstLaunch = defaults.double(forKey: Key.firstLaunchDate)
guard now - firstLaunch >= minDaysSinceInstall * day else { return false }
// C: not prompted too recently
let lastPrompt = defaults.double(forKey: Key.lastPromptDate)
if lastPrompt > 0, now - lastPrompt < minDaysBetweenPrompts * day { return false }
// D: at most once per app version
if defaults.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
return true
}
@MainActor
private static func request() {
// Mark as requested up front the OS may suppress the dialog, but we still
// count it against our own throttle so we don't ask again immediately.
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
defaults.set(currentVersion, forKey: Key.lastPromptedVersion)
AnalyticsTracker.log("Review Prompt Requested", properties: [
"version": currentVersion,
"userType": defaults.string(forKey: Key.userType) ?? "unknown",
])
guard let scene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { return }
AppStore.requestReview(in: scene)
}
private static var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
}
}

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

@@ -346,6 +346,7 @@ struct SystemBillOfMaterialsView: View {
)
await MainActor.run {
activeShareItem = ExportedPDFShareItem(url: url)
ReviewPrompt.registerSuccessfulExport()
}
} catch {
await MainActor.run {

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

5
android/.gitignore vendored
View File

@@ -11,3 +11,8 @@
# Local build artifacts
*.apk
# Signing — never commit the keystore or its passwords
keystore.properties
*.jks
*.keystore

View File

@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -6,6 +9,14 @@ plugins {
alias(libs.plugins.ksp)
}
// Release signing credentials, loaded from android/keystore.properties (gitignored).
// Falls back to no release signing when the file/keystore is absent (e.g. CI without secrets).
val keystoreProps = Properties().apply {
val f = rootProject.file("keystore.properties")
if (f.exists()) load(FileInputStream(f))
}
val hasReleaseSigning = keystoreProps.getProperty("storeFile")?.let { file(it).exists() } == true
android {
namespace = "app.voltplan.cable"
compileSdk = 35
@@ -25,17 +36,35 @@ android {
resourceConfigurations += listOf("en", "de", "es", "fr", "nl")
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Bundle native debug symbols so Play can symbolicate native crashes/ANRs.
ndk {
debugSymbolLevel = "FULL"
}
}
}
@@ -86,4 +115,6 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
implementation(libs.play.review.ktx)
}

View File

@@ -3,6 +3,7 @@ package app.voltplan.cable
import android.app.Application
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.CableRepository
import app.voltplan.cable.data.ReviewPrompt
import app.voltplan.cable.data.UnitSystemSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -24,9 +25,11 @@ class CableApplication : Application() {
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
if (settings.consumeFirstLaunch()) {
val isFirstLaunch = settings.consumeFirstLaunch()
if (isFirstLaunch) {
Analytics.log("First Launch")
}
ReviewPrompt.migrateIfNeeded(this@CableApplication, isFirstLaunch)
Analytics.log("App Launched")
}
}

View File

@@ -0,0 +1,128 @@
package app.voltplan.cable.data
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import app.voltplan.cable.BuildConfig
import app.voltplan.cable.analytics.Analytics
import com.google.android.play.core.ktx.launchReview
import com.google.android.play.core.ktx.requestReview
import com.google.android.play.core.review.ReviewManagerFactory
import kotlinx.coroutines.flow.first
/**
* Decides when to ask the user for a Play Store rating via the Play In-App Review API.
* Google throttles the actual dialog (and shows nothing in debug/sideload builds), so this gate
* keeps requests rare and tied to genuine success moments — a completed export/share.
* Mirrors the iOS `ReviewPrompt` enum, sharing the same gate thresholds and the `cable_settings`
* DataStore so both platforms behave identically.
*/
object ReviewPrompt {
private val MIGRATION_DONE = stringPreferencesKey("review.migrationDone")
private val FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate")
private val EXPORT_COUNT = intPreferencesKey("review.successfulExportCount")
private val LAST_PROMPT_DATE = longPreferencesKey("review.lastPromptDate")
private val LAST_PROMPTED_VERSION = stringPreferencesKey("review.lastPromptedVersion")
private val USER_TYPE = stringPreferencesKey("review.userType")
private const val MIN_EXPORTS = 2
private const val MIN_DAYS_SINCE_INSTALL = 3L
private const val MIN_DAYS_BETWEEN_PROMPTS = 120L
private const val DAY_MS = 24L * 60 * 60 * 1000
/**
* One-time setup distinguishing fresh installs from users updating into this feature.
* Existing users are backdated and pre-seeded so the prompt can fire on their *first*
* successful export after updating. Pass the value returned by [UnitSystemSettings.consumeFirstLaunch].
*/
suspend fun migrateIfNeeded(context: Context, isFirstLaunch: Boolean) {
if (context.dataStore.data.first()[MIGRATION_DONE] != null) return
val now = System.currentTimeMillis()
context.dataStore.edit {
if (isFirstLaunch) {
// Genuine new install: normal flow — needs 2 exports and 3 days.
it[FIRST_LAUNCH_DATE] = now
it[EXPORT_COUNT] = 0
it[USER_TYPE] = "new"
} else {
// Existing user updating in: backdate install past the age gate and pre-seed the
// counter so the very next successful export satisfies the gate.
it[FIRST_LAUNCH_DATE] = now - MIN_DAYS_SINCE_INSTALL * DAY_MS
it[EXPORT_COUNT] = MIN_EXPORTS - 1
it[USER_TYPE] = "existing"
}
it[MIGRATION_DONE] = "true"
}
}
/**
* Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
* Increments the shared counter, then requests a review if every gate condition holds.
*/
suspend fun registerSuccessfulExport(context: Context) {
var count = 0
var firstLaunch = 0L
context.dataStore.edit {
if (it[FIRST_LAUNCH_DATE] == null) it[FIRST_LAUNCH_DATE] = System.currentTimeMillis()
count = (it[EXPORT_COUNT] ?: 0) + 1
it[EXPORT_COUNT] = count
firstLaunch = it[FIRST_LAUNCH_DATE] ?: 0L
}
if (shouldRequest(context, count, firstLaunch)) {
requestReview(context)
}
}
private suspend fun shouldRequest(context: Context, exportCount: Int, firstLaunch: Long): Boolean {
// A: enough successful exports
if (exportCount < MIN_EXPORTS) return false
val now = System.currentTimeMillis()
// B: installed long enough
if (now - firstLaunch < MIN_DAYS_SINCE_INSTALL * DAY_MS) return false
val prefs = context.dataStore.data.first()
// C: not prompted too recently
val lastPrompt = prefs[LAST_PROMPT_DATE] ?: 0L
if (lastPrompt > 0 && now - lastPrompt < MIN_DAYS_BETWEEN_PROMPTS * DAY_MS) return false
// D: at most once per app version
if (prefs[LAST_PROMPTED_VERSION] == BuildConfig.VERSION_NAME) return false
return true
}
private suspend fun requestReview(context: Context) {
// Mark as requested up front — Google may suppress the dialog, but we still count it
// against our own throttle so we don't ask again immediately.
context.dataStore.edit {
it[LAST_PROMPT_DATE] = System.currentTimeMillis()
it[LAST_PROMPTED_VERSION] = BuildConfig.VERSION_NAME
}
val userType = context.dataStore.data.first()[USER_TYPE] ?: "unknown"
Analytics.log(
"Review Prompt Requested",
mapOf("version" to BuildConfig.VERSION_NAME, "userType" to userType),
)
val activity = context.findActivity() ?: return
runCatching {
val manager = ReviewManagerFactory.create(context)
val reviewInfo = manager.requestReview()
manager.launchReview(activity, reviewInfo)
}
}
private fun Context.findActivity(): Activity? {
var ctx: Context? = this
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
}

View File

@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.first
private val Context.dataStore by preferencesDataStore(name = "cable_settings")
internal val Context.dataStore by preferencesDataStore(name = "cable_settings")
private val UNIT_SYSTEM_KEY = stringPreferencesKey("unitSystem")
private val LAUNCHED_BEFORE_KEY = stringPreferencesKey("hasLaunchedBefore")

View File

@@ -20,6 +20,7 @@ data class ComponentLibraryItem(
val watt: Double?,
val dutyCyclePercent: Double?,
val defaultUtilizationFactorPercent: Double?,
val componentCategory: String?,
val iconURL: String?,
val affiliateLinks: List<AffiliateLink>,
) {
@@ -32,11 +33,43 @@ data class ComponentLibraryItem(
return if (v > 0) w / v else null
}
/** Battery capacity derived from stored energy (Wh) and nominal voltage. */
val capacityAmpHours: Double?
get() {
val v = displayVoltage ?: return null
val w = watt ?: return null
return if (v > 0) w / v else null
}
/** Charger output current derived from rated power and output voltage. */
val outputCurrent: Double?
get() {
val v = voltageOut ?: displayVoltage ?: return null
val w = watt ?: return null
return if (v > 0) w / v else null
}
val localizedName: String get() = resolveLocalizedName(Locale.getDefault()) ?: name
val voltageLabel: String? get() = displayVoltage?.let { String.format(Locale.US, "%.1fV", it) }
val powerLabel: String? get() = watt?.let { String.format(Locale.US, "%.0fW", it) }
val currentLabel: String? get() = current?.let { String.format(Locale.US, "%.1fA", it) }
val capacityLabel: String? get() = capacityAmpHours?.let { String.format(Locale.US, "%.0fAh", it) }
val energyLabel: String? get() = watt?.let { String.format(Locale.US, "%.0fWh", it) }
val voltageRangeLabel: String?
get() = if (voltageIn != null && voltageOut != null) {
String.format(Locale.US, "%.0fV → %.0fV", voltageIn, voltageOut)
} else {
voltageLabel
}
val outputCurrentLabel: String? get() = outputCurrent?.let { String.format(Locale.US, "%.1fA", it) }
/** Detail metrics shown in a library row, tailored to the component type. */
fun detailLabels(type: ComponentLibraryType): List<String> = when (type) {
ComponentLibraryType.LOAD -> listOfNotNull(voltageLabel, powerLabel, currentLabel)
ComponentLibraryType.BATTERY -> listOfNotNull(voltageLabel, capacityLabel, energyLabel)
ComponentLibraryType.CHARGER -> listOfNotNull(voltageRangeLabel, outputCurrentLabel, powerLabel)
}
val normalizedDutyCyclePercent: Double? get() = normalizePercent(dutyCyclePercent)
private val normalizedUtilizationFactorPercent: Double? get() = normalizePercent(defaultUtilizationFactorPercent)
@@ -99,6 +132,7 @@ data class ComponentLibraryItem(
watt = record.watt,
dutyCyclePercent = record.dutyCycle,
defaultUtilizationFactorPercent = record.defaultUtilizationFactor,
componentCategory = record.componentCategory,
iconURL = iconUrl,
affiliateLinks = affiliateLinks,
)

View File

@@ -3,20 +3,20 @@ package app.voltplan.cable.library
/** Fetches and assembles the component library from PocketBase. Mirrors `ComponentLibraryViewModel` data flow. */
class ComponentLibraryRepository(private val api: PocketBaseApi = PocketBaseApi.create()) {
suspend fun fetchAll(): List<ComponentLibraryItem> {
val records = fetchComponents()
suspend fun fetchAll(type: ComponentLibraryType = ComponentLibraryType.LOAD): List<ComponentLibraryItem> {
val records = fetchComponents(type)
val affiliateByComponent = fetchAffiliateLinks(records.map { it.id })
return records.map { record ->
ComponentLibraryItem.from(record, affiliateByComponent[record.id].orEmpty())
}
}
private suspend fun fetchComponents(): List<PbComponentRecord> {
private suspend fun fetchComponents(type: ComponentLibraryType): List<PbComponentRecord> {
val all = mutableListOf<PbComponentRecord>()
var page = 1
val perPage = 200
while (true) {
val response = api.components(page = page, perPage = perPage)
val response = api.components(filter = type.filter, page = page, perPage = perPage)
all += response.items
val done = (response.totalPages in 1..page) || response.items.size < perPage
if (done) break

View File

@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.model.Chemistry
import app.voltplan.cable.data.model.ElectricalSystem
import app.voltplan.cable.data.model.PowerSourceType
import app.voltplan.cable.data.model.SavedBattery
import app.voltplan.cable.data.model.SavedCharger
import app.voltplan.cable.data.model.SavedLoad
import app.voltplan.cable.ui.systems.SystemIconMapper
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,6 +34,7 @@ data class LibraryUiState(
class ComponentLibraryViewModel(
private val app: CableApplication,
val libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
private val libraryRepo: ComponentLibraryRepository = ComponentLibraryRepository(),
) : ViewModel() {
private val repo = app.repository
@@ -41,7 +46,7 @@ class ComponentLibraryViewModel(
fun load() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
runCatching { libraryRepo.fetchAll() }
runCatching { libraryRepo.fetchAll(libraryType) }
.onSuccess { _state.value = _state.value.copy(loading = false, items = it) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Error") }
}
@@ -50,23 +55,21 @@ class ComponentLibraryViewModel(
fun refresh() = load()
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
/** Adds the chosen component as a load. Returns (via [onDone]) the system id to open, or null to just go back. */
fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
viewModelScope.launch {
val systemId: String
val createdNewSystem: Boolean
if (targetSystemId != null) {
systemId = targetSystemId
createdNewSystem = false
} else {
/** Returns the system to add into, creating a new one when [targetSystemId] is null. */
private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> {
if (targetSystemId != null) return targetSystemId to false
val name = repo.uniqueSystemName("New System")
val system = ElectricalSystem(name = name, iconName = SystemIconMapper.iconFor(name), colorName = SystemIconMapper.colorOptions.random())
repo.upsertSystem(system)
Analytics.log("System Created", mapOf("name" to name, "source" to "library"))
systemId = system.id
createdNewSystem = true
return system.id to true
}
/** Adds the chosen component as a load. Returns (via [onDone]) the system id to open, or null to just go back. */
fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
viewModelScope.launch {
val (systemId, createdNewSystem) = ensureSystem(targetSystemId)
val baseName = item.localizedName.ifBlank { "Library Load" }
val loadName = repo.uniqueComponentName(systemId, baseName)
val voltage = item.displayVoltage ?: 12.0
@@ -97,4 +100,79 @@ class ComponentLibraryViewModel(
onDone(if (createdNewSystem) systemId else null)
}
}
/** Adds the chosen component as a battery, then opens its editor via [onDone] (systemId, batteryId). */
fun selectBattery(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String, String) -> Unit) {
viewModelScope.launch {
val (systemId, _) = ensureSystem(targetSystemId)
val baseName = item.localizedName.ifBlank { "New Battery" }
val name = repo.uniqueComponentName(systemId, baseName)
val voltage = item.displayVoltage ?: 12.8
val capacity = item.capacityAmpHours ?: 100.0
val affiliate = item.primaryAffiliateLink
val battery = SavedBattery(
name = name,
nominalVoltage = voltage,
capacityAmpHours = capacity,
chemistryRawValue = Chemistry.LIFEPO4.rawValue,
iconName = "battery.100",
colorName = "blue",
systemId = systemId,
affiliateURLString = affiliate?.url,
affiliateCountryCode = affiliate?.country,
)
repo.upsertBattery(battery)
Analytics.log("Library Battery Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
onDone(systemId, battery.id)
}
}
/** Adds the chosen component as a charger, then opens its editor via [onDone] (systemId, chargerId). */
fun selectCharger(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String, String) -> Unit) {
viewModelScope.launch {
val (systemId, _) = ensureSystem(targetSystemId)
val baseName = item.localizedName.ifBlank { "New Charger" }
val name = repo.uniqueComponentName(systemId, baseName)
val inputVoltage = item.voltageIn ?: 230.0
val outputVoltage = item.voltageOut ?: 14.2
val power = item.watt ?: 0.0
val current = item.outputCurrent ?: if (outputVoltage > 0) power / outputVoltage else 30.0
val sourceType = chargerSourceType(item.componentCategory)
val affiliate = item.primaryAffiliateLink
val charger = SavedCharger(
name = name,
inputVoltage = inputVoltage,
outputVoltage = outputVoltage,
maxCurrentAmps = current,
maxPowerWatts = power,
iconName = sourceType.iconName,
colorName = "orange",
systemId = systemId,
remoteIconURLString = item.iconURL,
affiliateURLString = affiliate?.url,
affiliateCountryCode = affiliate?.country,
powerSourceType = sourceType.rawValue,
)
repo.upsertCharger(charger)
Analytics.log("Library Charger Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
onDone(systemId, charger.id)
}
}
/** Maps a PocketBase `component_category` to a charger power source. */
private fun chargerSourceType(category: String?): PowerSourceType {
val c = category?.lowercase()?.takeUnless { it.isBlank() } ?: return PowerSourceType.SHORE
return when {
"solar" in c -> PowerSourceType.SOLAR
"wind" in c -> PowerSourceType.WIND
"dcdc" in c || "alternator" in c -> PowerSourceType.ALTERNATOR
"generator" in c -> PowerSourceType.GENERATOR
"mains" in c || "shore" in c -> PowerSourceType.SHORE
else -> PowerSourceType.SHORE
}
}
}

View File

@@ -13,6 +13,21 @@ import retrofit2.http.Query
const val POCKETBASE_BASE = "https://base.voltplan.app"
/** The kind of library being browsed. Mirrors the iOS `ComponentLibraryType`. */
enum class ComponentLibraryType(val typeValue: String) {
LOAD("load"),
BATTERY("battery"),
CHARGER("charger");
/** PocketBase filter expression selecting this type. */
val filter: String get() = "type='$typeValue'"
companion object {
fun fromArg(value: String?): ComponentLibraryType =
entries.firstOrNull { it.typeValue == value } ?: LOAD
}
}
@Serializable
data class PbComponentsResponse(
val page: Int = 1,
@@ -33,6 +48,7 @@ data class PbComponentRecord(
val watt: Double? = null,
@SerialName("duty_cycle") val dutyCycle: Double? = null,
@SerialName("default_utilization_factor") val defaultUtilizationFactor: Double? = null,
@SerialName("component_category") val componentCategory: String? = null,
)
@Serializable
@@ -55,7 +71,7 @@ interface PocketBaseApi {
suspend fun components(
@Query("filter") filter: String = "type='load'",
@Query("sort") sort: String = "+name",
@Query("fields") fields: String = "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor",
@Query("fields") fields: String = "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor,component_category",
@Query("page") page: Int,
@Query("perPage") perPage: Int = 200,
): PbComponentsResponse

View File

@@ -75,10 +75,12 @@ object PdfShare {
return file
}
fun share(context: Context, file: File) {
fun share(context: Context, file: File) = shareFile(context, file, "application/pdf")
fun shareFile(context: Context, file: File, mimeType: String) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

View File

@@ -0,0 +1,133 @@
package app.voltplan.cable.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.model.effectivePowerWatts
import app.voltplan.cable.data.model.energyWattHours
import app.voltplan.cable.data.model.sourceType
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.ui.system.DetailState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Fetches the system wiring diagram PNG from the VoltPlan diagram API — the same endpoint and
* payload the iOS app uses (`SystemOverviewPDFExporter.fetchDiagramImage`). Used both for the
* standalone "Wiring Diagram" image export and the diagram page embedded in the overview PDF.
*/
object SystemDiagram {
private const val ENDPOINT = "https://voltplan.app/api/diagram/generate"
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.SECONDS)
.build()
/** Fetches the diagram as a [Bitmap], or null on any network/decoding failure. */
suspend fun fetch(state: DetailState, unit: UnitSystem): Bitmap? = withContext(Dispatchers.IO) {
val payload = buildPayload(state, unit)
val request = Request.Builder()
.url(ENDPOINT)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "image/png")
.post(Json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.build()
runCatching {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.bytes()?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
}.getOrNull()
}
/** Fetches the diagram, flattens it onto a white background, and opens the Android share sheet. */
suspend fun exportAndShare(
context: Context,
state: DetailState,
unit: UnitSystem,
onError: () -> Unit,
) {
val bitmap = fetch(state, unit)
if (bitmap == null) {
withContext(Dispatchers.Main) { onError() }
return
}
val file = withContext(Dispatchers.IO) {
val opaque = flattenOnWhite(bitmap)
val name = state.system?.name?.takeIf { it.isNotBlank() } ?: "System"
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
val out = File(dir, "${name.replace(Regex("[^A-Za-z0-9-_]"), "_")}-Diagram.png")
out.outputStream().use { opaque.compress(Bitmap.CompressFormat.PNG, 100, it) }
out
}
withContext(Dispatchers.Main) {
Analytics.log("Diagram Image Shared", mapOf("system" to (state.system?.name ?: "")))
PdfShare.shareFile(context, file, "image/png")
}
}
private fun flattenOnWhite(source: Bitmap): Bitmap {
val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
Canvas(result).apply {
drawColor(Color.WHITE)
drawBitmap(source, 0f, 0f, null)
}
return result
}
private fun buildPayload(state: DetailState, unit: UnitSystem): JsonObject = buildJsonObject {
put("systemName", state.system?.name ?: "System")
put("source", "cable")
put("unitSystem", if (unit == UnitSystem.METRIC) "metric" else "imperial")
put("loads", buildJsonArray {
state.loads.forEach { load ->
add(buildJsonObject {
put("name", load.name)
put("power", load.power)
put("voltage", load.voltage)
put("current", load.current)
load.remoteIconURLString?.let { put("iconUrl", it) }
})
}
})
put("batteries", buildJsonArray {
state.batteries.forEach { battery ->
add(buildJsonObject {
put("name", battery.name)
put("voltage", battery.nominalVoltage)
put("capacityAh", battery.capacityAmpHours)
put("energyWh", battery.energyWattHours)
})
}
})
put("chargers", buildJsonArray {
state.chargers.forEach { charger ->
add(buildJsonObject {
put("name", charger.name)
put("inputVoltage", charger.inputVoltage)
put("outputVoltage", charger.outputVoltage)
put("power", charger.effectivePowerWatts)
put("sourceType", charger.sourceType.rawValue)
charger.remoteIconURLString?.let { put("iconUrl", it) }
})
}
})
}
}

View File

@@ -1,7 +1,11 @@
package app.voltplan.cable.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.pdf.PdfDocument
import app.voltplan.cable.R
import app.voltplan.cable.calc.ElectricalCalculations
@@ -23,6 +27,8 @@ private val ACCENT = Color.rgb(115, 87, 219)
/** Renders a full system overview PDF and opens the Android share sheet. */
object SystemOverviewPdf {
suspend fun exportAndShare(context: Context, state: DetailState, unit: UnitSystem) {
// Fetch the wiring diagram first (falls back to no diagram page if unavailable).
val diagram = SystemDiagram.fetch(state, unit)
val file = withContext(Dispatchers.IO) {
val doc = PdfDocument()
val w = PdfWriter(doc)
@@ -42,6 +48,9 @@ object SystemOverviewPdf {
summaryLine(w, context.getString(R.string.overview_pdf_summary_batterycapacity), "${Fmt.number(m.totalCapacity)} Ah")
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargerpower), "${Fmt.number(m.totalChargerPower)} W")
// Full-page wiring diagram, followed by a fresh page for the entity tables.
diagram?.let { drawDiagramPage(w, it); w.beginPage() }
if (state.loads.isNotEmpty()) {
w.gap(12f); w.text(context.getString(R.string.overview_pdf_loads_section), 18f, ACCENT, bold = true); w.divider()
state.loads.forEach { load ->
@@ -89,4 +98,23 @@ object SystemOverviewPdf {
private fun summaryLine(w: PdfWriter, label: String, value: String) {
w.text("$label: $value", 12f, Color.DKGRAY)
}
/** Draws the diagram bitmap on its own page, scaled to fit the margins while keeping aspect ratio. */
private fun drawDiagramPage(w: PdfWriter, diagram: Bitmap) {
w.beginPage()
val availableWidth = PAGE_W - MARGIN * 2
val availableHeight = PAGE_H - MARGIN * 2 - 30 // leave room for footer
val imageAspect = diagram.width.toFloat() / diagram.height.toFloat()
val rectAspect = availableWidth / availableHeight
val dest = if (imageAspect > rectAspect) {
val drawHeight = availableWidth / imageAspect
RectF(MARGIN, MARGIN + (availableHeight - drawHeight) / 2f, MARGIN + availableWidth, MARGIN + (availableHeight - drawHeight) / 2f + drawHeight)
} else {
val drawWidth = availableHeight * imageAspect
RectF(MARGIN + (availableWidth - drawWidth) / 2f, MARGIN, MARGIN + (availableWidth - drawWidth) / 2f + drawWidth, MARGIN + availableHeight)
}
val src = Rect(0, 0, diagram.width, diagram.height)
w.canvas.drawBitmap(diagram, src, dest, Paint().apply { isFilterBitmap = true })
}
}

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

@@ -46,6 +46,7 @@ import app.voltplan.cable.R
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.ui.LocalUnitSettings
import app.voltplan.cable.ui.loads.CalcState
import app.voltplan.cable.data.ReviewPrompt
import app.voltplan.cable.pdf.SystemBomPdf
import kotlinx.coroutines.launch
@@ -74,7 +75,10 @@ fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) {
enabled = state.sections.isNotEmpty(),
onClick = {
vm.logPdfExported()
scope.launch { SystemBomPdf.exportAndShare(context, state, unit) }
scope.launch {
SystemBomPdf.exportAndShare(context, state, unit)
ReviewPrompt.registerSuccessfulExport(context)
}
},
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }
},

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,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)
@@ -167,10 +154,26 @@ 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

@@ -0,0 +1,59 @@
package app.voltplan.cable.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import kotlinx.coroutines.delay
/**
* Auto-advancing onboarding illustration carousel. Mirrors iOS `OnboardingCarouselView`:
* cycles through the images every 8s with a horizontal slide. A single image stays static.
*/
@Composable
fun OnboardingCarousel(
@DrawableRes images: List<Int>,
modifier: Modifier = Modifier,
) {
if (images.isEmpty()) return
var index by remember(images) { mutableIntStateOf(0) }
if (images.size > 1) {
LaunchedEffect(images) {
while (true) {
delay(8_000)
index = (index + 1) % images.size
}
}
}
AnimatedContent(
targetState = index,
transitionSpec = {
slideInHorizontally(tween(800)) { it } togetherWith
slideOutHorizontally(tween(800)) { -it }
},
label = "onboarding-carousel",
modifier = modifier,
) { i ->
Image(
painter = painterResource(images[i]),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
}
}

View File

@@ -1,10 +1,12 @@
package app.voltplan.cable.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.Button
@@ -29,6 +31,7 @@ fun OnboardingInfo(
onPrimary: () -> Unit,
secondaryLabel: String? = null,
onSecondary: (() -> Unit)? = null,
@DrawableRes images: List<Int> = emptyList(),
modifier: Modifier = Modifier,
) {
Column(
@@ -36,7 +39,11 @@ fun OnboardingInfo(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (images.isNotEmpty()) {
OnboardingCarousel(images = images, modifier = Modifier.fillMaxWidth().height(200.dp))
} else {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(56.dp))
}
Spacer(Modifier.size(16.dp))
Text(title, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center)
Spacer(Modifier.size(8.dp))

View File

@@ -42,6 +42,7 @@ import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.library.ComponentLibraryItem
import app.voltplan.cable.library.ComponentLibraryType
import app.voltplan.cable.library.ComponentLibraryViewModel
import app.voltplan.cable.ui.components.LoadIcon
import app.voltplan.cable.ui.theme.SysBlue
@@ -51,18 +52,28 @@ import app.voltplan.cable.ui.theme.SysOrange
@Composable
fun ComponentLibraryScreen(
targetSystemId: String?,
libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
onBack: () -> Unit,
onOpenSystem: (String) -> Unit,
onOpenBatteryEditor: (String, String) -> Unit = { _, _ -> },
onOpenChargerEditor: (String, String) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
val vm: ComponentLibraryViewModel = viewModel(
factory = viewModelFactory { initializer { ComponentLibraryViewModel(app) } },
key = "library-${libraryType.typeValue}",
factory = viewModelFactory { initializer { ComponentLibraryViewModel(app, libraryType) } },
)
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
Analytics.log("Component Library Opened", mapOf("source" to if (targetSystemId != null) "system" else "systems-list"))
Analytics.log(
"Component Library Opened",
mapOf(
"source" to if (targetSystemId != null) "system" else "systems-list",
"type" to libraryType.typeValue,
),
)
}
Scaffold(
@@ -99,10 +110,18 @@ fun ComponentLibraryScreen(
}
else -> LazyColumn(Modifier.fillMaxSize()) {
items(state.filtered, key = { it.id }) { item ->
LibraryRow(item) {
vm.select(item, targetSystemId) { navigateId ->
LibraryRow(item, libraryType) {
when (libraryType) {
ComponentLibraryType.LOAD -> vm.select(item, targetSystemId) { navigateId ->
if (navigateId != null) onOpenSystem(navigateId) else onBack()
}
ComponentLibraryType.BATTERY -> vm.selectBattery(item, targetSystemId) { systemId, batteryId ->
onOpenBatteryEditor(systemId, batteryId)
}
ComponentLibraryType.CHARGER -> vm.selectCharger(item, targetSystemId) { systemId, chargerId ->
onOpenChargerEditor(systemId, chargerId)
}
}
}
}
}
@@ -121,17 +140,22 @@ private fun Centered(content: @Composable () -> Unit) {
}
@Composable
private fun LibraryRow(item: ComponentLibraryItem, onClick: () -> Unit) {
private fun LibraryRow(item: ComponentLibraryItem, libraryType: ComponentLibraryType, onClick: () -> Unit) {
val fallbackIcon = when (libraryType) {
ComponentLibraryType.LOAD -> "bolt"
ComponentLibraryType.BATTERY -> "battery.100"
ComponentLibraryType.CHARGER -> "bolt.fill"
}
Surface(modifier = Modifier.fillMaxWidth(), onClick = onClick, color = MaterialTheme.colorScheme.surface) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
LoadIcon(item.iconURL, "bolt", SysBlue, 44.dp)
LoadIcon(item.iconURL, fallbackIcon, SysBlue, 44.dp)
Column(Modifier.weight(1f)) {
Text(item.localizedName, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.titleSmall)
val details = listOfNotNull(item.voltageLabel, item.powerLabel, item.currentLabel)
val details = item.detailLabels(libraryType)
Text(
if (details.isEmpty()) stringResource(R.string.library_details_coming) else details.joinToString(""),
style = MaterialTheme.typography.bodySmall,

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

@@ -13,11 +13,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.BatteryFull
import androidx.compose.material.icons.filled.Bolt as BoltFilled
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Layers
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.BatteryFull
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.IosShare
import androidx.compose.material.icons.outlined.PictureAsPdf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -39,6 +41,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.library.ComponentLibraryType
import app.voltplan.cable.ui.LocalUnitSettings
import app.voltplan.cable.ui.batteries.BatteriesTab
import app.voltplan.cable.ui.chargers.ChargersTab
@@ -48,7 +51,11 @@ import app.voltplan.cable.ui.overview.OverviewTab
import app.voltplan.cable.ui.sfSymbol
import app.voltplan.cable.ui.systemIconOptions
import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.data.ReviewPrompt
import app.voltplan.cable.pdf.SystemDiagram
import app.voltplan.cable.pdf.SystemOverviewPdf
import android.widget.Toast
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -82,7 +89,7 @@ fun SystemDetailScreen(
onEditCharger: (String) -> Unit,
onNewCharger: () -> Unit,
onOpenBom: () -> Unit,
onOpenLibrary: () -> Unit,
onOpenLibrary: (ComponentLibraryType) -> Unit,
) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
@@ -98,8 +105,15 @@ fun SystemDetailScreen(
var tab by rememberSaveableTab()
var showSystemEditor by remember { mutableStateOf(false) }
var showOverviewMenu by remember { mutableStateOf(false) }
var exporting by remember { mutableStateOf(false) }
val system = state.system
// Switch to the matching tab before opening an editor, so returning from the
// editor lands on that tab with the newly created component visible.
val newLoad = { tab = ComponentTab.COMPONENTS; onNewLoad() }
val newBattery = { tab = ComponentTab.BATTERIES; onNewBattery() }
val newCharger = { tab = ComponentTab.CHARGERS; onNewCharger() }
Scaffold(
topBar = {
TopAppBar(
@@ -132,28 +146,56 @@ fun SystemDetailScreen(
actions = {
when (tab) {
ComponentTab.OVERVIEW -> {
if (exporting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(end = 12.dp),
strokeWidth = 2.dp,
)
} else {
IconButton(onClick = { showOverviewMenu = true }) {
Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.overview_share_pdf))
Icon(Icons.Outlined.IosShare, contentDescription = stringResource(R.string.overview_share_pdf))
}
}
DropdownMenu(expanded = showOverviewMenu, onDismissRequest = { showOverviewMenu = false }) {
DropdownMenuItem(
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
text = { Text(stringResource(R.string.overview_share_diagram)) },
onClick = {
showOverviewMenu = false
scope.launch {
exporting = true
var failed = false
SystemDiagram.exportAndShare(context, state, unitSystem) {
failed = true
Toast.makeText(context, R.string.overview_share_diagram_error, Toast.LENGTH_LONG).show()
}
exporting = false
if (!failed) ReviewPrompt.registerSuccessfulExport(context)
}
},
)
DropdownMenuItem(
leadingIcon = { Icon(Icons.Outlined.PictureAsPdf, contentDescription = null) },
text = { Text(stringResource(R.string.overview_share_pdf)) },
onClick = {
showOverviewMenu = false
scope.launch {
exporting = true
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
exporting = false
ReviewPrompt.registerSuccessfulExport(context)
}
},
)
}
}
ComponentTab.COMPONENTS -> IconButton(onClick = onNewLoad) {
ComponentTab.COMPONENTS -> IconButton(onClick = newLoad) {
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
}
ComponentTab.BATTERIES -> IconButton(onClick = onNewBattery) {
ComponentTab.BATTERIES -> IconButton(onClick = newBattery) {
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
}
ComponentTab.CHARGERS -> IconButton(onClick = onNewCharger) {
ComponentTab.CHARGERS -> IconButton(onClick = newCharger) {
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
}
}
@@ -162,10 +204,10 @@ fun SystemDetailScreen(
},
bottomBar = {
NavigationBar {
NavTab(tab, ComponentTab.OVERVIEW, Icons.Outlined.Dashboard, stringResource(R.string.tab_overview)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.COMPONENTS, Icons.Outlined.Layers, stringResource(R.string.tab_components)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.BATTERIES, Icons.Outlined.BatteryFull, stringResource(R.string.tab_batteries)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.CHARGERS, Icons.Outlined.Bolt, stringResource(R.string.tab_chargers)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.OVERVIEW, Icons.Filled.Dashboard, stringResource(R.string.tab_overview)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.COMPONENTS, Icons.Filled.Layers, stringResource(R.string.tab_components)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.BATTERIES, Icons.Filled.BatteryFull, stringResource(R.string.tab_batteries)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.CHARGERS, Icons.Filled.BoltFilled, stringResource(R.string.tab_chargers)) { tab = it; vm.logTabChange(it.analytics) }
}
},
) { padding ->
@@ -174,11 +216,14 @@ fun SystemDetailScreen(
ComponentTab.OVERVIEW -> OverviewTab(
state = state,
unitSystem = unitSystem,
onAddLoad = onNewLoad,
onAddBattery = onNewBattery,
onAddCharger = onNewCharger,
onOpenLibrary = onOpenLibrary,
onAddLoad = newLoad,
onAddBattery = newBattery,
onAddCharger = newCharger,
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
onOpenBom = { vm.logBomOpened(); onOpenBom() },
onSelectLoads = { tab = ComponentTab.COMPONENTS; vm.logTabChange(ComponentTab.COMPONENTS.analytics) },
onSelectBatteries = { tab = ComponentTab.BATTERIES; vm.logTabChange(ComponentTab.BATTERIES.analytics) },
onSelectChargers = { tab = ComponentTab.CHARGERS; vm.logTabChange(ComponentTab.CHARGERS.analytics) },
onSetRuntimeGoal = vm::setRuntimeGoal,
onSetChargeGoal = vm::setChargeGoal,
)
@@ -186,20 +231,22 @@ fun SystemDetailScreen(
state = state,
unitSystem = unitSystem,
onOpenLoad = onOpenLoad,
onNewLoad = onNewLoad,
onOpenLibrary = onOpenLibrary,
onNewLoad = newLoad,
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
onDeleteLoad = vm::deleteLoad,
)
ComponentTab.BATTERIES -> BatteriesTab(
state = state,
onEditBattery = onEditBattery,
onNewBattery = onNewBattery,
onNewBattery = newBattery,
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.BATTERY) },
onDeleteBattery = vm::deleteBattery,
)
ComponentTab.CHARGERS -> ChargersTab(
state = state,
onEditCharger = onEditCharger,
onNewCharger = onNewCharger,
onNewCharger = newCharger,
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.CHARGER) },
onDeleteCharger = vm::deleteCharger,
)
}
@@ -248,4 +295,4 @@ private fun RowScope.NavTab(
}
@Composable
private fun rememberSaveableTab() = remember { mutableStateOf(ComponentTab.OVERVIEW) }
private fun rememberSaveableTab() = rememberSaveable { mutableStateOf(ComponentTab.OVERVIEW) }

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple lightning bolt mark on the adaptive foreground safe zone. -->
<path
android:fillColor="#FFFFFF"
android:pathData="M60,28 L42,58 L54,58 L48,80 L70,48 L57,48 Z" />
</vector>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
</adaptive-icon>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -5,6 +5,7 @@
<!-- Actions -->
<string name="action_add">Hinzufügen</string>
<string name="action_back">Zurück</string>
<string name="action_save">Speichern</string>
<string name="action_delete">Löschen</string>
<!-- Systems -->
@@ -177,6 +178,8 @@
<string name="overview_chargers_empty_subtitle">Füge Landstrom-, DC-DC- oder Solarladegeräte hinzu, um deine Ladeleistung zu verstehen.</string>
<string name="overview_chargers_empty_create">Ladegerät hinzufügen</string>
<string name="overview_share_pdf">Vollständiger Bericht (PDF)</string>
<string name="overview_share_diagram">Schaltplan</string>
<string name="overview_share_diagram_error">Schaltplan konnte nicht erstellt werden. Überprüfe deine Internetverbindung.</string>
<!-- Goal editor steppers -->
<string name="goal_days">Tage</string>

View File

@@ -5,6 +5,7 @@
<!-- Actions -->
<string name="action_add">Añadir</string>
<string name="action_back">Atrás</string>
<string name="action_save">Guardar</string>
<string name="action_delete">Eliminar</string>
<!-- Systems -->
@@ -177,6 +178,8 @@
<string name="overview_chargers_empty_subtitle">Añade cargadores de toma de puerto, DC-DC o solares para conocer tu capacidad de carga.</string>
<string name="overview_chargers_empty_create">Añadir cargador</string>
<string name="overview_share_pdf">Informe completo (PDF)</string>
<string name="overview_share_diagram">Diagrama de cableado</string>
<string name="overview_share_diagram_error">No se pudo generar el diagrama. Comprueba tu conexión a Internet.</string>
<!-- Goal editor steppers -->
<string name="goal_days">Días</string>

View File

@@ -5,6 +5,7 @@
<!-- Actions -->
<string name="action_add">Ajouter</string>
<string name="action_back">Retour</string>
<string name="action_save">Enregistrer</string>
<string name="action_delete">Supprimer</string>
<!-- Systems -->
@@ -177,6 +178,8 @@
<string name="overview_chargers_empty_subtitle">Ajoutez des chargeurs secteur, DC-DC ou solaires pour comprendre votre capacité de charge.</string>
<string name="overview_chargers_empty_create">Ajouter un chargeur</string>
<string name="overview_share_pdf">Rapport complet (PDF)</string>
<string name="overview_share_diagram">Schéma de câblage</string>
<string name="overview_share_diagram_error">Impossible de générer le schéma. Vérifiez votre connexion Internet.</string>
<!-- Goal editor steppers -->
<string name="goal_days">Jours</string>

View File

@@ -5,6 +5,7 @@
<!-- Actions -->
<string name="action_add">Toevoegen</string>
<string name="action_back">Terug</string>
<string name="action_save">Opslaan</string>
<string name="action_delete">Verwijderen</string>
<!-- Systems -->
@@ -177,6 +178,8 @@
<string name="overview_chargers_empty_subtitle">Voeg walstroom-, DC-DC- of zonneladers toe om je laadcapaciteit te begrijpen.</string>
<string name="overview_chargers_empty_create">Lader toevoegen</string>
<string name="overview_share_pdf">Volledig rapport (PDF)</string>
<string name="overview_share_diagram">Bedradingsschema</string>
<string name="overview_share_diagram_error">Diagram kon niet worden gegenereerd. Controleer je internetverbinding.</string>
<!-- Goal editor steppers -->
<string name="goal_days">Dagen</string>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#519098</color>
<color name="ic_launcher_background">#F4FEF6</color>
</resources>

View File

@@ -5,6 +5,7 @@
<!-- Actions -->
<string name="action_add">Add</string>
<string name="action_back">Back</string>
<string name="action_save">Save</string>
<string name="action_delete">Delete</string>
<!-- Systems -->
@@ -177,6 +178,8 @@
<string name="overview_chargers_empty_subtitle">Add shore power, DC-DC, or solar chargers to understand your charging capacity.</string>
<string name="overview_chargers_empty_create">Add Charger</string>
<string name="overview_share_pdf">Full Report (PDF)</string>
<string name="overview_share_diagram">Wiring Diagram</string>
<string name="overview_share_diagram_error">Could not generate diagram. Check your internet connection.</string>
<!-- Goal editor steppers -->
<string name="goal_days">Days</string>

View File

@@ -14,6 +14,7 @@ okhttp = "4.12.0"
serialization = "1.7.3"
retrofitSerialization = "1.0.0"
coil = "2.7.0"
playReview = "2.0.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -39,6 +40,7 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "playReview" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }