Compare commits
7 Commits
d68170bc87
...
main
| 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.
|
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
|
## Build & Test Commands
|
||||||
|
|
||||||
Use the **Xcode MCP server** tools instead of `xcodebuild` CLI:
|
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.
|
- 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.
|
- 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:
|
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.
|
- **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.
|
- **ShareSheet** triggered via `@State` item binding in the parent view.
|
||||||
- **Toolbar button** (not inline content) for the export action.
|
- **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
|
## Screenshots
|
||||||
|
|
||||||
App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`.
|
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")
|
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
||||||
AnalyticsTracker.log("First Launch")
|
AnalyticsTracker.log("First Launch")
|
||||||
}
|
}
|
||||||
|
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
|
||||||
AnalyticsTracker.log("App Launched")
|
AnalyticsTracker.log("App Launched")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
var iconName: String
|
var iconName: String
|
||||||
var colorName: String
|
var colorName: String
|
||||||
var system: ElectricalSystem
|
var system: ElectricalSystem
|
||||||
|
var componentID: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -58,7 +59,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
maximumTemperatureCelsius: Double = 60,
|
maximumTemperatureCelsius: Double = 60,
|
||||||
iconName: String = "battery.100",
|
iconName: String = "battery.100",
|
||||||
colorName: String = "blue",
|
colorName: String = "blue",
|
||||||
system: ElectricalSystem
|
system: ElectricalSystem,
|
||||||
|
componentID: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -73,6 +75,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
self.iconName = iconName
|
self.iconName = iconName
|
||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
|
self.componentID = componentID
|
||||||
}
|
}
|
||||||
|
|
||||||
init(savedBattery: SavedBattery, system: ElectricalSystem) {
|
init(savedBattery: SavedBattery, system: ElectricalSystem) {
|
||||||
@@ -95,6 +98,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
self.iconName = savedBattery.iconName
|
self.iconName = savedBattery.iconName
|
||||||
self.colorName = savedBattery.colorName
|
self.colorName = savedBattery.colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
|
self.componentID = savedBattery.componentID
|
||||||
}
|
}
|
||||||
|
|
||||||
var energyWattHours: Double {
|
var energyWattHours: Double {
|
||||||
@@ -137,6 +141,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
|||||||
savedBattery.iconName = iconName
|
savedBattery.iconName = iconName
|
||||||
savedBattery.colorName = colorName
|
savedBattery.colorName = colorName
|
||||||
savedBattery.system = system
|
savedBattery.system = system
|
||||||
|
savedBattery.componentID = componentID
|
||||||
savedBattery.timestamp = Date()
|
savedBattery.timestamp = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +159,8 @@ extension BatteryConfiguration {
|
|||||||
lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius &&
|
lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius &&
|
||||||
lhs.chemistry == rhs.chemistry &&
|
lhs.chemistry == rhs.chemistry &&
|
||||||
lhs.iconName == rhs.iconName &&
|
lhs.iconName == rhs.iconName &&
|
||||||
lhs.colorName == rhs.colorName
|
lhs.colorName == rhs.colorName &&
|
||||||
|
lhs.componentID == rhs.componentID
|
||||||
}
|
}
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
@@ -170,5 +176,6 @@ extension BatteryConfiguration {
|
|||||||
hasher.combine(chemistry)
|
hasher.combine(chemistry)
|
||||||
hasher.combine(iconName)
|
hasher.combine(iconName)
|
||||||
hasher.combine(colorName)
|
hasher.combine(colorName)
|
||||||
|
hasher.combine(componentID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
var colorName: String
|
var colorName: String
|
||||||
var system: ElectricalSystem
|
var system: ElectricalSystem
|
||||||
var powerSourceType: SavedCharger.PowerSourceType
|
var powerSourceType: SavedCharger.PowerSourceType
|
||||||
|
var componentID: String?
|
||||||
|
var remoteIconURLString: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -23,7 +25,9 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
iconName: String = "bolt.fill",
|
iconName: String = "bolt.fill",
|
||||||
colorName: String = "orange",
|
colorName: String = "orange",
|
||||||
system: ElectricalSystem,
|
system: ElectricalSystem,
|
||||||
powerSourceType: SavedCharger.PowerSourceType = .shore
|
powerSourceType: SavedCharger.PowerSourceType = .shore,
|
||||||
|
componentID: String? = nil,
|
||||||
|
remoteIconURLString: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -35,6 +39,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
self.powerSourceType = powerSourceType
|
self.powerSourceType = powerSourceType
|
||||||
|
self.componentID = componentID
|
||||||
|
self.remoteIconURLString = remoteIconURLString
|
||||||
}
|
}
|
||||||
|
|
||||||
init(savedCharger: SavedCharger, system: ElectricalSystem) {
|
init(savedCharger: SavedCharger, system: ElectricalSystem) {
|
||||||
@@ -48,6 +54,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
self.colorName = savedCharger.colorName
|
self.colorName = savedCharger.colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
self.powerSourceType = savedCharger.sourceType
|
self.powerSourceType = savedCharger.sourceType
|
||||||
|
self.componentID = savedCharger.componentID
|
||||||
|
self.remoteIconURLString = savedCharger.remoteIconURLString
|
||||||
}
|
}
|
||||||
|
|
||||||
var effectivePowerWatts: Double {
|
var effectivePowerWatts: Double {
|
||||||
@@ -67,6 +75,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
|||||||
savedCharger.colorName = colorName
|
savedCharger.colorName = colorName
|
||||||
savedCharger.system = system
|
savedCharger.system = system
|
||||||
savedCharger.powerSourceType = powerSourceType.rawValue
|
savedCharger.powerSourceType = powerSourceType.rawValue
|
||||||
|
savedCharger.componentID = componentID
|
||||||
|
savedCharger.remoteIconURLString = remoteIconURLString
|
||||||
savedCharger.timestamp = Date()
|
savedCharger.timestamp = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct ChargersView: View {
|
|||||||
let onAdd: () -> Void
|
let onAdd: () -> Void
|
||||||
let onEdit: (SavedCharger) -> Void
|
let onEdit: (SavedCharger) -> Void
|
||||||
let onDelete: (IndexSet) -> Void
|
let onDelete: (IndexSet) -> Void
|
||||||
|
let onBrowseLibrary: () -> Void
|
||||||
|
|
||||||
private struct SummaryMetric: Identifiable {
|
private struct SummaryMetric: Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
@@ -94,13 +95,15 @@ struct ChargersView: View {
|
|||||||
editMode: Binding<EditMode> = .constant(.inactive),
|
editMode: Binding<EditMode> = .constant(.inactive),
|
||||||
onAdd: @escaping () -> Void = {},
|
onAdd: @escaping () -> Void = {},
|
||||||
onEdit: @escaping (SavedCharger) -> Void = { _ in },
|
onEdit: @escaping (SavedCharger) -> Void = { _ in },
|
||||||
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
onDelete: @escaping (IndexSet) -> Void = { _ in },
|
||||||
|
onBrowseLibrary: @escaping () -> Void = {}
|
||||||
) {
|
) {
|
||||||
self.system = system
|
self.system = system
|
||||||
self.chargers = chargers
|
self.chargers = chargers
|
||||||
self.onAdd = onAdd
|
self.onAdd = onAdd
|
||||||
self.onEdit = onEdit
|
self.onEdit = onEdit
|
||||||
self.onDelete = onDelete
|
self.onDelete = onDelete
|
||||||
|
self.onBrowseLibrary = onBrowseLibrary
|
||||||
_editMode = editMode
|
_editMode = editMode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +247,8 @@ struct ChargersView: View {
|
|||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
OnboardingInfoView(
|
OnboardingInfoView(
|
||||||
configuration: .charger(),
|
configuration: .charger(),
|
||||||
onPrimaryAction: onAdd
|
onPrimaryAction: onAdd,
|
||||||
|
onSecondaryAction: onBrowseLibrary
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 0)
|
.padding(.horizontal, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import SwiftUI
|
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 {
|
struct ComponentLibraryItem: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
@@ -9,6 +20,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
|||||||
let watt: Double?
|
let watt: Double?
|
||||||
let dutyCyclePercent: Double?
|
let dutyCyclePercent: Double?
|
||||||
let defaultUtilizationFactorPercent: Double?
|
let defaultUtilizationFactorPercent: Double?
|
||||||
|
let componentCategory: String?
|
||||||
let iconURL: URL?
|
let iconURL: URL?
|
||||||
|
|
||||||
var displayVoltage: Double? {
|
var displayVoltage: Double? {
|
||||||
@@ -20,6 +32,52 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
|||||||
return power / voltage
|
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? {
|
var voltageLabel: String? {
|
||||||
guard let voltage = displayVoltage else { return nil }
|
guard let voltage = displayVoltage else { return nil }
|
||||||
return String(format: "%.1fV", voltage)
|
return String(format: "%.1fV", voltage)
|
||||||
@@ -173,12 +231,15 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
|
|
||||||
private let baseURL = URL(string: "https://base.voltplan.app")!
|
private let baseURL = URL(string: "https://base.voltplan.app")!
|
||||||
private let urlSession: URLSession
|
private let urlSession: URLSession
|
||||||
|
let libraryType: ComponentLibraryType
|
||||||
|
|
||||||
init(urlSession: URLSession = .shared) {
|
init(libraryType: ComponentLibraryType = .load, urlSession: URLSession = .shared) {
|
||||||
|
self.libraryType = libraryType
|
||||||
self.urlSession = urlSession
|
self.urlSession = urlSession
|
||||||
}
|
}
|
||||||
|
|
||||||
init(previewItems: [ComponentLibraryItem]) {
|
init(previewItems: [ComponentLibraryItem], libraryType: ComponentLibraryType = .load) {
|
||||||
|
self.libraryType = libraryType
|
||||||
self.urlSession = .shared
|
self.urlSession = .shared
|
||||||
self.items = previewItems
|
self.items = previewItems
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
@@ -216,11 +277,11 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
)
|
)
|
||||||
components?.queryItems = [
|
components?.queryItems = [
|
||||||
URLQueryItem(name: "filter", value: "(type='load')"),
|
URLQueryItem(name: "filter", value: "(\(libraryType.filterValue))"),
|
||||||
URLQueryItem(name: "sort", value: "+name"),
|
URLQueryItem(name: "sort", value: "+name"),
|
||||||
URLQueryItem(
|
URLQueryItem(
|
||||||
name: "fields",
|
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: "page", value: "\(page)"),
|
||||||
URLQueryItem(name: "perPage", value: "\(perPage)")
|
URLQueryItem(name: "perPage", value: "\(perPage)")
|
||||||
@@ -266,6 +327,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
watt: record.watt,
|
watt: record.watt,
|
||||||
dutyCyclePercent: record.dutyCycle,
|
dutyCyclePercent: record.dutyCycle,
|
||||||
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
|
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
|
||||||
|
componentCategory: record.componentCategory,
|
||||||
iconURL: iconURL(for: record)
|
iconURL: iconURL(for: record)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -312,6 +374,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
let watt: Double?
|
let watt: Double?
|
||||||
let dutyCycle: Double?
|
let dutyCycle: Double?
|
||||||
let defaultUtilizationFactor: Double?
|
let defaultUtilizationFactor: Double?
|
||||||
|
let componentCategory: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
@@ -324,6 +387,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
case watt
|
case watt
|
||||||
case dutyCycle = "duty_cycle"
|
case dutyCycle = "duty_cycle"
|
||||||
case defaultUtilizationFactor = "default_utilization_factor"
|
case defaultUtilizationFactor = "default_utilization_factor"
|
||||||
|
case componentCategory = "component_category"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TranslationsContainer: Decodable {
|
struct TranslationsContainer: Decodable {
|
||||||
@@ -385,14 +449,17 @@ struct ComponentLibraryView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var viewModel: ComponentLibraryViewModel
|
@StateObject private var viewModel: ComponentLibraryViewModel
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
|
private let libraryType: ComponentLibraryType
|
||||||
let onSelect: (ComponentLibraryItem) -> Void
|
let onSelect: (ComponentLibraryItem) -> Void
|
||||||
|
|
||||||
init(onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
init(libraryType: ComponentLibraryType = .load, onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
||||||
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel())
|
self.libraryType = libraryType
|
||||||
|
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel(libraryType: libraryType))
|
||||||
self.onSelect = onSelect
|
self.onSelect = onSelect
|
||||||
}
|
}
|
||||||
|
|
||||||
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
||||||
|
self.libraryType = viewModel.libraryType
|
||||||
self._viewModel = StateObject(wrappedValue: viewModel)
|
self._viewModel = StateObject(wrappedValue: viewModel)
|
||||||
self.onSelect = onSelect
|
self.onSelect = onSelect
|
||||||
}
|
}
|
||||||
@@ -463,7 +530,7 @@ struct ComponentLibraryView: View {
|
|||||||
onSelect(item)
|
onSelect(item)
|
||||||
dismiss()
|
dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
ComponentRow(item: item)
|
ComponentRow(item: item, libraryType: libraryType)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -508,6 +575,7 @@ struct ComponentLibraryView: View {
|
|||||||
|
|
||||||
private struct ComponentRow: View {
|
private struct ComponentRow: View {
|
||||||
let item: ComponentLibraryItem
|
let item: ComponentLibraryItem
|
||||||
|
let libraryType: ComponentLibraryType
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -529,15 +597,23 @@ private struct ComponentRow: View {
|
|||||||
private var iconView: some View {
|
private var iconView: some View {
|
||||||
LoadIconView(
|
LoadIconView(
|
||||||
remoteIconURLString: item.iconURL?.absoluteString,
|
remoteIconURLString: item.iconURL?.absoluteString,
|
||||||
fallbackSystemName: "bolt",
|
fallbackSystemName: fallbackIcon,
|
||||||
fallbackColor: Color.blue.opacity(0.15),
|
fallbackColor: Color.blue.opacity(0.15),
|
||||||
size: 44
|
size: 44
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var fallbackIcon: String {
|
||||||
|
switch libraryType {
|
||||||
|
case .load: return "bolt"
|
||||||
|
case .battery: return "battery.100"
|
||||||
|
case .charger: return "bolt.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var detailLine: some View {
|
private var detailLine: some View {
|
||||||
let labels = [item.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 }
|
let labels = item.detailLabels(for: libraryType)
|
||||||
|
|
||||||
if labels.isEmpty {
|
if labels.isEmpty {
|
||||||
Text("Details coming soon")
|
Text("Details coming soon")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct LoadsView: View {
|
|||||||
@State private var showingSystemEditor = false
|
@State private var showingSystemEditor = false
|
||||||
@State private var hasPresentedSystemEditorOnAppear = false
|
@State private var hasPresentedSystemEditorOnAppear = false
|
||||||
@State private var hasOpenedLoadOnAppear = false
|
@State private var hasOpenedLoadOnAppear = false
|
||||||
@State private var showingComponentLibrary = false
|
@State private var activeLibrary: ComponentLibraryType?
|
||||||
@State private var showingSystemBOM = false
|
@State private var showingSystemBOM = false
|
||||||
@State private var selectedComponentTab: ComponentTab
|
@State private var selectedComponentTab: ComponentTab
|
||||||
@State private var batteryDraft: BatteryConfiguration?
|
@State private var batteryDraft: BatteryConfiguration?
|
||||||
@@ -86,23 +86,7 @@ struct LoadsView: View {
|
|||||||
.accessibilityIdentifier("components-tab")
|
.accessibilityIdentifier("components-tab")
|
||||||
}
|
}
|
||||||
|
|
||||||
Group {
|
batteriesTab
|
||||||
if savedBatteries.isEmpty {
|
|
||||||
OnboardingInfoView(
|
|
||||||
configuration: .battery(),
|
|
||||||
onPrimaryAction: { startBatteryConfiguration() }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
BatteriesView(
|
|
||||||
system: system,
|
|
||||||
batteries: savedBatteries,
|
|
||||||
editMode: $editMode,
|
|
||||||
onEdit: { editBattery($0) },
|
|
||||||
onDelete: deleteBatteries
|
|
||||||
)
|
|
||||||
.environment(\.editMode, $editMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tag(ComponentTab.batteries)
|
.tag(ComponentTab.batteries)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label(
|
Label(
|
||||||
@@ -117,14 +101,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
.environment(\.editMode, $editMode)
|
.environment(\.editMode, $editMode)
|
||||||
|
|
||||||
ChargersView(
|
chargersTab
|
||||||
system: system,
|
|
||||||
chargers: savedChargers,
|
|
||||||
editMode: $editMode,
|
|
||||||
onAdd: { startChargerConfiguration() },
|
|
||||||
onEdit: { editCharger($0) },
|
|
||||||
onDelete: deleteChargers
|
|
||||||
)
|
|
||||||
.tag(ComponentTab.chargers)
|
.tag(ComponentTab.chargers)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label(
|
Label(
|
||||||
@@ -272,9 +249,9 @@ struct LoadsView: View {
|
|||||||
exportDiagramImage()
|
exportDiagramImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingComponentLibrary) {
|
.sheet(item: $activeLibrary) { type in
|
||||||
ComponentLibraryView { item in
|
ComponentLibraryView(libraryType: type) { item in
|
||||||
addComponent(item)
|
handleLibrarySelection(item, for: type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingSystemBOM) {
|
.sheet(isPresented: $showingSystemBOM) {
|
||||||
@@ -439,9 +416,9 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var libraryButton: some View {
|
private func libraryButton(type: ComponentLibraryType) -> some View {
|
||||||
Button {
|
Button {
|
||||||
openComponentLibrary(source: "library-button")
|
openComponentLibrary(source: "library-button", type: type)
|
||||||
} label: {
|
} label: {
|
||||||
Group {
|
Group {
|
||||||
if #available(iOS 26.0, *) {
|
if #available(iOS 26.0, *) {
|
||||||
@@ -490,6 +467,53 @@ struct LoadsView: View {
|
|||||||
.background(Color(.systemGroupedBackground))
|
.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
|
@ViewBuilder
|
||||||
private var loadsListWithHeader: some View {
|
private var loadsListWithHeader: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -507,7 +531,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottomTrailing) {
|
.overlay(alignment: .bottomTrailing) {
|
||||||
libraryButton
|
libraryButton(type: .load)
|
||||||
.padding(.trailing, 24)
|
.padding(.trailing, 24)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
}
|
}
|
||||||
@@ -802,15 +826,63 @@ struct LoadsView: View {
|
|||||||
showingSystemEditor = true
|
showingSystemEditor = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openComponentLibrary(source: String) {
|
private func openComponentLibrary(source: String, type: ComponentLibraryType = .load) {
|
||||||
AnalyticsTracker.log(
|
AnalyticsTracker.log(
|
||||||
"Component Library Opened",
|
"Component Library Opened",
|
||||||
properties: [
|
properties: [
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"type": type.rawValue,
|
||||||
"system": system.name
|
"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() {
|
private func openBillOfMaterials() {
|
||||||
@@ -1061,6 +1133,7 @@ struct LoadsView: View {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
||||||
isExportingOverview = false
|
isExportingOverview = false
|
||||||
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -1090,6 +1163,7 @@ struct LoadsView: View {
|
|||||||
"system": snapshot.systemName,
|
"system": snapshot.systemName,
|
||||||
])
|
])
|
||||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
||||||
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
} else {
|
} else {
|
||||||
overviewExportError = OverviewExportError(
|
overviewExportError = OverviewExportError(
|
||||||
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")
|
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"),
|
subtitle: LocalizedStringKey("battery.onboarding.subtitle"),
|
||||||
primaryActionTitle: LocalizedStringKey("battery.overview.empty.create"),
|
primaryActionTitle: LocalizedStringKey("battery.overview.empty.create"),
|
||||||
primaryActionIcon: "plus",
|
primaryActionIcon: "plus",
|
||||||
secondaryActionTitle: nil,
|
secondaryActionTitle: LocalizedStringKey("loads.overview.empty.library"),
|
||||||
secondaryActionIcon: nil,
|
secondaryActionIcon: "books.vertical",
|
||||||
imageNames: [
|
imageNames: [
|
||||||
"battery-onboarding"
|
"battery-onboarding"
|
||||||
]
|
]
|
||||||
@@ -155,8 +155,8 @@ extension OnboardingInfoView.Configuration {
|
|||||||
subtitle: LocalizedStringKey("chargers.onboarding.subtitle"),
|
subtitle: LocalizedStringKey("chargers.onboarding.subtitle"),
|
||||||
primaryActionTitle: LocalizedStringKey("chargers.onboarding.primary"),
|
primaryActionTitle: LocalizedStringKey("chargers.onboarding.primary"),
|
||||||
primaryActionIcon: "plus",
|
primaryActionIcon: "plus",
|
||||||
secondaryActionTitle: nil,
|
secondaryActionTitle: LocalizedStringKey("loads.overview.empty.library"),
|
||||||
secondaryActionIcon: nil,
|
secondaryActionIcon: "books.vertical",
|
||||||
imageNames: [
|
imageNames: [
|
||||||
"charger-onboarding"
|
"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"],
|
translations: ["de": "Navigationslichter", "es": "Luces de navegación", "fr": "Feux de navigation", "nl": "Navigatieverlichting"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 25,
|
voltageIn: 12.8, voltageOut: nil, watt: 25,
|
||||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 40,
|
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 40,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "2", name: "Refrigerator Compressor",
|
id: "2", name: "Refrigerator Compressor",
|
||||||
translations: ["de": "Kühlschrank-Kompressor", "es": "Compresor de refrigerador", "fr": "Compresseur réfrigérateur", "nl": "Koelkastcompressor"],
|
translations: ["de": "Kühlschrank-Kompressor", "es": "Compresor de refrigerador", "fr": "Compresseur réfrigérateur", "nl": "Koelkastcompressor"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 48,
|
voltageIn: 12.8, voltageOut: nil, watt: 48,
|
||||||
dutyCyclePercent: 40, defaultUtilizationFactorPercent: 100,
|
dutyCyclePercent: 40, defaultUtilizationFactorPercent: 100,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "3", name: "Anchor Windlass",
|
id: "3", name: "Anchor Windlass",
|
||||||
translations: ["de": "Ankerwinde", "es": "Molinete de ancla", "fr": "Guindeau", "nl": "Ankerlier"],
|
translations: ["de": "Ankerwinde", "es": "Molinete de ancla", "fr": "Guindeau", "nl": "Ankerlier"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 960,
|
voltageIn: 12.8, voltageOut: nil, watt: 960,
|
||||||
dutyCyclePercent: 5, defaultUtilizationFactorPercent: 2,
|
dutyCyclePercent: 5, defaultUtilizationFactorPercent: 2,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "4", name: "VHF Radio",
|
id: "4", name: "VHF Radio",
|
||||||
translations: ["de": "UKW Funkgerät", "es": "Radio VHF", "fr": "Radio VHF", "nl": "Marifoon"],
|
translations: ["de": "UKW Funkgerät", "es": "Radio VHF", "fr": "Radio VHF", "nl": "Marifoon"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 72,
|
voltageIn: 12.8, voltageOut: nil, watt: 72,
|
||||||
dutyCyclePercent: 30, defaultUtilizationFactorPercent: 33,
|
dutyCyclePercent: 30, defaultUtilizationFactorPercent: 33,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "5", name: "LED Interior Lights",
|
id: "5", name: "LED Interior Lights",
|
||||||
translations: ["de": "LED Innenbeleuchtung", "es": "Iluminación LED interior", "fr": "Éclairage LED intérieur", "nl": "LED binnenverlichting"],
|
translations: ["de": "LED Innenbeleuchtung", "es": "Iluminación LED interior", "fr": "Éclairage LED intérieur", "nl": "LED binnenverlichting"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 18,
|
voltageIn: 12.8, voltageOut: nil, watt: 18,
|
||||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 25,
|
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 25,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "6", name: "Water Pump",
|
id: "6", name: "Water Pump",
|
||||||
translations: ["de": "Wasserpumpe", "es": "Bomba de agua", "fr": "Pompe à eau", "nl": "Waterpomp"],
|
translations: ["de": "Wasserpumpe", "es": "Bomba de agua", "fr": "Pompe à eau", "nl": "Waterpomp"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 42,
|
voltageIn: 12.8, voltageOut: nil, watt: 42,
|
||||||
dutyCyclePercent: 20, defaultUtilizationFactorPercent: 10,
|
dutyCyclePercent: 20, defaultUtilizationFactorPercent: 10,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "7", name: "Diesel Heater",
|
id: "7", name: "Diesel Heater",
|
||||||
translations: ["de": "Dieselheizung", "es": "Calefactor diésel", "fr": "Chauffage diesel", "nl": "Dieselverwarming"],
|
translations: ["de": "Dieselheizung", "es": "Calefactor diésel", "fr": "Chauffage diesel", "nl": "Dieselverwarming"],
|
||||||
voltageIn: 12.8, voltageOut: nil, watt: 36,
|
voltageIn: 12.8, voltageOut: nil, watt: 36,
|
||||||
dutyCyclePercent: 60, defaultUtilizationFactorPercent: 50,
|
dutyCyclePercent: 60, defaultUtilizationFactorPercent: 50,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
ComponentLibraryItem(
|
ComponentLibraryItem(
|
||||||
id: "8", name: "USB Charging Station",
|
id: "8", name: "USB Charging Station",
|
||||||
translations: ["de": "USB-Ladestation", "es": "Estación de carga USB", "fr": "Station de charge USB", "nl": "USB-laadstation"],
|
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,
|
voltageIn: 12.8, voltageOut: nil, watt: 24,
|
||||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 30,
|
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 30,
|
||||||
iconURL: nil
|
componentCategory: nil, iconURL: nil
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ struct SystemBillOfMaterialsView: View {
|
|||||||
)
|
)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
activeShareItem = ExportedPDFShareItem(url: url)
|
activeShareItem = ExportedPDFShareItem(url: url)
|
||||||
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
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(
|
static func makeChargerDraft(
|
||||||
for system: ElectricalSystem,
|
for system: ElectricalSystem,
|
||||||
existingLoads: [SavedLoad],
|
existingLoads: [SavedLoad],
|
||||||
@@ -160,7 +241,8 @@ struct SystemComponentsPersistence {
|
|||||||
maximumTemperatureCelsius: configuration.maximumTemperatureCelsius,
|
maximumTemperatureCelsius: configuration.maximumTemperatureCelsius,
|
||||||
iconName: configuration.iconName,
|
iconName: configuration.iconName,
|
||||||
colorName: configuration.colorName,
|
colorName: configuration.colorName,
|
||||||
system: system
|
system: system,
|
||||||
|
componentID: configuration.componentID
|
||||||
)
|
)
|
||||||
context.insert(newBattery)
|
context.insert(newBattery)
|
||||||
}
|
}
|
||||||
@@ -184,7 +266,9 @@ struct SystemComponentsPersistence {
|
|||||||
maxPowerWatts: configuration.maxPowerWatts,
|
maxPowerWatts: configuration.maxPowerWatts,
|
||||||
iconName: configuration.iconName,
|
iconName: configuration.iconName,
|
||||||
colorName: configuration.colorName,
|
colorName: configuration.colorName,
|
||||||
system: system
|
system: system,
|
||||||
|
remoteIconURLString: configuration.remoteIconURLString,
|
||||||
|
componentID: configuration.componentID
|
||||||
)
|
)
|
||||||
context.insert(newCharger)
|
context.insert(newCharger)
|
||||||
}
|
}
|
||||||
|
|||||||
5
android/.gitignore
vendored
@@ -11,3 +11,8 @@
|
|||||||
|
|
||||||
# Local build artifacts
|
# Local build artifacts
|
||||||
*.apk
|
*.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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -6,6 +9,14 @@ plugins {
|
|||||||
alias(libs.plugins.ksp)
|
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 {
|
android {
|
||||||
namespace = "app.voltplan.cable"
|
namespace = "app.voltplan.cable"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -25,17 +36,35 @@ android {
|
|||||||
resourceConfigurations += listOf("en", "de", "es", "fr", "nl")
|
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 {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"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.kotlinx.serialization.json)
|
||||||
|
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
implementation(libs.play.review.ktx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.voltplan.cable
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.data.CableRepository
|
import app.voltplan.cable.data.CableRepository
|
||||||
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.data.UnitSystemSettings
|
import app.voltplan.cable.data.UnitSystemSettings
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -24,9 +25,11 @@ class CableApplication : Application() {
|
|||||||
|
|
||||||
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
if (settings.consumeFirstLaunch()) {
|
val isFirstLaunch = settings.consumeFirstLaunch()
|
||||||
|
if (isFirstLaunch) {
|
||||||
Analytics.log("First Launch")
|
Analytics.log("First Launch")
|
||||||
}
|
}
|
||||||
|
ReviewPrompt.migrateIfNeeded(this@CableApplication, isFirstLaunch)
|
||||||
Analytics.log("App Launched")
|
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.runBlocking
|
||||||
import kotlinx.coroutines.flow.first
|
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 UNIT_SYSTEM_KEY = stringPreferencesKey("unitSystem")
|
||||||
private val LAUNCHED_BEFORE_KEY = stringPreferencesKey("hasLaunchedBefore")
|
private val LAUNCHED_BEFORE_KEY = stringPreferencesKey("hasLaunchedBefore")
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ data class ComponentLibraryItem(
|
|||||||
val watt: Double?,
|
val watt: Double?,
|
||||||
val dutyCyclePercent: Double?,
|
val dutyCyclePercent: Double?,
|
||||||
val defaultUtilizationFactorPercent: Double?,
|
val defaultUtilizationFactorPercent: Double?,
|
||||||
|
val componentCategory: String?,
|
||||||
val iconURL: String?,
|
val iconURL: String?,
|
||||||
val affiliateLinks: List<AffiliateLink>,
|
val affiliateLinks: List<AffiliateLink>,
|
||||||
) {
|
) {
|
||||||
@@ -32,11 +33,43 @@ data class ComponentLibraryItem(
|
|||||||
return if (v > 0) w / v else null
|
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 localizedName: String get() = resolveLocalizedName(Locale.getDefault()) ?: name
|
||||||
|
|
||||||
val voltageLabel: String? get() = displayVoltage?.let { String.format(Locale.US, "%.1fV", it) }
|
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 powerLabel: String? get() = watt?.let { String.format(Locale.US, "%.0fW", it) }
|
||||||
val currentLabel: String? get() = current?.let { String.format(Locale.US, "%.1fA", 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)
|
val normalizedDutyCyclePercent: Double? get() = normalizePercent(dutyCyclePercent)
|
||||||
private val normalizedUtilizationFactorPercent: Double? get() = normalizePercent(defaultUtilizationFactorPercent)
|
private val normalizedUtilizationFactorPercent: Double? get() = normalizePercent(defaultUtilizationFactorPercent)
|
||||||
@@ -99,6 +132,7 @@ data class ComponentLibraryItem(
|
|||||||
watt = record.watt,
|
watt = record.watt,
|
||||||
dutyCyclePercent = record.dutyCycle,
|
dutyCyclePercent = record.dutyCycle,
|
||||||
defaultUtilizationFactorPercent = record.defaultUtilizationFactor,
|
defaultUtilizationFactorPercent = record.defaultUtilizationFactor,
|
||||||
|
componentCategory = record.componentCategory,
|
||||||
iconURL = iconUrl,
|
iconURL = iconUrl,
|
||||||
affiliateLinks = affiliateLinks,
|
affiliateLinks = affiliateLinks,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ package app.voltplan.cable.library
|
|||||||
/** Fetches and assembles the component library from PocketBase. Mirrors `ComponentLibraryViewModel` data flow. */
|
/** Fetches and assembles the component library from PocketBase. Mirrors `ComponentLibraryViewModel` data flow. */
|
||||||
class ComponentLibraryRepository(private val api: PocketBaseApi = PocketBaseApi.create()) {
|
class ComponentLibraryRepository(private val api: PocketBaseApi = PocketBaseApi.create()) {
|
||||||
|
|
||||||
suspend fun fetchAll(): List<ComponentLibraryItem> {
|
suspend fun fetchAll(type: ComponentLibraryType = ComponentLibraryType.LOAD): List<ComponentLibraryItem> {
|
||||||
val records = fetchComponents()
|
val records = fetchComponents(type)
|
||||||
val affiliateByComponent = fetchAffiliateLinks(records.map { it.id })
|
val affiliateByComponent = fetchAffiliateLinks(records.map { it.id })
|
||||||
return records.map { record ->
|
return records.map { record ->
|
||||||
ComponentLibraryItem.from(record, affiliateByComponent[record.id].orEmpty())
|
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>()
|
val all = mutableListOf<PbComponentRecord>()
|
||||||
var page = 1
|
var page = 1
|
||||||
val perPage = 200
|
val perPage = 200
|
||||||
while (true) {
|
while (true) {
|
||||||
val response = api.components(page = page, perPage = perPage)
|
val response = api.components(filter = type.filter, page = page, perPage = perPage)
|
||||||
all += response.items
|
all += response.items
|
||||||
val done = (response.totalPages in 1..page) || response.items.size < perPage
|
val done = (response.totalPages in 1..page) || response.items.size < perPage
|
||||||
if (done) break
|
if (done) break
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.voltplan.cable.CableApplication
|
import app.voltplan.cable.CableApplication
|
||||||
import app.voltplan.cable.analytics.Analytics
|
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.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.data.model.SavedLoad
|
||||||
import app.voltplan.cable.ui.systems.SystemIconMapper
|
import app.voltplan.cable.ui.systems.SystemIconMapper
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -30,6 +34,7 @@ data class LibraryUiState(
|
|||||||
|
|
||||||
class ComponentLibraryViewModel(
|
class ComponentLibraryViewModel(
|
||||||
private val app: CableApplication,
|
private val app: CableApplication,
|
||||||
|
val libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
|
||||||
private val libraryRepo: ComponentLibraryRepository = ComponentLibraryRepository(),
|
private val libraryRepo: ComponentLibraryRepository = ComponentLibraryRepository(),
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val repo = app.repository
|
private val repo = app.repository
|
||||||
@@ -41,7 +46,7 @@ class ComponentLibraryViewModel(
|
|||||||
fun load() {
|
fun load() {
|
||||||
_state.value = _state.value.copy(loading = true, error = null)
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { libraryRepo.fetchAll() }
|
runCatching { libraryRepo.fetchAll(libraryType) }
|
||||||
.onSuccess { _state.value = _state.value.copy(loading = false, items = it) }
|
.onSuccess { _state.value = _state.value.copy(loading = false, items = it) }
|
||||||
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Error") }
|
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Error") }
|
||||||
}
|
}
|
||||||
@@ -50,22 +55,20 @@ class ComponentLibraryViewModel(
|
|||||||
fun refresh() = load()
|
fun refresh() = load()
|
||||||
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
|
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. */
|
/** 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) {
|
fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val systemId: String
|
val (systemId, createdNewSystem) = ensureSystem(targetSystemId)
|
||||||
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 baseName = item.localizedName.ifBlank { "Library Load" }
|
val baseName = item.localizedName.ifBlank { "Library Load" }
|
||||||
val loadName = repo.uniqueComponentName(systemId, baseName)
|
val loadName = repo.uniqueComponentName(systemId, baseName)
|
||||||
@@ -97,4 +100,79 @@ class ComponentLibraryViewModel(
|
|||||||
onDone(if (createdNewSystem) systemId else null)
|
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"
|
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
|
@Serializable
|
||||||
data class PbComponentsResponse(
|
data class PbComponentsResponse(
|
||||||
val page: Int = 1,
|
val page: Int = 1,
|
||||||
@@ -33,6 +48,7 @@ data class PbComponentRecord(
|
|||||||
val watt: Double? = null,
|
val watt: Double? = null,
|
||||||
@SerialName("duty_cycle") val dutyCycle: Double? = null,
|
@SerialName("duty_cycle") val dutyCycle: Double? = null,
|
||||||
@SerialName("default_utilization_factor") val defaultUtilizationFactor: Double? = null,
|
@SerialName("default_utilization_factor") val defaultUtilizationFactor: Double? = null,
|
||||||
|
@SerialName("component_category") val componentCategory: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -55,7 +71,7 @@ interface PocketBaseApi {
|
|||||||
suspend fun components(
|
suspend fun components(
|
||||||
@Query("filter") filter: String = "type='load'",
|
@Query("filter") filter: String = "type='load'",
|
||||||
@Query("sort") sort: String = "+name",
|
@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("page") page: Int,
|
||||||
@Query("perPage") perPage: Int = 200,
|
@Query("perPage") perPage: Int = 200,
|
||||||
): PbComponentsResponse
|
): PbComponentsResponse
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ object PdfShare {
|
|||||||
return file
|
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 uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "application/pdf"
|
type = mimeType
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
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
|
package app.voltplan.cable.pdf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.RectF
|
||||||
import android.graphics.pdf.PdfDocument
|
import android.graphics.pdf.PdfDocument
|
||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.calc.ElectricalCalculations
|
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. */
|
/** Renders a full system overview PDF and opens the Android share sheet. */
|
||||||
object SystemOverviewPdf {
|
object SystemOverviewPdf {
|
||||||
suspend fun exportAndShare(context: Context, state: DetailState, unit: UnitSystem) {
|
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 file = withContext(Dispatchers.IO) {
|
||||||
val doc = PdfDocument()
|
val doc = PdfDocument()
|
||||||
val w = PdfWriter(doc)
|
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_batterycapacity), "${Fmt.number(m.totalCapacity)} Ah")
|
||||||
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargerpower), "${Fmt.number(m.totalChargerPower)} W")
|
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()) {
|
if (state.loads.isNotEmpty()) {
|
||||||
w.gap(12f); w.text(context.getString(R.string.overview_pdf_loads_section), 18f, ACCENT, bold = true); w.divider()
|
w.gap(12f); w.text(context.getString(R.string.overview_pdf_loads_section), 18f, ACCENT, bold = true); w.divider()
|
||||||
state.loads.forEach { load ->
|
state.loads.forEach { load ->
|
||||||
@@ -89,4 +98,23 @@ object SystemOverviewPdf {
|
|||||||
private fun summaryLine(w: PdfWriter, label: String, value: String) {
|
private fun summaryLine(w: PdfWriter, label: String, value: String) {
|
||||||
w.text("$label: $value", 12f, Color.DKGRAY)
|
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.BatteryFull
|
||||||
import androidx.compose.material.icons.outlined.Bolt
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
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.Speed
|
||||||
import androidx.compose.material.icons.outlined.Warning
|
import androidx.compose.material.icons.outlined.Warning
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -63,6 +65,7 @@ fun BatteriesTab(
|
|||||||
state: DetailState,
|
state: DetailState,
|
||||||
onEditBattery: (String) -> Unit,
|
onEditBattery: (String) -> Unit,
|
||||||
onNewBattery: () -> Unit,
|
onNewBattery: () -> Unit,
|
||||||
|
onOpenLibrary: () -> Unit,
|
||||||
onDeleteBattery: (SavedBattery) -> Unit,
|
onDeleteBattery: (SavedBattery) -> Unit,
|
||||||
) {
|
) {
|
||||||
val batteries = state.batteries
|
val batteries = state.batteries
|
||||||
@@ -73,11 +76,15 @@ fun BatteriesTab(
|
|||||||
subtitle = stringResource(R.string.battery_onboarding_subtitle),
|
subtitle = stringResource(R.string.battery_onboarding_subtitle),
|
||||||
primaryLabel = stringResource(R.string.battery_empty_create),
|
primaryLabel = stringResource(R.string.battery_empty_create),
|
||||||
onPrimary = onNewBattery,
|
onPrimary = onNewBattery,
|
||||||
|
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||||
|
onSecondary = onOpenLibrary,
|
||||||
|
images = listOf(R.drawable.onboarding_battery),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val m = state.metrics
|
val m = state.metrics
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
StatsHeader {
|
StatsHeader {
|
||||||
Text(stringResource(R.string.battery_bank_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
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 ->
|
items(batteries, key = { it.id }) { battery ->
|
||||||
BatteryRow(battery, onClick = { onEditBattery(battery.id) }, onDelete = { onDeleteBattery(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
|
@Composable
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -90,6 +91,9 @@ fun BatteryEditorScreen(systemId: String, batteryId: String?, onBack: () -> Unit
|
|||||||
title = {
|
title = {
|
||||||
Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true })
|
Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true })
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import app.voltplan.cable.R
|
|||||||
import app.voltplan.cable.data.UnitSystem
|
import app.voltplan.cable.data.UnitSystem
|
||||||
import app.voltplan.cable.ui.LocalUnitSettings
|
import app.voltplan.cable.ui.LocalUnitSettings
|
||||||
import app.voltplan.cable.ui.loads.CalcState
|
import app.voltplan.cable.ui.loads.CalcState
|
||||||
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.pdf.SystemBomPdf
|
import app.voltplan.cable.pdf.SystemBomPdf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -74,7 +75,10 @@ fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) {
|
|||||||
enabled = state.sections.isNotEmpty(),
|
enabled = state.sections.isNotEmpty(),
|
||||||
onClick = {
|
onClick = {
|
||||||
vm.logPdfExported()
|
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)) }
|
) { 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)) }
|
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 }) },
|
title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) },
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.voltplan.cable.ui.chargers
|
|||||||
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.Bolt
|
||||||
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
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.Speed
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -55,6 +58,7 @@ fun ChargersTab(
|
|||||||
state: DetailState,
|
state: DetailState,
|
||||||
onEditCharger: (String) -> Unit,
|
onEditCharger: (String) -> Unit,
|
||||||
onNewCharger: () -> Unit,
|
onNewCharger: () -> Unit,
|
||||||
|
onOpenLibrary: () -> Unit,
|
||||||
onDeleteCharger: (SavedCharger) -> Unit,
|
onDeleteCharger: (SavedCharger) -> Unit,
|
||||||
) {
|
) {
|
||||||
val chargers = state.chargers
|
val chargers = state.chargers
|
||||||
@@ -65,11 +69,15 @@ fun ChargersTab(
|
|||||||
subtitle = stringResource(R.string.chargers_onboarding_subtitle),
|
subtitle = stringResource(R.string.chargers_onboarding_subtitle),
|
||||||
primaryLabel = stringResource(R.string.chargers_onboarding_primary),
|
primaryLabel = stringResource(R.string.chargers_onboarding_primary),
|
||||||
onPrimary = onNewCharger,
|
onPrimary = onNewCharger,
|
||||||
|
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||||
|
onSecondary = onOpenLibrary,
|
||||||
|
images = listOf(R.drawable.onboarding_charger),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val m = state.metrics
|
val m = state.metrics
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
StatsHeader {
|
StatsHeader {
|
||||||
Text(stringResource(R.string.chargers_summary_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
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)
|
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 ->
|
items(chargers, key = { it.id }) { charger ->
|
||||||
ChargerRow(charger, onClick = { onEditCharger(charger.id) }, onDelete = { onDeleteCharger(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
|
@Composable
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ package app.voltplan.cable.ui.components
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -69,6 +68,7 @@ fun AppearanceEditorSheet(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 20.dp)
|
.padding(horizontal = 20.dp)
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
@@ -113,55 +113,41 @@ fun AppearanceEditorSheet(
|
|||||||
extra?.invoke()
|
extra?.invoke()
|
||||||
|
|
||||||
Text("Icon", style = MaterialTheme.typography.titleSmall)
|
Text("Icon", style = MaterialTheme.typography.titleSmall)
|
||||||
LazyVerticalGrid(
|
GridRows(items = icons, columns = 5) { symbol ->
|
||||||
columns = GridCells.Fixed(5),
|
val selected = symbol == icon
|
||||||
modifier = Modifier.fillMaxWidth().heightForRows(icons.size, 5),
|
Box(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
.weight(1f)
|
||||||
userScrollEnabled = false,
|
.aspectRatio(1f)
|
||||||
) {
|
.clip(RoundedCornerShape(12.dp))
|
||||||
items(icons) { symbol ->
|
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
|
||||||
val selected = symbol == icon
|
.clickable { icon = symbol },
|
||||||
Box(
|
contentAlignment = Alignment.Center,
|
||||||
Modifier
|
) {
|
||||||
.aspectRatio(1f)
|
Icon(
|
||||||
.clip(RoundedCornerShape(12.dp))
|
sfSymbol(symbol),
|
||||||
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
|
contentDescription = symbol,
|
||||||
.clickable { icon = symbol },
|
tint = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
|
||||||
contentAlignment = Alignment.Center,
|
modifier = Modifier.size(24.dp),
|
||||||
) {
|
)
|
||||||
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)
|
Text("Color", style = MaterialTheme.typography.titleSmall)
|
||||||
LazyVerticalGrid(
|
GridRows(items = curatedColorNames, columns = 6) { colorName ->
|
||||||
columns = GridCells.Fixed(6),
|
val c = componentColor(colorName)
|
||||||
modifier = Modifier.fillMaxWidth().heightForRows(curatedColorNames.size, 6),
|
Box(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
.weight(1f)
|
||||||
userScrollEnabled = false,
|
.aspectRatio(1f)
|
||||||
) {
|
.clip(CircleShape)
|
||||||
items(curatedColorNames) { colorName ->
|
.background(c)
|
||||||
val c = componentColor(colorName)
|
.border(2.dp, if (colorName == color) Color.White else Color.Transparent, CircleShape)
|
||||||
Box(
|
.clickable { color = colorName },
|
||||||
Modifier
|
contentAlignment = Alignment.Center,
|
||||||
.aspectRatio(1f)
|
) {
|
||||||
.clip(CircleShape)
|
if (colorName == color) {
|
||||||
.background(c)
|
Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||||
.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
|
* Lays out [items] in a non-lazy grid of fixed [columns], sizing to its content so it can live
|
||||||
// ~56dp per cell including spacing; gives a non-scrolling grid inside the sheet.
|
* inside a vertically scrolling container without a fixed height. Empty trailing cells are padded
|
||||||
return this.height((rows * 56).dp)
|
* 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
|
package app.voltplan.cable.ui.components
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -29,6 +31,7 @@ fun OnboardingInfo(
|
|||||||
onPrimary: () -> Unit,
|
onPrimary: () -> Unit,
|
||||||
secondaryLabel: String? = null,
|
secondaryLabel: String? = null,
|
||||||
onSecondary: (() -> Unit)? = null,
|
onSecondary: (() -> Unit)? = null,
|
||||||
|
@DrawableRes images: List<Int> = emptyList(),
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -36,7 +39,11 @@ fun OnboardingInfo(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
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))
|
Spacer(Modifier.size(16.dp))
|
||||||
Text(title, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center)
|
Text(title, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import app.voltplan.cable.CableApplication
|
|||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.library.ComponentLibraryItem
|
import app.voltplan.cable.library.ComponentLibraryItem
|
||||||
|
import app.voltplan.cable.library.ComponentLibraryType
|
||||||
import app.voltplan.cable.library.ComponentLibraryViewModel
|
import app.voltplan.cable.library.ComponentLibraryViewModel
|
||||||
import app.voltplan.cable.ui.components.LoadIcon
|
import app.voltplan.cable.ui.components.LoadIcon
|
||||||
import app.voltplan.cable.ui.theme.SysBlue
|
import app.voltplan.cable.ui.theme.SysBlue
|
||||||
@@ -51,18 +52,28 @@ import app.voltplan.cable.ui.theme.SysOrange
|
|||||||
@Composable
|
@Composable
|
||||||
fun ComponentLibraryScreen(
|
fun ComponentLibraryScreen(
|
||||||
targetSystemId: String?,
|
targetSystemId: String?,
|
||||||
|
libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenSystem: (String) -> Unit,
|
onOpenSystem: (String) -> Unit,
|
||||||
|
onOpenBatteryEditor: (String, String) -> Unit = { _, _ -> },
|
||||||
|
onOpenChargerEditor: (String, String) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val app = context.applicationContext as CableApplication
|
val app = context.applicationContext as CableApplication
|
||||||
val vm: ComponentLibraryViewModel = viewModel(
|
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()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
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(
|
Scaffold(
|
||||||
@@ -99,9 +110,17 @@ fun ComponentLibraryScreen(
|
|||||||
}
|
}
|
||||||
else -> LazyColumn(Modifier.fillMaxSize()) {
|
else -> LazyColumn(Modifier.fillMaxSize()) {
|
||||||
items(state.filtered, key = { it.id }) { item ->
|
items(state.filtered, key = { it.id }) { item ->
|
||||||
LibraryRow(item) {
|
LibraryRow(item, libraryType) {
|
||||||
vm.select(item, targetSystemId) { navigateId ->
|
when (libraryType) {
|
||||||
if (navigateId != null) onOpenSystem(navigateId) else onBack()
|
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
|
@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) {
|
Surface(modifier = Modifier.fillMaxWidth(), onClick = onClick, color = MaterialTheme.colorScheme.surface) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
LoadIcon(item.iconURL, "bolt", SysBlue, 44.dp)
|
LoadIcon(item.iconURL, fallbackIcon, SysBlue, 44.dp)
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(item.localizedName, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.titleSmall)
|
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(
|
Text(
|
||||||
if (details.isEmpty()) stringResource(R.string.library_details_coming) else details.joinToString(" • "),
|
if (details.isEmpty()) stringResource(R.string.library_details_coming) else details.joinToString(" • "),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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))
|
Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ fun ComponentsTab(
|
|||||||
onPrimary = onNewLoad,
|
onPrimary = onNewLoad,
|
||||||
secondaryLabel = stringResource(R.string.loads_empty_library),
|
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||||
onSecondary = onOpenLibrary,
|
onSecondary = onOpenLibrary,
|
||||||
|
images = listOf(R.drawable.onboarding_coffee, R.drawable.onboarding_router, R.drawable.onboarding_charger),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import app.voltplan.cable.ui.batteries.BatteryEditorScreen
|
import app.voltplan.cable.ui.batteries.BatteryEditorScreen
|
||||||
import app.voltplan.cable.ui.bom.BillOfMaterialsScreen
|
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.chargers.ChargerEditorScreen
|
||||||
import app.voltplan.cable.ui.library.ComponentLibraryScreen
|
import app.voltplan.cable.ui.library.ComponentLibraryScreen
|
||||||
import app.voltplan.cable.ui.loads.CalculatorScreen
|
import app.voltplan.cable.ui.loads.CalculatorScreen
|
||||||
@@ -22,11 +23,17 @@ object Routes {
|
|||||||
const val BATTERY = "battery/{systemId}?batteryId={batteryId}"
|
const val BATTERY = "battery/{systemId}?batteryId={batteryId}"
|
||||||
const val CHARGER = "charger/{systemId}?chargerId={chargerId}"
|
const val CHARGER = "charger/{systemId}?chargerId={chargerId}"
|
||||||
const val BOM = "bom/{systemId}"
|
const val BOM = "bom/{systemId}"
|
||||||
const val LIBRARY = "library?systemId={systemId}"
|
const val LIBRARY = "library?systemId={systemId}&type={type}"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
|
|
||||||
fun system(id: String) = "system/$id"
|
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) =
|
fun calculator(systemId: String, loadId: String? = null) =
|
||||||
"calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "")
|
"calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "")
|
||||||
fun battery(systemId: String, batteryId: String? = null) =
|
fun battery(systemId: String, batteryId: String? = null) =
|
||||||
@@ -64,7 +71,7 @@ fun CableNavHost() {
|
|||||||
onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) },
|
onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) },
|
||||||
onNewCharger = { nav.navigate(Routes.charger(systemId)) },
|
onNewCharger = { nav.navigate(Routes.charger(systemId)) },
|
||||||
onOpenBom = { nav.navigate(Routes.bom(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(
|
composable(
|
||||||
Routes.LIBRARY,
|
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 ->
|
) { entry ->
|
||||||
ComponentLibraryScreen(
|
ComponentLibraryScreen(
|
||||||
targetSystemId = entry.arguments?.getString("systemId"),
|
targetSystemId = entry.arguments?.getString("systemId"),
|
||||||
|
libraryType = ComponentLibraryType.fromArg(entry.arguments?.getString("type")),
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
onOpenSystem = { systemId ->
|
onOpenSystem = { systemId ->
|
||||||
nav.popBackStack()
|
nav.popBackStack()
|
||||||
nav.navigate(Routes.system(systemId))
|
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,
|
onAddCharger: () -> Unit,
|
||||||
onOpenLibrary: () -> Unit,
|
onOpenLibrary: () -> Unit,
|
||||||
onOpenBom: () -> Unit,
|
onOpenBom: () -> Unit,
|
||||||
|
onSelectLoads: () -> Unit,
|
||||||
|
onSelectBatteries: () -> Unit,
|
||||||
|
onSelectChargers: () -> Unit,
|
||||||
onSetRuntimeGoal: (Double?) -> Unit,
|
onSetRuntimeGoal: (Double?) -> Unit,
|
||||||
onSetChargeGoal: (Double?) -> Unit,
|
onSetChargeGoal: (Double?) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -104,9 +107,9 @@ fun OverviewTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadsCard(state, m, onAddLoad, onOpenLibrary)
|
LoadsCard(state, m, onAddLoad, onOpenLibrary, onSelectLoads)
|
||||||
BatteriesCard(state, m, onAddBattery)
|
BatteriesCard(state, m, onAddBattery, onSelectBatteries)
|
||||||
ChargersCard(state, m, onAddCharger)
|
ChargersCard(state, m, onAddCharger, onSelectChargers)
|
||||||
}
|
}
|
||||||
|
|
||||||
goalEditor?.let { kind ->
|
goalEditor?.let { kind ->
|
||||||
@@ -163,9 +166,10 @@ private fun MetricRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverviewCard(title: String, content: @Composable () -> Unit) {
|
private fun OverviewCard(title: String, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
|
||||||
Surface(
|
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),
|
shape = RoundedCornerShape(20.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||||
) {
|
) {
|
||||||
@@ -177,8 +181,8 @@ private fun OverviewCard(title: String, content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit) {
|
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit, onSelect: () -> Unit) {
|
||||||
OverviewCard(stringResource(R.string.loads_overview_header_title)) {
|
OverviewCard(stringResource(R.string.loads_overview_header_title), onClick = if (state.loads.isEmpty()) null else onSelect) {
|
||||||
if (state.loads.isEmpty()) {
|
if (state.loads.isEmpty()) {
|
||||||
Text(stringResource(R.string.overview_loads_empty_title), fontWeight = FontWeight.Medium)
|
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)
|
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
|
@Composable
|
||||||
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit) {
|
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit, onSelect: () -> Unit) {
|
||||||
OverviewCard(stringResource(R.string.battery_bank_header_title)) {
|
OverviewCard(stringResource(R.string.battery_bank_header_title), onClick = if (state.batteries.isEmpty()) null else onSelect) {
|
||||||
if (state.batteries.isEmpty()) {
|
if (state.batteries.isEmpty()) {
|
||||||
Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium)
|
||||||
Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) }
|
Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) }
|
||||||
@@ -212,8 +216,8 @@ private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit) {
|
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit, onSelect: () -> Unit) {
|
||||||
OverviewCard(stringResource(R.string.overview_chargers_header_title)) {
|
OverviewCard(stringResource(R.string.overview_chargers_header_title), onClick = if (state.chargers.isEmpty()) null else onSelect) {
|
||||||
if (state.chargers.isEmpty()) {
|
if (state.chargers.isEmpty()) {
|
||||||
Text(stringResource(R.string.overview_chargers_empty_title), fontWeight = FontWeight.Medium)
|
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)
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
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.Add
|
||||||
import androidx.compose.material.icons.outlined.BatteryFull
|
|
||||||
import androidx.compose.material.icons.outlined.Bolt
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material.icons.outlined.Dashboard
|
import androidx.compose.material.icons.outlined.IosShare
|
||||||
import androidx.compose.material.icons.outlined.Layers
|
|
||||||
import androidx.compose.material.icons.outlined.PictureAsPdf
|
import androidx.compose.material.icons.outlined.PictureAsPdf
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -39,6 +41,7 @@ import androidx.lifecycle.viewmodel.initializer
|
|||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import app.voltplan.cable.CableApplication
|
import app.voltplan.cable.CableApplication
|
||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
|
import app.voltplan.cable.library.ComponentLibraryType
|
||||||
import app.voltplan.cable.ui.LocalUnitSettings
|
import app.voltplan.cable.ui.LocalUnitSettings
|
||||||
import app.voltplan.cable.ui.batteries.BatteriesTab
|
import app.voltplan.cable.ui.batteries.BatteriesTab
|
||||||
import app.voltplan.cable.ui.chargers.ChargersTab
|
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.sfSymbol
|
||||||
import app.voltplan.cable.ui.systemIconOptions
|
import app.voltplan.cable.ui.systemIconOptions
|
||||||
import app.voltplan.cable.ui.theme.componentColor
|
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 app.voltplan.cable.pdf.SystemOverviewPdf
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -82,7 +89,7 @@ fun SystemDetailScreen(
|
|||||||
onEditCharger: (String) -> Unit,
|
onEditCharger: (String) -> Unit,
|
||||||
onNewCharger: () -> Unit,
|
onNewCharger: () -> Unit,
|
||||||
onOpenBom: () -> Unit,
|
onOpenBom: () -> Unit,
|
||||||
onOpenLibrary: () -> Unit,
|
onOpenLibrary: (ComponentLibraryType) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val app = context.applicationContext as CableApplication
|
val app = context.applicationContext as CableApplication
|
||||||
@@ -98,8 +105,15 @@ fun SystemDetailScreen(
|
|||||||
var tab by rememberSaveableTab()
|
var tab by rememberSaveableTab()
|
||||||
var showSystemEditor by remember { mutableStateOf(false) }
|
var showSystemEditor by remember { mutableStateOf(false) }
|
||||||
var showOverviewMenu by remember { mutableStateOf(false) }
|
var showOverviewMenu by remember { mutableStateOf(false) }
|
||||||
|
var exporting by remember { mutableStateOf(false) }
|
||||||
val system = state.system
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -132,28 +146,56 @@ fun SystemDetailScreen(
|
|||||||
actions = {
|
actions = {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
ComponentTab.OVERVIEW -> {
|
ComponentTab.OVERVIEW -> {
|
||||||
IconButton(onClick = { showOverviewMenu = true }) {
|
if (exporting) {
|
||||||
Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.overview_share_pdf))
|
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 }) {
|
DropdownMenu(expanded = showOverviewMenu, onDismissRequest = { showOverviewMenu = false }) {
|
||||||
DropdownMenuItem(
|
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)) },
|
text = { Text(stringResource(R.string.overview_share_pdf)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
showOverviewMenu = false
|
showOverviewMenu = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
exporting = true
|
||||||
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
|
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))
|
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))
|
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))
|
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,10 +204,10 @@ fun SystemDetailScreen(
|
|||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
NavTab(tab, ComponentTab.OVERVIEW, Icons.Outlined.Dashboard, stringResource(R.string.tab_overview)) { 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.Outlined.Layers, stringResource(R.string.tab_components)) { 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.Outlined.BatteryFull, stringResource(R.string.tab_batteries)) { 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.Outlined.Bolt, stringResource(R.string.tab_chargers)) { 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 ->
|
) { padding ->
|
||||||
@@ -174,11 +216,14 @@ fun SystemDetailScreen(
|
|||||||
ComponentTab.OVERVIEW -> OverviewTab(
|
ComponentTab.OVERVIEW -> OverviewTab(
|
||||||
state = state,
|
state = state,
|
||||||
unitSystem = unitSystem,
|
unitSystem = unitSystem,
|
||||||
onAddLoad = onNewLoad,
|
onAddLoad = newLoad,
|
||||||
onAddBattery = onNewBattery,
|
onAddBattery = newBattery,
|
||||||
onAddCharger = onNewCharger,
|
onAddCharger = newCharger,
|
||||||
onOpenLibrary = onOpenLibrary,
|
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
|
||||||
onOpenBom = { vm.logBomOpened(); onOpenBom() },
|
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,
|
onSetRuntimeGoal = vm::setRuntimeGoal,
|
||||||
onSetChargeGoal = vm::setChargeGoal,
|
onSetChargeGoal = vm::setChargeGoal,
|
||||||
)
|
)
|
||||||
@@ -186,20 +231,22 @@ fun SystemDetailScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
unitSystem = unitSystem,
|
unitSystem = unitSystem,
|
||||||
onOpenLoad = onOpenLoad,
|
onOpenLoad = onOpenLoad,
|
||||||
onNewLoad = onNewLoad,
|
onNewLoad = newLoad,
|
||||||
onOpenLibrary = onOpenLibrary,
|
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
|
||||||
onDeleteLoad = vm::deleteLoad,
|
onDeleteLoad = vm::deleteLoad,
|
||||||
)
|
)
|
||||||
ComponentTab.BATTERIES -> BatteriesTab(
|
ComponentTab.BATTERIES -> BatteriesTab(
|
||||||
state = state,
|
state = state,
|
||||||
onEditBattery = onEditBattery,
|
onEditBattery = onEditBattery,
|
||||||
onNewBattery = onNewBattery,
|
onNewBattery = newBattery,
|
||||||
|
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.BATTERY) },
|
||||||
onDeleteBattery = vm::deleteBattery,
|
onDeleteBattery = vm::deleteBattery,
|
||||||
)
|
)
|
||||||
ComponentTab.CHARGERS -> ChargersTab(
|
ComponentTab.CHARGERS -> ChargersTab(
|
||||||
state = state,
|
state = state,
|
||||||
onEditCharger = onEditCharger,
|
onEditCharger = onEditCharger,
|
||||||
onNewCharger = onNewCharger,
|
onNewCharger = newCharger,
|
||||||
|
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.CHARGER) },
|
||||||
onDeleteCharger = vm::deleteCharger,
|
onDeleteCharger = vm::deleteCharger,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -248,4 +295,4 @@ private fun RowScope.NavTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.Icons
|
||||||
import androidx.compose.material.icons.outlined.Add
|
import androidx.compose.material.icons.outlined.Add
|
||||||
import androidx.compose.material.icons.outlined.ChevronRight
|
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.Delete
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
|
import app.voltplan.cable.ui.components.OnboardingCarousel
|
||||||
import app.voltplan.cable.ui.sfSymbol
|
import app.voltplan.cable.ui.sfSymbol
|
||||||
import app.voltplan.cable.ui.theme.componentColor
|
import app.voltplan.cable.ui.theme.componentColor
|
||||||
|
|
||||||
@@ -174,7 +175,10 @@ private fun SystemsOnboarding(modifier: Modifier = Modifier, onCreate: (String)
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
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))
|
Spacer(Modifier.size(16.dp))
|
||||||
Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
Spacer(Modifier.size(8.dp))
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
<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>
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
<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>
|
</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 -->
|
<!-- Actions -->
|
||||||
<string name="action_add">Hinzufügen</string>
|
<string name="action_add">Hinzufügen</string>
|
||||||
<string name="action_back">Zurück</string>
|
<string name="action_back">Zurück</string>
|
||||||
|
<string name="action_save">Speichern</string>
|
||||||
<string name="action_delete">Löschen</string>
|
<string name="action_delete">Löschen</string>
|
||||||
|
|
||||||
<!-- Systems -->
|
<!-- 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_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_chargers_empty_create">Ladegerät hinzufügen</string>
|
||||||
<string name="overview_share_pdf">Vollständiger Bericht (PDF)</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 -->
|
<!-- Goal editor steppers -->
|
||||||
<string name="goal_days">Tage</string>
|
<string name="goal_days">Tage</string>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<string name="action_add">Añadir</string>
|
<string name="action_add">Añadir</string>
|
||||||
<string name="action_back">Atrás</string>
|
<string name="action_back">Atrás</string>
|
||||||
|
<string name="action_save">Guardar</string>
|
||||||
<string name="action_delete">Eliminar</string>
|
<string name="action_delete">Eliminar</string>
|
||||||
|
|
||||||
<!-- Systems -->
|
<!-- 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_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_chargers_empty_create">Añadir cargador</string>
|
||||||
<string name="overview_share_pdf">Informe completo (PDF)</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 -->
|
<!-- Goal editor steppers -->
|
||||||
<string name="goal_days">Días</string>
|
<string name="goal_days">Días</string>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<string name="action_add">Ajouter</string>
|
<string name="action_add">Ajouter</string>
|
||||||
<string name="action_back">Retour</string>
|
<string name="action_back">Retour</string>
|
||||||
|
<string name="action_save">Enregistrer</string>
|
||||||
<string name="action_delete">Supprimer</string>
|
<string name="action_delete">Supprimer</string>
|
||||||
|
|
||||||
<!-- Systems -->
|
<!-- 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_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_chargers_empty_create">Ajouter un chargeur</string>
|
||||||
<string name="overview_share_pdf">Rapport complet (PDF)</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 -->
|
<!-- Goal editor steppers -->
|
||||||
<string name="goal_days">Jours</string>
|
<string name="goal_days">Jours</string>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<string name="action_add">Toevoegen</string>
|
<string name="action_add">Toevoegen</string>
|
||||||
<string name="action_back">Terug</string>
|
<string name="action_back">Terug</string>
|
||||||
|
<string name="action_save">Opslaan</string>
|
||||||
<string name="action_delete">Verwijderen</string>
|
<string name="action_delete">Verwijderen</string>
|
||||||
|
|
||||||
<!-- Systems -->
|
<!-- 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_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_chargers_empty_create">Lader toevoegen</string>
|
||||||
<string name="overview_share_pdf">Volledig rapport (PDF)</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 -->
|
<!-- Goal editor steppers -->
|
||||||
<string name="goal_days">Dagen</string>
|
<string name="goal_days">Dagen</string>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#519098</color>
|
<color name="ic_launcher_background">#F4FEF6</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<string name="action_add">Add</string>
|
<string name="action_add">Add</string>
|
||||||
<string name="action_back">Back</string>
|
<string name="action_back">Back</string>
|
||||||
|
<string name="action_save">Save</string>
|
||||||
<string name="action_delete">Delete</string>
|
<string name="action_delete">Delete</string>
|
||||||
|
|
||||||
<!-- Systems -->
|
<!-- 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_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_chargers_empty_create">Add Charger</string>
|
||||||
<string name="overview_share_pdf">Full Report (PDF)</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 -->
|
<!-- Goal editor steppers -->
|
||||||
<string name="goal_days">Days</string>
|
<string name="goal_days">Days</string>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ okhttp = "4.12.0"
|
|||||||
serialization = "1.7.3"
|
serialization = "1.7.3"
|
||||||
retrofitSerialization = "1.0.0"
|
retrofitSerialization = "1.0.0"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
|
playReview = "2.0.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||