Compare commits
10 Commits
61f340a870
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ee36c1a4 | |||
| 23b117bfe2 | |||
| d97e3a2b7c | |||
| 67ec44e60a | |||
| 0aa3184406 | |||
| 38118ebc36 | |||
| b1fbac3ec1 | |||
| d68170bc87 | |||
| b448a1b4f7 | |||
| 8b30fabaa2 |
91
CLAUDE.md
@@ -2,6 +2,15 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Two apps in this repo
|
||||
|
||||
- **iOS** (`Cable/`) — SwiftUI + SwiftData. **This document describes the iOS app unless stated otherwise.**
|
||||
- **Android** (`android/`) — a native Kotlin/Jetpack Compose port that mirrors every iOS feature (package root `app.voltplan.cable`, Room persistence, same Aptabase analytics). See **`android/README.md`** for its architecture and build instructions before working on it.
|
||||
|
||||
Behavior and data shape are meant to stay in sync across both — when changing a user-facing feature on one platform, check the other. See [Export Options](#export-options) for one such cross-platform contract.
|
||||
|
||||
**Apply every instruction to both the iOS and Android versions unless explicitly told otherwise.** Any feature, fix, or change requested without naming a platform must land on both apps and stay behaviorally in sync.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
Use the **Xcode MCP server** tools instead of `xcodebuild` CLI:
|
||||
@@ -106,44 +115,80 @@ All list views use consistent styling:
|
||||
- Use `String(localized:defaultValue:)` — **not** `NSLocalizedString`. The `defaultValue` serves as English fallback and avoids showing raw keys when a Localizable.strings entry is missing.
|
||||
- When adding new user-facing strings, add translations to **all 5** Localizable.strings files immediately.
|
||||
|
||||
## PDF Export Pattern
|
||||
## Export Options
|
||||
|
||||
Three export options, available from the Overview tab's share menu plus the BOM sheet. **Keep iOS and Android (`android/.../pdf/`) in sync — they must offer the same exports.**
|
||||
|
||||
1. **System Overview (PDF)** — summary + a full-page wiring diagram + per-entity tables.
|
||||
2. **Bill of Materials (PDF)** — categorized component list.
|
||||
3. **Wiring Diagram (PNG)** — standalone diagram image.
|
||||
|
||||
The wiring diagram (used both as the standalone PNG and the Overview PDF's diagram page) is fetched from the shared **VoltPlan diagram API** (`POST https://voltplan.app/api/diagram/generate`, JSON payload of system/loads/batteries/chargers, returns PNG). Both platforms send the identical payload shape; falls back gracefully when the API is unreachable (iOS draws a Core Graphics diagram; Android omits the PDF page / shows an error toast for the standalone export).
|
||||
|
||||
### PDF Export Pattern (iOS)
|
||||
|
||||
PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is:
|
||||
- **Exporter struct** (e.g. `SystemOverviewPDFExporter`, `SystemBillOfMaterialsPDFExporter`) with snapshot data types — keeps Core Graphics rendering isolated from SwiftUI/SwiftData.
|
||||
- **ShareSheet** triggered via `@State` item binding in the parent view.
|
||||
- **Toolbar button** (not inline content) for the export action.
|
||||
|
||||
## Screenshots & Previews
|
||||
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`).
|
||||
|
||||
Screenshot previews for all major views live in `Cable/ScreenshotPreviews.swift`. This file contains wrapper views and realistic sample data so screenshots include full app chrome (NavigationBar, TabBar).
|
||||
## Screenshots
|
||||
|
||||
### How to render screenshots
|
||||
App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`.
|
||||
|
||||
1. Use `mcp__xcode__RenderPreview` with `sourceFilePath: "Cable/ScreenshotPreviews.swift"` and `previewDefinitionIndexInFile` (0–44, grouped by view × 5 languages).
|
||||
2. Output goes to `Shots/Screenshots/`.
|
||||
### Running screenshots
|
||||
|
||||
### Preview index mapping
|
||||
```bash
|
||||
./shooter.sh # reads ./screenshot.config
|
||||
./shooter.sh other.config # explicit config file
|
||||
VERBOSE=1 ./shooter.sh # full logs on failure
|
||||
PARALLEL=0 ./shooter.sh # sequential mode
|
||||
```
|
||||
|
||||
Previews are grouped in blocks of 5 (EN, DE, ES, FR, NL):
|
||||
- 0–4: Overview tab
|
||||
- 5–9: Components tab
|
||||
- 10–14: Batteries tab
|
||||
- 15–19: Chargers tab
|
||||
- 20–24: Systems list
|
||||
- 25–29: Parts Library
|
||||
- 30–34: Load editor (CalculatorView)
|
||||
- 35–39: Battery editor
|
||||
- 40–44: Charger editor
|
||||
Requires `xcparse`: `brew install chargepoint/xcparse/xcparse`.
|
||||
|
||||
### Key patterns for preview-friendly views
|
||||
### How it works
|
||||
|
||||
- **LoadsView** accepts an `initialTab` parameter (`LoadsView.ComponentTab`) to control which tab is shown. Wrap in `NavigationStack` to get the navigation bar.
|
||||
- **ComponentLibraryView** accepts an optional `viewModel` parameter via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to inject mock data and avoid network dependency.
|
||||
- All preview wrappers inject `.modelContainer(container)` (in-memory) and `.environmentObject(UnitSystemSettings())`.
|
||||
1. `shooter.sh` reads `screenshot.config` for scheme, bundle ID, devices, and languages.
|
||||
2. Builds once with `build-for-testing`, then runs `test-without-building` per language.
|
||||
3. Per language run: erases simulator, sets locale via `simctl spawn ... defaults write`, suppresses system notifications (DND, Apple Intelligence), overrides status bar (9:41, full battery).
|
||||
4. `xcparse` extracts screenshot attachments from `.xcresult` bundles into `Shots/Screenshots/{device-slug}/{lang}/`.
|
||||
5. Devices run in parallel (languages sequential per device — same simulator).
|
||||
|
||||
### Localization limitation
|
||||
### Test structure
|
||||
|
||||
`.environment(\.locale, Locale(identifier: "xx"))` does **not** affect `String(localized:defaultValue:)` — those resolve from the app bundle, not the SwiftUI environment. All preview screenshots render in the system language. For multi-language screenshots, use UI tests with `-AppleLanguages` launch arguments or change the Xcode scheme language.
|
||||
- **Test target**: `CableUITestsScreenshot` (scheme: `CableScreenshots`, test plan: `CableScreenshots.xctestplan`)
|
||||
- **Test file**: `CableUITestsScreenshot/CableUITestsScreenshot.swift`
|
||||
- **Sample data**: `UITestSampleData.swift` — seeded via `--uitest-sample-data` launch argument, cleared via `--uitest-reset-data`
|
||||
|
||||
### Screenshot inventory
|
||||
|
||||
| # | Name | Source |
|
||||
|---|------|--------|
|
||||
| 01 | OnboardingSystemsView | Empty state after reset |
|
||||
| 02 | OnboardingSystemView | New system overview |
|
||||
| 03 | LoadEditorView | CalculatorView for new load |
|
||||
| 04 | ComponentSelectorView | Component library (network) |
|
||||
| 05 | SystemsWithSampleData | Systems list with sample data |
|
||||
| 06 | AdventureVanOverview | Overview tab |
|
||||
| 07 | AdventureVanLoads | Components tab |
|
||||
| 08 | BillOfMaterials | System BOM sheet |
|
||||
| 09 | AdventureVanCalculator | Load calculator |
|
||||
| 10 | AdventureVanBatteries | Batteries tab |
|
||||
| 11 | BatteryEditor | Battery editor |
|
||||
| 12 | AdventureVanChargers | Chargers tab |
|
||||
| 13 | ChargerEditor | Charger editor |
|
||||
|
||||
### Accessibility identifiers for UI tests
|
||||
|
||||
Key identifiers used by the screenshot tests: `create-system-button`, `systems-list`, `system-overview`, `overview-tab`, `components-tab`, `batteries-tab`, `chargers-tab`, `loads-list`, `batteries-list`, `chargers-list`, `system-bom-button`, `system-bom-close-button`, `library-view-close-button`, `create-component-button`, `select-component-button`.
|
||||
|
||||
### Preview-friendly view inits
|
||||
|
||||
- **LoadsView** accepts `initialTab` (`LoadsView.ComponentTab`) to control which tab is shown.
|
||||
- **ComponentLibraryView** accepts an optional `viewModel` via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to bypass network.
|
||||
|
||||
## Model Definitions
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -454,7 +454,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 84;
|
||||
CURRENT_PROJECT_VERSION = 85;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
||||
@@ -1,77 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
enum AmazonAffiliate {
|
||||
private static let fallbackDomain = "www.amazon.com"
|
||||
private static let fallbackTag: String? = "voltplan-20"
|
||||
enum VoltPlanRedirect {
|
||||
private static let baseURL = "https://voltplan.app"
|
||||
|
||||
private static let domainsByCountry: [String: String] = [
|
||||
"US": "www.amazon.com",
|
||||
"DE": "www.amazon.de",
|
||||
"FR": "www.amazon.fr",
|
||||
"ES": "www.amazon.es",
|
||||
"IT": "www.amazon.it",
|
||||
"GB": "www.amazon.co.uk",
|
||||
"CA": "www.amazon.ca",
|
||||
"JP": "www.amazon.co.jp",
|
||||
"AU": "www.amazon.com.au",
|
||||
"NL": "www.amazon.nl",
|
||||
"SE": "www.amazon.se",
|
||||
"PL": "www.amazon.pl",
|
||||
"MX": "www.amazon.com.mx",
|
||||
"BR": "www.amazon.com.br",
|
||||
"IN": "www.amazon.in"
|
||||
]
|
||||
static func componentURL(id: String) -> URL? {
|
||||
var components = URLComponents(string: "\(baseURL)/\(id)")
|
||||
components?.queryItems = [URLQueryItem(name: "src", value: "cable")]
|
||||
return components?.url
|
||||
}
|
||||
|
||||
// Configure Amazon affiliate tracking IDs by country code.
|
||||
private static let tagsByCountry: [String: String] = [
|
||||
"US": "voltplan-20",
|
||||
"DE": "voltplan-21",
|
||||
"AU": "voltplan-22",
|
||||
"GB": "voltplan00-21",
|
||||
"FR": "voltplan0f-21",
|
||||
"CA": "voltplan01-20"
|
||||
]
|
||||
|
||||
private static let countryAliases: [String: String] = [
|
||||
"UK": "GB"
|
||||
]
|
||||
|
||||
static func searchURL(query: String, countryCode: String?) -> URL? {
|
||||
static func searchURL(query: String) -> URL? {
|
||||
guard !query.isEmpty else { return nil }
|
||||
|
||||
var components = URLComponents()
|
||||
components.scheme = "https"
|
||||
components.host = domain(for: countryCode)
|
||||
components.path = "/s"
|
||||
|
||||
var queryItems = [URLQueryItem(name: "k", value: query)]
|
||||
if let tag = affiliateTag(for: countryCode), !tag.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "tag", value: tag))
|
||||
}
|
||||
components.queryItems = queryItems
|
||||
|
||||
return components.url
|
||||
}
|
||||
|
||||
static func domain(for countryCode: String?) -> String {
|
||||
guard let normalized = normalizedCountryCode(from: countryCode) else {
|
||||
return fallbackDomain
|
||||
}
|
||||
return domainsByCountry[normalized] ?? fallbackDomain
|
||||
}
|
||||
|
||||
static func affiliateTag(for countryCode: String?) -> String? {
|
||||
guard let normalized = normalizedCountryCode(from: countryCode) else {
|
||||
return fallbackTag
|
||||
}
|
||||
return tagsByCountry[normalized] ?? fallbackTag
|
||||
}
|
||||
|
||||
private static func normalizedCountryCode(from countryCode: String?) -> String? {
|
||||
guard let raw = countryCode?.uppercased(), !raw.isEmpty else { return nil }
|
||||
if let alias = countryAliases[raw] {
|
||||
return alias
|
||||
}
|
||||
return raw
|
||||
var components = URLComponents(string: "\(baseURL)/search")
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "src", value: "cable"),
|
||||
]
|
||||
return components?.url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
|
||||
AnalyticsTracker.log("First Launch")
|
||||
}
|
||||
ReviewPrompt.migrateIfNeeded(isFirstLaunch: isFirstLaunch)
|
||||
AnalyticsTracker.log("App Launched")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ struct BatteriesView: View {
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.environment(\.editMode, $editMode)
|
||||
.accessibilityIdentifier("batteries-list")
|
||||
}
|
||||
|
||||
private func batteryRow(for battery: SavedBattery) -> some View {
|
||||
|
||||
@@ -44,6 +44,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
var iconName: String
|
||||
var colorName: String
|
||||
var system: ElectricalSystem
|
||||
var componentID: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
@@ -58,7 +59,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
maximumTemperatureCelsius: Double = 60,
|
||||
iconName: String = "battery.100",
|
||||
colorName: String = "blue",
|
||||
system: ElectricalSystem
|
||||
system: ElectricalSystem,
|
||||
componentID: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -73,6 +75,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.componentID = componentID
|
||||
}
|
||||
|
||||
init(savedBattery: SavedBattery, system: ElectricalSystem) {
|
||||
@@ -95,6 +98,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
self.iconName = savedBattery.iconName
|
||||
self.colorName = savedBattery.colorName
|
||||
self.system = system
|
||||
self.componentID = savedBattery.componentID
|
||||
}
|
||||
|
||||
var energyWattHours: Double {
|
||||
@@ -137,6 +141,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
savedBattery.iconName = iconName
|
||||
savedBattery.colorName = colorName
|
||||
savedBattery.system = system
|
||||
savedBattery.componentID = componentID
|
||||
savedBattery.timestamp = Date()
|
||||
}
|
||||
}
|
||||
@@ -154,7 +159,8 @@ extension BatteryConfiguration {
|
||||
lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius &&
|
||||
lhs.chemistry == rhs.chemistry &&
|
||||
lhs.iconName == rhs.iconName &&
|
||||
lhs.colorName == rhs.colorName
|
||||
lhs.colorName == rhs.colorName &&
|
||||
lhs.componentID == rhs.componentID
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
@@ -170,5 +176,6 @@ extension BatteryConfiguration {
|
||||
hasher.combine(chemistry)
|
||||
hasher.combine(iconName)
|
||||
hasher.combine(colorName)
|
||||
hasher.combine(componentID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@ class SavedBattery {
|
||||
var iconName: String = "battery.100"
|
||||
var colorName: String = "blue"
|
||||
var system: ElectricalSystem?
|
||||
var affiliateURLString: String?
|
||||
var affiliateCountryCode: String?
|
||||
var componentID: String?
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var timestamp: Date
|
||||
|
||||
@@ -35,8 +34,7 @@ class SavedBattery {
|
||||
iconName: String = "battery.100",
|
||||
colorName: String = "blue",
|
||||
system: ElectricalSystem? = nil,
|
||||
affiliateURLString: String? = nil,
|
||||
affiliateCountryCode: String? = nil,
|
||||
componentID: String? = nil,
|
||||
bomCompletedItemIDs: [String] = [],
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
@@ -53,8 +51,7 @@ class SavedBattery {
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.componentID = componentID
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
var colorName: String
|
||||
var system: ElectricalSystem
|
||||
var powerSourceType: SavedCharger.PowerSourceType
|
||||
var componentID: String?
|
||||
var remoteIconURLString: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
@@ -23,7 +25,9 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
iconName: String = "bolt.fill",
|
||||
colorName: String = "orange",
|
||||
system: ElectricalSystem,
|
||||
powerSourceType: SavedCharger.PowerSourceType = .shore
|
||||
powerSourceType: SavedCharger.PowerSourceType = .shore,
|
||||
componentID: String? = nil,
|
||||
remoteIconURLString: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -35,6 +39,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.powerSourceType = powerSourceType
|
||||
self.componentID = componentID
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
}
|
||||
|
||||
init(savedCharger: SavedCharger, system: ElectricalSystem) {
|
||||
@@ -48,6 +54,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
self.colorName = savedCharger.colorName
|
||||
self.system = system
|
||||
self.powerSourceType = savedCharger.sourceType
|
||||
self.componentID = savedCharger.componentID
|
||||
self.remoteIconURLString = savedCharger.remoteIconURLString
|
||||
}
|
||||
|
||||
var effectivePowerWatts: Double {
|
||||
@@ -67,6 +75,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
|
||||
savedCharger.colorName = colorName
|
||||
savedCharger.system = system
|
||||
savedCharger.powerSourceType = powerSourceType.rawValue
|
||||
savedCharger.componentID = componentID
|
||||
savedCharger.remoteIconURLString = remoteIconURLString
|
||||
savedCharger.timestamp = Date()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ struct ChargersView: View {
|
||||
let onAdd: () -> Void
|
||||
let onEdit: (SavedCharger) -> Void
|
||||
let onDelete: (IndexSet) -> Void
|
||||
let onBrowseLibrary: () -> Void
|
||||
|
||||
private struct SummaryMetric: Identifiable {
|
||||
let id: String
|
||||
@@ -94,13 +95,15 @@ struct ChargersView: View {
|
||||
editMode: Binding<EditMode> = .constant(.inactive),
|
||||
onAdd: @escaping () -> Void = {},
|
||||
onEdit: @escaping (SavedCharger) -> Void = { _ in },
|
||||
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
||||
onDelete: @escaping (IndexSet) -> Void = { _ in },
|
||||
onBrowseLibrary: @escaping () -> Void = {}
|
||||
) {
|
||||
self.system = system
|
||||
self.chargers = chargers
|
||||
self.onAdd = onAdd
|
||||
self.onEdit = onEdit
|
||||
self.onDelete = onDelete
|
||||
self.onBrowseLibrary = onBrowseLibrary
|
||||
_editMode = editMode
|
||||
}
|
||||
|
||||
@@ -244,7 +247,8 @@ struct ChargersView: View {
|
||||
private var emptyState: some View {
|
||||
OnboardingInfoView(
|
||||
configuration: .charger(),
|
||||
onPrimaryAction: onAdd
|
||||
onPrimaryAction: onAdd,
|
||||
onSecondaryAction: onBrowseLibrary
|
||||
)
|
||||
.padding(.horizontal, 0)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ final class SavedCharger {
|
||||
var system: ElectricalSystem?
|
||||
var timestamp: Date
|
||||
var remoteIconURLString: String?
|
||||
var affiliateURLString: String?
|
||||
var affiliateCountryCode: String?
|
||||
var componentID: String?
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var identifier: String
|
||||
var powerSourceType: String = "shore"
|
||||
@@ -67,8 +66,7 @@ final class SavedCharger {
|
||||
system: ElectricalSystem? = nil,
|
||||
timestamp: Date = Date(),
|
||||
remoteIconURLString: String? = nil,
|
||||
affiliateURLString: String? = nil,
|
||||
affiliateCountryCode: String? = nil,
|
||||
componentID: String? = nil,
|
||||
bomCompletedItemIDs: [String] = [],
|
||||
identifier: String = UUID().uuidString,
|
||||
powerSourceType: String = "shore"
|
||||
@@ -84,8 +82,7 @@ final class SavedCharger {
|
||||
self.system = system
|
||||
self.timestamp = timestamp
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.componentID = componentID
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.identifier = identifier
|
||||
self.powerSourceType = powerSourceType
|
||||
|
||||
@@ -127,8 +127,7 @@ class SavedLoad {
|
||||
var dailyUsageHours: Double = 24.0
|
||||
var system: ElectricalSystem?
|
||||
var remoteIconURLString: String? = nil
|
||||
var affiliateURLString: String? = nil
|
||||
var affiliateCountryCode: String? = nil
|
||||
var componentID: String? = nil
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var identifier: String = UUID().uuidString
|
||||
|
||||
@@ -146,8 +145,7 @@ class SavedLoad {
|
||||
dailyUsageHours: Double = 24.0,
|
||||
system: ElectricalSystem? = nil,
|
||||
remoteIconURLString: String? = nil,
|
||||
affiliateURLString: String? = nil,
|
||||
affiliateCountryCode: String? = nil,
|
||||
componentID: String? = nil,
|
||||
bomCompletedItemIDs: [String] = [],
|
||||
identifier: String = UUID().uuidString
|
||||
) {
|
||||
@@ -165,8 +163,7 @@ class SavedLoad {
|
||||
self.dailyUsageHours = dailyUsageHours
|
||||
self.system = system
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.componentID = componentID
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.identifier = identifier
|
||||
}
|
||||
|
||||
@@ -51,16 +51,14 @@ struct CalculatorView: View {
|
||||
|
||||
struct AffiliateLinkInfo: Identifiable, Equatable {
|
||||
let id: String
|
||||
let affiliateURL: URL?
|
||||
let componentID: String?
|
||||
let buttonTitle: String
|
||||
let regionName: String?
|
||||
let countryCode: String?
|
||||
}
|
||||
|
||||
struct BOMItem: Identifiable, Equatable {
|
||||
enum Destination: Equatable {
|
||||
case affiliate(URL)
|
||||
case amazonSearch(String)
|
||||
case component(String)
|
||||
case search(String)
|
||||
}
|
||||
|
||||
let id: String
|
||||
@@ -391,7 +389,7 @@ struct CalculatorView: View {
|
||||
private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View {
|
||||
BillOfMaterialsView(
|
||||
info: info,
|
||||
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL),
|
||||
items: buildBillOfMaterialsItems(componentID: info.componentID),
|
||||
completedItemIDs: $completedItemIDs
|
||||
)
|
||||
}
|
||||
@@ -467,18 +465,6 @@ struct CalculatorView: View {
|
||||
}
|
||||
|
||||
private var affiliateLinkInfo: AffiliateLinkInfo {
|
||||
let affiliateURL: URL?
|
||||
if let urlString = savedLoad?.affiliateURLString,
|
||||
let parsedURL = URL(string: urlString) {
|
||||
affiliateURL = parsedURL
|
||||
} else {
|
||||
affiliateURL = nil
|
||||
}
|
||||
|
||||
let rawCountryCode = savedLoad?.affiliateCountryCode ?? Locale.current.region?.identifier
|
||||
let countryCode = rawCountryCode?.uppercased()
|
||||
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
|
||||
|
||||
let buttonTitle = String(localized: "affiliate.button.review_parts", comment: "Button title to review a bill of materials before shopping")
|
||||
let name = savedLoad?.name ?? calculator.loadName
|
||||
let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0)
|
||||
@@ -486,10 +472,8 @@ struct CalculatorView: View {
|
||||
|
||||
return AffiliateLinkInfo(
|
||||
id: identifier,
|
||||
affiliateURL: affiliateURL,
|
||||
buttonTitle: buttonTitle,
|
||||
regionName: regionName,
|
||||
countryCode: countryCode
|
||||
componentID: savedLoad?.componentID,
|
||||
buttonTitle: buttonTitle
|
||||
)
|
||||
}
|
||||
|
||||
@@ -756,7 +740,7 @@ struct CalculatorView: View {
|
||||
Button {
|
||||
AnalyticsTracker.log("Review Parts Tapped", properties: [
|
||||
"load": info.id,
|
||||
"has_affiliate": info.affiliateURL != nil,
|
||||
"has_component": info.componentID != nil,
|
||||
])
|
||||
presentedAffiliateLink = info
|
||||
} label: {
|
||||
@@ -773,7 +757,7 @@ struct CalculatorView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
let description = info.affiliateURL != nil
|
||||
let description = info.componentID != nil
|
||||
? String(localized: "affiliate.description.with_link", defaultValue: "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.")
|
||||
: String(localized: "affiliate.description.without_link", defaultValue: "Tapping above shows a full bill of materials with shopping searches to help you source parts.")
|
||||
Text(description)
|
||||
@@ -785,7 +769,7 @@ struct CalculatorView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private func buildBillOfMaterialsItems(deviceLink: URL?) -> [BOMItem] {
|
||||
private func buildBillOfMaterialsItems(componentID: String?) -> [BOMItem] {
|
||||
let unitSystem = unitSettings.unitSystem
|
||||
let lengthValue = calculator.length
|
||||
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
|
||||
@@ -847,7 +831,7 @@ struct CalculatorView: View {
|
||||
title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName,
|
||||
detail: powerDetail,
|
||||
iconSystemName: "bolt.fill",
|
||||
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase),
|
||||
destination: componentID.map { .component($0) } ?? .search(deviceQueryBase),
|
||||
isPrimaryComponent: true
|
||||
)
|
||||
)
|
||||
@@ -858,7 +842,7 @@ struct CalculatorView: View {
|
||||
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(redCableQuery),
|
||||
destination: .search(redCableQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
)
|
||||
@@ -869,7 +853,7 @@ struct CalculatorView: View {
|
||||
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(blackCableQuery),
|
||||
destination: .search(blackCableQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
)
|
||||
@@ -880,7 +864,7 @@ struct CalculatorView: View {
|
||||
title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"),
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery),
|
||||
destination: .search(fuseQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
)
|
||||
@@ -891,7 +875,7 @@ struct CalculatorView: View {
|
||||
title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"),
|
||||
detail: cableShoesDetail,
|
||||
iconSystemName: "wrench.and.screwdriver",
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
destination: .search(terminalQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
)
|
||||
@@ -1495,11 +1479,11 @@ private struct BillOfMaterialsView: View {
|
||||
return
|
||||
}
|
||||
if let destinationURL {
|
||||
let isAffiliate: Bool
|
||||
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false }
|
||||
let isComponent: Bool
|
||||
if case .component = item.destination { isComponent = true } else { isComponent = false }
|
||||
AnalyticsTracker.log("BOM Item Tapped", properties: [
|
||||
"item": item.title,
|
||||
"is_affiliate": isAffiliate,
|
||||
"is_component": isComponent,
|
||||
"domain": destinationURL.host ?? "unknown",
|
||||
"load": info.id,
|
||||
])
|
||||
@@ -1543,10 +1527,10 @@ private struct BillOfMaterialsView: View {
|
||||
|
||||
private func destinationURL(for item: CalculatorView.BOMItem) -> URL? {
|
||||
switch item.destination {
|
||||
case .affiliate(let url):
|
||||
return url
|
||||
case .amazonSearch(let query):
|
||||
return AmazonAffiliate.searchURL(query: query, countryCode: info.countryCode)
|
||||
case .component(let id):
|
||||
return VoltPlanRedirect.componentURL(id: id)
|
||||
case .search(let query):
|
||||
return VoltPlanRedirect.searchURL(query: query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
struct AffiliateLink: Identifiable, Equatable {
|
||||
let id: String
|
||||
let url: URL
|
||||
let country: String?
|
||||
}
|
||||
enum ComponentLibraryType: String, Identifiable, CaseIterable {
|
||||
case load
|
||||
case battery
|
||||
case charger
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// PocketBase filter expression selecting this type.
|
||||
var filterValue: String { "type='\(rawValue)'" }
|
||||
}
|
||||
|
||||
struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let translations: [String: String]
|
||||
@@ -15,8 +20,8 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
let watt: Double?
|
||||
let dutyCyclePercent: Double?
|
||||
let defaultUtilizationFactorPercent: Double?
|
||||
let componentCategory: String?
|
||||
let iconURL: URL?
|
||||
let affiliateLinks: [AffiliateLink]
|
||||
|
||||
var displayVoltage: Double? {
|
||||
voltageIn ?? voltageOut
|
||||
@@ -27,6 +32,52 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
return power / voltage
|
||||
}
|
||||
|
||||
/// Battery capacity derived from stored energy (Wh) and nominal voltage.
|
||||
var capacityAmpHours: Double? {
|
||||
guard let energy = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
|
||||
return energy / voltage
|
||||
}
|
||||
|
||||
/// Charger output current derived from rated power and output voltage.
|
||||
var outputCurrent: Double? {
|
||||
guard let power = watt, let voltage = voltageOut ?? displayVoltage, voltage > 0 else { return nil }
|
||||
return power / voltage
|
||||
}
|
||||
|
||||
var capacityLabel: String? {
|
||||
guard let capacity = capacityAmpHours else { return nil }
|
||||
return String(format: "%.0fAh", capacity)
|
||||
}
|
||||
|
||||
var energyLabel: String? {
|
||||
guard let energy = watt else { return nil }
|
||||
return String(format: "%.0fWh", energy)
|
||||
}
|
||||
|
||||
var voltageRangeLabel: String? {
|
||||
if let input = voltageIn, let output = voltageOut {
|
||||
return String(format: "%.0fV → %.0fV", input, output)
|
||||
}
|
||||
return voltageLabel
|
||||
}
|
||||
|
||||
var outputCurrentLabel: String? {
|
||||
guard let current = outputCurrent else { return nil }
|
||||
return String(format: "%.1fA", current)
|
||||
}
|
||||
|
||||
/// Detail metrics shown in a library row, tailored to the component type.
|
||||
func detailLabels(for type: ComponentLibraryType) -> [String] {
|
||||
switch type {
|
||||
case .load:
|
||||
return [voltageLabel, powerLabel, currentLabel].compactMap { $0 }
|
||||
case .battery:
|
||||
return [voltageLabel, capacityLabel, energyLabel].compactMap { $0 }
|
||||
case .charger:
|
||||
return [voltageRangeLabel, outputCurrentLabel, powerLabel].compactMap { $0 }
|
||||
}
|
||||
}
|
||||
|
||||
var voltageLabel: String? {
|
||||
guard let voltage = displayVoltage else { return nil }
|
||||
return String(format: "%.1fV", voltage)
|
||||
@@ -65,36 +116,10 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
return translation(for: locale)
|
||||
}
|
||||
|
||||
var primaryAffiliateLink: AffiliateLink? {
|
||||
affiliateLink(matching: Locale.current.region?.identifier)
|
||||
}
|
||||
|
||||
func localizedName(for locale: Locale) -> String {
|
||||
translation(for: locale) ?? name
|
||||
}
|
||||
|
||||
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
|
||||
guard !affiliateLinks.isEmpty else { return nil }
|
||||
|
||||
let normalizedRegionCode = regionCode?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
if let normalizedRegionCode, !normalizedRegionCode.isEmpty {
|
||||
if let exactMatch = affiliateLinks.first(where: { link in
|
||||
link.country?.lowercased() == normalizedRegionCode
|
||||
}) {
|
||||
return exactMatch
|
||||
}
|
||||
}
|
||||
|
||||
if let fallbackWithoutCountry = affiliateLinks.first(where: { $0.country == nil }) {
|
||||
return fallbackWithoutCountry
|
||||
}
|
||||
|
||||
return affiliateLinks.first
|
||||
}
|
||||
|
||||
private func translation(for locale: Locale) -> String? {
|
||||
guard !translations.isEmpty else { return nil }
|
||||
|
||||
@@ -206,12 +231,15 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
|
||||
private let baseURL = URL(string: "https://base.voltplan.app")!
|
||||
private let urlSession: URLSession
|
||||
let libraryType: ComponentLibraryType
|
||||
|
||||
init(urlSession: URLSession = .shared) {
|
||||
init(libraryType: ComponentLibraryType = .load, urlSession: URLSession = .shared) {
|
||||
self.libraryType = libraryType
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
|
||||
init(previewItems: [ComponentLibraryItem]) {
|
||||
init(previewItems: [ComponentLibraryItem], libraryType: ComponentLibraryType = .load) {
|
||||
self.libraryType = libraryType
|
||||
self.urlSession = .shared
|
||||
self.items = previewItems
|
||||
self.isLoading = false
|
||||
@@ -249,11 +277,11 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "filter", value: "(type='load')"),
|
||||
URLQueryItem(name: "filter", value: "(\(libraryType.filterValue))"),
|
||||
URLQueryItem(name: "sort", value: "+name"),
|
||||
URLQueryItem(
|
||||
name: "fields",
|
||||
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor"
|
||||
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor,component_category"
|
||||
),
|
||||
URLQueryItem(name: "page", value: "\(page)"),
|
||||
URLQueryItem(name: "perPage", value: "\(perPage)")
|
||||
@@ -289,7 +317,6 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
page += 1
|
||||
}
|
||||
|
||||
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
|
||||
let mappedItems = allRecords.map { record in
|
||||
ComponentLibraryItem(
|
||||
id: record.id,
|
||||
@@ -300,8 +327,8 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
watt: record.watt,
|
||||
dutyCyclePercent: record.dutyCycle,
|
||||
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
|
||||
iconURL: iconURL(for: record),
|
||||
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
|
||||
componentCategory: record.componentCategory,
|
||||
iconURL: iconURL(for: record)
|
||||
)
|
||||
}
|
||||
for item in mappedItems {
|
||||
@@ -314,110 +341,6 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
return mappedItems
|
||||
}
|
||||
|
||||
private func fetchAffiliateLinks(for componentIDs: [String]) async throws -> [String: [ComponentLibraryItem.AffiliateLink]] {
|
||||
let uniqueIDs = Array(Set(componentIDs))
|
||||
guard !uniqueIDs.isEmpty else { return [:] }
|
||||
|
||||
let idSet = Set(uniqueIDs)
|
||||
let perPage = 200
|
||||
let chunkSize = 15
|
||||
let chunks: [[String]] = stride(from: 0, to: uniqueIDs.count, by: chunkSize).map { index in
|
||||
let upperBound = min(index + chunkSize, uniqueIDs.count)
|
||||
return Array(uniqueIDs[index..<upperBound])
|
||||
}
|
||||
var aggregated: [String: [ComponentLibraryItem.AffiliateLink]] = [:]
|
||||
|
||||
for chunk in chunks {
|
||||
guard !chunk.isEmpty else { continue }
|
||||
|
||||
let filterValue = chunk
|
||||
.map { "component='\(escapeFilterValue($0))'" }
|
||||
.joined(separator: " || ")
|
||||
|
||||
var page = 1
|
||||
|
||||
while true {
|
||||
var components = URLComponents(
|
||||
url: baseURL.appendingPathComponent("api/collections/affiliate_links/records"),
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "page", value: "\(page)"),
|
||||
URLQueryItem(name: "perPage", value: "\(perPage)"),
|
||||
URLQueryItem(name: "fields", value: "id,url,component,country")
|
||||
]
|
||||
|
||||
if !filterValue.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "filter", value: "(\(filterValue))"))
|
||||
}
|
||||
|
||||
components?.queryItems = queryItems
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(AffiliateLinksResponse.self, from: data)
|
||||
|
||||
for record in decoded.items {
|
||||
guard let componentID = record.component, idSet.contains(componentID) else { continue }
|
||||
guard let url = URL(string: record.url) else { continue }
|
||||
|
||||
let normalizedCountry = record.country?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let countryCode = normalizedCountry?.isEmpty == true ? nil : normalizedCountry?.uppercased()
|
||||
|
||||
let link = ComponentLibraryItem.AffiliateLink(
|
||||
id: record.id,
|
||||
url: url,
|
||||
country: countryCode
|
||||
)
|
||||
|
||||
var links = aggregated[componentID, default: []]
|
||||
if !links.contains(where: { $0.id == record.id }) {
|
||||
links.append(link)
|
||||
aggregated[componentID] = links
|
||||
}
|
||||
}
|
||||
|
||||
let isLastPage: Bool
|
||||
if decoded.totalPages > 0 {
|
||||
isLastPage = page >= decoded.totalPages
|
||||
} else {
|
||||
isLastPage = decoded.items.count < perPage
|
||||
}
|
||||
|
||||
if isLastPage { break }
|
||||
page += 1
|
||||
}
|
||||
}
|
||||
|
||||
for key in Array(aggregated.keys) {
|
||||
aggregated[key]?.sort { lhs, rhs in
|
||||
let lhsCountry = lhs.country ?? ""
|
||||
let rhsCountry = rhs.country ?? ""
|
||||
|
||||
if lhsCountry == rhsCountry {
|
||||
return lhs.url.absoluteString < rhs.url.absoluteString
|
||||
}
|
||||
|
||||
return lhsCountry < rhsCountry
|
||||
}
|
||||
}
|
||||
|
||||
return aggregated
|
||||
}
|
||||
|
||||
private func iconURL(for record: PocketBaseRecord) -> URL? {
|
||||
guard let icon = record.icon else { return nil }
|
||||
|
||||
@@ -451,6 +374,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
let watt: Double?
|
||||
let dutyCycle: Double?
|
||||
let defaultUtilizationFactor: Double?
|
||||
let componentCategory: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
@@ -463,6 +387,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
case watt
|
||||
case dutyCycle = "duty_cycle"
|
||||
case defaultUtilizationFactor = "default_utilization_factor"
|
||||
case componentCategory = "component_category"
|
||||
}
|
||||
|
||||
struct TranslationsContainer: Decodable {
|
||||
@@ -518,32 +443,23 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AffiliateLinksResponse: Decodable {
|
||||
let page: Int
|
||||
let totalPages: Int
|
||||
let items: [AffiliateLinkRecord]
|
||||
}
|
||||
|
||||
private struct AffiliateLinkRecord: Decodable {
|
||||
let id: String
|
||||
let url: String
|
||||
let component: String?
|
||||
let country: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentLibraryView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel: ComponentLibraryViewModel
|
||||
@State private var searchText: String = ""
|
||||
private let libraryType: ComponentLibraryType
|
||||
let onSelect: (ComponentLibraryItem) -> Void
|
||||
|
||||
init(onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
||||
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel())
|
||||
init(libraryType: ComponentLibraryType = .load, onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
||||
self.libraryType = libraryType
|
||||
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel(libraryType: libraryType))
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
||||
self.libraryType = viewModel.libraryType
|
||||
self._viewModel = StateObject(wrappedValue: viewModel)
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
@@ -614,7 +530,7 @@ struct ComponentLibraryView: View {
|
||||
onSelect(item)
|
||||
dismiss()
|
||||
} label: {
|
||||
ComponentRow(item: item)
|
||||
ComponentRow(item: item, libraryType: libraryType)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -659,6 +575,7 @@ struct ComponentLibraryView: View {
|
||||
|
||||
private struct ComponentRow: View {
|
||||
let item: ComponentLibraryItem
|
||||
let libraryType: ComponentLibraryType
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
@@ -680,15 +597,23 @@ private struct ComponentRow: View {
|
||||
private var iconView: some View {
|
||||
LoadIconView(
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
fallbackSystemName: "bolt",
|
||||
fallbackSystemName: fallbackIcon,
|
||||
fallbackColor: Color.blue.opacity(0.15),
|
||||
size: 44
|
||||
)
|
||||
}
|
||||
|
||||
private var fallbackIcon: String {
|
||||
switch libraryType {
|
||||
case .load: return "bolt"
|
||||
case .battery: return "battery.100"
|
||||
case .charger: return "bolt.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailLine: some View {
|
||||
let labels = [item.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 }
|
||||
let labels = item.detailLabels(for: libraryType)
|
||||
|
||||
if labels.isEmpty {
|
||||
Text("Details coming soon")
|
||||
|
||||
@@ -19,7 +19,7 @@ struct LoadsView: View {
|
||||
@State private var showingSystemEditor = false
|
||||
@State private var hasPresentedSystemEditorOnAppear = false
|
||||
@State private var hasOpenedLoadOnAppear = false
|
||||
@State private var showingComponentLibrary = false
|
||||
@State private var activeLibrary: ComponentLibraryType?
|
||||
@State private var showingSystemBOM = false
|
||||
@State private var selectedComponentTab: ComponentTab
|
||||
@State private var batteryDraft: BatteryConfiguration?
|
||||
@@ -86,23 +86,7 @@ struct LoadsView: View {
|
||||
.accessibilityIdentifier("components-tab")
|
||||
}
|
||||
|
||||
Group {
|
||||
if savedBatteries.isEmpty {
|
||||
OnboardingInfoView(
|
||||
configuration: .battery(),
|
||||
onPrimaryAction: { startBatteryConfiguration() }
|
||||
)
|
||||
} else {
|
||||
BatteriesView(
|
||||
system: system,
|
||||
batteries: savedBatteries,
|
||||
editMode: $editMode,
|
||||
onEdit: { editBattery($0) },
|
||||
onDelete: deleteBatteries
|
||||
)
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
}
|
||||
batteriesTab
|
||||
.tag(ComponentTab.batteries)
|
||||
.tabItem {
|
||||
Label(
|
||||
@@ -117,14 +101,7 @@ struct LoadsView: View {
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
|
||||
ChargersView(
|
||||
system: system,
|
||||
chargers: savedChargers,
|
||||
editMode: $editMode,
|
||||
onAdd: { startChargerConfiguration() },
|
||||
onEdit: { editCharger($0) },
|
||||
onDelete: deleteChargers
|
||||
)
|
||||
chargersTab
|
||||
.tag(ComponentTab.chargers)
|
||||
.tabItem {
|
||||
Label(
|
||||
@@ -272,9 +249,9 @@ struct LoadsView: View {
|
||||
exportDiagramImage()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingComponentLibrary) {
|
||||
ComponentLibraryView { item in
|
||||
addComponent(item)
|
||||
.sheet(item: $activeLibrary) { type in
|
||||
ComponentLibraryView(libraryType: type) { item in
|
||||
handleLibrarySelection(item, for: type)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSystemBOM) {
|
||||
@@ -439,9 +416,9 @@ struct LoadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var libraryButton: some View {
|
||||
private func libraryButton(type: ComponentLibraryType) -> some View {
|
||||
Button {
|
||||
openComponentLibrary(source: "library-button")
|
||||
openComponentLibrary(source: "library-button", type: type)
|
||||
} label: {
|
||||
Group {
|
||||
if #available(iOS 26.0, *) {
|
||||
@@ -490,6 +467,53 @@ struct LoadsView: View {
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
private var batteriesTab: some View {
|
||||
Group {
|
||||
if savedBatteries.isEmpty {
|
||||
OnboardingInfoView(
|
||||
configuration: .battery(),
|
||||
onPrimaryAction: { startBatteryConfiguration() },
|
||||
onSecondaryAction: { openComponentLibrary(source: "batteries-onboarding", type: .battery) }
|
||||
)
|
||||
} else {
|
||||
BatteriesView(
|
||||
system: system,
|
||||
batteries: savedBatteries,
|
||||
editMode: $editMode,
|
||||
onEdit: { editBattery($0) },
|
||||
onDelete: deleteBatteries
|
||||
)
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !savedBatteries.isEmpty {
|
||||
libraryButton(type: .battery)
|
||||
.padding(.trailing, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chargersTab: some View {
|
||||
ChargersView(
|
||||
system: system,
|
||||
chargers: savedChargers,
|
||||
editMode: $editMode,
|
||||
onAdd: { startChargerConfiguration() },
|
||||
onEdit: { editCharger($0) },
|
||||
onDelete: deleteChargers,
|
||||
onBrowseLibrary: { openComponentLibrary(source: "chargers-onboarding", type: .charger) }
|
||||
)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !savedChargers.isEmpty {
|
||||
libraryButton(type: .charger)
|
||||
.padding(.trailing, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadsListWithHeader: some View {
|
||||
Group {
|
||||
@@ -507,7 +531,7 @@ struct LoadsView: View {
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
libraryButton
|
||||
libraryButton(type: .load)
|
||||
.padding(.trailing, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
@@ -802,15 +826,63 @@ struct LoadsView: View {
|
||||
showingSystemEditor = true
|
||||
}
|
||||
|
||||
private func openComponentLibrary(source: String) {
|
||||
private func openComponentLibrary(source: String, type: ComponentLibraryType = .load) {
|
||||
AnalyticsTracker.log(
|
||||
"Component Library Opened",
|
||||
properties: [
|
||||
"source": source,
|
||||
"type": type.rawValue,
|
||||
"system": system.name
|
||||
]
|
||||
)
|
||||
showingComponentLibrary = true
|
||||
activeLibrary = type
|
||||
}
|
||||
|
||||
private func handleLibrarySelection(_ item: ComponentLibraryItem, for type: ComponentLibraryType) {
|
||||
switch type {
|
||||
case .load:
|
||||
addComponent(item)
|
||||
case .battery:
|
||||
addBatteryFromLibrary(item)
|
||||
case .charger:
|
||||
addChargerFromLibrary(item)
|
||||
}
|
||||
}
|
||||
|
||||
private func addBatteryFromLibrary(_ item: ComponentLibraryItem) {
|
||||
AnalyticsTracker.log(
|
||||
"Library Battery Added",
|
||||
properties: [
|
||||
"id": item.id,
|
||||
"name": item.localizedName,
|
||||
"system": system.name
|
||||
]
|
||||
)
|
||||
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
|
||||
from: item,
|
||||
for: system,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries,
|
||||
existingChargers: savedChargers
|
||||
)
|
||||
}
|
||||
|
||||
private func addChargerFromLibrary(_ item: ComponentLibraryItem) {
|
||||
AnalyticsTracker.log(
|
||||
"Library Charger Added",
|
||||
properties: [
|
||||
"id": item.id,
|
||||
"name": item.localizedName,
|
||||
"system": system.name
|
||||
]
|
||||
)
|
||||
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
|
||||
from: item,
|
||||
for: system,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries,
|
||||
existingChargers: savedChargers
|
||||
)
|
||||
}
|
||||
|
||||
private func openBillOfMaterials() {
|
||||
@@ -1061,6 +1133,7 @@ struct LoadsView: View {
|
||||
await MainActor.run {
|
||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
||||
isExportingOverview = false
|
||||
ReviewPrompt.registerSuccessfulExport()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
@@ -1090,6 +1163,7 @@ struct LoadsView: View {
|
||||
"system": snapshot.systemName,
|
||||
])
|
||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
||||
ReviewPrompt.registerSuccessfulExport()
|
||||
} else {
|
||||
overviewExportError = OverviewExportError(
|
||||
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")
|
||||
|
||||
@@ -141,8 +141,8 @@ extension OnboardingInfoView.Configuration {
|
||||
subtitle: LocalizedStringKey("battery.onboarding.subtitle"),
|
||||
primaryActionTitle: LocalizedStringKey("battery.overview.empty.create"),
|
||||
primaryActionIcon: "plus",
|
||||
secondaryActionTitle: nil,
|
||||
secondaryActionIcon: nil,
|
||||
secondaryActionTitle: LocalizedStringKey("loads.overview.empty.library"),
|
||||
secondaryActionIcon: "books.vertical",
|
||||
imageNames: [
|
||||
"battery-onboarding"
|
||||
]
|
||||
@@ -155,8 +155,8 @@ extension OnboardingInfoView.Configuration {
|
||||
subtitle: LocalizedStringKey("chargers.onboarding.subtitle"),
|
||||
primaryActionTitle: LocalizedStringKey("chargers.onboarding.primary"),
|
||||
primaryActionIcon: "plus",
|
||||
secondaryActionTitle: nil,
|
||||
secondaryActionIcon: nil,
|
||||
secondaryActionTitle: LocalizedStringKey("loads.overview.empty.library"),
|
||||
secondaryActionIcon: "books.vertical",
|
||||
imageNames: [
|
||||
"charger-onboarding"
|
||||
]
|
||||
|
||||
110
Cable/ReviewPrompt.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// ReviewPrompt.swift
|
||||
// Cable
|
||||
//
|
||||
// Decides when to ask the user for an App Store rating via StoreKit's
|
||||
// `AppStore.requestReview(in:)`. The OS throttles the actual dialog (max ~3×/year and
|
||||
// may show nothing at all), so this gate keeps requests rare and tied to genuine success
|
||||
// moments — a completed export/share. Mirrors the Android `ReviewPrompt` object.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import UIKit
|
||||
|
||||
enum ReviewPrompt {
|
||||
private enum Key {
|
||||
static let migrationDone = "review.migrationDone"
|
||||
static let firstLaunchDate = "review.firstLaunchDate"
|
||||
static let exportCount = "review.successfulExportCount"
|
||||
static let lastPromptDate = "review.lastPromptDate"
|
||||
static let lastPromptedVersion = "review.lastPromptedVersion"
|
||||
static let userType = "review.userType"
|
||||
}
|
||||
|
||||
/// Gate thresholds — see CLAUDE-discussed spec.
|
||||
private static let minExports = 2
|
||||
private static let minDaysSinceInstall: TimeInterval = 3
|
||||
private static let minDaysBetweenPrompts: TimeInterval = 120
|
||||
private static let day: TimeInterval = 86_400
|
||||
|
||||
private static var defaults: UserDefaults { .standard }
|
||||
|
||||
/// One-time setup distinguishing fresh installs from users updating into this feature.
|
||||
/// Existing users are backdated and pre-seeded so the prompt can fire on their *first*
|
||||
/// successful export after updating. Pass the `isFirstLaunch` value already computed in
|
||||
/// `AppDelegate` (the existing `hasLaunchedBefore` flag).
|
||||
static func migrateIfNeeded(isFirstLaunch: Bool) {
|
||||
guard !defaults.bool(forKey: Key.migrationDone) else { return }
|
||||
let now = Date().timeIntervalSince1970
|
||||
if isFirstLaunch {
|
||||
// Genuine new install: normal flow — needs 2 exports and 3 days.
|
||||
defaults.set(now, forKey: Key.firstLaunchDate)
|
||||
defaults.set(0, forKey: Key.exportCount)
|
||||
defaults.set("new", forKey: Key.userType)
|
||||
} else {
|
||||
// Existing user updating in: backdate install past the age gate and pre-seed the
|
||||
// counter so the very next successful export satisfies the gate.
|
||||
defaults.set(now - minDaysSinceInstall * day, forKey: Key.firstLaunchDate)
|
||||
defaults.set(minExports - 1, forKey: Key.exportCount)
|
||||
defaults.set("existing", forKey: Key.userType)
|
||||
}
|
||||
defaults.set(true, forKey: Key.migrationDone)
|
||||
}
|
||||
|
||||
/// Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
|
||||
/// Increments the shared counter, then requests a review if every gate condition holds.
|
||||
@MainActor
|
||||
static func registerSuccessfulExport() {
|
||||
// Guard against an export that races ahead of migration.
|
||||
if defaults.object(forKey: Key.firstLaunchDate) == nil {
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: Key.firstLaunchDate)
|
||||
}
|
||||
let count = defaults.integer(forKey: Key.exportCount) + 1
|
||||
defaults.set(count, forKey: Key.exportCount)
|
||||
|
||||
guard shouldRequest(exportCount: count) else { return }
|
||||
request()
|
||||
}
|
||||
|
||||
private static func shouldRequest(exportCount: Int) -> Bool {
|
||||
// A: enough successful exports
|
||||
guard exportCount >= minExports else { return false }
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
|
||||
// B: installed long enough
|
||||
let firstLaunch = defaults.double(forKey: Key.firstLaunchDate)
|
||||
guard now - firstLaunch >= minDaysSinceInstall * day else { return false }
|
||||
|
||||
// C: not prompted too recently
|
||||
let lastPrompt = defaults.double(forKey: Key.lastPromptDate)
|
||||
if lastPrompt > 0, now - lastPrompt < minDaysBetweenPrompts * day { return false }
|
||||
|
||||
// D: at most once per app version
|
||||
if defaults.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func request() {
|
||||
// Mark as requested up front — the OS may suppress the dialog, but we still
|
||||
// count it against our own throttle so we don't ask again immediately.
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
|
||||
defaults.set(currentVersion, forKey: Key.lastPromptedVersion)
|
||||
|
||||
AnalyticsTracker.log("Review Prompt Requested", properties: [
|
||||
"version": currentVersion,
|
||||
"userType": defaults.string(forKey: Key.userType) ?? "unknown",
|
||||
])
|
||||
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { return }
|
||||
AppStore.requestReview(in: scene)
|
||||
}
|
||||
|
||||
private static var currentVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
}
|
||||
}
|
||||
559
Cable/ScreenshotPreviews.swift
Normal file
@@ -0,0 +1,559 @@
|
||||
//
|
||||
// ScreenshotPreviews.swift
|
||||
// Cable
|
||||
//
|
||||
// Screenshot previews for all views in all supported languages.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Sample Data
|
||||
|
||||
private enum ScreenshotData {
|
||||
@MainActor
|
||||
static func makeContainer() -> ModelContainer {
|
||||
let container = try! ModelContainer(
|
||||
for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self,
|
||||
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
)
|
||||
let ctx = container.mainContext
|
||||
|
||||
// System 1: Sailboat
|
||||
let sailboat = ElectricalSystem(
|
||||
name: "Sailboat Aurora",
|
||||
location: "Marina 7",
|
||||
iconName: "sailboat",
|
||||
colorName: "blue",
|
||||
targetRuntimeHours: 15,
|
||||
targetChargeTimeHours: 3
|
||||
)
|
||||
ctx.insert(sailboat)
|
||||
|
||||
let sailLoads: [(String, Double, Double, Double, Double, Double, String, String, Double, Double)] = [
|
||||
("Navigation Lights", 12.8, 2.4, 28.8, 5.0, 2.5, "light.beacon.max", "red", 100, 10),
|
||||
("Refrigerator", 12.8, 4.0, 48.0, 3.0, 2.5, "refrigerator", "blue", 40, 24),
|
||||
("VHF Radio", 12.8, 6.0, 72.0, 8.0, 4.0, "antenna.radiowaves.left.and.right", "green", 30, 8),
|
||||
("Anchor Windlass", 12.8, 80.0, 960.0, 6.0, 35.0, "arrow.up.and.down", "orange", 5, 0.5),
|
||||
("LED Cabin Lights", 12.8, 1.5, 18.0, 4.0, 1.5, "lightbulb", "yellow", 100, 6),
|
||||
]
|
||||
for (name, voltage, current, power, length, crossSection, icon, color, duty, hours) in sailLoads {
|
||||
ctx.insert(SavedLoad(
|
||||
name: name, voltage: voltage, current: current, power: power,
|
||||
length: length, crossSection: crossSection,
|
||||
iconName: icon, colorName: color,
|
||||
dutyCyclePercent: duty, dailyUsageHours: hours,
|
||||
system: sailboat
|
||||
))
|
||||
}
|
||||
|
||||
ctx.insert(SavedBattery(
|
||||
name: "House Bank",
|
||||
nominalVoltage: 12.8, capacityAmpHours: 200,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
iconName: "battery.100.bolt", colorName: "green",
|
||||
system: sailboat
|
||||
))
|
||||
ctx.insert(SavedBattery(
|
||||
name: "Starter Battery",
|
||||
nominalVoltage: 12.0, capacityAmpHours: 90,
|
||||
chemistry: .agm,
|
||||
iconName: "bolt", colorName: "orange",
|
||||
system: sailboat
|
||||
))
|
||||
|
||||
ctx.insert(SavedCharger(
|
||||
name: "Shore Charger",
|
||||
inputVoltage: 230, outputVoltage: 14.4,
|
||||
maxCurrentAmps: 40, maxPowerWatts: 580,
|
||||
iconName: "powerplug", colorName: "orange",
|
||||
system: sailboat, powerSourceType: "shore"
|
||||
))
|
||||
ctx.insert(SavedCharger(
|
||||
name: "Solar MPPT",
|
||||
inputVoltage: 36, outputVoltage: 14.2,
|
||||
maxCurrentAmps: 30, maxPowerWatts: 426,
|
||||
iconName: "sun.max.fill", colorName: "yellow",
|
||||
system: sailboat, powerSourceType: "solar"
|
||||
))
|
||||
|
||||
// System 2: Camper Van
|
||||
let camper = ElectricalSystem(
|
||||
name: "Camper Van",
|
||||
location: "Road Trip",
|
||||
iconName: "bus",
|
||||
colorName: "teal",
|
||||
targetRuntimeHours: 24,
|
||||
targetChargeTimeHours: 4
|
||||
)
|
||||
ctx.insert(camper)
|
||||
|
||||
let camperLoads: [(String, Double, Double, Double, Double, Double, String, String)] = [
|
||||
("Water Pump", 12.8, 3.5, 42.0, 4.0, 2.5, "drop", "cyan"),
|
||||
("USB Charger", 12.8, 2.0, 24.0, 2.0, 1.5, "cable.connector", "gray"),
|
||||
]
|
||||
for (name, voltage, current, power, length, crossSection, icon, color) in camperLoads {
|
||||
ctx.insert(SavedLoad(
|
||||
name: name, voltage: voltage, current: current, power: power,
|
||||
length: length, crossSection: crossSection,
|
||||
iconName: icon, colorName: color, system: camper
|
||||
))
|
||||
}
|
||||
|
||||
ctx.insert(SavedBattery(
|
||||
name: "LiFePO4 200Ah",
|
||||
nominalVoltage: 12.8, capacityAmpHours: 200,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
iconName: "battery.100.bolt", colorName: "green",
|
||||
system: camper
|
||||
))
|
||||
|
||||
// System 3: Cabin (empty, for variety)
|
||||
let cabin = ElectricalSystem(
|
||||
name: "Off-Grid Cabin",
|
||||
location: "Mountains",
|
||||
iconName: "house",
|
||||
colorName: "brown"
|
||||
)
|
||||
ctx.insert(cabin)
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func firstSystem(in container: ModelContainer) -> ElectricalSystem {
|
||||
let systems = try! container.mainContext.fetch(
|
||||
FetchDescriptor<ElectricalSystem>(sortBy: [SortDescriptor(\.timestamp)])
|
||||
)
|
||||
return systems.first!
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wrapper Views
|
||||
|
||||
private struct LoadsViewScreenshot: View {
|
||||
let container: ModelContainer
|
||||
let system: ElectricalSystem
|
||||
var initialTab: LoadsView.ComponentTab = .overview
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
LoadsView(system: system, initialTab: initialTab)
|
||||
}
|
||||
.modelContainer(container)
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
}
|
||||
|
||||
private struct SystemsViewScreenshot: View {
|
||||
let container: ModelContainer
|
||||
|
||||
var body: some View {
|
||||
SystemsView()
|
||||
.modelContainer(container)
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalculatorViewScreenshot: View {
|
||||
let container: ModelContainer
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
CalculatorView()
|
||||
}
|
||||
.modelContainer(container)
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
}
|
||||
|
||||
private struct BatteryEditorScreenshot: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
BatteryEditorView(
|
||||
configuration: BatteryConfiguration(
|
||||
name: "House Bank",
|
||||
nominalVoltage: 12.8,
|
||||
capacityAmpHours: 200,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
iconName: "battery.100.bolt",
|
||||
colorName: "green",
|
||||
system: ElectricalSystem(name: "Sailboat Aurora")
|
||||
),
|
||||
onSave: { _ in }
|
||||
)
|
||||
}
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChargerEditorScreenshot: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ChargerEditorView(
|
||||
configuration: ChargerConfiguration(
|
||||
name: "Shore Charger",
|
||||
inputVoltage: 230,
|
||||
outputVoltage: 14.4,
|
||||
maxCurrentAmps: 40,
|
||||
maxPowerWatts: 580,
|
||||
iconName: "powerplug",
|
||||
colorName: "orange",
|
||||
system: ElectricalSystem(name: "Sailboat Aurora")
|
||||
),
|
||||
onSave: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComponentLibraryScreenshot: View {
|
||||
var body: some View {
|
||||
ComponentLibraryView(
|
||||
viewModel: ComponentLibraryViewModel(previewItems: Self.sampleItems),
|
||||
onSelect: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
private static let sampleItems: [ComponentLibraryItem] = [
|
||||
ComponentLibraryItem(
|
||||
id: "1", name: "Navigation Lights",
|
||||
translations: ["de": "Navigationslichter", "es": "Luces de navegación", "fr": "Feux de navigation", "nl": "Navigatieverlichting"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 25,
|
||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 40,
|
||||
componentCategory: nil, iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "2", name: "Refrigerator Compressor",
|
||||
translations: ["de": "Kühlschrank-Kompressor", "es": "Compresor de refrigerador", "fr": "Compresseur réfrigérateur", "nl": "Koelkastcompressor"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 48,
|
||||
dutyCyclePercent: 40, defaultUtilizationFactorPercent: 100,
|
||||
componentCategory: nil, iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "3", name: "Anchor Windlass",
|
||||
translations: ["de": "Ankerwinde", "es": "Molinete de ancla", "fr": "Guindeau", "nl": "Ankerlier"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 960,
|
||||
dutyCyclePercent: 5, defaultUtilizationFactorPercent: 2,
|
||||
componentCategory: nil, iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "4", name: "VHF Radio",
|
||||
translations: ["de": "UKW Funkgerät", "es": "Radio VHF", "fr": "Radio VHF", "nl": "Marifoon"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 72,
|
||||
dutyCyclePercent: 30, defaultUtilizationFactorPercent: 33,
|
||||
componentCategory: nil, iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "5", name: "LED Interior Lights",
|
||||
translations: ["de": "LED Innenbeleuchtung", "es": "Iluminación LED interior", "fr": "Éclairage LED intérieur", "nl": "LED binnenverlichting"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 18,
|
||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 25,
|
||||
componentCategory: nil, iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "6", name: "Water Pump",
|
||||
translations: ["de": "Wasserpumpe", "es": "Bomba de agua", "fr": "Pompe à eau", "nl": "Waterpomp"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 42,
|
||||
dutyCyclePercent: 20, defaultUtilizationFactorPercent: 10,
|
||||
componentCategory: nil, iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "7", name: "Diesel Heater",
|
||||
translations: ["de": "Dieselheizung", "es": "Calefactor diésel", "fr": "Chauffage diesel", "nl": "Dieselverwarming"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 36,
|
||||
dutyCyclePercent: 60, defaultUtilizationFactorPercent: 50,
|
||||
componentCategory: nil, iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "8", name: "USB Charging Station",
|
||||
translations: ["de": "USB-Ladestation", "es": "Estación de carga USB", "fr": "Station de charge USB", "nl": "USB-laadstation"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 24,
|
||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 30,
|
||||
componentCategory: nil, iconURL: nil
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Language helper
|
||||
|
||||
private let locales: [(String, String)] = [
|
||||
("EN", "en"),
|
||||
("DE", "de"),
|
||||
("ES", "es"),
|
||||
("FR", "fr"),
|
||||
("NL", "nl"),
|
||||
]
|
||||
|
||||
// MARK: - 1. Overview Tab
|
||||
|
||||
#Preview("Overview – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Overview – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Overview – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Overview – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Overview – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 2. Components Tab
|
||||
|
||||
#Preview("Components – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Components – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Components – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Components – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Components – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 3. Batteries Tab
|
||||
|
||||
#Preview("Batteries – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Batteries – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Batteries – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Batteries – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Batteries – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 4. Chargers Tab
|
||||
|
||||
#Preview("Chargers – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Chargers – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Chargers – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Chargers – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Chargers – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 5. Systems List
|
||||
|
||||
#Preview("Systems – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Systems – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Systems – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Systems – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Systems – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 6. Parts Library
|
||||
|
||||
#Preview("Library – EN") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Library – DE") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Library – ES") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Library – FR") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Library – NL") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 7. Load Editor (Calculator)
|
||||
|
||||
#Preview("LoadEditor – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("LoadEditor – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("LoadEditor – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("LoadEditor – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("LoadEditor – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 8. Battery Editor
|
||||
|
||||
#Preview("BatteryEditor – EN") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("BatteryEditor – DE") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("BatteryEditor – ES") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("BatteryEditor – FR") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("BatteryEditor – NL") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 9. Charger Editor
|
||||
|
||||
#Preview("ChargerEditor – EN") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("ChargerEditor – DE") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("ChargerEditor – ES") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("ChargerEditor – FR") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("ChargerEditor – NL") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
@@ -111,8 +111,8 @@ struct SystemBillOfMaterialsView: View {
|
||||
|
||||
private struct Item: Identifiable {
|
||||
enum Destination {
|
||||
case affiliate(URL)
|
||||
case amazonSearch(String)
|
||||
case component(String)
|
||||
case search(String)
|
||||
}
|
||||
|
||||
let id: String
|
||||
@@ -346,6 +346,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
)
|
||||
await MainActor.run {
|
||||
activeShareItem = ExportedPDFShareItem(url: url)
|
||||
ReviewPrompt.registerSuccessfulExport()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
@@ -436,12 +437,12 @@ struct SystemBillOfMaterialsView: View {
|
||||
}
|
||||
|
||||
private func trackAffiliateTap(item: Item, url: URL) {
|
||||
let isAffiliate: Bool
|
||||
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false }
|
||||
let isComponent: Bool
|
||||
if case .component = item.destination { isComponent = true } else { isComponent = false }
|
||||
AnalyticsTracker.log("BOM Item Tapped", properties: [
|
||||
"item": item.title,
|
||||
"category": item.category.rawValue,
|
||||
"is_affiliate": isAffiliate,
|
||||
"is_component": isComponent,
|
||||
"domain": url.host ?? "unknown",
|
||||
"system": systemName,
|
||||
])
|
||||
@@ -585,7 +586,6 @@ struct SystemBillOfMaterialsView: View {
|
||||
cableShoesDetailFormat,
|
||||
crossSectionLabel.lowercased()
|
||||
)
|
||||
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
|
||||
let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV")
|
||||
let deviceQuery = load.name.isEmpty
|
||||
? String(format: deviceFallbackFormat, calculatedPower, load.voltage)
|
||||
@@ -619,7 +619,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
|
||||
detail: powerDetail,
|
||||
iconSystemName: "bolt.fill",
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery),
|
||||
destination: load.componentID.map { .component($0) } ?? .search(deviceQuery),
|
||||
isPrimaryComponent: true,
|
||||
components: [component],
|
||||
category: .components,
|
||||
@@ -636,7 +636,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
||||
detail: "",
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(redCableQuery),
|
||||
destination: .search(redCableQuery),
|
||||
isPrimaryComponent: false,
|
||||
components: [component],
|
||||
category: .cables,
|
||||
@@ -653,7 +653,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
||||
detail: "",
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(blackCableQuery),
|
||||
destination: .search(blackCableQuery),
|
||||
isPrimaryComponent: false,
|
||||
components: [component],
|
||||
category: .cables,
|
||||
@@ -670,7 +670,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery),
|
||||
destination: .search(fuseQuery),
|
||||
isPrimaryComponent: false,
|
||||
components: [component],
|
||||
category: .fuses,
|
||||
@@ -687,7 +687,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
|
||||
detail: cableShoesDetail,
|
||||
iconSystemName: "wrench.and.screwdriver",
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
destination: .search(terminalQuery),
|
||||
isPrimaryComponent: false,
|
||||
components: [component],
|
||||
category: .accessories,
|
||||
@@ -712,7 +712,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
let voltageQuery = max(1, Int(round(battery.nominalVoltage)))
|
||||
let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery")
|
||||
let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName)
|
||||
let affiliateURL = battery.affiliateURLString.flatMap { URL(string: $0) }
|
||||
let componentID = battery.componentID
|
||||
let storageKey = Self.storageKey(for: component, itemID: "battery")
|
||||
|
||||
return [
|
||||
@@ -723,7 +723,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: battery.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : battery.name,
|
||||
detail: detail,
|
||||
iconSystemName: battery.iconName,
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query),
|
||||
destination: componentID.map { .component($0) } ?? .search(query),
|
||||
isPrimaryComponent: true,
|
||||
components: [component],
|
||||
category: .batteries,
|
||||
@@ -746,7 +746,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
let voltageQuery = max(1, Int(round(charger.outputVoltage)))
|
||||
let currentQuery = max(1, Int(round(charger.maxCurrentAmps)))
|
||||
let query = "\(voltageQuery)V \(currentQuery)A battery charger"
|
||||
let affiliateURL = charger.affiliateURLString.flatMap { URL(string: $0) }
|
||||
let componentID = charger.componentID
|
||||
let storageKey = Self.storageKey(for: component, itemID: "charger")
|
||||
|
||||
return [
|
||||
@@ -757,7 +757,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: charger.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : charger.name,
|
||||
detail: detail,
|
||||
iconSystemName: charger.iconName,
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query),
|
||||
destination: componentID.map { .component($0) } ?? .search(query),
|
||||
isPrimaryComponent: true,
|
||||
components: [component],
|
||||
category: .components,
|
||||
@@ -772,22 +772,10 @@ struct SystemBillOfMaterialsView: View {
|
||||
|
||||
private func destinationURL(for destination: Item.Destination, component: Component) -> URL? {
|
||||
switch destination {
|
||||
case .affiliate(let url):
|
||||
return url
|
||||
case .amazonSearch(let query):
|
||||
let countryCode = affiliateCountryCode(for: component) ?? Locale.current.region?.identifier
|
||||
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
|
||||
}
|
||||
}
|
||||
|
||||
private func affiliateCountryCode(for component: Component) -> String? {
|
||||
switch component {
|
||||
case .load(let load):
|
||||
return load.affiliateCountryCode
|
||||
case .battery(let battery):
|
||||
return battery.affiliateCountryCode
|
||||
case .charger(let charger):
|
||||
return charger.affiliateCountryCode
|
||||
case .component(let id):
|
||||
return VoltPlanRedirect.componentURL(id: id)
|
||||
case .search(let query):
|
||||
return VoltPlanRedirect.searchURL(query: query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,8 +893,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
dailyUsageHours: 0,
|
||||
system: nil,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil,
|
||||
componentID: nil,
|
||||
bomCompletedItemIDs: [],
|
||||
identifier: UUID().uuidString
|
||||
)
|
||||
@@ -952,8 +939,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
dailyUsageHours: 0,
|
||||
system: nil,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil,
|
||||
componentID: nil,
|
||||
bomCompletedItemIDs: ["component", "cable-red"],
|
||||
identifier: UUID().uuidString
|
||||
)
|
||||
|
||||
@@ -66,7 +66,6 @@ struct SystemComponentsPersistence {
|
||||
current = 0
|
||||
}
|
||||
|
||||
let affiliateLink = item.primaryAffiliateLink
|
||||
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
|
||||
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
|
||||
|
||||
@@ -84,8 +83,7 @@ struct SystemComponentsPersistence {
|
||||
dailyUsageHours: dailyUsageHours,
|
||||
system: system,
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||
affiliateCountryCode: affiliateLink?.country
|
||||
componentID: item.id
|
||||
)
|
||||
|
||||
context.insert(newLoad)
|
||||
@@ -116,6 +114,87 @@ struct SystemComponentsPersistence {
|
||||
)
|
||||
}
|
||||
|
||||
static func makeBatteryDraft(
|
||||
from item: ComponentLibraryItem,
|
||||
for system: ElectricalSystem,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery],
|
||||
existingChargers: [SavedCharger]
|
||||
) -> BatteryConfiguration {
|
||||
let localizedName = item.localizedName
|
||||
let baseName = localizedName.isEmpty
|
||||
? String(localized: "battery.editor.default_name", defaultValue: "New Battery")
|
||||
: localizedName
|
||||
let batteryName = uniqueName(
|
||||
startingWith: baseName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries,
|
||||
chargers: existingChargers
|
||||
)
|
||||
let nominalVoltage = item.displayVoltage ?? 12.8
|
||||
let capacity = item.capacityAmpHours ?? 100
|
||||
|
||||
return BatteryConfiguration(
|
||||
name: batteryName,
|
||||
nominalVoltage: nominalVoltage,
|
||||
capacityAmpHours: capacity,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
iconName: "battery.100",
|
||||
colorName: system.colorName,
|
||||
system: system,
|
||||
componentID: item.id
|
||||
)
|
||||
}
|
||||
|
||||
static func makeChargerDraft(
|
||||
from item: ComponentLibraryItem,
|
||||
for system: ElectricalSystem,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery],
|
||||
existingChargers: [SavedCharger]
|
||||
) -> ChargerConfiguration {
|
||||
let localizedName = item.localizedName
|
||||
let baseName = localizedName.isEmpty
|
||||
? String(localized: "charger.editor.default_name", defaultValue: "New Charger")
|
||||
: localizedName
|
||||
let chargerName = uniqueName(
|
||||
startingWith: baseName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries,
|
||||
chargers: existingChargers
|
||||
)
|
||||
let inputVoltage = item.voltageIn ?? LocaleDefaults.mainsVoltage
|
||||
let outputVoltage = item.voltageOut ?? 14.2
|
||||
let power = item.watt ?? 0
|
||||
let current = item.outputCurrent ?? (outputVoltage > 0 ? power / outputVoltage : 30)
|
||||
let sourceType = chargerSourceType(forCategory: item.componentCategory)
|
||||
|
||||
return ChargerConfiguration(
|
||||
name: chargerName,
|
||||
inputVoltage: inputVoltage,
|
||||
outputVoltage: outputVoltage,
|
||||
maxCurrentAmps: current,
|
||||
maxPowerWatts: power,
|
||||
iconName: sourceType.iconName,
|
||||
colorName: system.colorName,
|
||||
system: system,
|
||||
powerSourceType: sourceType,
|
||||
componentID: item.id,
|
||||
remoteIconURLString: item.iconURL?.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
/// Maps a PocketBase `component_category` to a charger power source.
|
||||
static func chargerSourceType(forCategory category: String?) -> SavedCharger.PowerSourceType {
|
||||
guard let category = category?.lowercased(), !category.isEmpty else { return .shore }
|
||||
if category.contains("solar") { return .solar }
|
||||
if category.contains("wind") { return .wind }
|
||||
if category.contains("dcdc") || category.contains("alternator") { return .alternator }
|
||||
if category.contains("generator") { return .generator }
|
||||
if category.contains("mains") || category.contains("shore") { return .shore }
|
||||
return .shore
|
||||
}
|
||||
|
||||
static func makeChargerDraft(
|
||||
for system: ElectricalSystem,
|
||||
existingLoads: [SavedLoad],
|
||||
@@ -162,7 +241,8 @@ struct SystemComponentsPersistence {
|
||||
maximumTemperatureCelsius: configuration.maximumTemperatureCelsius,
|
||||
iconName: configuration.iconName,
|
||||
colorName: configuration.colorName,
|
||||
system: system
|
||||
system: system,
|
||||
componentID: configuration.componentID
|
||||
)
|
||||
context.insert(newBattery)
|
||||
}
|
||||
@@ -186,7 +266,9 @@ struct SystemComponentsPersistence {
|
||||
maxPowerWatts: configuration.maxPowerWatts,
|
||||
iconName: configuration.iconName,
|
||||
colorName: configuration.colorName,
|
||||
system: system
|
||||
system: system,
|
||||
remoteIconURLString: configuration.remoteIconURLString,
|
||||
componentID: configuration.componentID
|
||||
)
|
||||
context.insert(newCharger)
|
||||
}
|
||||
|
||||
@@ -372,7 +372,6 @@ struct SystemsView: View {
|
||||
current = 0
|
||||
}
|
||||
|
||||
let affiliateLink = item.primaryAffiliateLink
|
||||
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
|
||||
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
|
||||
|
||||
@@ -390,8 +389,7 @@ struct SystemsView: View {
|
||||
dailyUsageHours: dailyUsageHours,
|
||||
system: system,
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||
affiliateCountryCode: affiliateLink?.country
|
||||
componentID: item.id
|
||||
)
|
||||
|
||||
modelContext.insert(newLoad)
|
||||
@@ -549,8 +547,7 @@ struct SystemsView: View {
|
||||
isWattMode: false,
|
||||
system: system1,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
componentID: nil
|
||||
)
|
||||
|
||||
let load2 = SavedLoad(
|
||||
@@ -565,8 +562,7 @@ struct SystemsView: View {
|
||||
isWattMode: false,
|
||||
system: system1,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
componentID: nil
|
||||
)
|
||||
|
||||
// Sample loads for system 2
|
||||
@@ -582,8 +578,7 @@ struct SystemsView: View {
|
||||
isWattMode: false,
|
||||
system: system2,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
componentID: nil
|
||||
)
|
||||
|
||||
context.insert(load1)
|
||||
|
||||
@@ -15,7 +15,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let german = Foundation.Locale(identifier: "de_DE")
|
||||
@@ -33,7 +32,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let french = Foundation.Locale(identifier: "fr_FR")
|
||||
@@ -54,7 +52,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let languages = ["fr-FR", "de-DE", "es-ES"]
|
||||
@@ -72,7 +69,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let spanishMexico = Foundation.Locale(identifier: "es_MX")
|
||||
@@ -90,7 +86,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
|
||||
@@ -108,7 +103,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let french = Foundation.Locale(identifier: "fr_FR")
|
||||
@@ -126,7 +120,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: 0,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
#expect(item.normalizedDutyCyclePercent == 100)
|
||||
@@ -143,7 +136,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: 50,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
#expect(item.defaultDailyUsageHours == 12)
|
||||
|
||||
@@ -125,32 +125,21 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
try super.setUpWithError()
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
//ensureDoNotDisturbEnabled()
|
||||
//dismissSystemOverlays()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try super.tearDownWithError()
|
||||
//dismissSystemOverlays()
|
||||
}
|
||||
// MARK: - Onboarding Screenshots
|
||||
|
||||
@MainActor
|
||||
func testOnboardingScreenshots() throws {
|
||||
let app = launchApp(arguments: ["--uitest-reset-data"])
|
||||
waitForStability(long: true)
|
||||
dismissNotificationBannersIfNeeded()
|
||||
waitForStability(long: true)
|
||||
dismissNotificationBannersIfNeeded()
|
||||
waitForStability(long: true)
|
||||
dismissNotificationBannersIfNeeded()
|
||||
waitForStability(long: true)
|
||||
// Wait for Apple Intelligence and other system notifications to appear, then dismiss
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(6))
|
||||
dismissNotificationBannersIfNeeded()
|
||||
|
||||
let createSystemButton = app.buttons["create-system-button"]
|
||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
|
||||
waitForStability(long: true)
|
||||
dismissNotificationBannersIfNeeded()
|
||||
waitForStability(long: true)
|
||||
waitForStability()
|
||||
takeScreenshot(named: "01-OnboardingSystemsView")
|
||||
|
||||
createSystemButton.tap()
|
||||
@@ -159,14 +148,15 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
|
||||
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
|
||||
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
|
||||
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "02-OnboardingSystemView")
|
||||
|
||||
browseLibraryButton.tap()
|
||||
let libraryCloseButton = app.buttons["library-view-close-button"]
|
||||
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8))
|
||||
waitForStability(long: true)
|
||||
// Wait for library items AND remote icons to load from PocketBase
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(15))
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "04-ComponentSelectorView")
|
||||
libraryCloseButton.tap()
|
||||
|
||||
@@ -175,20 +165,130 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
|
||||
let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
|
||||
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
|
||||
waitForStability(long: true)
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "03-LoadEditorView")
|
||||
}
|
||||
|
||||
// MARK: - Sample Data Screenshots
|
||||
|
||||
@MainActor
|
||||
func testSampleDataScreenshots() throws {
|
||||
let app = launchAppWithSampleData()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
|
||||
// Systems list
|
||||
let systemsList = resolvedSystemsList(in: app)
|
||||
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "05-SystemsWithSampleData")
|
||||
|
||||
// Navigate to first system
|
||||
openFirstSystem(in: app, systemsList: systemsList)
|
||||
|
||||
// Overview tab — wait for navigation animation to complete
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "06-AdventureVanOverview")
|
||||
|
||||
// Bill of Materials
|
||||
let bomElement = resolveBillOfMaterialsElement(in: app)
|
||||
if !bomElement.waitForExistence(timeout: 6) {
|
||||
bringElementIntoView(bomElement, in: app)
|
||||
}
|
||||
XCTAssertTrue(bomElement.exists)
|
||||
if !bomElement.isHittable {
|
||||
bringElementIntoView(bomElement, in: app, requireHittable: true)
|
||||
}
|
||||
tapElement(bomElement)
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "08-BillOfMaterials")
|
||||
|
||||
let closeButton = app.buttons["system-bom-close-button"]
|
||||
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
|
||||
closeButton.tap()
|
||||
|
||||
// Components tab
|
||||
tapTab(.componentsTab, in: app)
|
||||
let loadsList = resolvedLoadsList(in: app)
|
||||
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "07-AdventureVanLoads")
|
||||
|
||||
// Open first load → Calculator
|
||||
let firstLoad = loadsList.cells.element(boundBy: 0)
|
||||
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
|
||||
firstLoad.tap()
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "09-AdventureVanCalculator")
|
||||
|
||||
// Navigate back to system tabs
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
waitForStability()
|
||||
|
||||
// Batteries tab
|
||||
tapTab(.batteriesTab, in: app)
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "10-AdventureVanBatteries")
|
||||
|
||||
// Open first battery → Battery Editor
|
||||
let batteriesList = resolvedBatteriesList(in: app)
|
||||
if batteriesList.waitForExistence(timeout: 4) {
|
||||
let firstBattery = batteriesList.cells.element(boundBy: 0)
|
||||
if firstBattery.waitForExistence(timeout: 2) {
|
||||
firstBattery.tap()
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "11-BatteryEditor")
|
||||
// Navigate back
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
waitForStability()
|
||||
}
|
||||
}
|
||||
|
||||
// Chargers tab
|
||||
tapTab(.chargersTab, in: app)
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "12-AdventureVanChargers")
|
||||
|
||||
// Open first charger → Charger Editor
|
||||
let chargersList = resolvedChargersList(in: app)
|
||||
if chargersList.waitForExistence(timeout: 4) {
|
||||
let firstCharger = chargersList.cells.element(boundBy: 0)
|
||||
if firstCharger.waitForExistence(timeout: 2) {
|
||||
firstCharger.tap()
|
||||
waitForStability()
|
||||
dismissNotificationBannersIfNeeded()
|
||||
takeScreenshot(named: "13-ChargerEditor")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Launch
|
||||
|
||||
private func launchApp(arguments: [String]) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
var launchArguments = ["--uitest-reset-data"]
|
||||
launchArguments.append(contentsOf: arguments)
|
||||
app.launchArguments = launchArguments
|
||||
app.launch()
|
||||
return app
|
||||
}
|
||||
|
||||
private func launchAppWithSampleData() -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
|
||||
app.launch()
|
||||
return app
|
||||
}
|
||||
|
||||
let systemsList = resolvedSystemsList(in: app)
|
||||
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
|
||||
takeScreenshot(named: "05-SystemsWithSampleData")
|
||||
// MARK: - Navigation Helpers
|
||||
|
||||
private func openFirstSystem(in app: XCUIApplication, systemsList: XCUIElement) {
|
||||
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
|
||||
let systemName = firstSystemCell.staticTexts.firstMatch.label
|
||||
@@ -206,68 +306,40 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
detailVisible = waitForSystemDetail(named: systemName, in: app)
|
||||
}
|
||||
XCTAssertTrue(detailVisible)
|
||||
takeScreenshot(named: "06-AdventureVanOverview")
|
||||
|
||||
// let overviewTab = app.buttons["overview-tab"]
|
||||
// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3))
|
||||
// overviewTab.tap()
|
||||
waitForStability(long: false)
|
||||
let bomElement = resolveBillOfMaterialsElement(in: app)
|
||||
|
||||
if !bomElement.waitForExistence(timeout: 6) {
|
||||
bringElementIntoView(bomElement, in: app)
|
||||
}
|
||||
|
||||
XCTAssertTrue(bomElement.exists)
|
||||
private func tapTab(_ key: UIStringKey, in app: XCUIApplication) {
|
||||
let identifierMap: [UIStringKey: String] = [
|
||||
.overviewTab: "overview-tab",
|
||||
.componentsTab: "components-tab",
|
||||
.batteriesTab: "batteries-tab",
|
||||
.chargersTab: "chargers-tab",
|
||||
]
|
||||
|
||||
if !bomElement.isHittable {
|
||||
bringElementIntoView(bomElement, in: app, requireHittable: true)
|
||||
// Use .matching + .firstMatch to avoid "multiple matches" error
|
||||
// with iOS 26 floating tab bar (which creates duplicate elements)
|
||||
if let identifier = identifierMap[key] {
|
||||
let tabButton = app.buttons.matching(identifier: identifier).firstMatch
|
||||
if tabButton.waitForExistence(timeout: 3) {
|
||||
tapElement(tabButton)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if bomElement.isHittable {
|
||||
bomElement.tap()
|
||||
let tabButton = button(in: app.buttons, for: key)
|
||||
XCTAssertTrue(tabButton.waitForExistence(timeout: 3))
|
||||
tapElement(tabButton)
|
||||
}
|
||||
|
||||
private func tapElement(_ element: XCUIElement) {
|
||||
if element.isHittable {
|
||||
element.tap()
|
||||
} else {
|
||||
bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
waitForStability(long: true)
|
||||
takeScreenshot(named: "08-BillOfMaterials")
|
||||
|
||||
let closeButton = app.buttons["system-bom-close-button"]
|
||||
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
|
||||
closeButton.tap()
|
||||
|
||||
let componentsTab = componentsTabButton(in: app)
|
||||
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
|
||||
if componentsTab.isHittable {
|
||||
componentsTab.tap()
|
||||
} else {
|
||||
componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
|
||||
let loadsList = resolvedLoadsList(in: app)
|
||||
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
|
||||
takeScreenshot(named: "07-AdventureVanLoads")
|
||||
|
||||
waitForStability()
|
||||
let firstLoad = loadsList.cells.element(boundBy: 0)
|
||||
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
|
||||
let loadName = firstLoad.staticTexts.firstMatch.label
|
||||
firstLoad.tap()
|
||||
|
||||
let loadNavButton = app.navigationBars.buttons[loadName]
|
||||
XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3))
|
||||
takeScreenshot(named: "09-AdventureVanCalculator")
|
||||
}
|
||||
|
||||
private func launchApp(arguments: [String]) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
var launchArguments = ["--uitest-reset-data"]
|
||||
launchArguments.append(contentsOf: arguments)
|
||||
app.launchArguments = launchArguments
|
||||
app.launch()
|
||||
//dismissSystemOverlays()
|
||||
return app
|
||||
}
|
||||
// MARK: - Element Resolution
|
||||
|
||||
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
|
||||
let collection = app.collectionViews["systems-list"]
|
||||
@@ -299,6 +371,62 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
return table
|
||||
}
|
||||
|
||||
private func resolvedBatteriesList(in app: XCUIApplication) -> XCUIElement {
|
||||
let collection = app.collectionViews["batteries-list"]
|
||||
if collection.waitForExistence(timeout: 6) {
|
||||
return collection
|
||||
}
|
||||
|
||||
let table = app.tables["batteries-list"]
|
||||
if table.waitForExistence(timeout: 6) {
|
||||
return table
|
||||
}
|
||||
|
||||
// Fallback: any list on screen
|
||||
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
|
||||
return app.collectionViews.firstMatch
|
||||
}
|
||||
return app.tables.firstMatch
|
||||
}
|
||||
|
||||
private func resolvedChargersList(in app: XCUIApplication) -> XCUIElement {
|
||||
let collection = app.collectionViews["chargers-list"]
|
||||
if collection.waitForExistence(timeout: 6) {
|
||||
return collection
|
||||
}
|
||||
|
||||
let table = app.tables["chargers-list"]
|
||||
if table.waitForExistence(timeout: 6) {
|
||||
return table
|
||||
}
|
||||
|
||||
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
|
||||
return app.collectionViews.firstMatch
|
||||
}
|
||||
return app.tables.firstMatch
|
||||
}
|
||||
|
||||
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
|
||||
let identifier = "system-bom-button"
|
||||
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
|
||||
if buttonByIdentifier.exists { return buttonByIdentifier }
|
||||
|
||||
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
|
||||
if elementByIdentifier.exists { return elementByIdentifier }
|
||||
|
||||
let candidates = candidateStrings(for: .billOfMaterials)
|
||||
for candidate in candidates {
|
||||
let button = app.buttons[candidate]
|
||||
if button.exists { return button }
|
||||
let other = app.otherElements[candidate]
|
||||
if other.exists { return other }
|
||||
}
|
||||
|
||||
return buttonByIdentifier
|
||||
}
|
||||
|
||||
// MARK: - Screenshots & Stability
|
||||
|
||||
private func takeScreenshot(named name: String) {
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
@@ -308,52 +436,7 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitForStability(long: Bool = false) {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5))
|
||||
}
|
||||
|
||||
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
|
||||
let identifierMatch = app.descendants(matching: .any)
|
||||
.matching(identifier: "components-tab").firstMatch
|
||||
if identifierMatch.exists {
|
||||
return identifierMatch
|
||||
}
|
||||
|
||||
let localizedLabels = [
|
||||
"Components", "Verbraucher", "Componentes", "Composants", "Componenten"
|
||||
]
|
||||
for label in localizedLabels {
|
||||
let button = app.buttons[label]
|
||||
if button.exists {
|
||||
return button
|
||||
}
|
||||
|
||||
let tabBarButton = app.tabBars.buttons[label]
|
||||
if tabBarButton.exists {
|
||||
return tabBarButton
|
||||
}
|
||||
|
||||
let segmentedButton = app.segmentedControls.buttons[label]
|
||||
if segmentedButton.exists {
|
||||
return segmentedButton
|
||||
}
|
||||
|
||||
let segmentedOther = app.segmentedControls.otherElements[label]
|
||||
if segmentedOther.exists {
|
||||
return segmentedOther
|
||||
}
|
||||
}
|
||||
|
||||
let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1)
|
||||
if fallbackSegmented.exists {
|
||||
return fallbackSegmented
|
||||
}
|
||||
|
||||
let tabBarButton = app.tabBars.buttons.element(boundBy: 1)
|
||||
if tabBarButton.exists {
|
||||
return tabBarButton
|
||||
}
|
||||
|
||||
return app.tabBars.descendants(matching: .any).firstMatch
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(long ? 3.0 : 0.5))
|
||||
}
|
||||
|
||||
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
|
||||
@@ -362,15 +445,12 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
if app.otherElements["system-overview"].exists {
|
||||
return true
|
||||
}
|
||||
|
||||
let navBar = app.navigationBars.firstMatch
|
||||
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
|
||||
return true
|
||||
}
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
||||
}
|
||||
|
||||
return app.otherElements["system-overview"].exists
|
||||
}
|
||||
|
||||
@@ -395,32 +475,13 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
|
||||
let identifier = "system-bom-button"
|
||||
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
|
||||
if buttonByIdentifier.exists { return buttonByIdentifier }
|
||||
|
||||
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
|
||||
if elementByIdentifier.exists { return elementByIdentifier }
|
||||
|
||||
let candidates = candidateStrings(for: .billOfMaterials)
|
||||
for candidate in candidates {
|
||||
let button = app.buttons[candidate]
|
||||
if button.exists {
|
||||
return button
|
||||
}
|
||||
let other = app.otherElements[candidate]
|
||||
if other.exists {
|
||||
return other
|
||||
}
|
||||
}
|
||||
|
||||
return buttonByIdentifier
|
||||
}
|
||||
// MARK: - Notification Dismissal
|
||||
|
||||
private func dismissNotificationBannersIfNeeded() {
|
||||
// Try multiple times — notifications can appear with a delay
|
||||
for _ in 0..<3 {
|
||||
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
|
||||
if banner.waitForExistence(timeout: 1) {
|
||||
guard banner.waitForExistence(timeout: 1) else { return }
|
||||
if banner.isHittable {
|
||||
banner.swipeUp()
|
||||
} else {
|
||||
@@ -432,6 +493,8 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Localized Element Matching
|
||||
|
||||
private func candidateStrings(for key: UIStringKey) -> [String] {
|
||||
var values = Set<String>()
|
||||
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
|
||||
@@ -444,9 +507,6 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
if let others = translations[key]?.values {
|
||||
values.formUnion(others)
|
||||
}
|
||||
if key == .settings {
|
||||
values.insert("gearshape")
|
||||
}
|
||||
return Array(values)
|
||||
}
|
||||
|
||||
@@ -465,139 +525,4 @@ final class CableUITestsScreenshot: XCTestCase {
|
||||
)
|
||||
return query.matching(predicate).firstMatch
|
||||
}
|
||||
|
||||
private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? {
|
||||
let element = button(in: query, for: key)
|
||||
return element.exists ? element : nil
|
||||
}
|
||||
|
||||
private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
|
||||
let tabSpecific = button(in: app.tabBars.buttons, for: key)
|
||||
if tabSpecific.exists {
|
||||
return tabSpecific
|
||||
}
|
||||
return button(in: app.buttons, for: key)
|
||||
}
|
||||
|
||||
private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
|
||||
let candidates = candidateStrings(for: key)
|
||||
for candidate in candidates {
|
||||
let bar = app.navigationBars[candidate]
|
||||
if bar.exists {
|
||||
return bar
|
||||
}
|
||||
}
|
||||
return app.navigationBars.element(boundBy: 0)
|
||||
}
|
||||
|
||||
private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) {
|
||||
let candidates = candidateStrings(for: key)
|
||||
for candidate in candidates {
|
||||
let button = app.buttons[candidate]
|
||||
if button.waitForExistence(timeout: 2) {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openBillOfMaterials(app: XCUIApplication) {
|
||||
let bomButton = button(in: app.buttons, for: .billOfMaterials)
|
||||
XCTAssertTrue(bomButton.waitForExistence(timeout: 6))
|
||||
bomButton.tap()
|
||||
let bomView = app.otherElements["system-bom-view"]
|
||||
XCTAssertTrue(bomView.waitForExistence(timeout: 8))
|
||||
waitForStability(long: true)
|
||||
}
|
||||
|
||||
private func closeBillOfMaterials(app: XCUIApplication) {
|
||||
tapButtonIfPresent(app: app, key: .close)
|
||||
}
|
||||
|
||||
private func navigateBack(app: XCUIApplication) {
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists {
|
||||
backButton.tap()
|
||||
} else {
|
||||
app.swipeRight()
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings(app: XCUIApplication) {
|
||||
let systemsBar = navigationBar(in: app, key: .systemsTitle)
|
||||
let settingsButton = button(in: systemsBar.buttons, for: .settings)
|
||||
if settingsButton.exists {
|
||||
settingsButton.tap()
|
||||
} else {
|
||||
systemsBar.buttons.element(boundBy: 0).tap()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDoNotDisturbEnabled() {
|
||||
springboard.activate()
|
||||
let pullStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.02))
|
||||
let pullEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.30))
|
||||
pullStart.press(forDuration: 0.1, thenDragTo: pullEnd)
|
||||
|
||||
let focusTile = springboard.otherElements["Focus"]
|
||||
let focusButton = springboard.buttons["Focus"]
|
||||
if focusTile.waitForExistence(timeout: 2) {
|
||||
focusTile.press(forDuration: 1.0)
|
||||
} else if focusButton.waitForExistence(timeout: 2) {
|
||||
focusButton.press(forDuration: 1.0)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let dndButton = springboard.buttons["Do Not Disturb"]
|
||||
if dndButton.waitForExistence(timeout: 1) {
|
||||
if !dndButton.isSelected {
|
||||
dndButton.tap()
|
||||
}
|
||||
} else {
|
||||
let dndCell = springboard.cells["Do Not Disturb"]
|
||||
if dndCell.waitForExistence(timeout: 1) && !dndCell.isSelected {
|
||||
dndCell.tap()
|
||||
} else {
|
||||
let dndLabel = springboard.staticTexts["Do Not Disturb"]
|
||||
if dndLabel.waitForExistence(timeout: 1) {
|
||||
dndLabel.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dismissStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
|
||||
let dismissEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
|
||||
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
|
||||
}
|
||||
|
||||
private func dismissSystemOverlays() {
|
||||
let app = XCUIApplication()
|
||||
let alertButtons = [
|
||||
"OK", "Allow", "Later", "Not Now", "Close",
|
||||
"Continue", "Remind Me Later", "Maybe Later",
|
||||
]
|
||||
|
||||
if app.alerts.firstMatch.exists {
|
||||
handleAlerts(in: app, buttons: alertButtons)
|
||||
}
|
||||
|
||||
if springboard.alerts.firstMatch.exists || springboard.scrollViews.firstMatch.exists {
|
||||
handleAlerts(in: springboard, buttons: alertButtons + ["Enable Later"])
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAlerts(in application: XCUIApplication, buttons: [String]) {
|
||||
for buttonLabel in buttons {
|
||||
let button = application.buttons[buttonLabel]
|
||||
if button.waitForExistence(timeout: 0.5) {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
let closeButton = application.buttons.matching(NSPredicate(format: "identifier CONTAINS[c] %@", "Close")).firstMatch
|
||||
if closeButton.exists {
|
||||
closeButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,6 @@
|
||||
//
|
||||
// CableUITestsScreenshotLaunchTests.swift
|
||||
// CableUITestsScreenshot
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 06.10.25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
||||
private func launchApp(arguments: [String] = []) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
var launchArguments = ["--uitest-reset-data"]
|
||||
launchArguments.append(contentsOf: arguments)
|
||||
app.launchArguments = launchArguments
|
||||
app.launch()
|
||||
return app
|
||||
}
|
||||
|
||||
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
|
||||
let collection = app.collectionViews["systems-list"]
|
||||
if collection.waitForExistence(timeout: 6) {
|
||||
return collection
|
||||
}
|
||||
|
||||
let table = app.tables["systems-list"]
|
||||
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
||||
return table
|
||||
}
|
||||
|
||||
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
|
||||
let collection = app.collectionViews["loads-list"]
|
||||
if collection.waitForExistence(timeout: 6) {
|
||||
return collection
|
||||
}
|
||||
|
||||
let table = app.tables["loads-list"]
|
||||
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
||||
return table
|
||||
}
|
||||
|
||||
|
||||
private func takeScreenshot(name: String,
|
||||
lifetime: XCTAttachment.Lifetime = .keepAlways) {
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = name
|
||||
attachment.lifetime = lifetime
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
false
|
||||
@@ -57,110 +10,15 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func testOnboardingLoadsView() throws {
|
||||
let app = launchApp(arguments: ["--uitest-reset-data"])
|
||||
takeScreenshot(name: "01-OnboardingSystemsView")
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
|
||||
app.launch()
|
||||
|
||||
let createSystemButton = app.buttons["create-system-button"]
|
||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
||||
createSystemButton.tap()
|
||||
takeScreenshot(name: "02-OnboardingLoadsView")
|
||||
|
||||
let componentsTab = app.buttons["components-tab"]
|
||||
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
|
||||
componentsTab.tap()
|
||||
|
||||
let libraryCloseButton = app.buttons["library-view-close-button"]
|
||||
let browseLibraryButton = onboardingSecondaryButton(in: app)
|
||||
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 5))
|
||||
browseLibraryButton.tap()
|
||||
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
|
||||
Thread.sleep(forTimeInterval: 10)
|
||||
takeScreenshot(name: "04-ComponentSelectorView")
|
||||
libraryCloseButton.tap()
|
||||
|
||||
let createComponentButton = onboardingPrimaryButton(in: app)
|
||||
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
||||
createComponentButton.tap()
|
||||
takeScreenshot(name: "03-LoadEditorView")
|
||||
}
|
||||
|
||||
func testWithSampleData() throws {
|
||||
let app = launchApp(arguments: ["--uitest-sample-data"])
|
||||
|
||||
let systemsList = resolvedSystemsList(in: app)
|
||||
|
||||
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
|
||||
let systemName = firstSystemCell.staticTexts.firstMatch.label
|
||||
|
||||
takeScreenshot(name: "05-SystemsWithSampleData")
|
||||
|
||||
let rowButton = firstSystemCell.buttons.firstMatch
|
||||
if rowButton.waitForExistence(timeout: 2) {
|
||||
rowButton.tap()
|
||||
} else {
|
||||
firstSystemCell.tap()
|
||||
}
|
||||
|
||||
let navButton = app.navigationBars.buttons[systemName]
|
||||
if !navButton.waitForExistence(timeout: 3) {
|
||||
let coordinate = firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||
coordinate.tap()
|
||||
XCTAssertTrue(navButton.waitForExistence(timeout: 3))
|
||||
}
|
||||
|
||||
tapComponentsTab(in: app)
|
||||
|
||||
let loadsElement = resolvedLoadsList(in: app)
|
||||
XCTAssertTrue(loadsElement.waitForExistence(timeout: 6))
|
||||
|
||||
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
takeScreenshot(name: "06-AdventureVanLoads")
|
||||
|
||||
let bomButton = app.buttons["system-bom-button"]
|
||||
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
|
||||
bomButton.tap()
|
||||
|
||||
// let bomView = app.otherElements["system-bom-view"]
|
||||
// XCTAssertTrue(bomView.waitForExistence(timeout: 3))
|
||||
//
|
||||
// Thread.sleep(forTimeInterval: 1)
|
||||
// takeScreenshot(name: "07-AdventureVanBillOfMaterials")
|
||||
}
|
||||
|
||||
private func tapComponentsTab(in app: XCUIApplication) {
|
||||
let button = componentsTabButton(in: app)
|
||||
XCTAssertTrue(button.waitForExistence(timeout: 3))
|
||||
button.tap()
|
||||
}
|
||||
|
||||
private func onboardingPrimaryButton(in app: XCUIApplication) -> XCUIElement {
|
||||
let button = app.buttons["create-component-button"]
|
||||
if button.exists { return button }
|
||||
return app.buttons["onboarding-primary-button"]
|
||||
}
|
||||
|
||||
private func onboardingSecondaryButton(in app: XCUIApplication) -> XCUIElement {
|
||||
let button = app.buttons["select-component-button"]
|
||||
if button.exists { return button }
|
||||
return app.buttons["onboarding-secondary-button"]
|
||||
}
|
||||
|
||||
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
|
||||
let idButton = app.buttons["components-tab"]
|
||||
if idButton.exists {
|
||||
return idButton
|
||||
}
|
||||
|
||||
let labels = ["Components", "Verbraucher", "Componentes", "Composants", "Componenten"]
|
||||
for label in labels {
|
||||
let button = app.buttons[label]
|
||||
if button.exists { return button }
|
||||
}
|
||||
return app.tabBars.buttons.element(boundBy: 1)
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
5
android/.gitignore
vendored
@@ -11,3 +11,8 @@
|
||||
|
||||
# Local build artifacts
|
||||
*.apk
|
||||
|
||||
# Signing — never commit the keystore or its passwords
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
@@ -6,6 +9,14 @@ plugins {
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
// Release signing credentials, loaded from android/keystore.properties (gitignored).
|
||||
// Falls back to no release signing when the file/keystore is absent (e.g. CI without secrets).
|
||||
val keystoreProps = Properties().apply {
|
||||
val f = rootProject.file("keystore.properties")
|
||||
if (f.exists()) load(FileInputStream(f))
|
||||
}
|
||||
val hasReleaseSigning = keystoreProps.getProperty("storeFile")?.let { file(it).exists() } == true
|
||||
|
||||
android {
|
||||
namespace = "app.voltplan.cable"
|
||||
compileSdk = 35
|
||||
@@ -25,17 +36,35 @@ android {
|
||||
resourceConfigurations += listOf("en", "de", "es", "fr", "nl")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProps.getProperty("storeFile"))
|
||||
storePassword = keystoreProps.getProperty("storePassword")
|
||||
keyAlias = keystoreProps.getProperty("keyAlias")
|
||||
keyPassword = keystoreProps.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
release {
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// Bundle native debug symbols so Play can symbolicate native crashes/ANRs.
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +115,6 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
implementation(libs.play.review.ktx)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package app.voltplan.cable
|
||||
import android.app.Application
|
||||
import app.voltplan.cable.analytics.Analytics
|
||||
import app.voltplan.cable.data.CableRepository
|
||||
import app.voltplan.cable.data.ReviewPrompt
|
||||
import app.voltplan.cable.data.UnitSystemSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -24,9 +25,11 @@ class CableApplication : Application() {
|
||||
|
||||
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
if (settings.consumeFirstLaunch()) {
|
||||
val isFirstLaunch = settings.consumeFirstLaunch()
|
||||
if (isFirstLaunch) {
|
||||
Analytics.log("First Launch")
|
||||
}
|
||||
ReviewPrompt.migrateIfNeeded(this@CableApplication, isFirstLaunch)
|
||||
Analytics.log("App Launched")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package app.voltplan.cable.data
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import app.voltplan.cable.BuildConfig
|
||||
import app.voltplan.cable.analytics.Analytics
|
||||
import com.google.android.play.core.ktx.launchReview
|
||||
import com.google.android.play.core.ktx.requestReview
|
||||
import com.google.android.play.core.review.ReviewManagerFactory
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* Decides when to ask the user for a Play Store rating via the Play In-App Review API.
|
||||
* Google throttles the actual dialog (and shows nothing in debug/sideload builds), so this gate
|
||||
* keeps requests rare and tied to genuine success moments — a completed export/share.
|
||||
* Mirrors the iOS `ReviewPrompt` enum, sharing the same gate thresholds and the `cable_settings`
|
||||
* DataStore so both platforms behave identically.
|
||||
*/
|
||||
object ReviewPrompt {
|
||||
private val MIGRATION_DONE = stringPreferencesKey("review.migrationDone")
|
||||
private val FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate")
|
||||
private val EXPORT_COUNT = intPreferencesKey("review.successfulExportCount")
|
||||
private val LAST_PROMPT_DATE = longPreferencesKey("review.lastPromptDate")
|
||||
private val LAST_PROMPTED_VERSION = stringPreferencesKey("review.lastPromptedVersion")
|
||||
private val USER_TYPE = stringPreferencesKey("review.userType")
|
||||
|
||||
private const val MIN_EXPORTS = 2
|
||||
private const val MIN_DAYS_SINCE_INSTALL = 3L
|
||||
private const val MIN_DAYS_BETWEEN_PROMPTS = 120L
|
||||
private const val DAY_MS = 24L * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* One-time setup distinguishing fresh installs from users updating into this feature.
|
||||
* Existing users are backdated and pre-seeded so the prompt can fire on their *first*
|
||||
* successful export after updating. Pass the value returned by [UnitSystemSettings.consumeFirstLaunch].
|
||||
*/
|
||||
suspend fun migrateIfNeeded(context: Context, isFirstLaunch: Boolean) {
|
||||
if (context.dataStore.data.first()[MIGRATION_DONE] != null) return
|
||||
val now = System.currentTimeMillis()
|
||||
context.dataStore.edit {
|
||||
if (isFirstLaunch) {
|
||||
// Genuine new install: normal flow — needs 2 exports and 3 days.
|
||||
it[FIRST_LAUNCH_DATE] = now
|
||||
it[EXPORT_COUNT] = 0
|
||||
it[USER_TYPE] = "new"
|
||||
} else {
|
||||
// Existing user updating in: backdate install past the age gate and pre-seed the
|
||||
// counter so the very next successful export satisfies the gate.
|
||||
it[FIRST_LAUNCH_DATE] = now - MIN_DAYS_SINCE_INSTALL * DAY_MS
|
||||
it[EXPORT_COUNT] = MIN_EXPORTS - 1
|
||||
it[USER_TYPE] = "existing"
|
||||
}
|
||||
it[MIGRATION_DONE] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
|
||||
* Increments the shared counter, then requests a review if every gate condition holds.
|
||||
*/
|
||||
suspend fun registerSuccessfulExport(context: Context) {
|
||||
var count = 0
|
||||
var firstLaunch = 0L
|
||||
context.dataStore.edit {
|
||||
if (it[FIRST_LAUNCH_DATE] == null) it[FIRST_LAUNCH_DATE] = System.currentTimeMillis()
|
||||
count = (it[EXPORT_COUNT] ?: 0) + 1
|
||||
it[EXPORT_COUNT] = count
|
||||
firstLaunch = it[FIRST_LAUNCH_DATE] ?: 0L
|
||||
}
|
||||
if (shouldRequest(context, count, firstLaunch)) {
|
||||
requestReview(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shouldRequest(context: Context, exportCount: Int, firstLaunch: Long): Boolean {
|
||||
// A: enough successful exports
|
||||
if (exportCount < MIN_EXPORTS) return false
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
// B: installed long enough
|
||||
if (now - firstLaunch < MIN_DAYS_SINCE_INSTALL * DAY_MS) return false
|
||||
|
||||
val prefs = context.dataStore.data.first()
|
||||
// C: not prompted too recently
|
||||
val lastPrompt = prefs[LAST_PROMPT_DATE] ?: 0L
|
||||
if (lastPrompt > 0 && now - lastPrompt < MIN_DAYS_BETWEEN_PROMPTS * DAY_MS) return false
|
||||
|
||||
// D: at most once per app version
|
||||
if (prefs[LAST_PROMPTED_VERSION] == BuildConfig.VERSION_NAME) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun requestReview(context: Context) {
|
||||
// Mark as requested up front — Google may suppress the dialog, but we still count it
|
||||
// against our own throttle so we don't ask again immediately.
|
||||
context.dataStore.edit {
|
||||
it[LAST_PROMPT_DATE] = System.currentTimeMillis()
|
||||
it[LAST_PROMPTED_VERSION] = BuildConfig.VERSION_NAME
|
||||
}
|
||||
val userType = context.dataStore.data.first()[USER_TYPE] ?: "unknown"
|
||||
Analytics.log(
|
||||
"Review Prompt Requested",
|
||||
mapOf("version" to BuildConfig.VERSION_NAME, "userType" to userType),
|
||||
)
|
||||
|
||||
val activity = context.findActivity() ?: return
|
||||
runCatching {
|
||||
val manager = ReviewManagerFactory.create(context)
|
||||
val reviewInfo = manager.requestReview()
|
||||
manager.launchReview(activity, reviewInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.findActivity(): Activity? {
|
||||
var ctx: Context? = this
|
||||
while (ctx is ContextWrapper) {
|
||||
if (ctx is Activity) return ctx
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "cable_settings")
|
||||
internal val Context.dataStore by preferencesDataStore(name = "cable_settings")
|
||||
private val UNIT_SYSTEM_KEY = stringPreferencesKey("unitSystem")
|
||||
private val LAUNCHED_BEFORE_KEY = stringPreferencesKey("hasLaunchedBefore")
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ data class ComponentLibraryItem(
|
||||
val watt: Double?,
|
||||
val dutyCyclePercent: Double?,
|
||||
val defaultUtilizationFactorPercent: Double?,
|
||||
val componentCategory: String?,
|
||||
val iconURL: String?,
|
||||
val affiliateLinks: List<AffiliateLink>,
|
||||
) {
|
||||
@@ -32,11 +33,43 @@ data class ComponentLibraryItem(
|
||||
return if (v > 0) w / v else null
|
||||
}
|
||||
|
||||
/** Battery capacity derived from stored energy (Wh) and nominal voltage. */
|
||||
val capacityAmpHours: Double?
|
||||
get() {
|
||||
val v = displayVoltage ?: return null
|
||||
val w = watt ?: return null
|
||||
return if (v > 0) w / v else null
|
||||
}
|
||||
|
||||
/** Charger output current derived from rated power and output voltage. */
|
||||
val outputCurrent: Double?
|
||||
get() {
|
||||
val v = voltageOut ?: displayVoltage ?: return null
|
||||
val w = watt ?: return null
|
||||
return if (v > 0) w / v else null
|
||||
}
|
||||
|
||||
val localizedName: String get() = resolveLocalizedName(Locale.getDefault()) ?: name
|
||||
|
||||
val voltageLabel: String? get() = displayVoltage?.let { String.format(Locale.US, "%.1fV", it) }
|
||||
val powerLabel: String? get() = watt?.let { String.format(Locale.US, "%.0fW", it) }
|
||||
val currentLabel: String? get() = current?.let { String.format(Locale.US, "%.1fA", it) }
|
||||
val capacityLabel: String? get() = capacityAmpHours?.let { String.format(Locale.US, "%.0fAh", it) }
|
||||
val energyLabel: String? get() = watt?.let { String.format(Locale.US, "%.0fWh", it) }
|
||||
val voltageRangeLabel: String?
|
||||
get() = if (voltageIn != null && voltageOut != null) {
|
||||
String.format(Locale.US, "%.0fV → %.0fV", voltageIn, voltageOut)
|
||||
} else {
|
||||
voltageLabel
|
||||
}
|
||||
val outputCurrentLabel: String? get() = outputCurrent?.let { String.format(Locale.US, "%.1fA", it) }
|
||||
|
||||
/** Detail metrics shown in a library row, tailored to the component type. */
|
||||
fun detailLabels(type: ComponentLibraryType): List<String> = when (type) {
|
||||
ComponentLibraryType.LOAD -> listOfNotNull(voltageLabel, powerLabel, currentLabel)
|
||||
ComponentLibraryType.BATTERY -> listOfNotNull(voltageLabel, capacityLabel, energyLabel)
|
||||
ComponentLibraryType.CHARGER -> listOfNotNull(voltageRangeLabel, outputCurrentLabel, powerLabel)
|
||||
}
|
||||
|
||||
val normalizedDutyCyclePercent: Double? get() = normalizePercent(dutyCyclePercent)
|
||||
private val normalizedUtilizationFactorPercent: Double? get() = normalizePercent(defaultUtilizationFactorPercent)
|
||||
@@ -99,6 +132,7 @@ data class ComponentLibraryItem(
|
||||
watt = record.watt,
|
||||
dutyCyclePercent = record.dutyCycle,
|
||||
defaultUtilizationFactorPercent = record.defaultUtilizationFactor,
|
||||
componentCategory = record.componentCategory,
|
||||
iconURL = iconUrl,
|
||||
affiliateLinks = affiliateLinks,
|
||||
)
|
||||
|
||||
@@ -3,20 +3,20 @@ package app.voltplan.cable.library
|
||||
/** Fetches and assembles the component library from PocketBase. Mirrors `ComponentLibraryViewModel` data flow. */
|
||||
class ComponentLibraryRepository(private val api: PocketBaseApi = PocketBaseApi.create()) {
|
||||
|
||||
suspend fun fetchAll(): List<ComponentLibraryItem> {
|
||||
val records = fetchComponents()
|
||||
suspend fun fetchAll(type: ComponentLibraryType = ComponentLibraryType.LOAD): List<ComponentLibraryItem> {
|
||||
val records = fetchComponents(type)
|
||||
val affiliateByComponent = fetchAffiliateLinks(records.map { it.id })
|
||||
return records.map { record ->
|
||||
ComponentLibraryItem.from(record, affiliateByComponent[record.id].orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchComponents(): List<PbComponentRecord> {
|
||||
private suspend fun fetchComponents(type: ComponentLibraryType): List<PbComponentRecord> {
|
||||
val all = mutableListOf<PbComponentRecord>()
|
||||
var page = 1
|
||||
val perPage = 200
|
||||
while (true) {
|
||||
val response = api.components(page = page, perPage = perPage)
|
||||
val response = api.components(filter = type.filter, page = page, perPage = perPage)
|
||||
all += response.items
|
||||
val done = (response.totalPages in 1..page) || response.items.size < perPage
|
||||
if (done) break
|
||||
|
||||
@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.voltplan.cable.CableApplication
|
||||
import app.voltplan.cable.analytics.Analytics
|
||||
import app.voltplan.cable.data.model.Chemistry
|
||||
import app.voltplan.cable.data.model.ElectricalSystem
|
||||
import app.voltplan.cable.data.model.PowerSourceType
|
||||
import app.voltplan.cable.data.model.SavedBattery
|
||||
import app.voltplan.cable.data.model.SavedCharger
|
||||
import app.voltplan.cable.data.model.SavedLoad
|
||||
import app.voltplan.cable.ui.systems.SystemIconMapper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -30,6 +34,7 @@ data class LibraryUiState(
|
||||
|
||||
class ComponentLibraryViewModel(
|
||||
private val app: CableApplication,
|
||||
val libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
|
||||
private val libraryRepo: ComponentLibraryRepository = ComponentLibraryRepository(),
|
||||
) : ViewModel() {
|
||||
private val repo = app.repository
|
||||
@@ -41,7 +46,7 @@ class ComponentLibraryViewModel(
|
||||
fun load() {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
viewModelScope.launch {
|
||||
runCatching { libraryRepo.fetchAll() }
|
||||
runCatching { libraryRepo.fetchAll(libraryType) }
|
||||
.onSuccess { _state.value = _state.value.copy(loading = false, items = it) }
|
||||
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Error") }
|
||||
}
|
||||
@@ -50,23 +55,21 @@ class ComponentLibraryViewModel(
|
||||
fun refresh() = load()
|
||||
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
|
||||
|
||||
/** Adds the chosen component as a load. Returns (via [onDone]) the system id to open, or null to just go back. */
|
||||
fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val systemId: String
|
||||
val createdNewSystem: Boolean
|
||||
if (targetSystemId != null) {
|
||||
systemId = targetSystemId
|
||||
createdNewSystem = false
|
||||
} else {
|
||||
/** Returns the system to add into, creating a new one when [targetSystemId] is null. */
|
||||
private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> {
|
||||
if (targetSystemId != null) return targetSystemId to false
|
||||
val name = repo.uniqueSystemName("New System")
|
||||
val system = ElectricalSystem(name = name, iconName = SystemIconMapper.iconFor(name), colorName = SystemIconMapper.colorOptions.random())
|
||||
repo.upsertSystem(system)
|
||||
Analytics.log("System Created", mapOf("name" to name, "source" to "library"))
|
||||
systemId = system.id
|
||||
createdNewSystem = true
|
||||
return system.id to true
|
||||
}
|
||||
|
||||
/** Adds the chosen component as a load. Returns (via [onDone]) the system id to open, or null to just go back. */
|
||||
fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val (systemId, createdNewSystem) = ensureSystem(targetSystemId)
|
||||
|
||||
val baseName = item.localizedName.ifBlank { "Library Load" }
|
||||
val loadName = repo.uniqueComponentName(systemId, baseName)
|
||||
val voltage = item.displayVoltage ?: 12.0
|
||||
@@ -97,4 +100,79 @@ class ComponentLibraryViewModel(
|
||||
onDone(if (createdNewSystem) systemId else null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the chosen component as a battery, then opens its editor via [onDone] (systemId, batteryId). */
|
||||
fun selectBattery(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val (systemId, _) = ensureSystem(targetSystemId)
|
||||
val baseName = item.localizedName.ifBlank { "New Battery" }
|
||||
val name = repo.uniqueComponentName(systemId, baseName)
|
||||
val voltage = item.displayVoltage ?: 12.8
|
||||
val capacity = item.capacityAmpHours ?: 100.0
|
||||
val affiliate = item.primaryAffiliateLink
|
||||
|
||||
val battery = SavedBattery(
|
||||
name = name,
|
||||
nominalVoltage = voltage,
|
||||
capacityAmpHours = capacity,
|
||||
chemistryRawValue = Chemistry.LIFEPO4.rawValue,
|
||||
iconName = "battery.100",
|
||||
colorName = "blue",
|
||||
systemId = systemId,
|
||||
affiliateURLString = affiliate?.url,
|
||||
affiliateCountryCode = affiliate?.country,
|
||||
)
|
||||
repo.upsertBattery(battery)
|
||||
Analytics.log("Library Battery Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
|
||||
|
||||
onDone(systemId, battery.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the chosen component as a charger, then opens its editor via [onDone] (systemId, chargerId). */
|
||||
fun selectCharger(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val (systemId, _) = ensureSystem(targetSystemId)
|
||||
val baseName = item.localizedName.ifBlank { "New Charger" }
|
||||
val name = repo.uniqueComponentName(systemId, baseName)
|
||||
val inputVoltage = item.voltageIn ?: 230.0
|
||||
val outputVoltage = item.voltageOut ?: 14.2
|
||||
val power = item.watt ?: 0.0
|
||||
val current = item.outputCurrent ?: if (outputVoltage > 0) power / outputVoltage else 30.0
|
||||
val sourceType = chargerSourceType(item.componentCategory)
|
||||
val affiliate = item.primaryAffiliateLink
|
||||
|
||||
val charger = SavedCharger(
|
||||
name = name,
|
||||
inputVoltage = inputVoltage,
|
||||
outputVoltage = outputVoltage,
|
||||
maxCurrentAmps = current,
|
||||
maxPowerWatts = power,
|
||||
iconName = sourceType.iconName,
|
||||
colorName = "orange",
|
||||
systemId = systemId,
|
||||
remoteIconURLString = item.iconURL,
|
||||
affiliateURLString = affiliate?.url,
|
||||
affiliateCountryCode = affiliate?.country,
|
||||
powerSourceType = sourceType.rawValue,
|
||||
)
|
||||
repo.upsertCharger(charger)
|
||||
Analytics.log("Library Charger Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
|
||||
|
||||
onDone(systemId, charger.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a PocketBase `component_category` to a charger power source. */
|
||||
private fun chargerSourceType(category: String?): PowerSourceType {
|
||||
val c = category?.lowercase()?.takeUnless { it.isBlank() } ?: return PowerSourceType.SHORE
|
||||
return when {
|
||||
"solar" in c -> PowerSourceType.SOLAR
|
||||
"wind" in c -> PowerSourceType.WIND
|
||||
"dcdc" in c || "alternator" in c -> PowerSourceType.ALTERNATOR
|
||||
"generator" in c -> PowerSourceType.GENERATOR
|
||||
"mains" in c || "shore" in c -> PowerSourceType.SHORE
|
||||
else -> PowerSourceType.SHORE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,21 @@ import retrofit2.http.Query
|
||||
|
||||
const val POCKETBASE_BASE = "https://base.voltplan.app"
|
||||
|
||||
/** The kind of library being browsed. Mirrors the iOS `ComponentLibraryType`. */
|
||||
enum class ComponentLibraryType(val typeValue: String) {
|
||||
LOAD("load"),
|
||||
BATTERY("battery"),
|
||||
CHARGER("charger");
|
||||
|
||||
/** PocketBase filter expression selecting this type. */
|
||||
val filter: String get() = "type='$typeValue'"
|
||||
|
||||
companion object {
|
||||
fun fromArg(value: String?): ComponentLibraryType =
|
||||
entries.firstOrNull { it.typeValue == value } ?: LOAD
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PbComponentsResponse(
|
||||
val page: Int = 1,
|
||||
@@ -33,6 +48,7 @@ data class PbComponentRecord(
|
||||
val watt: Double? = null,
|
||||
@SerialName("duty_cycle") val dutyCycle: Double? = null,
|
||||
@SerialName("default_utilization_factor") val defaultUtilizationFactor: Double? = null,
|
||||
@SerialName("component_category") val componentCategory: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -55,7 +71,7 @@ interface PocketBaseApi {
|
||||
suspend fun components(
|
||||
@Query("filter") filter: String = "type='load'",
|
||||
@Query("sort") sort: String = "+name",
|
||||
@Query("fields") fields: String = "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor",
|
||||
@Query("fields") fields: String = "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor,component_category",
|
||||
@Query("page") page: Int,
|
||||
@Query("perPage") perPage: Int = 200,
|
||||
): PbComponentsResponse
|
||||
|
||||
@@ -75,10 +75,12 @@ object PdfShare {
|
||||
return file
|
||||
}
|
||||
|
||||
fun share(context: Context, file: File) {
|
||||
fun share(context: Context, file: File) = shareFile(context, file, "application/pdf")
|
||||
|
||||
fun shareFile(context: Context, file: File, mimeType: String) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/pdf"
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package app.voltplan.cable.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import app.voltplan.cable.R
|
||||
import app.voltplan.cable.analytics.Analytics
|
||||
import app.voltplan.cable.data.model.effectivePowerWatts
|
||||
import app.voltplan.cable.data.model.energyWattHours
|
||||
import app.voltplan.cable.data.model.sourceType
|
||||
import app.voltplan.cable.data.UnitSystem
|
||||
import app.voltplan.cable.ui.system.DetailState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fetches the system wiring diagram PNG from the VoltPlan diagram API — the same endpoint and
|
||||
* payload the iOS app uses (`SystemOverviewPDFExporter.fetchDiagramImage`). Used both for the
|
||||
* standalone "Wiring Diagram" image export and the diagram page embedded in the overview PDF.
|
||||
*/
|
||||
object SystemDiagram {
|
||||
private const val ENDPOINT = "https://voltplan.app/api/diagram/generate"
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
private val client = OkHttpClient.Builder()
|
||||
.callTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/** Fetches the diagram as a [Bitmap], or null on any network/decoding failure. */
|
||||
suspend fun fetch(state: DetailState, unit: UnitSystem): Bitmap? = withContext(Dispatchers.IO) {
|
||||
val payload = buildPayload(state, unit)
|
||||
val request = Request.Builder()
|
||||
.url(ENDPOINT)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Accept", "image/png")
|
||||
.post(Json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
runCatching {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body?.bytes()?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Fetches the diagram, flattens it onto a white background, and opens the Android share sheet. */
|
||||
suspend fun exportAndShare(
|
||||
context: Context,
|
||||
state: DetailState,
|
||||
unit: UnitSystem,
|
||||
onError: () -> Unit,
|
||||
) {
|
||||
val bitmap = fetch(state, unit)
|
||||
if (bitmap == null) {
|
||||
withContext(Dispatchers.Main) { onError() }
|
||||
return
|
||||
}
|
||||
val file = withContext(Dispatchers.IO) {
|
||||
val opaque = flattenOnWhite(bitmap)
|
||||
val name = state.system?.name?.takeIf { it.isNotBlank() } ?: "System"
|
||||
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
|
||||
val out = File(dir, "${name.replace(Regex("[^A-Za-z0-9-_]"), "_")}-Diagram.png")
|
||||
out.outputStream().use { opaque.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
out
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Analytics.log("Diagram Image Shared", mapOf("system" to (state.system?.name ?: "")))
|
||||
PdfShare.shareFile(context, file, "image/png")
|
||||
}
|
||||
}
|
||||
|
||||
private fun flattenOnWhite(source: Bitmap): Bitmap {
|
||||
val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
|
||||
Canvas(result).apply {
|
||||
drawColor(Color.WHITE)
|
||||
drawBitmap(source, 0f, 0f, null)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun buildPayload(state: DetailState, unit: UnitSystem): JsonObject = buildJsonObject {
|
||||
put("systemName", state.system?.name ?: "System")
|
||||
put("source", "cable")
|
||||
put("unitSystem", if (unit == UnitSystem.METRIC) "metric" else "imperial")
|
||||
put("loads", buildJsonArray {
|
||||
state.loads.forEach { load ->
|
||||
add(buildJsonObject {
|
||||
put("name", load.name)
|
||||
put("power", load.power)
|
||||
put("voltage", load.voltage)
|
||||
put("current", load.current)
|
||||
load.remoteIconURLString?.let { put("iconUrl", it) }
|
||||
})
|
||||
}
|
||||
})
|
||||
put("batteries", buildJsonArray {
|
||||
state.batteries.forEach { battery ->
|
||||
add(buildJsonObject {
|
||||
put("name", battery.name)
|
||||
put("voltage", battery.nominalVoltage)
|
||||
put("capacityAh", battery.capacityAmpHours)
|
||||
put("energyWh", battery.energyWattHours)
|
||||
})
|
||||
}
|
||||
})
|
||||
put("chargers", buildJsonArray {
|
||||
state.chargers.forEach { charger ->
|
||||
add(buildJsonObject {
|
||||
put("name", charger.name)
|
||||
put("inputVoltage", charger.inputVoltage)
|
||||
put("outputVoltage", charger.outputVoltage)
|
||||
put("power", charger.effectivePowerWatts)
|
||||
put("sourceType", charger.sourceType.rawValue)
|
||||
charger.remoteIconURLString?.let { put("iconUrl", it) }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package app.voltplan.cable.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import app.voltplan.cable.R
|
||||
import app.voltplan.cable.calc.ElectricalCalculations
|
||||
@@ -23,6 +27,8 @@ private val ACCENT = Color.rgb(115, 87, 219)
|
||||
/** Renders a full system overview PDF and opens the Android share sheet. */
|
||||
object SystemOverviewPdf {
|
||||
suspend fun exportAndShare(context: Context, state: DetailState, unit: UnitSystem) {
|
||||
// Fetch the wiring diagram first (falls back to no diagram page if unavailable).
|
||||
val diagram = SystemDiagram.fetch(state, unit)
|
||||
val file = withContext(Dispatchers.IO) {
|
||||
val doc = PdfDocument()
|
||||
val w = PdfWriter(doc)
|
||||
@@ -42,6 +48,9 @@ object SystemOverviewPdf {
|
||||
summaryLine(w, context.getString(R.string.overview_pdf_summary_batterycapacity), "${Fmt.number(m.totalCapacity)} Ah")
|
||||
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargerpower), "${Fmt.number(m.totalChargerPower)} W")
|
||||
|
||||
// Full-page wiring diagram, followed by a fresh page for the entity tables.
|
||||
diagram?.let { drawDiagramPage(w, it); w.beginPage() }
|
||||
|
||||
if (state.loads.isNotEmpty()) {
|
||||
w.gap(12f); w.text(context.getString(R.string.overview_pdf_loads_section), 18f, ACCENT, bold = true); w.divider()
|
||||
state.loads.forEach { load ->
|
||||
@@ -89,4 +98,23 @@ object SystemOverviewPdf {
|
||||
private fun summaryLine(w: PdfWriter, label: String, value: String) {
|
||||
w.text("$label: $value", 12f, Color.DKGRAY)
|
||||
}
|
||||
|
||||
/** Draws the diagram bitmap on its own page, scaled to fit the margins while keeping aspect ratio. */
|
||||
private fun drawDiagramPage(w: PdfWriter, diagram: Bitmap) {
|
||||
w.beginPage()
|
||||
val availableWidth = PAGE_W - MARGIN * 2
|
||||
val availableHeight = PAGE_H - MARGIN * 2 - 30 // leave room for footer
|
||||
val imageAspect = diagram.width.toFloat() / diagram.height.toFloat()
|
||||
val rectAspect = availableWidth / availableHeight
|
||||
|
||||
val dest = if (imageAspect > rectAspect) {
|
||||
val drawHeight = availableWidth / imageAspect
|
||||
RectF(MARGIN, MARGIN + (availableHeight - drawHeight) / 2f, MARGIN + availableWidth, MARGIN + (availableHeight - drawHeight) / 2f + drawHeight)
|
||||
} else {
|
||||
val drawWidth = availableHeight * imageAspect
|
||||
RectF(MARGIN + (availableWidth - drawWidth) / 2f, MARGIN, MARGIN + (availableWidth - drawWidth) / 2f + drawWidth, MARGIN + availableHeight)
|
||||
}
|
||||
val src = Rect(0, 0, diagram.width, diagram.height)
|
||||
w.canvas.drawBitmap(diagram, src, dest, Paint().apply { isFilterBitmap = true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||
import androidx.compose.material.icons.outlined.BatteryFull
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.LibraryBooks
|
||||
import androidx.compose.material.icons.outlined.Speed
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -63,6 +65,7 @@ fun BatteriesTab(
|
||||
state: DetailState,
|
||||
onEditBattery: (String) -> Unit,
|
||||
onNewBattery: () -> Unit,
|
||||
onOpenLibrary: () -> Unit,
|
||||
onDeleteBattery: (SavedBattery) -> Unit,
|
||||
) {
|
||||
val batteries = state.batteries
|
||||
@@ -73,11 +76,15 @@ fun BatteriesTab(
|
||||
subtitle = stringResource(R.string.battery_onboarding_subtitle),
|
||||
primaryLabel = stringResource(R.string.battery_empty_create),
|
||||
onPrimary = onNewBattery,
|
||||
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||
onSecondary = onOpenLibrary,
|
||||
images = listOf(R.drawable.onboarding_battery),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val m = state.metrics
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
StatsHeader {
|
||||
Text(stringResource(R.string.battery_bank_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
@@ -101,12 +108,20 @@ fun BatteriesTab(
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp)) {
|
||||
items(batteries, key = { it.id }) { battery ->
|
||||
BatteryRow(battery, onClick = { onEditBattery(battery.id) }, onDelete = { onDeleteBattery(battery) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onOpenLibrary,
|
||||
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.loads_library_button)) },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -90,6 +91,9 @@ fun BatteryEditorScreen(systemId: String, batteryId: String?, onBack: () -> Unit
|
||||
title = {
|
||||
Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true })
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
||||
@@ -46,6 +46,7 @@ import app.voltplan.cable.R
|
||||
import app.voltplan.cable.data.UnitSystem
|
||||
import app.voltplan.cable.ui.LocalUnitSettings
|
||||
import app.voltplan.cable.ui.loads.CalcState
|
||||
import app.voltplan.cable.data.ReviewPrompt
|
||||
import app.voltplan.cable.pdf.SystemBomPdf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -74,7 +75,10 @@ fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) {
|
||||
enabled = state.sections.isNotEmpty(),
|
||||
onClick = {
|
||||
vm.logPdfExported()
|
||||
scope.launch { SystemBomPdf.exportAndShare(context, state, unit) }
|
||||
scope.launch {
|
||||
SystemBomPdf.exportAndShare(context, state, unit)
|
||||
ReviewPrompt.registerSuccessfulExport(context)
|
||||
}
|
||||
},
|
||||
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }
|
||||
},
|
||||
|
||||
@@ -81,6 +81,9 @@ fun ChargerEditorScreen(systemId: String, chargerId: String?, onBack: () -> Unit
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
|
||||
},
|
||||
title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) },
|
||||
actions = {
|
||||
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
||||
@@ -2,6 +2,7 @@ package app.voltplan.cable.ui.chargers
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -15,7 +16,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.BatteryChargingFull
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.LibraryBooks
|
||||
import androidx.compose.material.icons.outlined.Speed
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -55,6 +58,7 @@ fun ChargersTab(
|
||||
state: DetailState,
|
||||
onEditCharger: (String) -> Unit,
|
||||
onNewCharger: () -> Unit,
|
||||
onOpenLibrary: () -> Unit,
|
||||
onDeleteCharger: (SavedCharger) -> Unit,
|
||||
) {
|
||||
val chargers = state.chargers
|
||||
@@ -65,11 +69,15 @@ fun ChargersTab(
|
||||
subtitle = stringResource(R.string.chargers_onboarding_subtitle),
|
||||
primaryLabel = stringResource(R.string.chargers_onboarding_primary),
|
||||
onPrimary = onNewCharger,
|
||||
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||
onSecondary = onOpenLibrary,
|
||||
images = listOf(R.drawable.onboarding_charger),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val m = state.metrics
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
StatsHeader {
|
||||
Text(stringResource(R.string.chargers_summary_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
@@ -85,12 +93,20 @@ fun ChargersTab(
|
||||
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalChargerPower)} W", stringResource(R.string.chargers_metric_power), SysPink)
|
||||
}
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp)) {
|
||||
items(chargers, key = { it.id }) { charger ->
|
||||
ChargerRow(charger, onClick = { onEditCharger(charger.id) }, onDelete = { onDeleteCharger(charger) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onOpenLibrary,
|
||||
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.loads_library_button)) },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -3,18 +3,17 @@ package app.voltplan.cable.ui.components
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -69,6 +68,7 @@ fun AppearanceEditorSheet(
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -113,17 +113,11 @@ fun AppearanceEditorSheet(
|
||||
extra?.invoke()
|
||||
|
||||
Text("Icon", style = MaterialTheme.typography.titleSmall)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(5),
|
||||
modifier = Modifier.fillMaxWidth().heightForRows(icons.size, 5),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
userScrollEnabled = false,
|
||||
) {
|
||||
items(icons) { symbol ->
|
||||
GridRows(items = icons, columns = 5) { symbol ->
|
||||
val selected = symbol == icon
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
|
||||
@@ -138,20 +132,13 @@ fun AppearanceEditorSheet(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Color", style = MaterialTheme.typography.titleSmall)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(6),
|
||||
modifier = Modifier.fillMaxWidth().heightForRows(curatedColorNames.size, 6),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
userScrollEnabled = false,
|
||||
) {
|
||||
items(curatedColorNames) { colorName ->
|
||||
GridRows(items = curatedColorNames, columns = 6) { colorName ->
|
||||
val c = componentColor(colorName)
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.background(c)
|
||||
@@ -166,11 +153,27 @@ fun AppearanceEditorSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.heightForRows(itemCount: Int, columns: Int): Modifier {
|
||||
val rows = (itemCount + columns - 1) / columns
|
||||
// ~56dp per cell including spacing; gives a non-scrolling grid inside the sheet.
|
||||
return this.height((rows * 56).dp)
|
||||
/**
|
||||
* Lays out [items] in a non-lazy grid of fixed [columns], sizing to its content so it can live
|
||||
* inside a vertically scrolling container without a fixed height. Empty trailing cells are padded
|
||||
* with spacers so cells keep equal widths on the final row.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> GridRows(
|
||||
items: List<T>,
|
||||
columns: Int,
|
||||
cell: @Composable RowScope.(T) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items.chunked(columns).forEach { rowItems ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
rowItems.forEach { cell(it) }
|
||||
repeat(columns - rowItems.size) {
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package app.voltplan.cable.ui.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Auto-advancing onboarding illustration carousel. Mirrors iOS `OnboardingCarouselView`:
|
||||
* cycles through the images every 8s with a horizontal slide. A single image stays static.
|
||||
*/
|
||||
@Composable
|
||||
fun OnboardingCarousel(
|
||||
@DrawableRes images: List<Int>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (images.isEmpty()) return
|
||||
var index by remember(images) { mutableIntStateOf(0) }
|
||||
|
||||
if (images.size > 1) {
|
||||
LaunchedEffect(images) {
|
||||
while (true) {
|
||||
delay(8_000)
|
||||
index = (index + 1) % images.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = index,
|
||||
transitionSpec = {
|
||||
slideInHorizontally(tween(800)) { it } togetherWith
|
||||
slideOutHorizontally(tween(800)) { -it }
|
||||
},
|
||||
label = "onboarding-carousel",
|
||||
modifier = modifier,
|
||||
) { i ->
|
||||
Image(
|
||||
painter = painterResource(images[i]),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package app.voltplan.cable.ui.components
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
@@ -29,6 +31,7 @@ fun OnboardingInfo(
|
||||
onPrimary: () -> Unit,
|
||||
secondaryLabel: String? = null,
|
||||
onSecondary: (() -> Unit)? = null,
|
||||
@DrawableRes images: List<Int> = emptyList(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
@@ -36,7 +39,11 @@ fun OnboardingInfo(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (images.isNotEmpty()) {
|
||||
OnboardingCarousel(images = images, modifier = Modifier.fillMaxWidth().height(200.dp))
|
||||
} else {
|
||||
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(56.dp))
|
||||
}
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
@@ -42,6 +42,7 @@ import app.voltplan.cable.CableApplication
|
||||
import app.voltplan.cable.R
|
||||
import app.voltplan.cable.analytics.Analytics
|
||||
import app.voltplan.cable.library.ComponentLibraryItem
|
||||
import app.voltplan.cable.library.ComponentLibraryType
|
||||
import app.voltplan.cable.library.ComponentLibraryViewModel
|
||||
import app.voltplan.cable.ui.components.LoadIcon
|
||||
import app.voltplan.cable.ui.theme.SysBlue
|
||||
@@ -51,18 +52,28 @@ import app.voltplan.cable.ui.theme.SysOrange
|
||||
@Composable
|
||||
fun ComponentLibraryScreen(
|
||||
targetSystemId: String?,
|
||||
libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
|
||||
onBack: () -> Unit,
|
||||
onOpenSystem: (String) -> Unit,
|
||||
onOpenBatteryEditor: (String, String) -> Unit = { _, _ -> },
|
||||
onOpenChargerEditor: (String, String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as CableApplication
|
||||
val vm: ComponentLibraryViewModel = viewModel(
|
||||
factory = viewModelFactory { initializer { ComponentLibraryViewModel(app) } },
|
||||
key = "library-${libraryType.typeValue}",
|
||||
factory = viewModelFactory { initializer { ComponentLibraryViewModel(app, libraryType) } },
|
||||
)
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Analytics.log("Component Library Opened", mapOf("source" to if (targetSystemId != null) "system" else "systems-list"))
|
||||
Analytics.log(
|
||||
"Component Library Opened",
|
||||
mapOf(
|
||||
"source" to if (targetSystemId != null) "system" else "systems-list",
|
||||
"type" to libraryType.typeValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -99,10 +110,18 @@ fun ComponentLibraryScreen(
|
||||
}
|
||||
else -> LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(state.filtered, key = { it.id }) { item ->
|
||||
LibraryRow(item) {
|
||||
vm.select(item, targetSystemId) { navigateId ->
|
||||
LibraryRow(item, libraryType) {
|
||||
when (libraryType) {
|
||||
ComponentLibraryType.LOAD -> vm.select(item, targetSystemId) { navigateId ->
|
||||
if (navigateId != null) onOpenSystem(navigateId) else onBack()
|
||||
}
|
||||
ComponentLibraryType.BATTERY -> vm.selectBattery(item, targetSystemId) { systemId, batteryId ->
|
||||
onOpenBatteryEditor(systemId, batteryId)
|
||||
}
|
||||
ComponentLibraryType.CHARGER -> vm.selectCharger(item, targetSystemId) { systemId, chargerId ->
|
||||
onOpenChargerEditor(systemId, chargerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,17 +140,22 @@ private fun Centered(content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryRow(item: ComponentLibraryItem, onClick: () -> Unit) {
|
||||
private fun LibraryRow(item: ComponentLibraryItem, libraryType: ComponentLibraryType, onClick: () -> Unit) {
|
||||
val fallbackIcon = when (libraryType) {
|
||||
ComponentLibraryType.LOAD -> "bolt"
|
||||
ComponentLibraryType.BATTERY -> "battery.100"
|
||||
ComponentLibraryType.CHARGER -> "bolt.fill"
|
||||
}
|
||||
Surface(modifier = Modifier.fillMaxWidth(), onClick = onClick, color = MaterialTheme.colorScheme.surface) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
LoadIcon(item.iconURL, "bolt", SysBlue, 44.dp)
|
||||
LoadIcon(item.iconURL, fallbackIcon, SysBlue, 44.dp)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.localizedName, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.titleSmall)
|
||||
val details = listOfNotNull(item.voltageLabel, item.powerLabel, item.currentLabel)
|
||||
val details = item.detailLabels(libraryType)
|
||||
Text(
|
||||
if (details.isEmpty()) stringResource(R.string.library_details_coming) else details.joinToString(" • "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -110,6 +110,9 @@ fun CalculatorScreen(systemId: String, loadId: String?, onBack: () -> Unit) {
|
||||
Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
||||
@@ -69,6 +69,7 @@ fun ComponentsTab(
|
||||
onPrimary = onNewLoad,
|
||||
secondaryLabel = stringResource(R.string.loads_empty_library),
|
||||
onSecondary = onOpenLibrary,
|
||||
images = listOf(R.drawable.onboarding_coffee, R.drawable.onboarding_router, R.drawable.onboarding_charger),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import app.voltplan.cable.ui.batteries.BatteryEditorScreen
|
||||
import app.voltplan.cable.ui.bom.BillOfMaterialsScreen
|
||||
import app.voltplan.cable.library.ComponentLibraryType
|
||||
import app.voltplan.cable.ui.chargers.ChargerEditorScreen
|
||||
import app.voltplan.cable.ui.library.ComponentLibraryScreen
|
||||
import app.voltplan.cable.ui.loads.CalculatorScreen
|
||||
@@ -22,11 +23,17 @@ object Routes {
|
||||
const val BATTERY = "battery/{systemId}?batteryId={batteryId}"
|
||||
const val CHARGER = "charger/{systemId}?chargerId={chargerId}"
|
||||
const val BOM = "bom/{systemId}"
|
||||
const val LIBRARY = "library?systemId={systemId}"
|
||||
const val LIBRARY = "library?systemId={systemId}&type={type}"
|
||||
const val SETTINGS = "settings"
|
||||
|
||||
fun system(id: String) = "system/$id"
|
||||
fun library(systemId: String? = null) = "library" + (systemId?.let { "?systemId=$it" } ?: "")
|
||||
fun library(systemId: String? = null, type: String = "load"): String {
|
||||
val params = buildList {
|
||||
systemId?.let { add("systemId=$it") }
|
||||
add("type=$type")
|
||||
}
|
||||
return "library?" + params.joinToString("&")
|
||||
}
|
||||
fun calculator(systemId: String, loadId: String? = null) =
|
||||
"calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "")
|
||||
fun battery(systemId: String, batteryId: String? = null) =
|
||||
@@ -64,7 +71,7 @@ fun CableNavHost() {
|
||||
onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) },
|
||||
onNewCharger = { nav.navigate(Routes.charger(systemId)) },
|
||||
onOpenBom = { nav.navigate(Routes.bom(systemId)) },
|
||||
onOpenLibrary = { nav.navigate(Routes.library(systemId)) },
|
||||
onOpenLibrary = { type -> nav.navigate(Routes.library(systemId, type.typeValue)) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,15 +129,27 @@ fun CableNavHost() {
|
||||
|
||||
composable(
|
||||
Routes.LIBRARY,
|
||||
arguments = listOf(navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null }),
|
||||
arguments = listOf(
|
||||
navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null },
|
||||
navArgument("type") { type = NavType.StringType; nullable = true; defaultValue = "load" },
|
||||
),
|
||||
) { entry ->
|
||||
ComponentLibraryScreen(
|
||||
targetSystemId = entry.arguments?.getString("systemId"),
|
||||
libraryType = ComponentLibraryType.fromArg(entry.arguments?.getString("type")),
|
||||
onBack = { nav.popBackStack() },
|
||||
onOpenSystem = { systemId ->
|
||||
nav.popBackStack()
|
||||
nav.navigate(Routes.system(systemId))
|
||||
},
|
||||
onOpenBatteryEditor = { systemId, batteryId ->
|
||||
nav.popBackStack()
|
||||
nav.navigate(Routes.battery(systemId, batteryId))
|
||||
},
|
||||
onOpenChargerEditor = { systemId, chargerId ->
|
||||
nav.popBackStack()
|
||||
nav.navigate(Routes.charger(systemId, chargerId))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ fun OverviewTab(
|
||||
onAddCharger: () -> Unit,
|
||||
onOpenLibrary: () -> Unit,
|
||||
onOpenBom: () -> Unit,
|
||||
onSelectLoads: () -> Unit,
|
||||
onSelectBatteries: () -> Unit,
|
||||
onSelectChargers: () -> Unit,
|
||||
onSetRuntimeGoal: (Double?) -> Unit,
|
||||
onSetChargeGoal: (Double?) -> Unit,
|
||||
) {
|
||||
@@ -104,9 +107,9 @@ fun OverviewTab(
|
||||
}
|
||||
}
|
||||
|
||||
LoadsCard(state, m, onAddLoad, onOpenLibrary)
|
||||
BatteriesCard(state, m, onAddBattery)
|
||||
ChargersCard(state, m, onAddCharger)
|
||||
LoadsCard(state, m, onAddLoad, onOpenLibrary, onSelectLoads)
|
||||
BatteriesCard(state, m, onAddBattery, onSelectBatteries)
|
||||
ChargersCard(state, m, onAddCharger, onSelectChargers)
|
||||
}
|
||||
|
||||
goalEditor?.let { kind ->
|
||||
@@ -163,9 +166,10 @@ private fun MetricRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewCard(title: String, content: @Composable () -> Unit) {
|
||||
private fun OverviewCard(title: String, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
) {
|
||||
@@ -177,8 +181,8 @@ private fun OverviewCard(title: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.loads_overview_header_title)) {
|
||||
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit, onSelect: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.loads_overview_header_title), onClick = if (state.loads.isEmpty()) null else onSelect) {
|
||||
if (state.loads.isEmpty()) {
|
||||
Text(stringResource(R.string.overview_loads_empty_title), fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.overview_loads_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -196,8 +200,8 @@ private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Uni
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.battery_bank_header_title)) {
|
||||
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit, onSelect: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.battery_bank_header_title), onClick = if (state.batteries.isEmpty()) null else onSelect) {
|
||||
if (state.batteries.isEmpty()) {
|
||||
Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium)
|
||||
Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) }
|
||||
@@ -212,8 +216,8 @@ private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: ()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.overview_chargers_header_title)) {
|
||||
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit, onSelect: () -> Unit) {
|
||||
OverviewCard(stringResource(R.string.overview_chargers_header_title), onClick = if (state.chargers.isEmpty()) null else onSelect) {
|
||||
if (state.chargers.isEmpty()) {
|
||||
Text(stringResource(R.string.overview_chargers_empty_title), fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.overview_chargers_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
@@ -13,11 +13,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.filled.BatteryFull
|
||||
import androidx.compose.material.icons.filled.Bolt as BoltFilled
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Layers
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.BatteryFull
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.Dashboard
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.IosShare
|
||||
import androidx.compose.material.icons.outlined.PictureAsPdf
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -39,6 +41,7 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import app.voltplan.cable.CableApplication
|
||||
import app.voltplan.cable.R
|
||||
import app.voltplan.cable.library.ComponentLibraryType
|
||||
import app.voltplan.cable.ui.LocalUnitSettings
|
||||
import app.voltplan.cable.ui.batteries.BatteriesTab
|
||||
import app.voltplan.cable.ui.chargers.ChargersTab
|
||||
@@ -48,7 +51,11 @@ import app.voltplan.cable.ui.overview.OverviewTab
|
||||
import app.voltplan.cable.ui.sfSymbol
|
||||
import app.voltplan.cable.ui.systemIconOptions
|
||||
import app.voltplan.cable.ui.theme.componentColor
|
||||
import app.voltplan.cable.data.ReviewPrompt
|
||||
import app.voltplan.cable.pdf.SystemDiagram
|
||||
import app.voltplan.cable.pdf.SystemOverviewPdf
|
||||
import android.widget.Toast
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -82,7 +89,7 @@ fun SystemDetailScreen(
|
||||
onEditCharger: (String) -> Unit,
|
||||
onNewCharger: () -> Unit,
|
||||
onOpenBom: () -> Unit,
|
||||
onOpenLibrary: () -> Unit,
|
||||
onOpenLibrary: (ComponentLibraryType) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as CableApplication
|
||||
@@ -98,8 +105,15 @@ fun SystemDetailScreen(
|
||||
var tab by rememberSaveableTab()
|
||||
var showSystemEditor by remember { mutableStateOf(false) }
|
||||
var showOverviewMenu by remember { mutableStateOf(false) }
|
||||
var exporting by remember { mutableStateOf(false) }
|
||||
val system = state.system
|
||||
|
||||
// Switch to the matching tab before opening an editor, so returning from the
|
||||
// editor lands on that tab with the newly created component visible.
|
||||
val newLoad = { tab = ComponentTab.COMPONENTS; onNewLoad() }
|
||||
val newBattery = { tab = ComponentTab.BATTERIES; onNewBattery() }
|
||||
val newCharger = { tab = ComponentTab.CHARGERS; onNewCharger() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -132,28 +146,56 @@ fun SystemDetailScreen(
|
||||
actions = {
|
||||
when (tab) {
|
||||
ComponentTab.OVERVIEW -> {
|
||||
if (exporting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).padding(end = 12.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = { showOverviewMenu = true }) {
|
||||
Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.overview_share_pdf))
|
||||
Icon(Icons.Outlined.IosShare, contentDescription = stringResource(R.string.overview_share_pdf))
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = showOverviewMenu, onDismissRequest = { showOverviewMenu = false }) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.overview_share_diagram)) },
|
||||
onClick = {
|
||||
showOverviewMenu = false
|
||||
scope.launch {
|
||||
exporting = true
|
||||
var failed = false
|
||||
SystemDiagram.exportAndShare(context, state, unitSystem) {
|
||||
failed = true
|
||||
Toast.makeText(context, R.string.overview_share_diagram_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
exporting = false
|
||||
if (!failed) ReviewPrompt.registerSuccessfulExport(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
leadingIcon = { Icon(Icons.Outlined.PictureAsPdf, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.overview_share_pdf)) },
|
||||
onClick = {
|
||||
showOverviewMenu = false
|
||||
scope.launch {
|
||||
exporting = true
|
||||
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
|
||||
exporting = false
|
||||
ReviewPrompt.registerSuccessfulExport(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
ComponentTab.COMPONENTS -> IconButton(onClick = onNewLoad) {
|
||||
ComponentTab.COMPONENTS -> IconButton(onClick = newLoad) {
|
||||
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
|
||||
}
|
||||
ComponentTab.BATTERIES -> IconButton(onClick = onNewBattery) {
|
||||
ComponentTab.BATTERIES -> IconButton(onClick = newBattery) {
|
||||
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
|
||||
}
|
||||
ComponentTab.CHARGERS -> IconButton(onClick = onNewCharger) {
|
||||
ComponentTab.CHARGERS -> IconButton(onClick = newCharger) {
|
||||
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
|
||||
}
|
||||
}
|
||||
@@ -162,10 +204,10 @@ fun SystemDetailScreen(
|
||||
},
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavTab(tab, ComponentTab.OVERVIEW, Icons.Outlined.Dashboard, stringResource(R.string.tab_overview)) { tab = it; vm.logTabChange(it.analytics) }
|
||||
NavTab(tab, ComponentTab.COMPONENTS, Icons.Outlined.Layers, stringResource(R.string.tab_components)) { tab = it; vm.logTabChange(it.analytics) }
|
||||
NavTab(tab, ComponentTab.BATTERIES, Icons.Outlined.BatteryFull, stringResource(R.string.tab_batteries)) { tab = it; vm.logTabChange(it.analytics) }
|
||||
NavTab(tab, ComponentTab.CHARGERS, Icons.Outlined.Bolt, stringResource(R.string.tab_chargers)) { tab = it; vm.logTabChange(it.analytics) }
|
||||
NavTab(tab, ComponentTab.OVERVIEW, Icons.Filled.Dashboard, stringResource(R.string.tab_overview)) { tab = it; vm.logTabChange(it.analytics) }
|
||||
NavTab(tab, ComponentTab.COMPONENTS, Icons.Filled.Layers, stringResource(R.string.tab_components)) { tab = it; vm.logTabChange(it.analytics) }
|
||||
NavTab(tab, ComponentTab.BATTERIES, Icons.Filled.BatteryFull, stringResource(R.string.tab_batteries)) { tab = it; vm.logTabChange(it.analytics) }
|
||||
NavTab(tab, ComponentTab.CHARGERS, Icons.Filled.BoltFilled, stringResource(R.string.tab_chargers)) { tab = it; vm.logTabChange(it.analytics) }
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
@@ -174,11 +216,14 @@ fun SystemDetailScreen(
|
||||
ComponentTab.OVERVIEW -> OverviewTab(
|
||||
state = state,
|
||||
unitSystem = unitSystem,
|
||||
onAddLoad = onNewLoad,
|
||||
onAddBattery = onNewBattery,
|
||||
onAddCharger = onNewCharger,
|
||||
onOpenLibrary = onOpenLibrary,
|
||||
onAddLoad = newLoad,
|
||||
onAddBattery = newBattery,
|
||||
onAddCharger = newCharger,
|
||||
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
|
||||
onOpenBom = { vm.logBomOpened(); onOpenBom() },
|
||||
onSelectLoads = { tab = ComponentTab.COMPONENTS; vm.logTabChange(ComponentTab.COMPONENTS.analytics) },
|
||||
onSelectBatteries = { tab = ComponentTab.BATTERIES; vm.logTabChange(ComponentTab.BATTERIES.analytics) },
|
||||
onSelectChargers = { tab = ComponentTab.CHARGERS; vm.logTabChange(ComponentTab.CHARGERS.analytics) },
|
||||
onSetRuntimeGoal = vm::setRuntimeGoal,
|
||||
onSetChargeGoal = vm::setChargeGoal,
|
||||
)
|
||||
@@ -186,20 +231,22 @@ fun SystemDetailScreen(
|
||||
state = state,
|
||||
unitSystem = unitSystem,
|
||||
onOpenLoad = onOpenLoad,
|
||||
onNewLoad = onNewLoad,
|
||||
onOpenLibrary = onOpenLibrary,
|
||||
onNewLoad = newLoad,
|
||||
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
|
||||
onDeleteLoad = vm::deleteLoad,
|
||||
)
|
||||
ComponentTab.BATTERIES -> BatteriesTab(
|
||||
state = state,
|
||||
onEditBattery = onEditBattery,
|
||||
onNewBattery = onNewBattery,
|
||||
onNewBattery = newBattery,
|
||||
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.BATTERY) },
|
||||
onDeleteBattery = vm::deleteBattery,
|
||||
)
|
||||
ComponentTab.CHARGERS -> ChargersTab(
|
||||
state = state,
|
||||
onEditCharger = onEditCharger,
|
||||
onNewCharger = onNewCharger,
|
||||
onNewCharger = newCharger,
|
||||
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.CHARGER) },
|
||||
onDeleteCharger = vm::deleteCharger,
|
||||
)
|
||||
}
|
||||
@@ -248,4 +295,4 @@ private fun RowScope.NavTab(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberSaveableTab() = remember { mutableStateOf(ComponentTab.OVERVIEW) }
|
||||
private fun rememberSaveableTab() = rememberSaveable { mutableStateOf(ComponentTab.OVERVIEW) }
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -16,7 +17,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.ChevronRight
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Button
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import app.voltplan.cable.R
|
||||
import app.voltplan.cable.ui.components.OnboardingCarousel
|
||||
import app.voltplan.cable.ui.sfSymbol
|
||||
import app.voltplan.cable.ui.theme.componentColor
|
||||
|
||||
@@ -174,7 +175,10 @@ private fun SystemsOnboarding(modifier: Modifier = Modifier, onCreate: (String)
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Outlined.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp))
|
||||
OnboardingCarousel(
|
||||
images = listOf(R.drawable.onboarding_van, R.drawable.onboarding_cabin, R.drawable.onboarding_boat),
|
||||
modifier = Modifier.fillMaxWidth().height(220.dp),
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 28 KiB |
BIN
android/app/src/main/res/drawable-night-nodpi/onboarding_van.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_battery.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_boat.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_cabin.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_charger.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_coffee.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_router.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
android/app/src/main/res/drawable-nodpi/onboarding_van.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@@ -1,10 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Simple lightning bolt mark on the adaptive foreground safe zone. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M60,28 L42,58 L54,58 L48,80 L70,48 L57,48 Z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
@@ -5,6 +5,7 @@
|
||||
<!-- Actions -->
|
||||
<string name="action_add">Hinzufügen</string>
|
||||
<string name="action_back">Zurück</string>
|
||||
<string name="action_save">Speichern</string>
|
||||
<string name="action_delete">Löschen</string>
|
||||
|
||||
<!-- Systems -->
|
||||
@@ -177,6 +178,8 @@
|
||||
<string name="overview_chargers_empty_subtitle">Füge Landstrom-, DC-DC- oder Solarladegeräte hinzu, um deine Ladeleistung zu verstehen.</string>
|
||||
<string name="overview_chargers_empty_create">Ladegerät hinzufügen</string>
|
||||
<string name="overview_share_pdf">Vollständiger Bericht (PDF)</string>
|
||||
<string name="overview_share_diagram">Schaltplan</string>
|
||||
<string name="overview_share_diagram_error">Schaltplan konnte nicht erstellt werden. Überprüfe deine Internetverbindung.</string>
|
||||
|
||||
<!-- Goal editor steppers -->
|
||||
<string name="goal_days">Tage</string>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- Actions -->
|
||||
<string name="action_add">Añadir</string>
|
||||
<string name="action_back">Atrás</string>
|
||||
<string name="action_save">Guardar</string>
|
||||
<string name="action_delete">Eliminar</string>
|
||||
|
||||
<!-- Systems -->
|
||||
@@ -177,6 +178,8 @@
|
||||
<string name="overview_chargers_empty_subtitle">Añade cargadores de toma de puerto, DC-DC o solares para conocer tu capacidad de carga.</string>
|
||||
<string name="overview_chargers_empty_create">Añadir cargador</string>
|
||||
<string name="overview_share_pdf">Informe completo (PDF)</string>
|
||||
<string name="overview_share_diagram">Diagrama de cableado</string>
|
||||
<string name="overview_share_diagram_error">No se pudo generar el diagrama. Comprueba tu conexión a Internet.</string>
|
||||
|
||||
<!-- Goal editor steppers -->
|
||||
<string name="goal_days">Días</string>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- Actions -->
|
||||
<string name="action_add">Ajouter</string>
|
||||
<string name="action_back">Retour</string>
|
||||
<string name="action_save">Enregistrer</string>
|
||||
<string name="action_delete">Supprimer</string>
|
||||
|
||||
<!-- Systems -->
|
||||
@@ -177,6 +178,8 @@
|
||||
<string name="overview_chargers_empty_subtitle">Ajoutez des chargeurs secteur, DC-DC ou solaires pour comprendre votre capacité de charge.</string>
|
||||
<string name="overview_chargers_empty_create">Ajouter un chargeur</string>
|
||||
<string name="overview_share_pdf">Rapport complet (PDF)</string>
|
||||
<string name="overview_share_diagram">Schéma de câblage</string>
|
||||
<string name="overview_share_diagram_error">Impossible de générer le schéma. Vérifiez votre connexion Internet.</string>
|
||||
|
||||
<!-- Goal editor steppers -->
|
||||
<string name="goal_days">Jours</string>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- Actions -->
|
||||
<string name="action_add">Toevoegen</string>
|
||||
<string name="action_back">Terug</string>
|
||||
<string name="action_save">Opslaan</string>
|
||||
<string name="action_delete">Verwijderen</string>
|
||||
|
||||
<!-- Systems -->
|
||||
@@ -177,6 +178,8 @@
|
||||
<string name="overview_chargers_empty_subtitle">Voeg walstroom-, DC-DC- of zonneladers toe om je laadcapaciteit te begrijpen.</string>
|
||||
<string name="overview_chargers_empty_create">Lader toevoegen</string>
|
||||
<string name="overview_share_pdf">Volledig rapport (PDF)</string>
|
||||
<string name="overview_share_diagram">Bedradingsschema</string>
|
||||
<string name="overview_share_diagram_error">Diagram kon niet worden gegenereerd. Controleer je internetverbinding.</string>
|
||||
|
||||
<!-- Goal editor steppers -->
|
||||
<string name="goal_days">Dagen</string>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#519098</color>
|
||||
<color name="ic_launcher_background">#F4FEF6</color>
|
||||
</resources>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- Actions -->
|
||||
<string name="action_add">Add</string>
|
||||
<string name="action_back">Back</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="action_delete">Delete</string>
|
||||
|
||||
<!-- Systems -->
|
||||
@@ -177,6 +178,8 @@
|
||||
<string name="overview_chargers_empty_subtitle">Add shore power, DC-DC, or solar chargers to understand your charging capacity.</string>
|
||||
<string name="overview_chargers_empty_create">Add Charger</string>
|
||||
<string name="overview_share_pdf">Full Report (PDF)</string>
|
||||
<string name="overview_share_diagram">Wiring Diagram</string>
|
||||
<string name="overview_share_diagram_error">Could not generate diagram. Check your internet connection.</string>
|
||||
|
||||
<!-- Goal editor steppers -->
|
||||
<string name="goal_days">Days</string>
|
||||
|
||||
@@ -14,6 +14,7 @@ okhttp = "4.12.0"
|
||||
serialization = "1.7.3"
|
||||
retrofitSerialization = "1.0.0"
|
||||
coil = "2.7.0"
|
||||
playReview = "2.0.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -39,6 +40,7 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "playReview" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
14
screenshot.config
Normal file
@@ -0,0 +1,14 @@
|
||||
# Screenshot configuration for Cable (VoltPlan)
|
||||
|
||||
SCHEME="CableScreenshots"
|
||||
APP_BUNDLE_ID="app.voltplan.CableApp"
|
||||
UITEST_BUNDLE_ID="com.yuzuhub.CableUITestsScreenshot"
|
||||
OUTPUT_DIR="Shots/Screenshots"
|
||||
|
||||
LANGUAGES=(de fr en es nl)
|
||||
|
||||
# Format: "Simulator Name|Runtime|Slug|Device Type ID"
|
||||
DEVICE_MATRIX=(
|
||||
"iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
|
||||
"iPad Pro Screenshot|26.4|ipad-pro-13-inch-m4|com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M4-8GB"
|
||||
)
|
||||
318
shooter.sh
@@ -1,102 +1,288 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCHEME="CableScreenshots"
|
||||
# Kill all child processes on Ctrl-C
|
||||
trap 'printf "\n\033[31m ✘\033[0m Interrupted — stopping all jobs...\n"; kill 0; exit 130' INT TERM
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
# Override these via environment variables, a config file, or CLI argument.
|
||||
#
|
||||
# Config file format (shell):
|
||||
# SCHEME="MyAppScreenshots"
|
||||
# APP_BUNDLE_ID="com.example.myapp"
|
||||
# UITEST_BUNDLE_ID="com.example.myapp.UITests"
|
||||
# LANGUAGES=(en de fr)
|
||||
# DEVICE_MATRIX=(
|
||||
# "iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
|
||||
# )
|
||||
# OUTPUT_DIR="Shots/Screenshots"
|
||||
|
||||
CONFIG_FILE="${1:-./screenshot.config}"
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Required — must be set in config or environment
|
||||
SCHEME="${SCHEME:?Set SCHEME in $CONFIG_FILE or environment}"
|
||||
APP_BUNDLE_ID="${APP_BUNDLE_ID:?Set APP_BUNDLE_ID in $CONFIG_FILE or environment}"
|
||||
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-}"
|
||||
|
||||
# Optional with defaults
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-Shots/Screenshots}"
|
||||
DERIVED_DATA="${DERIVED_DATA:-DerivedData-Screenshots}"
|
||||
RESET_SIMULATOR="${RESET_SIMULATOR:-1}"
|
||||
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}"
|
||||
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}"
|
||||
PARALLEL="${PARALLEL:-1}"
|
||||
VERBOSE="${VERBOSE:-0}"
|
||||
STATUS_BAR_TIME="${STATUS_BAR_TIME:-9:41}"
|
||||
|
||||
if [[ -z "${LANGUAGES+x}" ]]; then
|
||||
LANGUAGES=(en)
|
||||
fi
|
||||
|
||||
if [[ -z "${DEVICE_MATRIX+x}" ]]; then
|
||||
DEVICE_MATRIX=(
|
||||
"iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
|
||||
)
|
||||
fi
|
||||
|
||||
# ─── Pretty output ────────────────────────────────────────────────────────────
|
||||
BOLD="\033[1m"
|
||||
DIM="\033[2m"
|
||||
GREEN="\033[32m"
|
||||
RED="\033[31m"
|
||||
CYAN="\033[36m"
|
||||
RST="\033[0m"
|
||||
|
||||
ok() { printf "${GREEN} ✔${RST} %s\n" "$*"; }
|
||||
fail() { printf "${RED} ✘${RST} %s\n" "$*"; }
|
||||
info() { printf "${CYAN} ›${RST} %s\n" "$*"; }
|
||||
step() { printf "\n${BOLD}%s${RST}\n" "$*"; }
|
||||
|
||||
is_truthy() {
|
||||
case "$1" in
|
||||
1|true|TRUE|yes|YES|on|ON) return 0 ;;
|
||||
0|false|FALSE|no|NO|off|OFF|"") return 1 ;;
|
||||
*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
DEVICE_MATRIX=(
|
||||
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
|
||||
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
|
||||
)
|
||||
|
||||
# ─── Dependency check ─────────────────────────────────────────────────────────
|
||||
command -v xcparse >/dev/null 2>&1 || {
|
||||
echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2
|
||||
fail "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1)
|
||||
# ─── Simulator helpers ────────────────────────────────────────────────────────
|
||||
|
||||
resolve_udid() {
|
||||
local name="$1"; local os="$2"
|
||||
if [[ -n "$os" ]]; then
|
||||
# Prefer Shutdown state for a clean start
|
||||
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \
|
||||
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}'
|
||||
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' '
|
||||
/^--.*--$/ { in_section = ($0 ~ o) }
|
||||
in_section && $0 ~ n { print $2; exit }
|
||||
'
|
||||
else
|
||||
xcrun simctl list devices | awk -v n="$name" -F '[()]' \
|
||||
'$0 ~ n && /Shutdown/ {print $2; exit}'
|
||||
xcrun simctl list devices | awk -v n="$name" -F '[()]' '
|
||||
$0 ~ n { print $2; exit }
|
||||
'
|
||||
fi
|
||||
}
|
||||
|
||||
for device_entry in "${DEVICE_MATRIX[@]}"; do
|
||||
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry"
|
||||
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ==="
|
||||
|
||||
for lang in de fr en es nl; do
|
||||
echo "Resetting simulator for a clean start..."
|
||||
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
|
||||
ensure_simulator() {
|
||||
local name="$1"; local runtime="$2"; local device_type="$3"
|
||||
local udid
|
||||
udid=$(resolve_udid "$name" "$runtime")
|
||||
if [[ -n "$udid" ]]; then
|
||||
echo "$udid"
|
||||
return 0
|
||||
fi
|
||||
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
if is_truthy "$RESET_SIMULATOR"; then
|
||||
xcrun simctl erase "$UDID"
|
||||
else
|
||||
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do
|
||||
if [[ -n "$bundle" ]]; then
|
||||
xcrun simctl terminate "$UDID" "$bundle" || true
|
||||
xcrun simctl uninstall "$UDID" "$bundle" || true
|
||||
local runtime_id
|
||||
runtime_id=$(xcrun simctl list runtimes | awk -v r="iOS $runtime" '$0 ~ r {for(i=1;i<=NF;i++) if($i ~ /com\.apple/) {print $i; exit}}')
|
||||
if [[ -z "$runtime_id" ]]; then
|
||||
fail "Runtime iOS $runtime not found" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
echo "Running screenshots for $lang"
|
||||
|
||||
info "Creating simulator: $name (iOS $runtime)" >&2
|
||||
udid=$(xcrun simctl create "$name" "$device_type" "$runtime_id")
|
||||
echo "$udid"
|
||||
}
|
||||
|
||||
prepare_simulator() {
|
||||
local udid="$1" lang="$2"
|
||||
local region
|
||||
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
|
||||
|
||||
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
|
||||
fi
|
||||
xcrun simctl boot "$udid" 2>/dev/null || true
|
||||
|
||||
xcrun simctl boot "$UDID" || true
|
||||
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
|
||||
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
xcrun simctl boot "$UDID"
|
||||
xcrun simctl status_bar booted override \
|
||||
--time "9:41" \
|
||||
# Language & locale
|
||||
xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
|
||||
xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
|
||||
|
||||
# Suppress notifications
|
||||
xcrun simctl spawn "$udid" defaults write com.apple.springboard DoNotDisturb -bool true 2>/dev/null || true
|
||||
xcrun simctl spawn "$udid" defaults write com.apple.generativeexperiences.corefollowup \
|
||||
DateOfLastAppleIntelligenceReadinessCFU -date "2020-01-01T00:00:00Z" 2>/dev/null || true
|
||||
xcrun simctl spawn "$udid" defaults write com.apple.corefollow DisableFollowUp -bool true 2>/dev/null || true
|
||||
xcrun simctl spawn "$udid" defaults write com.apple.corefollowup DisableFollowUp -bool true 2>/dev/null || true
|
||||
|
||||
# Reboot for language change
|
||||
xcrun simctl shutdown "$udid" 2>/dev/null || true
|
||||
xcrun simctl boot "$udid"
|
||||
|
||||
# Clean status bar
|
||||
xcrun simctl status_bar "$udid" override \
|
||||
--time "$STATUS_BAR_TIME" \
|
||||
--batteryState charged --batteryLevel 100 \
|
||||
--wifiBars 3
|
||||
--wifiBars 3 \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
xcrun simctl spawn "$UDID" defaults write com.apple.springboard DoNotDisturb -bool true
|
||||
# ─── Build ────────────────────────────────────────────────────────────────────
|
||||
|
||||
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
|
||||
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
|
||||
build_for_testing() {
|
||||
local device_entry="$1"
|
||||
IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry"
|
||||
|
||||
local udid
|
||||
udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type")
|
||||
if [[ -z "$udid" ]]; then
|
||||
fail "Could not resolve or create simulator for $dev_name"; return 1
|
||||
fi
|
||||
|
||||
info "Building $dev_name..."
|
||||
local log_file="/tmp/shooter-build-${dev_slug}.log"
|
||||
if xcodebuild build-for-testing \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "id=$udid" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-quiet \
|
||||
> "$log_file" 2>&1; then
|
||||
ok "Build succeeded ($dev_name)"
|
||||
else
|
||||
fail "Build failed ($dev_name)"
|
||||
cat "$log_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Test one language ────────────────────────────────────────────────────────
|
||||
|
||||
run_one_language() {
|
||||
local device_entry="$1"
|
||||
local lang="$2"
|
||||
IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry"
|
||||
|
||||
local label="${dev_slug}/${lang}"
|
||||
|
||||
local udid
|
||||
udid=$(resolve_udid "$dev_name" "$dev_runtime")
|
||||
if [[ -z "$udid" ]]; then
|
||||
udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type")
|
||||
fi
|
||||
if [[ -z "$udid" ]]; then
|
||||
fail "[$label] Could not resolve simulator"; return 1
|
||||
fi
|
||||
|
||||
# Reset
|
||||
xcrun simctl shutdown "$udid" 2>/dev/null || true
|
||||
if is_truthy "$RESET_SIMULATOR"; then
|
||||
xcrun simctl erase "$udid"
|
||||
else
|
||||
for bundle in "$APP_BUNDLE_ID" ${UITEST_BUNDLE_ID:+"$UITEST_BUNDLE_ID"}; do
|
||||
xcrun simctl terminate "$udid" "$bundle" 2>/dev/null || true
|
||||
xcrun simctl uninstall "$udid" "$bundle" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
udid=$(resolve_udid "$dev_name" "$dev_runtime")
|
||||
prepare_simulator "$udid" "$lang"
|
||||
|
||||
local bundle="results-${dev_slug}-${lang}.xcresult"
|
||||
local outdir="${OUTPUT_DIR}/${dev_slug}/$lang"
|
||||
rm -rf "$bundle" "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
|
||||
xcodebuild test \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "id=$UDID" \
|
||||
-resultBundlePath "$bundle"
|
||||
local log_file="/tmp/shooter-test-${dev_slug}-${lang}.log"
|
||||
info "[$label] Testing..."
|
||||
|
||||
xcparse screenshots "$bundle" "$outdir"
|
||||
echo "Exported screenshots to $outdir"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
done
|
||||
local test_exit=0
|
||||
xcodebuild test-without-building \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "id=$udid" \
|
||||
-derivedDataPath "$DERIVED_DATA" \
|
||||
-resultBundlePath "$bundle" \
|
||||
> "$log_file" 2>&1 || test_exit=$?
|
||||
|
||||
if [[ $test_exit -eq 0 ]]; then
|
||||
xcparse screenshots "$bundle" "$outdir" > /dev/null 2>&1
|
||||
local count
|
||||
count=$(find "$outdir" -name '*.png' 2>/dev/null | wc -l | tr -d ' ')
|
||||
ok "[$label] ${count} screenshots"
|
||||
else
|
||||
fail "[$label] Tests failed"
|
||||
grep -E '(error:|FAIL|failed)' "$log_file" | head -20
|
||||
if is_truthy "$VERBOSE"; then
|
||||
printf "${DIM}"; cat "$log_file"; printf "${RST}"
|
||||
else
|
||||
printf " ${DIM}Full log: $log_file${RST}\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
xcrun simctl shutdown "$udid" 2>/dev/null || true
|
||||
return $test_exit
|
||||
}
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
step "Configuration"
|
||||
info "Scheme: $SCHEME"
|
||||
info "Bundle ID: $APP_BUNDLE_ID"
|
||||
info "Output: $OUTPUT_DIR"
|
||||
info "Languages: ${LANGUAGES[*]}"
|
||||
info "Devices: ${#DEVICE_MATRIX[@]}"
|
||||
|
||||
step "Building for testing"
|
||||
for device_entry in "${DEVICE_MATRIX[@]}"; do
|
||||
build_for_testing "$device_entry"
|
||||
done
|
||||
|
||||
step "Running screenshot tests"
|
||||
total_runs=$((${#DEVICE_MATRIX[@]} * ${#LANGUAGES[@]}))
|
||||
info "${#DEVICE_MATRIX[@]} devices × ${#LANGUAGES[@]} languages = ${total_runs} runs"
|
||||
|
||||
failed=0
|
||||
|
||||
if is_truthy "$PARALLEL"; then
|
||||
info "Devices run in parallel"
|
||||
pids=()
|
||||
for device_entry in "${DEVICE_MATRIX[@]}"; do
|
||||
(
|
||||
for lang in "${LANGUAGES[@]}"; do
|
||||
run_one_language "$device_entry" "$lang" || true
|
||||
done
|
||||
) &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || failed=$((failed + 1))
|
||||
done
|
||||
else
|
||||
for device_entry in "${DEVICE_MATRIX[@]}"; do
|
||||
for lang in "${LANGUAGES[@]}"; do
|
||||
run_one_language "$device_entry" "$lang" || failed=$((failed + 1))
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
step "Done"
|
||||
total_screenshots=$(find "$OUTPUT_DIR" -name '*.png' 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [[ $failed -eq 0 ]]; then
|
||||
ok "All runs passed — ${total_screenshots} screenshots in ${OUTPUT_DIR}/"
|
||||
else
|
||||
fail "${failed} run(s) had errors — ${total_screenshots} screenshots in ${OUTPUT_DIR}/"
|
||||
printf " ${DIM}Re-run with VERBOSE=1 for full logs${RST}\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||