Compare commits
7 Commits
d68170bc87
...
89ee36c1a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ee36c1a4 | |||
| 23b117bfe2 | |||
| d97e3a2b7c | |||
| 67ec44e60a | |||
| 0aa3184406 | |||
| 38118ebc36 | |||
| b1fbac3ec1 |
23
CLAUDE.md
@@ -2,6 +2,15 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Two apps in this repo
|
||||
|
||||
- **iOS** (`Cable/`) — SwiftUI + SwiftData. **This document describes the iOS app unless stated otherwise.**
|
||||
- **Android** (`android/`) — a native Kotlin/Jetpack Compose port that mirrors every iOS feature (package root `app.voltplan.cable`, Room persistence, same Aptabase analytics). See **`android/README.md`** for its architecture and build instructions before working on it.
|
||||
|
||||
Behavior and data shape are meant to stay in sync across both — when changing a user-facing feature on one platform, check the other. See [Export Options](#export-options) for one such cross-platform contract.
|
||||
|
||||
**Apply every instruction to both the iOS and Android versions unless explicitly told otherwise.** Any feature, fix, or change requested without naming a platform must land on both apps and stay behaviorally in sync.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
Use the **Xcode MCP server** tools instead of `xcodebuild` CLI:
|
||||
@@ -106,13 +115,25 @@ All list views use consistent styling:
|
||||
- Use `String(localized:defaultValue:)` — **not** `NSLocalizedString`. The `defaultValue` serves as English fallback and avoids showing raw keys when a Localizable.strings entry is missing.
|
||||
- When adding new user-facing strings, add translations to **all 5** Localizable.strings files immediately.
|
||||
|
||||
## PDF Export Pattern
|
||||
## Export Options
|
||||
|
||||
Three export options, available from the Overview tab's share menu plus the BOM sheet. **Keep iOS and Android (`android/.../pdf/`) in sync — they must offer the same exports.**
|
||||
|
||||
1. **System Overview (PDF)** — summary + a full-page wiring diagram + per-entity tables.
|
||||
2. **Bill of Materials (PDF)** — categorized component list.
|
||||
3. **Wiring Diagram (PNG)** — standalone diagram image.
|
||||
|
||||
The wiring diagram (used both as the standalone PNG and the Overview PDF's diagram page) is fetched from the shared **VoltPlan diagram API** (`POST https://voltplan.app/api/diagram/generate`, JSON payload of system/loads/batteries/chargers, returns PNG). Both platforms send the identical payload shape; falls back gracefully when the API is unreachable (iOS draws a Core Graphics diagram; Android omits the PDF page / shows an error toast for the standalone export).
|
||||
|
||||
### PDF Export Pattern (iOS)
|
||||
|
||||
PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is:
|
||||
- **Exporter struct** (e.g. `SystemOverviewPDFExporter`, `SystemBillOfMaterialsPDFExporter`) with snapshot data types — keeps Core Graphics rendering isolated from SwiftUI/SwiftData.
|
||||
- **ShareSheet** triggered via `@State` item binding in the parent view.
|
||||
- **Toolbar button** (not inline content) for the export action.
|
||||
|
||||
On Android the equivalents live in `android/app/src/main/java/app/voltplan/cable/pdf/`: `SystemOverviewPdf`, `SystemBomPdf`, `SystemDiagram` (diagram fetch + PNG export), and `PdfShare` (`PdfDocument` + `FileProvider`/`ACTION_SEND`).
|
||||
|
||||
## Screenshots
|
||||
|
||||
App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
var iconName: String
|
||||
var colorName: String
|
||||
var system: ElectricalSystem
|
||||
|
||||
var componentID: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
@@ -58,7 +59,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
maximumTemperatureCelsius: Double = 60,
|
||||
iconName: String = "battery.100",
|
||||
colorName: String = "blue",
|
||||
system: ElectricalSystem
|
||||
system: ElectricalSystem,
|
||||
componentID: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -73,6 +75,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.componentID = componentID
|
||||
}
|
||||
|
||||
init(savedBattery: SavedBattery, system: ElectricalSystem) {
|
||||
@@ -95,6 +98,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
self.iconName = savedBattery.iconName
|
||||
self.colorName = savedBattery.colorName
|
||||
self.system = system
|
||||
self.componentID = savedBattery.componentID
|
||||
}
|
||||
|
||||
var energyWattHours: Double {
|
||||
@@ -137,6 +141,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
savedBattery.iconName = iconName
|
||||
savedBattery.colorName = colorName
|
||||
savedBattery.system = system
|
||||
savedBattery.componentID = componentID
|
||||
savedBattery.timestamp = Date()
|
||||
}
|
||||
}
|
||||
@@ -154,7 +159,8 @@ extension BatteryConfiguration {
|
||||
lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius &&
|
||||
lhs.chemistry == rhs.chemistry &&
|
||||
lhs.iconName == rhs.iconName &&
|
||||
lhs.colorName == rhs.colorName
|
||||
lhs.colorName == rhs.colorName &&
|
||||
lhs.componentID == rhs.componentID
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
@@ -170,5 +176,6 @@ extension BatteryConfiguration {
|
||||
hasher.combine(chemistry)
|
||||
hasher.combine(iconName)
|
||||
hasher.combine(colorName)
|
||||
hasher.combine(componentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
var colorName: String
|
||||
var system: ElectricalSystem
|
||||
var powerSourceType: SavedCharger.PowerSourceType
|
||||
var componentID: String?
|
||||
var remoteIconURLString: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
@@ -23,7 +25,9 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
iconName: String = "bolt.fill",
|
||||
colorName: String = "orange",
|
||||
system: ElectricalSystem,
|
||||
powerSourceType: SavedCharger.PowerSourceType = .shore
|
||||
powerSourceType: SavedCharger.PowerSourceType = .shore,
|
||||
componentID: String? = nil,
|
||||
remoteIconURLString: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -35,6 +39,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.powerSourceType = powerSourceType
|
||||
self.componentID = componentID
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
}
|
||||
|
||||
init(savedCharger: SavedCharger, system: ElectricalSystem) {
|
||||
@@ -48,6 +54,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
self.colorName = savedCharger.colorName
|
||||
self.system = system
|
||||
self.powerSourceType = savedCharger.sourceType
|
||||
self.componentID = savedCharger.componentID
|
||||
self.remoteIconURLString = savedCharger.remoteIconURLString
|
||||
}
|
||||
|
||||
var effectivePowerWatts: Double {
|
||||
@@ -67,6 +75,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
savedCharger.colorName = colorName
|
||||
savedCharger.system = system
|
||||
savedCharger.powerSourceType = powerSourceType.rawValue
|
||||
savedCharger.componentID = componentID
|
||||
savedCharger.remoteIconURLString = remoteIconURLString
|
||||
savedCharger.timestamp = Date()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ struct ChargersView: View {
|
||||
let onAdd: () -> Void
|
||||
let onEdit: (SavedCharger) -> Void
|
||||
let onDelete: (IndexSet) -> Void
|
||||
let onBrowseLibrary: () -> Void
|
||||
|
||||
private struct SummaryMetric: Identifiable {
|
||||
let id: String
|
||||
@@ -94,13 +95,15 @@ struct ChargersView: View {
|
||||
editMode: Binding<EditMode> = .constant(.inactive),
|
||||
onAdd: @escaping () -> Void = {},
|
||||
onEdit: @escaping (SavedCharger) -> Void = { _ in },
|
||||
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
||||
onDelete: @escaping (IndexSet) -> Void = { _ in },
|
||||
onBrowseLibrary: @escaping () -> Void = {}
|
||||
) {
|
||||
self.system = system
|
||||
self.chargers = chargers
|
||||
self.onAdd = onAdd
|
||||
self.onEdit = onEdit
|
||||
self.onDelete = onDelete
|
||||
self.onBrowseLibrary = onBrowseLibrary
|
||||
_editMode = editMode
|
||||
}
|
||||
|
||||
@@ -244,7 +247,8 @@ struct ChargersView: View {
|
||||
private var emptyState: some View {
|
||||
OnboardingInfoView(
|
||||
configuration: .charger(),
|
||||
onPrimaryAction: onAdd
|
||||
onPrimaryAction: onAdd,
|
||||
onSecondaryAction: onBrowseLibrary
|
||||
)
|
||||
.padding(.horizontal, 0)
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
@@ -19,7 +31,53 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
guard let power = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
|
||||
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")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -346,6 +346,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
)
|
||||
await MainActor.run {
|
||||
activeShareItem = ExportedPDFShareItem(url: url)
|
||||
ReviewPrompt.registerSuccessfulExport()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -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
@@ -11,3 +11,8 @@
|
||||
|
||||
# Local build artifacts
|
||||
*.apk
|
||||
|
||||
# Signing — never commit the keystore or its passwords
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,22 +55,20 @@ class ComponentLibraryViewModel(
|
||||
fun refresh() = load()
|
||||
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
|
||||
|
||||
/** 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"))
|
||||
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: String
|
||||
val createdNewSystem: Boolean
|
||||
if (targetSystemId != null) {
|
||||
systemId = targetSystemId
|
||||
createdNewSystem = false
|
||||
} else {
|
||||
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
|
||||
}
|
||||
val (systemId, createdNewSystem) = ensureSystem(targetSystemId)
|
||||
|
||||
val baseName = item.localizedName.ifBlank { "Library Load" }
|
||||
val loadName = repo.uniqueComponentName(systemId, baseName)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||
import androidx.compose.material.icons.outlined.BatteryFull
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.LibraryBooks
|
||||
import androidx.compose.material.icons.outlined.Speed
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -63,6 +65,7 @@ fun BatteriesTab(
|
||||
state: DetailState,
|
||||
onEditBattery: (String) -> Unit,
|
||||
onNewBattery: () -> Unit,
|
||||
onOpenLibrary: () -> Unit,
|
||||
onDeleteBattery: (SavedBattery) -> Unit,
|
||||
) {
|
||||
val batteries = state.batteries
|
||||
@@ -73,11 +76,15 @@ fun BatteriesTab(
|
||||
subtitle = stringResource(R.string.battery_onboarding_subtitle),
|
||||
primaryLabel = stringResource(R.string.battery_empty_create),
|
||||
onPrimary = onNewBattery,
|
||||
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||
onSecondary = onOpenLibrary,
|
||||
images = listOf(R.drawable.onboarding_battery),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val m = state.metrics
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
StatsHeader {
|
||||
Text(stringResource(R.string.battery_bank_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
@@ -101,12 +108,20 @@ fun BatteriesTab(
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp)) {
|
||||
items(batteries, key = { it.id }) { battery ->
|
||||
BatteryRow(battery, onClick = { onEditBattery(battery.id) }, onDelete = { onDeleteBattery(battery) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onOpenLibrary,
|
||||
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.loads_library_button)) },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -90,6 +91,9 @@ fun BatteryEditorScreen(systemId: String, batteryId: String?, onBack: () -> Unit
|
||||
title = {
|
||||
Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true })
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
||||
@@ -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)) }
|
||||
},
|
||||
|
||||
@@ -81,6 +81,9 @@ fun ChargerEditorScreen(systemId: String, chargerId: String?, onBack: () -> Unit
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
|
||||
},
|
||||
title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) },
|
||||
actions = {
|
||||
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.voltplan.cable.ui.chargers
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -15,7 +16,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.LibraryBooks
|
||||
import androidx.compose.material.icons.outlined.Speed
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -55,6 +58,7 @@ fun ChargersTab(
|
||||
state: DetailState,
|
||||
onEditCharger: (String) -> Unit,
|
||||
onNewCharger: () -> Unit,
|
||||
onOpenLibrary: () -> Unit,
|
||||
onDeleteCharger: (SavedCharger) -> Unit,
|
||||
) {
|
||||
val chargers = state.chargers
|
||||
@@ -65,11 +69,15 @@ fun ChargersTab(
|
||||
subtitle = stringResource(R.string.chargers_onboarding_subtitle),
|
||||
primaryLabel = stringResource(R.string.chargers_onboarding_primary),
|
||||
onPrimary = onNewCharger,
|
||||
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||
onSecondary = onOpenLibrary,
|
||||
images = listOf(R.drawable.onboarding_charger),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val m = state.metrics
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
StatsHeader {
|
||||
Text(stringResource(R.string.chargers_summary_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
@@ -85,12 +93,20 @@ fun ChargersTab(
|
||||
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalChargerPower)} W", stringResource(R.string.chargers_metric_power), SysPink)
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp)) {
|
||||
items(chargers, key = { it.id }) { charger ->
|
||||
ChargerRow(charger, onClick = { onEditCharger(charger.id) }, onDelete = { onDeleteCharger(charger) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onOpenLibrary,
|
||||
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.loads_library_button)) },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -3,18 +3,17 @@ package app.voltplan.cable.ui.components
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -69,6 +68,7 @@ fun AppearanceEditorSheet(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -113,55 +113,41 @@ fun AppearanceEditorSheet(
|
||||
extra?.invoke()
|
||||
|
||||
Text("Icon", style = MaterialTheme.typography.titleSmall)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(5),
|
||||
modifier = Modifier.fillMaxWidth().heightForRows(icons.size, 5),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
userScrollEnabled = false,
|
||||
) {
|
||||
items(icons) { symbol ->
|
||||
val selected = symbol == icon
|
||||
Box(
|
||||
Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable { icon = symbol },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
sfSymbol(symbol),
|
||||
contentDescription = symbol,
|
||||
tint = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
GridRows(items = icons, columns = 5) { symbol ->
|
||||
val selected = symbol == icon
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable { icon = symbol },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
sfSymbol(symbol),
|
||||
contentDescription = symbol,
|
||||
tint = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Color", style = MaterialTheme.typography.titleSmall)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(6),
|
||||
modifier = Modifier.fillMaxWidth().heightForRows(curatedColorNames.size, 6),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
userScrollEnabled = false,
|
||||
) {
|
||||
items(curatedColorNames) { colorName ->
|
||||
val c = componentColor(colorName)
|
||||
Box(
|
||||
Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.background(c)
|
||||
.border(2.dp, if (colorName == color) Color.White else Color.Transparent, CircleShape)
|
||||
.clickable { color = colorName },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (colorName == color) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
GridRows(items = curatedColorNames, columns = 6) { colorName ->
|
||||
val c = componentColor(colorName)
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.background(c)
|
||||
.border(2.dp, if (colorName == color) Color.White else Color.Transparent, CircleShape)
|
||||
.clickable { color = colorName },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (colorName == color) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,8 +155,25 @@ fun AppearanceEditorSheet(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.heightForRows(itemCount: Int, columns: Int): Modifier {
|
||||
val rows = (itemCount + columns - 1) / columns
|
||||
// ~56dp per cell including spacing; gives a non-scrolling grid inside the sheet.
|
||||
return this.height((rows * 56).dp)
|
||||
/**
|
||||
* Lays out [items] in a non-lazy grid of fixed [columns], sizing to its content so it can live
|
||||
* inside a vertically scrolling container without a fixed height. Empty trailing cells are padded
|
||||
* with spacers so cells keep equal widths on the final row.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> GridRows(
|
||||
items: List<T>,
|
||||
columns: Int,
|
||||
cell: @Composable RowScope.(T) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items.chunked(columns).forEach { rowItems ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
rowItems.forEach { cell(it) }
|
||||
repeat(columns - rowItems.size) {
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(56.dp))
|
||||
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))
|
||||
|
||||
@@ -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,9 +110,17 @@ fun ComponentLibraryScreen(
|
||||
}
|
||||
else -> LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(state.filtered, key = { it.id }) { item ->
|
||||
LibraryRow(item) {
|
||||
vm.select(item, targetSystemId) { navigateId ->
|
||||
if (navigateId != null) onOpenSystem(navigateId) else onBack()
|
||||
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,
|
||||
|
||||
@@ -110,6 +110,9 @@ fun CalculatorScreen(systemId: String, loadId: String?, onBack: () -> Unit) {
|
||||
Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
||||
@@ -69,6 +69,7 @@ fun ComponentsTab(
|
||||
onPrimary = onNewLoad,
|
||||
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||
onSecondary = onOpenLibrary,
|
||||
images = listOf(R.drawable.onboarding_coffee, R.drawable.onboarding_router, R.drawable.onboarding_charger),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import app.voltplan.cable.ui.batteries.BatteryEditorScreen
|
||||
import app.voltplan.cable.ui.bom.BillOfMaterialsScreen
|
||||
import app.voltplan.cable.library.ComponentLibraryType
|
||||
import app.voltplan.cable.ui.chargers.ChargerEditorScreen
|
||||
import app.voltplan.cable.ui.library.ComponentLibraryScreen
|
||||
import app.voltplan.cable.ui.loads.CalculatorScreen
|
||||
@@ -22,11 +23,17 @@ object Routes {
|
||||
const val BATTERY = "battery/{systemId}?batteryId={batteryId}"
|
||||
const val CHARGER = "charger/{systemId}?chargerId={chargerId}"
|
||||
const val BOM = "bom/{systemId}"
|
||||
const val LIBRARY = "library?systemId={systemId}"
|
||||
const val LIBRARY = "library?systemId={systemId}&type={type}"
|
||||
const val SETTINGS = "settings"
|
||||
|
||||
fun system(id: String) = "system/$id"
|
||||
fun library(systemId: String? = null) = "library" + (systemId?.let { "?systemId=$it" } ?: "")
|
||||
fun library(systemId: String? = null, type: String = "load"): String {
|
||||
val params = buildList {
|
||||
systemId?.let { add("systemId=$it") }
|
||||
add("type=$type")
|
||||
}
|
||||
return "library?" + params.joinToString("&")
|
||||
}
|
||||
fun calculator(systemId: String, loadId: String? = null) =
|
||||
"calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "")
|
||||
fun battery(systemId: String, batteryId: String? = null) =
|
||||
@@ -64,7 +71,7 @@ fun CableNavHost() {
|
||||
onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) },
|
||||
onNewCharger = { nav.navigate(Routes.charger(systemId)) },
|
||||
onOpenBom = { nav.navigate(Routes.bom(systemId)) },
|
||||
onOpenLibrary = { nav.navigate(Routes.library(systemId)) },
|
||||
onOpenLibrary = { type -> nav.navigate(Routes.library(systemId, type.typeValue)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,15 +129,27 @@ fun CableNavHost() {
|
||||
|
||||
composable(
|
||||
Routes.LIBRARY,
|
||||
arguments = listOf(navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null }),
|
||||
arguments = listOf(
|
||||
navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument("type") { type = NavType.StringType; nullable = true; defaultValue = "load" },
|
||||
),
|
||||
) { entry ->
|
||||
ComponentLibraryScreen(
|
||||
targetSystemId = entry.arguments?.getString("systemId"),
|
||||
libraryType = ComponentLibraryType.fromArg(entry.arguments?.getString("type")),
|
||||
onBack = { nav.popBackStack() },
|
||||
onOpenSystem = { systemId ->
|
||||
nav.popBackStack()
|
||||
nav.navigate(Routes.system(systemId))
|
||||
},
|
||||
onOpenBatteryEditor = { systemId, batteryId ->
|
||||
nav.popBackStack()
|
||||
nav.navigate(Routes.battery(systemId, batteryId))
|
||||
},
|
||||
onOpenChargerEditor = { systemId, chargerId ->
|
||||
nav.popBackStack()
|
||||
nav.navigate(Routes.charger(systemId, chargerId))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ fun OverviewTab(
|
||||
onAddCharger: () -> Unit,
|
||||
onOpenLibrary: () -> Unit,
|
||||
onOpenBom: () -> Unit,
|
||||
onSelectLoads: () -> Unit,
|
||||
onSelectBatteries: () -> Unit,
|
||||
onSelectChargers: () -> Unit,
|
||||
onSetRuntimeGoal: (Double?) -> Unit,
|
||||
onSetChargeGoal: (Double?) -> Unit,
|
||||
) {
|
||||
@@ -104,9 +107,9 @@ fun OverviewTab(
|
||||
}
|
||||
}
|
||||
|
||||
LoadsCard(state, m, onAddLoad, onOpenLibrary)
|
||||
BatteriesCard(state, m, onAddBattery)
|
||||
ChargersCard(state, m, onAddCharger)
|
||||
LoadsCard(state, m, onAddLoad, onOpenLibrary, onSelectLoads)
|
||||
BatteriesCard(state, m, onAddBattery, onSelectBatteries)
|
||||
ChargersCard(state, m, onAddCharger, onSelectChargers)
|
||||
}
|
||||
|
||||
goalEditor?.let { kind ->
|
||||
@@ -163,9 +166,10 @@ private fun MetricRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewCard(title: String, content: @Composable () -> Unit) {
|
||||
private fun OverviewCard(title: String, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
) {
|
||||
@@ -177,8 +181,8 @@ private fun OverviewCard(title: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.loads_overview_header_title)) {
|
||||
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit, onSelect: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.loads_overview_header_title), onClick = if (state.loads.isEmpty()) null else onSelect) {
|
||||
if (state.loads.isEmpty()) {
|
||||
Text(stringResource(R.string.overview_loads_empty_title), fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.overview_loads_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -196,8 +200,8 @@ private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Uni
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.battery_bank_header_title)) {
|
||||
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit, onSelect: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.battery_bank_header_title), onClick = if (state.batteries.isEmpty()) null else onSelect) {
|
||||
if (state.batteries.isEmpty()) {
|
||||
Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium)
|
||||
Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) }
|
||||
@@ -212,8 +216,8 @@ private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: ()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.overview_chargers_header_title)) {
|
||||
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit, onSelect: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.overview_chargers_header_title), onClick = if (state.chargers.isEmpty()) null else onSelect) {
|
||||
if (state.chargers.isEmpty()) {
|
||||
Text(stringResource(R.string.overview_chargers_empty_title), fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.overview_chargers_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
@@ -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 -> {
|
||||
IconButton(onClick = { showOverviewMenu = true }) {
|
||||
Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.overview_share_pdf))
|
||||
if (exporting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).padding(end = 12.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = { showOverviewMenu = true }) {
|
||||
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) }
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 28 KiB |
BIN
android/app/src/main/res/drawable-night-nodpi/onboarding_van.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_battery.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_boat.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_cabin.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_charger.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_coffee.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_router.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_van.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||