Compare commits

..

10 Commits

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:05:24 +02:00
23b117bfe2 Polish editors, previews, persistence and docs
Cross-platform refinements to appearance/battery/charger editors, tabs
and navigation, plus persistence, screenshot previews and CLAUDE.md docs.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:03:38 +02:00
d68170bc87 Bump build version to 85
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:39:46 +02:00
b448a1b4f7 Automate App Store screenshots via XCUITests
Replace preview-based screenshot rendering with simulator XCUITests
driven by shooter.sh (per-language locale + status bar overrides,
xcparse extraction). Add batteries-list accessibility identifier and
update CLAUDE.md screenshot docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:39:46 +02:00
8b30fabaa2 Route all affiliate links through voltplan.app
Replace client-side Amazon affiliate resolution with server-side
redirects via voltplan.app/{componentId} and voltplan.app/search?q=.
Remove AmazonAffiliate, affiliate link fetching, and per-model
affiliate fields in favor of a single componentID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 10:39:46 +02:00
96 changed files with 2442 additions and 1069 deletions

View File

@@ -2,6 +2,15 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Two apps in this repo
- **iOS** (`Cable/`) — SwiftUI + SwiftData. **This document describes the iOS app unless stated otherwise.**
- **Android** (`android/`) — a native Kotlin/Jetpack Compose port that mirrors every iOS feature (package root `app.voltplan.cable`, Room persistence, same Aptabase analytics). See **`android/README.md`** for its architecture and build instructions before working on it.
Behavior and data shape are meant to stay in sync across both — when changing a user-facing feature on one platform, check the other. See [Export Options](#export-options) for one such cross-platform contract.
**Apply every instruction to both the iOS and Android versions unless explicitly told otherwise.** Any feature, fix, or change requested without naming a platform must land on both apps and stay behaviorally in sync.
## Build & Test Commands ## Build & Test Commands
Use the **Xcode MCP server** tools instead of `xcodebuild` CLI: Use the **Xcode MCP server** tools instead of `xcodebuild` CLI:
@@ -106,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. - Use `String(localized:defaultValue:)`**not** `NSLocalizedString`. The `defaultValue` serves as English fallback and avoids showing raw keys when a Localizable.strings entry is missing.
- When adding new user-facing strings, add translations to **all 5** Localizable.strings files immediately. - When adding new user-facing strings, add translations to **all 5** Localizable.strings files immediately.
## PDF Export Pattern ## Export Options
Three export options, available from the Overview tab's share menu plus the BOM sheet. **Keep iOS and Android (`android/.../pdf/`) in sync — they must offer the same exports.**
1. **System Overview (PDF)** — summary + a full-page wiring diagram + per-entity tables.
2. **Bill of Materials (PDF)** — categorized component list.
3. **Wiring Diagram (PNG)** — standalone diagram image.
The wiring diagram (used both as the standalone PNG and the Overview PDF's diagram page) is fetched from the shared **VoltPlan diagram API** (`POST https://voltplan.app/api/diagram/generate`, JSON payload of system/loads/batteries/chargers, returns PNG). Both platforms send the identical payload shape; falls back gracefully when the API is unreachable (iOS draws a Core Graphics diagram; Android omits the PDF page / shows an error toast for the standalone export).
### PDF Export Pattern (iOS)
PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is: PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is:
- **Exporter struct** (e.g. `SystemOverviewPDFExporter`, `SystemBillOfMaterialsPDFExporter`) with snapshot data types — keeps Core Graphics rendering isolated from SwiftUI/SwiftData. - **Exporter struct** (e.g. `SystemOverviewPDFExporter`, `SystemBillOfMaterialsPDFExporter`) with snapshot data types — keeps Core Graphics rendering isolated from SwiftUI/SwiftData.
- **ShareSheet** triggered via `@State` item binding in the parent view. - **ShareSheet** triggered via `@State` item binding in the parent view.
- **Toolbar button** (not inline content) for the export action. - **Toolbar button** (not inline content) for the export action.
## 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` (044, grouped by view × 5 languages). ### Running screenshots
2. Output goes to `Shots/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): Requires `xcparse`: `brew install chargepoint/xcparse/xcparse`.
- 04: Overview tab
- 59: Components tab
- 1014: Batteries tab
- 1519: Chargers tab
- 2024: Systems list
- 2529: Parts Library
- 3034: Load editor (CalculatorView)
- 3539: Battery editor
- 4044: Charger editor
### 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. 1. `shooter.sh` reads `screenshot.config` for scheme, bundle ID, devices, and languages.
- **ComponentLibraryView** accepts an optional `viewModel` parameter via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to inject mock data and avoid network dependency. 2. Builds once with `build-for-testing`, then runs `test-without-building` per language.
- All preview wrappers inject `.modelContainer(container)` (in-memory) and `.environmentObject(UnitSystemSettings())`. 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 ## Model Definitions

View File

@@ -418,7 +418,7 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 84; CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = RE4FXQ754N; DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -454,7 +454,7 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 84; CURRENT_PROJECT_VERSION = 85;
DEVELOPMENT_TEAM = RE4FXQ754N; DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;

View File

@@ -1,77 +1,21 @@
import Foundation import Foundation
enum AmazonAffiliate { enum VoltPlanRedirect {
private static let fallbackDomain = "www.amazon.com" private static let baseURL = "https://voltplan.app"
private static let fallbackTag: String? = "voltplan-20"
private static let domainsByCountry: [String: String] = [ static func componentURL(id: String) -> URL? {
"US": "www.amazon.com", var components = URLComponents(string: "\(baseURL)/\(id)")
"DE": "www.amazon.de", components?.queryItems = [URLQueryItem(name: "src", value: "cable")]
"FR": "www.amazon.fr", return components?.url
"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"
]
// Configure Amazon affiliate tracking IDs by country code. static func searchURL(query: String) -> URL? {
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? {
guard !query.isEmpty else { return nil } guard !query.isEmpty else { return nil }
var components = URLComponents(string: "\(baseURL)/search")
var components = URLComponents() components?.queryItems = [
components.scheme = "https" URLQueryItem(name: "q", value: query),
components.host = domain(for: countryCode) URLQueryItem(name: "src", value: "cable"),
components.path = "/s" ]
return components?.url
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
} }
} }

View File

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

View File

@@ -249,6 +249,7 @@ struct BatteriesView: View {
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.environment(\.editMode, $editMode) .environment(\.editMode, $editMode)
.accessibilityIdentifier("batteries-list")
} }
private func batteryRow(for battery: SavedBattery) -> some View { private func batteryRow(for battery: SavedBattery) -> some View {

View File

@@ -44,6 +44,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
var iconName: String var iconName: String
var colorName: String var colorName: String
var system: ElectricalSystem var system: ElectricalSystem
var componentID: String?
init( init(
id: UUID = UUID(), id: UUID = UUID(),
@@ -58,7 +59,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
maximumTemperatureCelsius: Double = 60, maximumTemperatureCelsius: Double = 60,
iconName: String = "battery.100", iconName: String = "battery.100",
colorName: String = "blue", colorName: String = "blue",
system: ElectricalSystem system: ElectricalSystem,
componentID: String? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -73,6 +75,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
self.iconName = iconName self.iconName = iconName
self.colorName = colorName self.colorName = colorName
self.system = system self.system = system
self.componentID = componentID
} }
init(savedBattery: SavedBattery, system: ElectricalSystem) { init(savedBattery: SavedBattery, system: ElectricalSystem) {
@@ -95,6 +98,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
self.iconName = savedBattery.iconName self.iconName = savedBattery.iconName
self.colorName = savedBattery.colorName self.colorName = savedBattery.colorName
self.system = system self.system = system
self.componentID = savedBattery.componentID
} }
var energyWattHours: Double { var energyWattHours: Double {
@@ -137,6 +141,7 @@ struct BatteryConfiguration: Identifiable, Hashable {
savedBattery.iconName = iconName savedBattery.iconName = iconName
savedBattery.colorName = colorName savedBattery.colorName = colorName
savedBattery.system = system savedBattery.system = system
savedBattery.componentID = componentID
savedBattery.timestamp = Date() savedBattery.timestamp = Date()
} }
} }
@@ -154,7 +159,8 @@ extension BatteryConfiguration {
lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius && lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius &&
lhs.chemistry == rhs.chemistry && lhs.chemistry == rhs.chemistry &&
lhs.iconName == rhs.iconName && lhs.iconName == rhs.iconName &&
lhs.colorName == rhs.colorName lhs.colorName == rhs.colorName &&
lhs.componentID == rhs.componentID
} }
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
@@ -170,5 +176,6 @@ extension BatteryConfiguration {
hasher.combine(chemistry) hasher.combine(chemistry)
hasher.combine(iconName) hasher.combine(iconName)
hasher.combine(colorName) hasher.combine(colorName)
hasher.combine(componentID)
} }
} }

View File

@@ -16,8 +16,7 @@ class SavedBattery {
var iconName: String = "battery.100" var iconName: String = "battery.100"
var colorName: String = "blue" var colorName: String = "blue"
var system: ElectricalSystem? var system: ElectricalSystem?
var affiliateURLString: String? var componentID: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var timestamp: Date var timestamp: Date
@@ -35,8 +34,7 @@ class SavedBattery {
iconName: String = "battery.100", iconName: String = "battery.100",
colorName: String = "blue", colorName: String = "blue",
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
timestamp: Date = Date() timestamp: Date = Date()
) { ) {
@@ -53,8 +51,7 @@ class SavedBattery {
self.iconName = iconName self.iconName = iconName
self.colorName = colorName self.colorName = colorName
self.system = system self.system = system
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.timestamp = timestamp self.timestamp = timestamp
} }

View File

@@ -12,6 +12,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
var colorName: String var colorName: String
var system: ElectricalSystem var system: ElectricalSystem
var powerSourceType: SavedCharger.PowerSourceType var powerSourceType: SavedCharger.PowerSourceType
var componentID: String?
var remoteIconURLString: String?
init( init(
id: UUID = UUID(), id: UUID = UUID(),
@@ -23,7 +25,9 @@ struct ChargerConfiguration: Identifiable, Hashable {
iconName: String = "bolt.fill", iconName: String = "bolt.fill",
colorName: String = "orange", colorName: String = "orange",
system: ElectricalSystem, system: ElectricalSystem,
powerSourceType: SavedCharger.PowerSourceType = .shore powerSourceType: SavedCharger.PowerSourceType = .shore,
componentID: String? = nil,
remoteIconURLString: String? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -35,6 +39,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
self.colorName = colorName self.colorName = colorName
self.system = system self.system = system
self.powerSourceType = powerSourceType self.powerSourceType = powerSourceType
self.componentID = componentID
self.remoteIconURLString = remoteIconURLString
} }
init(savedCharger: SavedCharger, system: ElectricalSystem) { init(savedCharger: SavedCharger, system: ElectricalSystem) {
@@ -48,6 +54,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
self.colorName = savedCharger.colorName self.colorName = savedCharger.colorName
self.system = system self.system = system
self.powerSourceType = savedCharger.sourceType self.powerSourceType = savedCharger.sourceType
self.componentID = savedCharger.componentID
self.remoteIconURLString = savedCharger.remoteIconURLString
} }
var effectivePowerWatts: Double { var effectivePowerWatts: Double {
@@ -67,6 +75,8 @@ struct ChargerConfiguration: Identifiable, Hashable {
savedCharger.colorName = colorName savedCharger.colorName = colorName
savedCharger.system = system savedCharger.system = system
savedCharger.powerSourceType = powerSourceType.rawValue savedCharger.powerSourceType = powerSourceType.rawValue
savedCharger.componentID = componentID
savedCharger.remoteIconURLString = remoteIconURLString
savedCharger.timestamp = Date() savedCharger.timestamp = Date()
} }
} }

View File

@@ -7,6 +7,7 @@ struct ChargersView: View {
let onAdd: () -> Void let onAdd: () -> Void
let onEdit: (SavedCharger) -> Void let onEdit: (SavedCharger) -> Void
let onDelete: (IndexSet) -> Void let onDelete: (IndexSet) -> Void
let onBrowseLibrary: () -> Void
private struct SummaryMetric: Identifiable { private struct SummaryMetric: Identifiable {
let id: String let id: String
@@ -94,13 +95,15 @@ struct ChargersView: View {
editMode: Binding<EditMode> = .constant(.inactive), editMode: Binding<EditMode> = .constant(.inactive),
onAdd: @escaping () -> Void = {}, onAdd: @escaping () -> Void = {},
onEdit: @escaping (SavedCharger) -> Void = { _ in }, onEdit: @escaping (SavedCharger) -> Void = { _ in },
onDelete: @escaping (IndexSet) -> Void = { _ in } onDelete: @escaping (IndexSet) -> Void = { _ in },
onBrowseLibrary: @escaping () -> Void = {}
) { ) {
self.system = system self.system = system
self.chargers = chargers self.chargers = chargers
self.onAdd = onAdd self.onAdd = onAdd
self.onEdit = onEdit self.onEdit = onEdit
self.onDelete = onDelete self.onDelete = onDelete
self.onBrowseLibrary = onBrowseLibrary
_editMode = editMode _editMode = editMode
} }
@@ -244,7 +247,8 @@ struct ChargersView: View {
private var emptyState: some View { private var emptyState: some View {
OnboardingInfoView( OnboardingInfoView(
configuration: .charger(), configuration: .charger(),
onPrimaryAction: onAdd onPrimaryAction: onAdd,
onSecondaryAction: onBrowseLibrary
) )
.padding(.horizontal, 0) .padding(.horizontal, 0)
} }

View File

@@ -14,8 +14,7 @@ final class SavedCharger {
var system: ElectricalSystem? var system: ElectricalSystem?
var timestamp: Date var timestamp: Date
var remoteIconURLString: String? var remoteIconURLString: String?
var affiliateURLString: String? var componentID: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var identifier: String var identifier: String
var powerSourceType: String = "shore" var powerSourceType: String = "shore"
@@ -67,8 +66,7 @@ final class SavedCharger {
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
timestamp: Date = Date(), timestamp: Date = Date(),
remoteIconURLString: String? = nil, remoteIconURLString: String? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString, identifier: String = UUID().uuidString,
powerSourceType: String = "shore" powerSourceType: String = "shore"
@@ -84,8 +82,7 @@ final class SavedCharger {
self.system = system self.system = system
self.timestamp = timestamp self.timestamp = timestamp
self.remoteIconURLString = remoteIconURLString self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier self.identifier = identifier
self.powerSourceType = powerSourceType self.powerSourceType = powerSourceType

View File

@@ -127,8 +127,7 @@ class SavedLoad {
var dailyUsageHours: Double = 24.0 var dailyUsageHours: Double = 24.0
var system: ElectricalSystem? var system: ElectricalSystem?
var remoteIconURLString: String? = nil var remoteIconURLString: String? = nil
var affiliateURLString: String? = nil var componentID: String? = nil
var affiliateCountryCode: String? = nil
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var identifier: String = UUID().uuidString var identifier: String = UUID().uuidString
@@ -146,8 +145,7 @@ class SavedLoad {
dailyUsageHours: Double = 24.0, dailyUsageHours: Double = 24.0,
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
remoteIconURLString: String? = nil, remoteIconURLString: String? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString identifier: String = UUID().uuidString
) { ) {
@@ -165,8 +163,7 @@ class SavedLoad {
self.dailyUsageHours = dailyUsageHours self.dailyUsageHours = dailyUsageHours
self.system = system self.system = system
self.remoteIconURLString = remoteIconURLString self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier self.identifier = identifier
} }

View File

@@ -51,16 +51,14 @@ struct CalculatorView: View {
struct AffiliateLinkInfo: Identifiable, Equatable { struct AffiliateLinkInfo: Identifiable, Equatable {
let id: String let id: String
let affiliateURL: URL? let componentID: String?
let buttonTitle: String let buttonTitle: String
let regionName: String?
let countryCode: String?
} }
struct BOMItem: Identifiable, Equatable { struct BOMItem: Identifiable, Equatable {
enum Destination: Equatable { enum Destination: Equatable {
case affiliate(URL) case component(String)
case amazonSearch(String) case search(String)
} }
let id: String let id: String
@@ -391,7 +389,7 @@ struct CalculatorView: View {
private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View { private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View {
BillOfMaterialsView( BillOfMaterialsView(
info: info, info: info,
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL), items: buildBillOfMaterialsItems(componentID: info.componentID),
completedItemIDs: $completedItemIDs completedItemIDs: $completedItemIDs
) )
} }
@@ -467,18 +465,6 @@ struct CalculatorView: View {
} }
private var affiliateLinkInfo: AffiliateLinkInfo { 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 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 name = savedLoad?.name ?? calculator.loadName
let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0) let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0)
@@ -486,10 +472,8 @@ struct CalculatorView: View {
return AffiliateLinkInfo( return AffiliateLinkInfo(
id: identifier, id: identifier,
affiliateURL: affiliateURL, componentID: savedLoad?.componentID,
buttonTitle: buttonTitle, buttonTitle: buttonTitle
regionName: regionName,
countryCode: countryCode
) )
} }
@@ -756,7 +740,7 @@ struct CalculatorView: View {
Button { Button {
AnalyticsTracker.log("Review Parts Tapped", properties: [ AnalyticsTracker.log("Review Parts Tapped", properties: [
"load": info.id, "load": info.id,
"has_affiliate": info.affiliateURL != nil, "has_component": info.componentID != nil,
]) ])
presentedAffiliateLink = info presentedAffiliateLink = info
} label: { } label: {
@@ -773,7 +757,7 @@ struct CalculatorView: View {
} }
.buttonStyle(.plain) .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.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.") : 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) Text(description)
@@ -785,7 +769,7 @@ struct CalculatorView: View {
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
private func buildBillOfMaterialsItems(deviceLink: URL?) -> [BOMItem] { private func buildBillOfMaterialsItems(componentID: String?) -> [BOMItem] {
let unitSystem = unitSettings.unitSystem let unitSystem = unitSettings.unitSystem
let lengthValue = calculator.length let lengthValue = calculator.length
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit) let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
@@ -847,7 +831,7 @@ struct CalculatorView: View {
title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName, title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName,
detail: powerDetail, detail: powerDetail,
iconSystemName: "bolt.fill", iconSystemName: "bolt.fill",
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase), destination: componentID.map { .component($0) } ?? .search(deviceQueryBase),
isPrimaryComponent: true isPrimaryComponent: true
) )
) )
@@ -858,7 +842,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"), title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: cableDetail, detail: cableDetail,
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery), destination: .search(redCableQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -869,7 +853,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"), title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: cableDetail, detail: cableDetail,
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery), destination: .search(blackCableQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -880,7 +864,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"), title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"),
detail: fuseDetail, detail: fuseDetail,
iconSystemName: "bolt.shield", iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery), destination: .search(fuseQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -891,7 +875,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"), title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"),
detail: cableShoesDetail, detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver", iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery), destination: .search(terminalQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -1495,11 +1479,11 @@ private struct BillOfMaterialsView: View {
return return
} }
if let destinationURL { if let destinationURL {
let isAffiliate: Bool let isComponent: Bool
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false } if case .component = item.destination { isComponent = true } else { isComponent = false }
AnalyticsTracker.log("BOM Item Tapped", properties: [ AnalyticsTracker.log("BOM Item Tapped", properties: [
"item": item.title, "item": item.title,
"is_affiliate": isAffiliate, "is_component": isComponent,
"domain": destinationURL.host ?? "unknown", "domain": destinationURL.host ?? "unknown",
"load": info.id, "load": info.id,
]) ])
@@ -1543,10 +1527,10 @@ private struct BillOfMaterialsView: View {
private func destinationURL(for item: CalculatorView.BOMItem) -> URL? { private func destinationURL(for item: CalculatorView.BOMItem) -> URL? {
switch item.destination { switch item.destination {
case .affiliate(let url): case .component(let id):
return url return VoltPlanRedirect.componentURL(id: id)
case .amazonSearch(let query): case .search(let query):
return AmazonAffiliate.searchURL(query: query, countryCode: info.countryCode) return VoltPlanRedirect.searchURL(query: query)
} }
} }
} }

View File

@@ -1,12 +1,17 @@
import SwiftUI import SwiftUI
struct ComponentLibraryItem: Identifiable, Equatable { enum ComponentLibraryType: String, Identifiable, CaseIterable {
struct AffiliateLink: Identifiable, Equatable { case load
let id: String case battery
let url: URL case charger
let country: String?
var id: String { rawValue }
/// PocketBase filter expression selecting this type.
var filterValue: String { "type='\(rawValue)'" }
} }
struct ComponentLibraryItem: Identifiable, Equatable {
let id: String let id: String
let name: String let name: String
let translations: [String: String] let translations: [String: String]
@@ -15,8 +20,8 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let watt: Double? let watt: Double?
let dutyCyclePercent: Double? let dutyCyclePercent: Double?
let defaultUtilizationFactorPercent: Double? let defaultUtilizationFactorPercent: Double?
let componentCategory: String?
let iconURL: URL? let iconURL: URL?
let affiliateLinks: [AffiliateLink]
var displayVoltage: Double? { var displayVoltage: Double? {
voltageIn ?? voltageOut voltageIn ?? voltageOut
@@ -27,6 +32,52 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return power / voltage return power / voltage
} }
/// Battery capacity derived from stored energy (Wh) and nominal voltage.
var capacityAmpHours: Double? {
guard let energy = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
return energy / voltage
}
/// Charger output current derived from rated power and output voltage.
var outputCurrent: Double? {
guard let power = watt, let voltage = voltageOut ?? displayVoltage, voltage > 0 else { return nil }
return power / voltage
}
var capacityLabel: String? {
guard let capacity = capacityAmpHours else { return nil }
return String(format: "%.0fAh", capacity)
}
var energyLabel: String? {
guard let energy = watt else { return nil }
return String(format: "%.0fWh", energy)
}
var voltageRangeLabel: String? {
if let input = voltageIn, let output = voltageOut {
return String(format: "%.0fV → %.0fV", input, output)
}
return voltageLabel
}
var outputCurrentLabel: String? {
guard let current = outputCurrent else { return nil }
return String(format: "%.1fA", current)
}
/// Detail metrics shown in a library row, tailored to the component type.
func detailLabels(for type: ComponentLibraryType) -> [String] {
switch type {
case .load:
return [voltageLabel, powerLabel, currentLabel].compactMap { $0 }
case .battery:
return [voltageLabel, capacityLabel, energyLabel].compactMap { $0 }
case .charger:
return [voltageRangeLabel, outputCurrentLabel, powerLabel].compactMap { $0 }
}
}
var voltageLabel: String? { var voltageLabel: String? {
guard let voltage = displayVoltage else { return nil } guard let voltage = displayVoltage else { return nil }
return String(format: "%.1fV", voltage) return String(format: "%.1fV", voltage)
@@ -65,36 +116,10 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return translation(for: locale) return translation(for: locale)
} }
var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.region?.identifier)
}
func localizedName(for locale: Locale) -> String { func localizedName(for locale: Locale) -> String {
translation(for: locale) ?? name 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? { private func translation(for locale: Locale) -> String? {
guard !translations.isEmpty else { return nil } 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 baseURL = URL(string: "https://base.voltplan.app")!
private let urlSession: URLSession private let urlSession: URLSession
let libraryType: ComponentLibraryType
init(urlSession: URLSession = .shared) { init(libraryType: ComponentLibraryType = .load, urlSession: URLSession = .shared) {
self.libraryType = libraryType
self.urlSession = urlSession self.urlSession = urlSession
} }
init(previewItems: [ComponentLibraryItem]) { init(previewItems: [ComponentLibraryItem], libraryType: ComponentLibraryType = .load) {
self.libraryType = libraryType
self.urlSession = .shared self.urlSession = .shared
self.items = previewItems self.items = previewItems
self.isLoading = false self.isLoading = false
@@ -249,11 +277,11 @@ final class ComponentLibraryViewModel: ObservableObject {
resolvingAgainstBaseURL: false resolvingAgainstBaseURL: false
) )
components?.queryItems = [ components?.queryItems = [
URLQueryItem(name: "filter", value: "(type='load')"), URLQueryItem(name: "filter", value: "(\(libraryType.filterValue))"),
URLQueryItem(name: "sort", value: "+name"), URLQueryItem(name: "sort", value: "+name"),
URLQueryItem( URLQueryItem(
name: "fields", name: "fields",
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor" value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor,component_category"
), ),
URLQueryItem(name: "page", value: "\(page)"), URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "perPage", value: "\(perPage)") URLQueryItem(name: "perPage", value: "\(perPage)")
@@ -289,7 +317,6 @@ final class ComponentLibraryViewModel: ObservableObject {
page += 1 page += 1
} }
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
let mappedItems = allRecords.map { record in let mappedItems = allRecords.map { record in
ComponentLibraryItem( ComponentLibraryItem(
id: record.id, id: record.id,
@@ -300,8 +327,8 @@ final class ComponentLibraryViewModel: ObservableObject {
watt: record.watt, watt: record.watt,
dutyCyclePercent: record.dutyCycle, dutyCyclePercent: record.dutyCycle,
defaultUtilizationFactorPercent: record.defaultUtilizationFactor, defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
iconURL: iconURL(for: record), componentCategory: record.componentCategory,
affiliateLinks: affiliateLinksByComponent[record.id] ?? [] iconURL: iconURL(for: record)
) )
} }
for item in mappedItems { for item in mappedItems {
@@ -314,110 +341,6 @@ final class ComponentLibraryViewModel: ObservableObject {
return mappedItems 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? { private func iconURL(for record: PocketBaseRecord) -> URL? {
guard let icon = record.icon else { return nil } guard let icon = record.icon else { return nil }
@@ -451,6 +374,7 @@ final class ComponentLibraryViewModel: ObservableObject {
let watt: Double? let watt: Double?
let dutyCycle: Double? let dutyCycle: Double?
let defaultUtilizationFactor: Double? let defaultUtilizationFactor: Double?
let componentCategory: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
@@ -463,6 +387,7 @@ final class ComponentLibraryViewModel: ObservableObject {
case watt case watt
case dutyCycle = "duty_cycle" case dutyCycle = "duty_cycle"
case defaultUtilizationFactor = "default_utilization_factor" case defaultUtilizationFactor = "default_utilization_factor"
case componentCategory = "component_category"
} }
struct TranslationsContainer: Decodable { struct TranslationsContainer: Decodable {
@@ -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 { struct ComponentLibraryView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ComponentLibraryViewModel @StateObject private var viewModel: ComponentLibraryViewModel
@State private var searchText: String = "" @State private var searchText: String = ""
private let libraryType: ComponentLibraryType
let onSelect: (ComponentLibraryItem) -> Void let onSelect: (ComponentLibraryItem) -> Void
init(onSelect: @escaping (ComponentLibraryItem) -> Void) { init(libraryType: ComponentLibraryType = .load, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel()) self.libraryType = libraryType
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel(libraryType: libraryType))
self.onSelect = onSelect self.onSelect = onSelect
} }
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) { init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self.libraryType = viewModel.libraryType
self._viewModel = StateObject(wrappedValue: viewModel) self._viewModel = StateObject(wrappedValue: viewModel)
self.onSelect = onSelect self.onSelect = onSelect
} }
@@ -614,7 +530,7 @@ struct ComponentLibraryView: View {
onSelect(item) onSelect(item)
dismiss() dismiss()
} label: { } label: {
ComponentRow(item: item) ComponentRow(item: item, libraryType: libraryType)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -659,6 +575,7 @@ struct ComponentLibraryView: View {
private struct ComponentRow: View { private struct ComponentRow: View {
let item: ComponentLibraryItem let item: ComponentLibraryItem
let libraryType: ComponentLibraryType
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -680,15 +597,23 @@ private struct ComponentRow: View {
private var iconView: some View { private var iconView: some View {
LoadIconView( LoadIconView(
remoteIconURLString: item.iconURL?.absoluteString, remoteIconURLString: item.iconURL?.absoluteString,
fallbackSystemName: "bolt", fallbackSystemName: fallbackIcon,
fallbackColor: Color.blue.opacity(0.15), fallbackColor: Color.blue.opacity(0.15),
size: 44 size: 44
) )
} }
private var fallbackIcon: String {
switch libraryType {
case .load: return "bolt"
case .battery: return "battery.100"
case .charger: return "bolt.fill"
}
}
@ViewBuilder @ViewBuilder
private var detailLine: some View { private var detailLine: some View {
let labels = [item.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 } let labels = item.detailLabels(for: libraryType)
if labels.isEmpty { if labels.isEmpty {
Text("Details coming soon") Text("Details coming soon")

View File

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

View File

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

110
Cable/ReviewPrompt.swift Normal file
View File

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

View File

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

View File

@@ -111,8 +111,8 @@ struct SystemBillOfMaterialsView: View {
private struct Item: Identifiable { private struct Item: Identifiable {
enum Destination { enum Destination {
case affiliate(URL) case component(String)
case amazonSearch(String) case search(String)
} }
let id: String let id: String
@@ -346,6 +346,7 @@ struct SystemBillOfMaterialsView: View {
) )
await MainActor.run { await MainActor.run {
activeShareItem = ExportedPDFShareItem(url: url) activeShareItem = ExportedPDFShareItem(url: url)
ReviewPrompt.registerSuccessfulExport()
} }
} catch { } catch {
await MainActor.run { await MainActor.run {
@@ -436,12 +437,12 @@ struct SystemBillOfMaterialsView: View {
} }
private func trackAffiliateTap(item: Item, url: URL) { private func trackAffiliateTap(item: Item, url: URL) {
let isAffiliate: Bool let isComponent: Bool
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false } if case .component = item.destination { isComponent = true } else { isComponent = false }
AnalyticsTracker.log("BOM Item Tapped", properties: [ AnalyticsTracker.log("BOM Item Tapped", properties: [
"item": item.title, "item": item.title,
"category": item.category.rawValue, "category": item.category.rawValue,
"is_affiliate": isAffiliate, "is_component": isComponent,
"domain": url.host ?? "unknown", "domain": url.host ?? "unknown",
"system": systemName, "system": systemName,
]) ])
@@ -585,7 +586,6 @@ struct SystemBillOfMaterialsView: View {
cableShoesDetailFormat, cableShoesDetailFormat,
crossSectionLabel.lowercased() crossSectionLabel.lowercased()
) )
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV") let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV")
let deviceQuery = load.name.isEmpty let deviceQuery = load.name.isEmpty
? String(format: deviceFallbackFormat, calculatedPower, load.voltage) ? 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, title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
detail: powerDetail, detail: powerDetail,
iconSystemName: "bolt.fill", iconSystemName: "bolt.fill",
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery), destination: load.componentID.map { .component($0) } ?? .search(deviceQuery),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .components, category: .components,
@@ -636,7 +636,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"), title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: "", detail: "",
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery), destination: .search(redCableQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .cables, category: .cables,
@@ -653,7 +653,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"), title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: "", detail: "",
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery), destination: .search(blackCableQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .cables, category: .cables,
@@ -670,7 +670,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"), title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
detail: fuseDetail, detail: fuseDetail,
iconSystemName: "bolt.shield", iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery), destination: .search(fuseQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .fuses, category: .fuses,
@@ -687,7 +687,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"), title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
detail: cableShoesDetail, detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver", iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery), destination: .search(terminalQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .accessories, category: .accessories,
@@ -712,7 +712,7 @@ struct SystemBillOfMaterialsView: View {
let voltageQuery = max(1, Int(round(battery.nominalVoltage))) let voltageQuery = max(1, Int(round(battery.nominalVoltage)))
let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery") let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery")
let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName) 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") let storageKey = Self.storageKey(for: component, itemID: "battery")
return [ 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, title: battery.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : battery.name,
detail: detail, detail: detail,
iconSystemName: battery.iconName, iconSystemName: battery.iconName,
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query), destination: componentID.map { .component($0) } ?? .search(query),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .batteries, category: .batteries,
@@ -746,7 +746,7 @@ struct SystemBillOfMaterialsView: View {
let voltageQuery = max(1, Int(round(charger.outputVoltage))) let voltageQuery = max(1, Int(round(charger.outputVoltage)))
let currentQuery = max(1, Int(round(charger.maxCurrentAmps))) let currentQuery = max(1, Int(round(charger.maxCurrentAmps)))
let query = "\(voltageQuery)V \(currentQuery)A battery charger" 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") let storageKey = Self.storageKey(for: component, itemID: "charger")
return [ 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, title: charger.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : charger.name,
detail: detail, detail: detail,
iconSystemName: charger.iconName, iconSystemName: charger.iconName,
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query), destination: componentID.map { .component($0) } ?? .search(query),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .components, category: .components,
@@ -772,22 +772,10 @@ struct SystemBillOfMaterialsView: View {
private func destinationURL(for destination: Item.Destination, component: Component) -> URL? { private func destinationURL(for destination: Item.Destination, component: Component) -> URL? {
switch destination { switch destination {
case .affiliate(let url): case .component(let id):
return url return VoltPlanRedirect.componentURL(id: id)
case .amazonSearch(let query): case .search(let query):
let countryCode = affiliateCountryCode(for: component) ?? Locale.current.region?.identifier return VoltPlanRedirect.searchURL(query: query)
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
} }
} }
@@ -905,8 +893,7 @@ struct SystemBillOfMaterialsView: View {
dailyUsageHours: 0, dailyUsageHours: 0,
system: nil, system: nil,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil,
affiliateCountryCode: nil,
bomCompletedItemIDs: [], bomCompletedItemIDs: [],
identifier: UUID().uuidString identifier: UUID().uuidString
) )
@@ -952,8 +939,7 @@ struct SystemBillOfMaterialsView: View {
dailyUsageHours: 0, dailyUsageHours: 0,
system: nil, system: nil,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil,
affiliateCountryCode: nil,
bomCompletedItemIDs: ["component", "cable-red"], bomCompletedItemIDs: ["component", "cable-red"],
identifier: UUID().uuidString identifier: UUID().uuidString
) )

View File

@@ -66,7 +66,6 @@ struct SystemComponentsPersistence {
current = 0 current = 0
} }
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100 let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1 let dailyUsageHours = item.defaultDailyUsageHours ?? 1
@@ -84,8 +83,7 @@ struct SystemComponentsPersistence {
dailyUsageHours: dailyUsageHours, dailyUsageHours: dailyUsageHours,
system: system, system: system,
remoteIconURLString: item.iconURL?.absoluteString, remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString, componentID: item.id
affiliateCountryCode: affiliateLink?.country
) )
context.insert(newLoad) 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( static func makeChargerDraft(
for system: ElectricalSystem, for system: ElectricalSystem,
existingLoads: [SavedLoad], existingLoads: [SavedLoad],
@@ -162,7 +241,8 @@ struct SystemComponentsPersistence {
maximumTemperatureCelsius: configuration.maximumTemperatureCelsius, maximumTemperatureCelsius: configuration.maximumTemperatureCelsius,
iconName: configuration.iconName, iconName: configuration.iconName,
colorName: configuration.colorName, colorName: configuration.colorName,
system: system system: system,
componentID: configuration.componentID
) )
context.insert(newBattery) context.insert(newBattery)
} }
@@ -186,7 +266,9 @@ struct SystemComponentsPersistence {
maxPowerWatts: configuration.maxPowerWatts, maxPowerWatts: configuration.maxPowerWatts,
iconName: configuration.iconName, iconName: configuration.iconName,
colorName: configuration.colorName, colorName: configuration.colorName,
system: system system: system,
remoteIconURLString: configuration.remoteIconURLString,
componentID: configuration.componentID
) )
context.insert(newCharger) context.insert(newCharger)
} }

View File

@@ -372,7 +372,6 @@ struct SystemsView: View {
current = 0 current = 0
} }
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100 let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1 let dailyUsageHours = item.defaultDailyUsageHours ?? 1
@@ -390,8 +389,7 @@ struct SystemsView: View {
dailyUsageHours: dailyUsageHours, dailyUsageHours: dailyUsageHours,
system: system, system: system,
remoteIconURLString: item.iconURL?.absoluteString, remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString, componentID: item.id
affiliateCountryCode: affiliateLink?.country
) )
modelContext.insert(newLoad) modelContext.insert(newLoad)
@@ -549,8 +547,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system1, system: system1,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
let load2 = SavedLoad( let load2 = SavedLoad(
@@ -565,8 +562,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system1, system: system1,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
// Sample loads for system 2 // Sample loads for system 2
@@ -582,8 +578,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system2, system: system2,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
context.insert(load1) context.insert(load1)

View File

@@ -15,7 +15,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let german = Foundation.Locale(identifier: "de_DE") let german = Foundation.Locale(identifier: "de_DE")
@@ -33,7 +32,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let french = Foundation.Locale(identifier: "fr_FR") let french = Foundation.Locale(identifier: "fr_FR")
@@ -54,7 +52,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let languages = ["fr-FR", "de-DE", "es-ES"] let languages = ["fr-FR", "de-DE", "es-ES"]
@@ -72,7 +69,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let spanishMexico = Foundation.Locale(identifier: "es_MX") let spanishMexico = Foundation.Locale(identifier: "es_MX")
@@ -90,7 +86,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let germanSwitzerland = Foundation.Locale(identifier: "de_CH") let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
@@ -108,7 +103,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let french = Foundation.Locale(identifier: "fr_FR") let french = Foundation.Locale(identifier: "fr_FR")
@@ -126,7 +120,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: 0, dutyCyclePercent: 0,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
#expect(item.normalizedDutyCyclePercent == 100) #expect(item.normalizedDutyCyclePercent == 100)
@@ -143,7 +136,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: 50, defaultUtilizationFactorPercent: 50,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
#expect(item.defaultDailyUsageHours == 12) #expect(item.defaultDailyUsageHours == 12)

View File

@@ -125,32 +125,21 @@ final class CableUITestsScreenshot: XCTestCase {
try super.setUpWithError() try super.setUpWithError()
continueAfterFailure = false continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait XCUIDevice.shared.orientation = .portrait
//ensureDoNotDisturbEnabled()
//dismissSystemOverlays()
} }
override func tearDownWithError() throws { // MARK: - Onboarding Screenshots
try super.tearDownWithError()
//dismissSystemOverlays()
}
@MainActor @MainActor
func testOnboardingScreenshots() throws { func testOnboardingScreenshots() throws {
let app = launchApp(arguments: ["--uitest-reset-data"]) let app = launchApp(arguments: ["--uitest-reset-data"])
waitForStability(long: true) // Wait for Apple Intelligence and other system notifications to appear, then dismiss
dismissNotificationBannersIfNeeded() RunLoop.current.run(until: Date().addingTimeInterval(6))
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded() dismissNotificationBannersIfNeeded()
let createSystemButton = app.buttons["create-system-button"] let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8)) XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
waitForStability(long: true)
dismissNotificationBannersIfNeeded() dismissNotificationBannersIfNeeded()
waitForStability(long: true) waitForStability()
takeScreenshot(named: "01-OnboardingSystemsView") takeScreenshot(named: "01-OnboardingSystemsView")
createSystemButton.tap() createSystemButton.tap()
@@ -159,14 +148,15 @@ final class CableUITestsScreenshot: XCTestCase {
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8)) XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary) let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4)) XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
dismissNotificationBannersIfNeeded()
waitForStability()
takeScreenshot(named: "02-OnboardingSystemView") takeScreenshot(named: "02-OnboardingSystemView")
browseLibraryButton.tap() browseLibraryButton.tap()
let libraryCloseButton = app.buttons["library-view-close-button"] let libraryCloseButton = app.buttons["library-view-close-button"]
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8)) 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") takeScreenshot(named: "04-ComponentSelectorView")
libraryCloseButton.tap() libraryCloseButton.tap()
@@ -175,20 +165,130 @@ final class CableUITestsScreenshot: XCTestCase {
let newLoadButton = button(in: app.buttons, for: .defaultLoadName) let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8)) XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
waitForStability(long: true) waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "03-LoadEditorView") takeScreenshot(named: "03-LoadEditorView")
} }
// MARK: - Sample Data Screenshots
@MainActor @MainActor
func testSampleDataScreenshots() throws { 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() let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"] app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch() app.launch()
return app
}
let systemsList = resolvedSystemsList(in: app) // MARK: - Navigation Helpers
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
takeScreenshot(named: "05-SystemsWithSampleData")
private func openFirstSystem(in app: XCUIApplication, systemsList: XCUIElement) {
let firstSystemCell = systemsList.cells.element(boundBy: 0) let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2)) XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
let systemName = firstSystemCell.staticTexts.firstMatch.label let systemName = firstSystemCell.staticTexts.firstMatch.label
@@ -206,68 +306,40 @@ final class CableUITestsScreenshot: XCTestCase {
detailVisible = waitForSystemDetail(named: systemName, in: app) detailVisible = waitForSystemDetail(named: systemName, in: app)
} }
XCTAssertTrue(detailVisible) 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 { // Use .matching + .firstMatch to avoid "multiple matches" error
bringElementIntoView(bomElement, in: app, requireHittable: true) // 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 { let tabButton = button(in: app.buttons, for: key)
bomElement.tap() XCTAssertTrue(tabButton.waitForExistence(timeout: 3))
tapElement(tabButton)
}
private func tapElement(_ element: XCUIElement) {
if element.isHittable {
element.tap()
} else { } 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) // MARK: - Element Resolution
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
}
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement { private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"] let collection = app.collectionViews["systems-list"]
@@ -299,6 +371,62 @@ final class CableUITestsScreenshot: XCTestCase {
return table 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) { private func takeScreenshot(named name: String) {
let screenshot = XCUIScreen.main.screenshot() let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot) let attachment = XCTAttachment(screenshot: screenshot)
@@ -308,52 +436,7 @@ final class CableUITestsScreenshot: XCTestCase {
} }
private func waitForStability(long: Bool = false) { private func waitForStability(long: Bool = false) {
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5)) RunLoop.current.run(until: Date().addingTimeInterval(long ? 3.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
} }
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool { 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 { if app.otherElements["system-overview"].exists {
return true return true
} }
let navBar = app.navigationBars.firstMatch let navBar = app.navigationBars.firstMatch
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists { if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
return true return true
} }
RunLoop.current.run(until: Date().addingTimeInterval(0.25)) RunLoop.current.run(until: Date().addingTimeInterval(0.25))
} }
return app.otherElements["system-overview"].exists return app.otherElements["system-overview"].exists
} }
@@ -395,32 +475,13 @@ final class CableUITestsScreenshot: XCTestCase {
} }
} }
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement { // MARK: - Notification Dismissal
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
}
private func dismissNotificationBannersIfNeeded() { private func dismissNotificationBannersIfNeeded() {
// Try multiple times notifications can appear with a delay
for _ in 0..<3 {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
if banner.waitForExistence(timeout: 1) { guard banner.waitForExistence(timeout: 1) else { return }
if banner.isHittable { if banner.isHittable {
banner.swipeUp() banner.swipeUp()
} else { } else {
@@ -432,6 +493,8 @@ final class CableUITestsScreenshot: XCTestCase {
} }
} }
// MARK: - Localized Element Matching
private func candidateStrings(for key: UIStringKey) -> [String] { private func candidateStrings(for key: UIStringKey) -> [String] {
var values = Set<String>() var values = Set<String>()
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }), 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 { if let others = translations[key]?.values {
values.formUnion(others) values.formUnion(others)
} }
if key == .settings {
values.insert("gearshape")
}
return Array(values) return Array(values)
} }
@@ -465,139 +525,4 @@ final class CableUITestsScreenshot: XCTestCase {
) )
return query.matching(predicate).firstMatch 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()
}
}
} }

View File

@@ -1,53 +1,6 @@
//
// CableUITestsScreenshotLaunchTests.swift
// CableUITestsScreenshot
//
// Created by Stefan Lange-Hegermann on 06.10.25.
//
import XCTest import XCTest
final class CableUITestsScreenshotLaunchTests: XCTestCase { 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 { override class var runsForEachTargetApplicationUIConfiguration: Bool {
false false
@@ -57,110 +10,15 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
continueAfterFailure = false continueAfterFailure = false
} }
@MainActor @MainActor
func testOnboardingLoadsView() throws { func testLaunch() throws {
let app = launchApp(arguments: ["--uitest-reset-data"]) let app = XCUIApplication()
takeScreenshot(name: "01-OnboardingSystemsView") app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
let createSystemButton = app.buttons["create-system-button"] let attachment = XCTAttachment(screenshot: app.screenshot())
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5)) attachment.name = "Launch Screen"
createSystemButton.tap() attachment.lifetime = .keepAlways
takeScreenshot(name: "02-OnboardingLoadsView") add(attachment)
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)
} }
} }

5
android/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,8 +19,10 @@ import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.BatteryFull import androidx.compose.material.icons.outlined.BatteryFull
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.LibraryBooks
import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.Warning import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -63,6 +65,7 @@ fun BatteriesTab(
state: DetailState, state: DetailState,
onEditBattery: (String) -> Unit, onEditBattery: (String) -> Unit,
onNewBattery: () -> Unit, onNewBattery: () -> Unit,
onOpenLibrary: () -> Unit,
onDeleteBattery: (SavedBattery) -> Unit, onDeleteBattery: (SavedBattery) -> Unit,
) { ) {
val batteries = state.batteries val batteries = state.batteries
@@ -73,11 +76,15 @@ fun BatteriesTab(
subtitle = stringResource(R.string.battery_onboarding_subtitle), subtitle = stringResource(R.string.battery_onboarding_subtitle),
primaryLabel = stringResource(R.string.battery_empty_create), primaryLabel = stringResource(R.string.battery_empty_create),
onPrimary = onNewBattery, onPrimary = onNewBattery,
secondaryLabel = stringResource(R.string.loads_empty_library),
onSecondary = onOpenLibrary,
images = listOf(R.drawable.onboarding_battery),
) )
return return
} }
val m = state.metrics val m = state.metrics
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
StatsHeader { StatsHeader {
Text(stringResource(R.string.battery_bank_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.battery_bank_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
@@ -101,12 +108,20 @@ fun BatteriesTab(
} }
} }
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) { LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp)) {
items(batteries, key = { it.id }) { battery -> items(batteries, key = { it.id }) { battery ->
BatteryRow(battery, onClick = { onEditBattery(battery.id) }, onDelete = { onDeleteBattery(battery) }) BatteryRow(battery, onClick = { onEditBattery(battery.id) }, onDelete = { onDeleteBattery(battery) })
} }
} }
} }
ExtendedFloatingActionButton(
onClick = onOpenLibrary,
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
text = { Text(stringResource(R.string.loads_library_button)) },
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
)
}
} }
@Composable @Composable

View File

@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -90,6 +91,9 @@ fun BatteryEditorScreen(systemId: String, batteryId: String?, onBack: () -> Unit
title = { title = {
Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true })
}, },
actions = {
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
},
) )
}, },
) { padding -> ) { padding ->

View File

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

View File

@@ -81,6 +81,9 @@ fun ChargerEditorScreen(systemId: String, chargerId: String?, onBack: () -> Unit
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
}, },
title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) }, title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) },
actions = {
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
},
) )
}, },
) { padding -> ) { padding ->

View File

@@ -2,6 +2,7 @@ package app.voltplan.cable.ui.chargers
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -15,7 +16,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.BatteryChargingFull import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.LibraryBooks
import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -55,6 +58,7 @@ fun ChargersTab(
state: DetailState, state: DetailState,
onEditCharger: (String) -> Unit, onEditCharger: (String) -> Unit,
onNewCharger: () -> Unit, onNewCharger: () -> Unit,
onOpenLibrary: () -> Unit,
onDeleteCharger: (SavedCharger) -> Unit, onDeleteCharger: (SavedCharger) -> Unit,
) { ) {
val chargers = state.chargers val chargers = state.chargers
@@ -65,11 +69,15 @@ fun ChargersTab(
subtitle = stringResource(R.string.chargers_onboarding_subtitle), subtitle = stringResource(R.string.chargers_onboarding_subtitle),
primaryLabel = stringResource(R.string.chargers_onboarding_primary), primaryLabel = stringResource(R.string.chargers_onboarding_primary),
onPrimary = onNewCharger, onPrimary = onNewCharger,
secondaryLabel = stringResource(R.string.loads_empty_library),
onSecondary = onOpenLibrary,
images = listOf(R.drawable.onboarding_charger),
) )
return return
} }
val m = state.metrics val m = state.metrics
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
StatsHeader { StatsHeader {
Text(stringResource(R.string.chargers_summary_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.chargers_summary_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
@@ -85,12 +93,20 @@ fun ChargersTab(
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalChargerPower)} W", stringResource(R.string.chargers_metric_power), SysPink) SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalChargerPower)} W", stringResource(R.string.chargers_metric_power), SysPink)
} }
} }
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) { LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp)) {
items(chargers, key = { it.id }) { charger -> items(chargers, key = { it.id }) { charger ->
ChargerRow(charger, onClick = { onEditCharger(charger.id) }, onDelete = { onDeleteCharger(charger) }) ChargerRow(charger, onClick = { onEditCharger(charger.id) }, onDelete = { onDeleteCharger(charger) })
} }
} }
} }
ExtendedFloatingActionButton(
onClick = onOpenLibrary,
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
text = { Text(stringResource(R.string.loads_library_button)) },
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
)
}
} }
@Composable @Composable

View File

@@ -3,18 +3,17 @@ package app.voltplan.cable.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -69,6 +68,7 @@ fun AppearanceEditorSheet(
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp) .padding(horizontal = 20.dp)
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -113,17 +113,11 @@ fun AppearanceEditorSheet(
extra?.invoke() extra?.invoke()
Text("Icon", style = MaterialTheme.typography.titleSmall) Text("Icon", style = MaterialTheme.typography.titleSmall)
LazyVerticalGrid( GridRows(items = icons, columns = 5) { symbol ->
columns = GridCells.Fixed(5),
modifier = Modifier.fillMaxWidth().heightForRows(icons.size, 5),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
userScrollEnabled = false,
) {
items(icons) { symbol ->
val selected = symbol == icon val selected = symbol == icon
Box( Box(
Modifier Modifier
.weight(1f)
.aspectRatio(1f) .aspectRatio(1f)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant) .background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
@@ -138,20 +132,13 @@ fun AppearanceEditorSheet(
) )
} }
} }
}
Text("Color", style = MaterialTheme.typography.titleSmall) Text("Color", style = MaterialTheme.typography.titleSmall)
LazyVerticalGrid( GridRows(items = curatedColorNames, columns = 6) { colorName ->
columns = GridCells.Fixed(6),
modifier = Modifier.fillMaxWidth().heightForRows(curatedColorNames.size, 6),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
userScrollEnabled = false,
) {
items(curatedColorNames) { colorName ->
val c = componentColor(colorName) val c = componentColor(colorName)
Box( Box(
Modifier Modifier
.weight(1f)
.aspectRatio(1f) .aspectRatio(1f)
.clip(CircleShape) .clip(CircleShape)
.background(c) .background(c)
@@ -167,10 +154,26 @@ fun AppearanceEditorSheet(
} }
} }
} }
}
private fun Modifier.heightForRows(itemCount: Int, columns: Int): Modifier { /**
val rows = (itemCount + columns - 1) / columns * Lays out [items] in a non-lazy grid of fixed [columns], sizing to its content so it can live
// ~56dp per cell including spacing; gives a non-scrolling grid inside the sheet. * inside a vertically scrolling container without a fixed height. Empty trailing cells are padded
return this.height((rows * 56).dp) * with spacers so cells keep equal widths on the final row.
*/
@Composable
private fun <T> GridRows(
items: List<T>,
columns: Int,
cell: @Composable RowScope.(T) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
items.chunked(columns).forEach { rowItems ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
rowItems.forEach { cell(it) }
repeat(columns - rowItems.size) {
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
}
}
}
}
} }

View File

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

View File

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

View File

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

View File

@@ -110,6 +110,9 @@ fun CalculatorScreen(systemId: String, loadId: String?, onBack: () -> Unit) {
Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp)) Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp))
} }
}, },
actions = {
TextButton(onClick = onBack) { Text(stringResource(R.string.action_save)) }
},
) )
}, },
) { padding -> ) { padding ->

View File

@@ -69,6 +69,7 @@ fun ComponentsTab(
onPrimary = onNewLoad, onPrimary = onNewLoad,
secondaryLabel = stringResource(R.string.loads_empty_library), secondaryLabel = stringResource(R.string.loads_empty_library),
onSecondary = onOpenLibrary, onSecondary = onOpenLibrary,
images = listOf(R.drawable.onboarding_coffee, R.drawable.onboarding_router, R.drawable.onboarding_charger),
) )
return return
} }

View File

@@ -8,6 +8,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import app.voltplan.cable.ui.batteries.BatteryEditorScreen import app.voltplan.cable.ui.batteries.BatteryEditorScreen
import app.voltplan.cable.ui.bom.BillOfMaterialsScreen import app.voltplan.cable.ui.bom.BillOfMaterialsScreen
import app.voltplan.cable.library.ComponentLibraryType
import app.voltplan.cable.ui.chargers.ChargerEditorScreen import app.voltplan.cable.ui.chargers.ChargerEditorScreen
import app.voltplan.cable.ui.library.ComponentLibraryScreen import app.voltplan.cable.ui.library.ComponentLibraryScreen
import app.voltplan.cable.ui.loads.CalculatorScreen import app.voltplan.cable.ui.loads.CalculatorScreen
@@ -22,11 +23,17 @@ object Routes {
const val BATTERY = "battery/{systemId}?batteryId={batteryId}" const val BATTERY = "battery/{systemId}?batteryId={batteryId}"
const val CHARGER = "charger/{systemId}?chargerId={chargerId}" const val CHARGER = "charger/{systemId}?chargerId={chargerId}"
const val BOM = "bom/{systemId}" const val BOM = "bom/{systemId}"
const val LIBRARY = "library?systemId={systemId}" const val LIBRARY = "library?systemId={systemId}&type={type}"
const val SETTINGS = "settings" const val SETTINGS = "settings"
fun system(id: String) = "system/$id" fun system(id: String) = "system/$id"
fun library(systemId: String? = null) = "library" + (systemId?.let { "?systemId=$it" } ?: "") fun library(systemId: String? = null, type: String = "load"): String {
val params = buildList {
systemId?.let { add("systemId=$it") }
add("type=$type")
}
return "library?" + params.joinToString("&")
}
fun calculator(systemId: String, loadId: String? = null) = fun calculator(systemId: String, loadId: String? = null) =
"calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "") "calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "")
fun battery(systemId: String, batteryId: String? = null) = fun battery(systemId: String, batteryId: String? = null) =
@@ -64,7 +71,7 @@ fun CableNavHost() {
onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) }, onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) },
onNewCharger = { nav.navigate(Routes.charger(systemId)) }, onNewCharger = { nav.navigate(Routes.charger(systemId)) },
onOpenBom = { nav.navigate(Routes.bom(systemId)) }, onOpenBom = { nav.navigate(Routes.bom(systemId)) },
onOpenLibrary = { nav.navigate(Routes.library(systemId)) }, onOpenLibrary = { type -> nav.navigate(Routes.library(systemId, type.typeValue)) },
) )
} }
@@ -122,15 +129,27 @@ fun CableNavHost() {
composable( composable(
Routes.LIBRARY, Routes.LIBRARY,
arguments = listOf(navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null }), arguments = listOf(
navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument("type") { type = NavType.StringType; nullable = true; defaultValue = "load" },
),
) { entry -> ) { entry ->
ComponentLibraryScreen( ComponentLibraryScreen(
targetSystemId = entry.arguments?.getString("systemId"), targetSystemId = entry.arguments?.getString("systemId"),
libraryType = ComponentLibraryType.fromArg(entry.arguments?.getString("type")),
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
onOpenSystem = { systemId -> onOpenSystem = { systemId ->
nav.popBackStack() nav.popBackStack()
nav.navigate(Routes.system(systemId)) nav.navigate(Routes.system(systemId))
}, },
onOpenBatteryEditor = { systemId, batteryId ->
nav.popBackStack()
nav.navigate(Routes.battery(systemId, batteryId))
},
onOpenChargerEditor = { systemId, chargerId ->
nav.popBackStack()
nav.navigate(Routes.charger(systemId, chargerId))
},
) )
} }

View File

@@ -58,6 +58,9 @@ fun OverviewTab(
onAddCharger: () -> Unit, onAddCharger: () -> Unit,
onOpenLibrary: () -> Unit, onOpenLibrary: () -> Unit,
onOpenBom: () -> Unit, onOpenBom: () -> Unit,
onSelectLoads: () -> Unit,
onSelectBatteries: () -> Unit,
onSelectChargers: () -> Unit,
onSetRuntimeGoal: (Double?) -> Unit, onSetRuntimeGoal: (Double?) -> Unit,
onSetChargeGoal: (Double?) -> Unit, onSetChargeGoal: (Double?) -> Unit,
) { ) {
@@ -104,9 +107,9 @@ fun OverviewTab(
} }
} }
LoadsCard(state, m, onAddLoad, onOpenLibrary) LoadsCard(state, m, onAddLoad, onOpenLibrary, onSelectLoads)
BatteriesCard(state, m, onAddBattery) BatteriesCard(state, m, onAddBattery, onSelectBatteries)
ChargersCard(state, m, onAddCharger) ChargersCard(state, m, onAddCharger, onSelectChargers)
} }
goalEditor?.let { kind -> goalEditor?.let { kind ->
@@ -163,9 +166,10 @@ private fun MetricRow(
} }
@Composable @Composable
private fun OverviewCard(title: String, content: @Composable () -> Unit) { private fun OverviewCard(title: String, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
Surface( Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
) { ) {
@@ -177,8 +181,8 @@ private fun OverviewCard(title: String, content: @Composable () -> Unit) {
} }
@Composable @Composable
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit) { private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit, onSelect: () -> Unit) {
OverviewCard(stringResource(R.string.loads_overview_header_title)) { OverviewCard(stringResource(R.string.loads_overview_header_title), onClick = if (state.loads.isEmpty()) null else onSelect) {
if (state.loads.isEmpty()) { if (state.loads.isEmpty()) {
Text(stringResource(R.string.overview_loads_empty_title), fontWeight = FontWeight.Medium) Text(stringResource(R.string.overview_loads_empty_title), fontWeight = FontWeight.Medium)
Text(stringResource(R.string.overview_loads_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.overview_loads_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -196,8 +200,8 @@ private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Uni
} }
@Composable @Composable
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit) { private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit, onSelect: () -> Unit) {
OverviewCard(stringResource(R.string.battery_bank_header_title)) { OverviewCard(stringResource(R.string.battery_bank_header_title), onClick = if (state.batteries.isEmpty()) null else onSelect) {
if (state.batteries.isEmpty()) { if (state.batteries.isEmpty()) {
Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium) Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium)
Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) } Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) }
@@ -212,8 +216,8 @@ private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: ()
} }
@Composable @Composable
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit) { private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit, onSelect: () -> Unit) {
OverviewCard(stringResource(R.string.overview_chargers_header_title)) { OverviewCard(stringResource(R.string.overview_chargers_header_title), onClick = if (state.chargers.isEmpty()) null else onSelect) {
if (state.chargers.isEmpty()) { if (state.chargers.isEmpty()) {
Text(stringResource(R.string.overview_chargers_empty_title), fontWeight = FontWeight.Medium) Text(stringResource(R.string.overview_chargers_empty_title), fontWeight = FontWeight.Medium)
Text(stringResource(R.string.overview_chargers_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.overview_chargers_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)

View File

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

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -16,7 +17,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ChevronRight import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import app.voltplan.cable.R import app.voltplan.cable.R
import app.voltplan.cable.ui.components.OnboardingCarousel
import app.voltplan.cable.ui.sfSymbol import app.voltplan.cable.ui.sfSymbol
import app.voltplan.cable.ui.theme.componentColor import app.voltplan.cable.ui.theme.componentColor
@@ -174,7 +175,10 @@ private fun SystemsOnboarding(modifier: Modifier = Modifier, onCreate: (String)
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon(Icons.Outlined.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp)) OnboardingCarousel(
images = listOf(R.drawable.onboarding_van, R.drawable.onboarding_cabin, R.drawable.onboarding_boat),
modifier = Modifier.fillMaxWidth().height(220.dp),
)
Spacer(Modifier.size(16.dp)) Spacer(Modifier.size(16.dp))
Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

14
screenshot.config Normal file
View 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"
)

View File

@@ -1,102 +1,288 @@
#!/bin/bash #!/bin/bash
set -euo pipefail 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}" RESET_SIMULATOR="${RESET_SIMULATOR:-1}"
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}" PARALLEL="${PARALLEL:-1}"
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}" 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() { is_truthy() {
case "$1" in case "$1" in
1|true|TRUE|yes|YES|on|ON) return 0 ;; 1|true|TRUE|yes|YES|on|ON) return 0 ;;
0|false|FALSE|no|NO|off|OFF|"") return 1 ;; *) return 1 ;;
*) return 0 ;;
esac esac
} }
DEVICE_MATRIX=( # ─── Dependency check ─────────────────────────────────────────────────────────
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
)
command -v xcparse >/dev/null 2>&1 || { 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 exit 1
} }
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1) # ─── Simulator helpers ────────────────────────────────────────────────────────
resolve_udid() { resolve_udid() {
local name="$1"; local os="$2" local name="$1"; local os="$2"
if [[ -n "$os" ]]; then if [[ -n "$os" ]]; then
# Prefer Shutdown state for a clean start xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' '
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \ /^--.*--$/ { in_section = ($0 ~ o) }
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}' in_section && $0 ~ n { print $2; exit }
'
else else
xcrun simctl list devices | awk -v n="$name" -F '[()]' \ xcrun simctl list devices | awk -v n="$name" -F '[()]' '
'$0 ~ n && /Shutdown/ {print $2; exit}' $0 ~ n { print $2; exit }
'
fi fi
} }
for device_entry in "${DEVICE_MATRIX[@]}"; do ensure_simulator() {
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry" local name="$1"; local runtime="$2"; local device_type="$3"
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ===" local udid
udid=$(resolve_udid "$name" "$runtime")
for lang in de fr en es nl; do if [[ -n "$udid" ]]; then
echo "Resetting simulator for a clean start..." echo "$udid"
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME") return 0
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 fi
xcrun simctl shutdown "$UDID" || true local runtime_id
if is_truthy "$RESET_SIMULATOR"; then 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}}')
xcrun simctl erase "$UDID" if [[ -z "$runtime_id" ]]; then
else fail "Runtime iOS $runtime not found" >&2
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do return 1
if [[ -n "$bundle" ]]; then
xcrun simctl terminate "$UDID" "$bundle" || true
xcrun simctl uninstall "$UDID" "$bundle" || true
fi fi
done
fi info "Creating simulator: $name (iOS $runtime)" >&2
echo "Running screenshots for $lang" 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:]') region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME") xcrun simctl boot "$udid" 2>/dev/null || true
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" || true # Language & locale
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang" 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 spawn "$udid" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
xcrun simctl shutdown "$UDID" || true
xcrun simctl boot "$UDID" # Suppress notifications
xcrun simctl status_bar booted override \ xcrun simctl spawn "$udid" defaults write com.apple.springboard DoNotDisturb -bool true 2>/dev/null || true
--time "9:41" \ 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 \ --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" build_for_testing() {
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang" 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" rm -rf "$bundle" "$outdir"
mkdir -p "$outdir" mkdir -p "$outdir"
xcodebuild test \ local log_file="/tmp/shooter-test-${dev_slug}-${lang}.log"
-scheme "$SCHEME" \ info "[$label] Testing..."
-destination "id=$UDID" \
-resultBundlePath "$bundle"
xcparse screenshots "$bundle" "$outdir" local test_exit=0
echo "Exported screenshots to $outdir" xcodebuild test-without-building \
xcrun simctl shutdown "$UDID" || true -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
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