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.
## Two apps in this repo
- **iOS** (`Cable/`) — SwiftUI + SwiftData. **This document describes the iOS app unless stated otherwise.**
- **Android** (`android/`) — a native Kotlin/Jetpack Compose port that mirrors every iOS feature (package root `app.voltplan.cable`, Room persistence, same Aptabase analytics). See **`android/README.md`** for its architecture and build instructions before working on it.
Behavior and data shape are meant to stay in sync across both — when changing a user-facing feature on one platform, check the other. See [Export Options](#export-options) for one such cross-platform contract.
**Apply every instruction to both the iOS and Android versions unless explicitly told otherwise.** Any feature, fix, or change requested without naming a platform must land on both apps and stay behaviorally in sync.
## Build & Test Commands
Use the **Xcode MCP server** tools instead of `xcodebuild` CLI:
@@ -106,44 +115,80 @@ All list views use consistent styling:
- Use `String(localized:defaultValue:)`**not** `NSLocalizedString`. The `defaultValue` serves as English fallback and avoids showing raw keys when a Localizable.strings entry is missing.
- When adding new user-facing strings, add translations to **all 5** Localizable.strings files immediately.
## PDF Export Pattern
## Export Options
Three export options, available from the Overview tab's share menu plus the BOM sheet. **Keep iOS and Android (`android/.../pdf/`) in sync — they must offer the same exports.**
1. **System Overview (PDF)** — summary + a full-page wiring diagram + per-entity tables.
2. **Bill of Materials (PDF)** — categorized component list.
3. **Wiring Diagram (PNG)** — standalone diagram image.
The wiring diagram (used both as the standalone PNG and the Overview PDF's diagram page) is fetched from the shared **VoltPlan diagram API** (`POST https://voltplan.app/api/diagram/generate`, JSON payload of system/loads/batteries/chargers, returns PNG). Both platforms send the identical payload shape; falls back gracefully when the API is unreachable (iOS draws a Core Graphics diagram; Android omits the PDF page / shows an error toast for the standalone export).
### PDF Export Pattern (iOS)
PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is:
- **Exporter struct** (e.g. `SystemOverviewPDFExporter`, `SystemBillOfMaterialsPDFExporter`) with snapshot data types — keeps Core Graphics rendering isolated from SwiftUI/SwiftData.
- **ShareSheet** triggered via `@State` item binding in the parent view.
- **Toolbar button** (not inline content) for the export action.
## Screenshots & Previews
On Android the equivalents live in `android/app/src/main/java/app/voltplan/cable/pdf/`: `SystemOverviewPdf`, `SystemBomPdf`, `SystemDiagram` (diagram fetch + PNG export), and `PdfShare` (`PdfDocument` + `FileProvider`/`ACTION_SEND`).
Screenshot previews for all major views live in `Cable/ScreenshotPreviews.swift`. This file contains wrapper views and realistic sample data so screenshots include full app chrome (NavigationBar, TabBar).
## Screenshots
### How to render screenshots
App Store screenshots are generated via XCUITests running on simulators, automated by `shooter.sh`.
1. Use `mcp__xcode__RenderPreview` with `sourceFilePath: "Cable/ScreenshotPreviews.swift"` and `previewDefinitionIndexInFile` (044, grouped by view × 5 languages).
2. Output goes to `Shots/Screenshots/`.
### Running screenshots
### Preview index mapping
```bash
./shooter.sh # reads ./screenshot.config
./shooter.sh other.config # explicit config file
VERBOSE=1 ./shooter.sh # full logs on failure
PARALLEL=0 ./shooter.sh # sequential mode
```
Previews are grouped in blocks of 5 (EN, DE, ES, FR, NL):
- 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
Requires `xcparse`: `brew install chargepoint/xcparse/xcparse`.
### Key patterns for preview-friendly views
### How it works
- **LoadsView** accepts an `initialTab` parameter (`LoadsView.ComponentTab`) to control which tab is shown. Wrap in `NavigationStack` to get the navigation bar.
- **ComponentLibraryView** accepts an optional `viewModel` parameter via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to inject mock data and avoid network dependency.
- All preview wrappers inject `.modelContainer(container)` (in-memory) and `.environmentObject(UnitSystemSettings())`.
1. `shooter.sh` reads `screenshot.config` for scheme, bundle ID, devices, and languages.
2. Builds once with `build-for-testing`, then runs `test-without-building` per language.
3. Per language run: erases simulator, sets locale via `simctl spawn ... defaults write`, suppresses system notifications (DND, Apple Intelligence), overrides status bar (9:41, full battery).
4. `xcparse` extracts screenshot attachments from `.xcresult` bundles into `Shots/Screenshots/{device-slug}/{lang}/`.
5. Devices run in parallel (languages sequential per device — same simulator).
### Localization limitation
### Test structure
`.environment(\.locale, Locale(identifier: "xx"))` does **not** affect `String(localized:defaultValue:)` — those resolve from the app bundle, not the SwiftUI environment. All preview screenshots render in the system language. For multi-language screenshots, use UI tests with `-AppleLanguages` launch arguments or change the Xcode scheme language.
- **Test target**: `CableUITestsScreenshot` (scheme: `CableScreenshots`, test plan: `CableScreenshots.xctestplan`)
- **Test file**: `CableUITestsScreenshot/CableUITestsScreenshot.swift`
- **Sample data**: `UITestSampleData.swift` — seeded via `--uitest-sample-data` launch argument, cleared via `--uitest-reset-data`
### Screenshot inventory
| # | Name | Source |
|---|------|--------|
| 01 | OnboardingSystemsView | Empty state after reset |
| 02 | OnboardingSystemView | New system overview |
| 03 | LoadEditorView | CalculatorView for new load |
| 04 | ComponentSelectorView | Component library (network) |
| 05 | SystemsWithSampleData | Systems list with sample data |
| 06 | AdventureVanOverview | Overview tab |
| 07 | AdventureVanLoads | Components tab |
| 08 | BillOfMaterials | System BOM sheet |
| 09 | AdventureVanCalculator | Load calculator |
| 10 | AdventureVanBatteries | Batteries tab |
| 11 | BatteryEditor | Battery editor |
| 12 | AdventureVanChargers | Chargers tab |
| 13 | ChargerEditor | Charger editor |
### Accessibility identifiers for UI tests
Key identifiers used by the screenshot tests: `create-system-button`, `systems-list`, `system-overview`, `overview-tab`, `components-tab`, `batteries-tab`, `chargers-tab`, `loads-list`, `batteries-list`, `chargers-list`, `system-bom-button`, `system-bom-close-button`, `library-view-close-button`, `create-component-button`, `select-component-button`.
### Preview-friendly view inits
- **LoadsView** accepts `initialTab` (`LoadsView.ComponentTab`) to control which tab is shown.
- **ComponentLibraryView** accepts an optional `viewModel` via `init(viewModel:onSelect:)`. Use `ComponentLibraryViewModel(previewItems:)` to bypass network.
## Model Definitions

View File

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

View File

@@ -1,77 +1,21 @@
import Foundation
enum AmazonAffiliate {
private static let fallbackDomain = "www.amazon.com"
private static let fallbackTag: String? = "voltplan-20"
enum VoltPlanRedirect {
private static let baseURL = "https://voltplan.app"
private static let domainsByCountry: [String: String] = [
"US": "www.amazon.com",
"DE": "www.amazon.de",
"FR": "www.amazon.fr",
"ES": "www.amazon.es",
"IT": "www.amazon.it",
"GB": "www.amazon.co.uk",
"CA": "www.amazon.ca",
"JP": "www.amazon.co.jp",
"AU": "www.amazon.com.au",
"NL": "www.amazon.nl",
"SE": "www.amazon.se",
"PL": "www.amazon.pl",
"MX": "www.amazon.com.mx",
"BR": "www.amazon.com.br",
"IN": "www.amazon.in"
]
static func componentURL(id: String) -> URL? {
var components = URLComponents(string: "\(baseURL)/\(id)")
components?.queryItems = [URLQueryItem(name: "src", value: "cable")]
return components?.url
}
// Configure Amazon affiliate tracking IDs by country code.
private static let tagsByCountry: [String: String] = [
"US": "voltplan-20",
"DE": "voltplan-21",
"AU": "voltplan-22",
"GB": "voltplan00-21",
"FR": "voltplan0f-21",
"CA": "voltplan01-20"
]
private static let countryAliases: [String: String] = [
"UK": "GB"
]
static func searchURL(query: String, countryCode: String?) -> URL? {
static func searchURL(query: String) -> URL? {
guard !query.isEmpty else { return nil }
var components = URLComponents()
components.scheme = "https"
components.host = domain(for: countryCode)
components.path = "/s"
var queryItems = [URLQueryItem(name: "k", value: query)]
if let tag = affiliateTag(for: countryCode), !tag.isEmpty {
queryItems.append(URLQueryItem(name: "tag", value: tag))
}
components.queryItems = queryItems
return components.url
}
static func domain(for countryCode: String?) -> String {
guard let normalized = normalizedCountryCode(from: countryCode) else {
return fallbackDomain
}
return domainsByCountry[normalized] ?? fallbackDomain
}
static func affiliateTag(for countryCode: String?) -> String? {
guard let normalized = normalizedCountryCode(from: countryCode) else {
return fallbackTag
}
return tagsByCountry[normalized] ?? fallbackTag
}
private static func normalizedCountryCode(from countryCode: String?) -> String? {
guard let raw = countryCode?.uppercased(), !raw.isEmpty else { return nil }
if let alias = countryAliases[raw] {
return alias
}
return raw
var components = URLComponents(string: "\(baseURL)/search")
components?.queryItems = [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "src", value: "cable"),
]
return components?.url
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,17 @@
import SwiftUI
struct ComponentLibraryItem: Identifiable, Equatable {
struct AffiliateLink: Identifiable, Equatable {
let id: String
let url: URL
let country: String?
}
enum ComponentLibraryType: String, Identifiable, CaseIterable {
case load
case battery
case charger
var id: String { rawValue }
/// PocketBase filter expression selecting this type.
var filterValue: String { "type='\(rawValue)'" }
}
struct ComponentLibraryItem: Identifiable, Equatable {
let id: String
let name: String
let translations: [String: String]
@@ -15,8 +20,8 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let watt: Double?
let dutyCyclePercent: Double?
let defaultUtilizationFactorPercent: Double?
let componentCategory: String?
let iconURL: URL?
let affiliateLinks: [AffiliateLink]
var displayVoltage: Double? {
voltageIn ?? voltageOut
@@ -27,6 +32,52 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return power / voltage
}
/// Battery capacity derived from stored energy (Wh) and nominal voltage.
var capacityAmpHours: Double? {
guard let energy = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
return energy / voltage
}
/// Charger output current derived from rated power and output voltage.
var outputCurrent: Double? {
guard let power = watt, let voltage = voltageOut ?? displayVoltage, voltage > 0 else { return nil }
return power / voltage
}
var capacityLabel: String? {
guard let capacity = capacityAmpHours else { return nil }
return String(format: "%.0fAh", capacity)
}
var energyLabel: String? {
guard let energy = watt else { return nil }
return String(format: "%.0fWh", energy)
}
var voltageRangeLabel: String? {
if let input = voltageIn, let output = voltageOut {
return String(format: "%.0fV → %.0fV", input, output)
}
return voltageLabel
}
var outputCurrentLabel: String? {
guard let current = outputCurrent else { return nil }
return String(format: "%.1fA", current)
}
/// Detail metrics shown in a library row, tailored to the component type.
func detailLabels(for type: ComponentLibraryType) -> [String] {
switch type {
case .load:
return [voltageLabel, powerLabel, currentLabel].compactMap { $0 }
case .battery:
return [voltageLabel, capacityLabel, energyLabel].compactMap { $0 }
case .charger:
return [voltageRangeLabel, outputCurrentLabel, powerLabel].compactMap { $0 }
}
}
var voltageLabel: String? {
guard let voltage = displayVoltage else { return nil }
return String(format: "%.1fV", voltage)
@@ -65,36 +116,10 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return translation(for: locale)
}
var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.region?.identifier)
}
func localizedName(for locale: Locale) -> String {
translation(for: locale) ?? name
}
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
guard !affiliateLinks.isEmpty else { return nil }
let normalizedRegionCode = regionCode?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
if let normalizedRegionCode, !normalizedRegionCode.isEmpty {
if let exactMatch = affiliateLinks.first(where: { link in
link.country?.lowercased() == normalizedRegionCode
}) {
return exactMatch
}
}
if let fallbackWithoutCountry = affiliateLinks.first(where: { $0.country == nil }) {
return fallbackWithoutCountry
}
return affiliateLinks.first
}
private func translation(for locale: Locale) -> String? {
guard !translations.isEmpty else { return nil }
@@ -206,12 +231,15 @@ final class ComponentLibraryViewModel: ObservableObject {
private let baseURL = URL(string: "https://base.voltplan.app")!
private let urlSession: URLSession
let libraryType: ComponentLibraryType
init(urlSession: URLSession = .shared) {
init(libraryType: ComponentLibraryType = .load, urlSession: URLSession = .shared) {
self.libraryType = libraryType
self.urlSession = urlSession
}
init(previewItems: [ComponentLibraryItem]) {
init(previewItems: [ComponentLibraryItem], libraryType: ComponentLibraryType = .load) {
self.libraryType = libraryType
self.urlSession = .shared
self.items = previewItems
self.isLoading = false
@@ -249,11 +277,11 @@ final class ComponentLibraryViewModel: ObservableObject {
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "filter", value: "(type='load')"),
URLQueryItem(name: "filter", value: "(\(libraryType.filterValue))"),
URLQueryItem(name: "sort", value: "+name"),
URLQueryItem(
name: "fields",
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor"
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor,component_category"
),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "perPage", value: "\(perPage)")
@@ -289,7 +317,6 @@ final class ComponentLibraryViewModel: ObservableObject {
page += 1
}
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
let mappedItems = allRecords.map { record in
ComponentLibraryItem(
id: record.id,
@@ -300,8 +327,8 @@ final class ComponentLibraryViewModel: ObservableObject {
watt: record.watt,
dutyCyclePercent: record.dutyCycle,
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
iconURL: iconURL(for: record),
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
componentCategory: record.componentCategory,
iconURL: iconURL(for: record)
)
}
for item in mappedItems {
@@ -314,110 +341,6 @@ final class ComponentLibraryViewModel: ObservableObject {
return mappedItems
}
private func fetchAffiliateLinks(for componentIDs: [String]) async throws -> [String: [ComponentLibraryItem.AffiliateLink]] {
let uniqueIDs = Array(Set(componentIDs))
guard !uniqueIDs.isEmpty else { return [:] }
let idSet = Set(uniqueIDs)
let perPage = 200
let chunkSize = 15
let chunks: [[String]] = stride(from: 0, to: uniqueIDs.count, by: chunkSize).map { index in
let upperBound = min(index + chunkSize, uniqueIDs.count)
return Array(uniqueIDs[index..<upperBound])
}
var aggregated: [String: [ComponentLibraryItem.AffiliateLink]] = [:]
for chunk in chunks {
guard !chunk.isEmpty else { continue }
let filterValue = chunk
.map { "component='\(escapeFilterValue($0))'" }
.joined(separator: " || ")
var page = 1
while true {
var components = URLComponents(
url: baseURL.appendingPathComponent("api/collections/affiliate_links/records"),
resolvingAgainstBaseURL: false
)
var queryItems = [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "perPage", value: "\(perPage)"),
URLQueryItem(name: "fields", value: "id,url,component,country")
]
if !filterValue.isEmpty {
queryItems.append(URLQueryItem(name: "filter", value: "(\(filterValue))"))
}
components?.queryItems = queryItems
guard let url = components?.url else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
let (data, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
let decoded = try JSONDecoder().decode(AffiliateLinksResponse.self, from: data)
for record in decoded.items {
guard let componentID = record.component, idSet.contains(componentID) else { continue }
guard let url = URL(string: record.url) else { continue }
let normalizedCountry = record.country?
.trimmingCharacters(in: .whitespacesAndNewlines)
let countryCode = normalizedCountry?.isEmpty == true ? nil : normalizedCountry?.uppercased()
let link = ComponentLibraryItem.AffiliateLink(
id: record.id,
url: url,
country: countryCode
)
var links = aggregated[componentID, default: []]
if !links.contains(where: { $0.id == record.id }) {
links.append(link)
aggregated[componentID] = links
}
}
let isLastPage: Bool
if decoded.totalPages > 0 {
isLastPage = page >= decoded.totalPages
} else {
isLastPage = decoded.items.count < perPage
}
if isLastPage { break }
page += 1
}
}
for key in Array(aggregated.keys) {
aggregated[key]?.sort { lhs, rhs in
let lhsCountry = lhs.country ?? ""
let rhsCountry = rhs.country ?? ""
if lhsCountry == rhsCountry {
return lhs.url.absoluteString < rhs.url.absoluteString
}
return lhsCountry < rhsCountry
}
}
return aggregated
}
private func iconURL(for record: PocketBaseRecord) -> URL? {
guard let icon = record.icon else { return nil }
@@ -451,6 +374,7 @@ final class ComponentLibraryViewModel: ObservableObject {
let watt: Double?
let dutyCycle: Double?
let defaultUtilizationFactor: Double?
let componentCategory: String?
enum CodingKeys: String, CodingKey {
case id
@@ -463,6 +387,7 @@ final class ComponentLibraryViewModel: ObservableObject {
case watt
case dutyCycle = "duty_cycle"
case defaultUtilizationFactor = "default_utilization_factor"
case componentCategory = "component_category"
}
struct TranslationsContainer: Decodable {
@@ -518,32 +443,23 @@ final class ComponentLibraryViewModel: ObservableObject {
}
}
private struct AffiliateLinksResponse: Decodable {
let page: Int
let totalPages: Int
let items: [AffiliateLinkRecord]
}
private struct AffiliateLinkRecord: Decodable {
let id: String
let url: String
let component: String?
let country: String?
}
}
struct ComponentLibraryView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ComponentLibraryViewModel
@State private var searchText: String = ""
private let libraryType: ComponentLibraryType
let onSelect: (ComponentLibraryItem) -> Void
init(onSelect: @escaping (ComponentLibraryItem) -> Void) {
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel())
init(libraryType: ComponentLibraryType = .load, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self.libraryType = libraryType
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel(libraryType: libraryType))
self.onSelect = onSelect
}
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self.libraryType = viewModel.libraryType
self._viewModel = StateObject(wrappedValue: viewModel)
self.onSelect = onSelect
}
@@ -614,7 +530,7 @@ struct ComponentLibraryView: View {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
ComponentRow(item: item, libraryType: libraryType)
}
.buttonStyle(.plain)
}
@@ -659,6 +575,7 @@ struct ComponentLibraryView: View {
private struct ComponentRow: View {
let item: ComponentLibraryItem
let libraryType: ComponentLibraryType
var body: some View {
HStack(spacing: 12) {
@@ -680,15 +597,23 @@ private struct ComponentRow: View {
private var iconView: some View {
LoadIconView(
remoteIconURLString: item.iconURL?.absoluteString,
fallbackSystemName: "bolt",
fallbackSystemName: fallbackIcon,
fallbackColor: Color.blue.opacity(0.15),
size: 44
)
}
private var fallbackIcon: String {
switch libraryType {
case .load: return "bolt"
case .battery: return "battery.100"
case .charger: return "bolt.fill"
}
}
@ViewBuilder
private var detailLine: some View {
let labels = [item.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 }
let labels = item.detailLabels(for: libraryType)
if labels.isEmpty {
Text("Details coming soon")

View File

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

View File

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

110
Cable/ReviewPrompt.swift Normal file
View File

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

View File

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

View File

@@ -66,7 +66,6 @@ struct SystemComponentsPersistence {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
@@ -84,8 +83,7 @@ struct SystemComponentsPersistence {
dailyUsageHours: dailyUsageHours,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
componentID: item.id
)
context.insert(newLoad)
@@ -116,6 +114,87 @@ struct SystemComponentsPersistence {
)
}
static func makeBatteryDraft(
from item: ComponentLibraryItem,
for system: ElectricalSystem,
existingLoads: [SavedLoad],
existingBatteries: [SavedBattery],
existingChargers: [SavedCharger]
) -> BatteryConfiguration {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty
? String(localized: "battery.editor.default_name", defaultValue: "New Battery")
: localizedName
let batteryName = uniqueName(
startingWith: baseName,
loads: existingLoads,
batteries: existingBatteries,
chargers: existingChargers
)
let nominalVoltage = item.displayVoltage ?? 12.8
let capacity = item.capacityAmpHours ?? 100
return BatteryConfiguration(
name: batteryName,
nominalVoltage: nominalVoltage,
capacityAmpHours: capacity,
chemistry: .lithiumIronPhosphate,
iconName: "battery.100",
colorName: system.colorName,
system: system,
componentID: item.id
)
}
static func makeChargerDraft(
from item: ComponentLibraryItem,
for system: ElectricalSystem,
existingLoads: [SavedLoad],
existingBatteries: [SavedBattery],
existingChargers: [SavedCharger]
) -> ChargerConfiguration {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty
? String(localized: "charger.editor.default_name", defaultValue: "New Charger")
: localizedName
let chargerName = uniqueName(
startingWith: baseName,
loads: existingLoads,
batteries: existingBatteries,
chargers: existingChargers
)
let inputVoltage = item.voltageIn ?? LocaleDefaults.mainsVoltage
let outputVoltage = item.voltageOut ?? 14.2
let power = item.watt ?? 0
let current = item.outputCurrent ?? (outputVoltage > 0 ? power / outputVoltage : 30)
let sourceType = chargerSourceType(forCategory: item.componentCategory)
return ChargerConfiguration(
name: chargerName,
inputVoltage: inputVoltage,
outputVoltage: outputVoltage,
maxCurrentAmps: current,
maxPowerWatts: power,
iconName: sourceType.iconName,
colorName: system.colorName,
system: system,
powerSourceType: sourceType,
componentID: item.id,
remoteIconURLString: item.iconURL?.absoluteString
)
}
/// Maps a PocketBase `component_category` to a charger power source.
static func chargerSourceType(forCategory category: String?) -> SavedCharger.PowerSourceType {
guard let category = category?.lowercased(), !category.isEmpty else { return .shore }
if category.contains("solar") { return .solar }
if category.contains("wind") { return .wind }
if category.contains("dcdc") || category.contains("alternator") { return .alternator }
if category.contains("generator") { return .generator }
if category.contains("mains") || category.contains("shore") { return .shore }
return .shore
}
static func makeChargerDraft(
for system: ElectricalSystem,
existingLoads: [SavedLoad],
@@ -162,7 +241,8 @@ struct SystemComponentsPersistence {
maximumTemperatureCelsius: configuration.maximumTemperatureCelsius,
iconName: configuration.iconName,
colorName: configuration.colorName,
system: system
system: system,
componentID: configuration.componentID
)
context.insert(newBattery)
}
@@ -186,7 +266,9 @@ struct SystemComponentsPersistence {
maxPowerWatts: configuration.maxPowerWatts,
iconName: configuration.iconName,
colorName: configuration.colorName,
system: system
system: system,
remoteIconURLString: configuration.remoteIconURLString,
componentID: configuration.componentID
)
context.insert(newCharger)
}

View File

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

View File

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

View File

@@ -125,32 +125,21 @@ final class CableUITestsScreenshot: XCTestCase {
try super.setUpWithError()
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
//ensureDoNotDisturbEnabled()
//dismissSystemOverlays()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
//dismissSystemOverlays()
}
// MARK: - Onboarding Screenshots
@MainActor
func testOnboardingScreenshots() throws {
let app = launchApp(arguments: ["--uitest-reset-data"])
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
// Wait for Apple Intelligence and other system notifications to appear, then dismiss
RunLoop.current.run(until: Date().addingTimeInterval(6))
dismissNotificationBannersIfNeeded()
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
waitForStability()
takeScreenshot(named: "01-OnboardingSystemsView")
createSystemButton.tap()
@@ -159,14 +148,15 @@ final class CableUITestsScreenshot: XCTestCase {
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "02-OnboardingSystemView")
browseLibraryButton.tap()
let libraryCloseButton = app.buttons["library-view-close-button"]
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8))
waitForStability(long: true)
// Wait for library items AND remote icons to load from PocketBase
RunLoop.current.run(until: Date().addingTimeInterval(15))
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "04-ComponentSelectorView")
libraryCloseButton.tap()
@@ -175,20 +165,130 @@ final class CableUITestsScreenshot: XCTestCase {
let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
waitForStability(long: true)
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "03-LoadEditorView")
}
// MARK: - Sample Data Screenshots
@MainActor
func testSampleDataScreenshots() throws {
let app = launchAppWithSampleData()
dismissNotificationBannersIfNeeded()
// Systems list
let systemsList = resolvedSystemsList(in: app)
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "05-SystemsWithSampleData")
// Navigate to first system
openFirstSystem(in: app, systemsList: systemsList)
// Overview tab wait for navigation animation to complete
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "06-AdventureVanOverview")
// Bill of Materials
let bomElement = resolveBillOfMaterialsElement(in: app)
if !bomElement.waitForExistence(timeout: 6) {
bringElementIntoView(bomElement, in: app)
}
XCTAssertTrue(bomElement.exists)
if !bomElement.isHittable {
bringElementIntoView(bomElement, in: app, requireHittable: true)
}
tapElement(bomElement)
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "08-BillOfMaterials")
let closeButton = app.buttons["system-bom-close-button"]
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
closeButton.tap()
// Components tab
tapTab(.componentsTab, in: app)
let loadsList = resolvedLoadsList(in: app)
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "07-AdventureVanLoads")
// Open first load Calculator
let firstLoad = loadsList.cells.element(boundBy: 0)
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
firstLoad.tap()
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "09-AdventureVanCalculator")
// Navigate back to system tabs
app.navigationBars.buttons.element(boundBy: 0).tap()
waitForStability()
// Batteries tab
tapTab(.batteriesTab, in: app)
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "10-AdventureVanBatteries")
// Open first battery Battery Editor
let batteriesList = resolvedBatteriesList(in: app)
if batteriesList.waitForExistence(timeout: 4) {
let firstBattery = batteriesList.cells.element(boundBy: 0)
if firstBattery.waitForExistence(timeout: 2) {
firstBattery.tap()
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "11-BatteryEditor")
// Navigate back
app.navigationBars.buttons.element(boundBy: 0).tap()
waitForStability()
}
}
// Chargers tab
tapTab(.chargersTab, in: app)
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "12-AdventureVanChargers")
// Open first charger Charger Editor
let chargersList = resolvedChargersList(in: app)
if chargersList.waitForExistence(timeout: 4) {
let firstCharger = chargersList.cells.element(boundBy: 0)
if firstCharger.waitForExistence(timeout: 2) {
firstCharger.tap()
waitForStability()
dismissNotificationBannersIfNeeded()
takeScreenshot(named: "13-ChargerEditor")
}
}
}
// MARK: - App Launch
private func launchApp(arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
return app
}
private func launchAppWithSampleData() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
return app
}
let systemsList = resolvedSystemsList(in: app)
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
takeScreenshot(named: "05-SystemsWithSampleData")
// MARK: - Navigation Helpers
private func openFirstSystem(in app: XCUIApplication, systemsList: XCUIElement) {
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
let systemName = firstSystemCell.staticTexts.firstMatch.label
@@ -206,68 +306,40 @@ final class CableUITestsScreenshot: XCTestCase {
detailVisible = waitForSystemDetail(named: systemName, in: app)
}
XCTAssertTrue(detailVisible)
takeScreenshot(named: "06-AdventureVanOverview")
// let overviewTab = app.buttons["overview-tab"]
// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3))
// overviewTab.tap()
waitForStability(long: false)
let bomElement = resolveBillOfMaterialsElement(in: app)
if !bomElement.waitForExistence(timeout: 6) {
bringElementIntoView(bomElement, in: app)
}
XCTAssertTrue(bomElement.exists)
private func tapTab(_ key: UIStringKey, in app: XCUIApplication) {
let identifierMap: [UIStringKey: String] = [
.overviewTab: "overview-tab",
.componentsTab: "components-tab",
.batteriesTab: "batteries-tab",
.chargersTab: "chargers-tab",
]
if !bomElement.isHittable {
bringElementIntoView(bomElement, in: app, requireHittable: true)
// Use .matching + .firstMatch to avoid "multiple matches" error
// with iOS 26 floating tab bar (which creates duplicate elements)
if let identifier = identifierMap[key] {
let tabButton = app.buttons.matching(identifier: identifier).firstMatch
if tabButton.waitForExistence(timeout: 3) {
tapElement(tabButton)
return
}
}
if bomElement.isHittable {
bomElement.tap()
let tabButton = button(in: app.buttons, for: key)
XCTAssertTrue(tabButton.waitForExistence(timeout: 3))
tapElement(tabButton)
}
private func tapElement(_ element: XCUIElement) {
if element.isHittable {
element.tap()
} else {
bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
waitForStability(long: true)
takeScreenshot(named: "08-BillOfMaterials")
let closeButton = app.buttons["system-bom-close-button"]
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
closeButton.tap()
let componentsTab = componentsTabButton(in: app)
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
if componentsTab.isHittable {
componentsTab.tap()
} else {
componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
let loadsList = resolvedLoadsList(in: app)
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
takeScreenshot(named: "07-AdventureVanLoads")
waitForStability()
let firstLoad = loadsList.cells.element(boundBy: 0)
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
let loadName = firstLoad.staticTexts.firstMatch.label
firstLoad.tap()
let loadNavButton = app.navigationBars.buttons[loadName]
XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3))
takeScreenshot(named: "09-AdventureVanCalculator")
}
private func launchApp(arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
//dismissSystemOverlays()
return app
}
// MARK: - Element Resolution
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
@@ -299,6 +371,62 @@ final class CableUITestsScreenshot: XCTestCase {
return table
}
private func resolvedBatteriesList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["batteries-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["batteries-list"]
if table.waitForExistence(timeout: 6) {
return table
}
// Fallback: any list on screen
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
return app.collectionViews.firstMatch
}
return app.tables.firstMatch
}
private func resolvedChargersList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["chargers-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["chargers-list"]
if table.waitForExistence(timeout: 6) {
return table
}
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
return app.collectionViews.firstMatch
}
return app.tables.firstMatch
}
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
let identifier = "system-bom-button"
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
if buttonByIdentifier.exists { return buttonByIdentifier }
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
if elementByIdentifier.exists { return elementByIdentifier }
let candidates = candidateStrings(for: .billOfMaterials)
for candidate in candidates {
let button = app.buttons[candidate]
if button.exists { return button }
let other = app.otherElements[candidate]
if other.exists { return other }
}
return buttonByIdentifier
}
// MARK: - Screenshots & Stability
private func takeScreenshot(named name: String) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
@@ -308,52 +436,7 @@ final class CableUITestsScreenshot: XCTestCase {
}
private func waitForStability(long: Bool = false) {
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5))
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let identifierMatch = app.descendants(matching: .any)
.matching(identifier: "components-tab").firstMatch
if identifierMatch.exists {
return identifierMatch
}
let localizedLabels = [
"Components", "Verbraucher", "Componentes", "Composants", "Componenten"
]
for label in localizedLabels {
let button = app.buttons[label]
if button.exists {
return button
}
let tabBarButton = app.tabBars.buttons[label]
if tabBarButton.exists {
return tabBarButton
}
let segmentedButton = app.segmentedControls.buttons[label]
if segmentedButton.exists {
return segmentedButton
}
let segmentedOther = app.segmentedControls.otherElements[label]
if segmentedOther.exists {
return segmentedOther
}
}
let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1)
if fallbackSegmented.exists {
return fallbackSegmented
}
let tabBarButton = app.tabBars.buttons.element(boundBy: 1)
if tabBarButton.exists {
return tabBarButton
}
return app.tabBars.descendants(matching: .any).firstMatch
RunLoop.current.run(until: Date().addingTimeInterval(long ? 3.0 : 0.5))
}
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
@@ -362,15 +445,12 @@ final class CableUITestsScreenshot: XCTestCase {
if app.otherElements["system-overview"].exists {
return true
}
let navBar = app.navigationBars.firstMatch
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
}
return app.otherElements["system-overview"].exists
}
@@ -395,32 +475,13 @@ final class CableUITestsScreenshot: XCTestCase {
}
}
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
let identifier = "system-bom-button"
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
if buttonByIdentifier.exists { return buttonByIdentifier }
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
if elementByIdentifier.exists { return elementByIdentifier }
let candidates = candidateStrings(for: .billOfMaterials)
for candidate in candidates {
let button = app.buttons[candidate]
if button.exists {
return button
}
let other = app.otherElements[candidate]
if other.exists {
return other
}
}
return buttonByIdentifier
}
// MARK: - Notification Dismissal
private func dismissNotificationBannersIfNeeded() {
// Try multiple times notifications can appear with a delay
for _ in 0..<3 {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
if banner.waitForExistence(timeout: 1) {
guard banner.waitForExistence(timeout: 1) else { return }
if banner.isHittable {
banner.swipeUp()
} else {
@@ -432,6 +493,8 @@ final class CableUITestsScreenshot: XCTestCase {
}
}
// MARK: - Localized Element Matching
private func candidateStrings(for key: UIStringKey) -> [String] {
var values = Set<String>()
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
@@ -444,9 +507,6 @@ final class CableUITestsScreenshot: XCTestCase {
if let others = translations[key]?.values {
values.formUnion(others)
}
if key == .settings {
values.insert("gearshape")
}
return Array(values)
}
@@ -465,139 +525,4 @@ final class CableUITestsScreenshot: XCTestCase {
)
return query.matching(predicate).firstMatch
}
private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? {
let element = button(in: query, for: key)
return element.exists ? element : nil
}
private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let tabSpecific = button(in: app.tabBars.buttons, for: key)
if tabSpecific.exists {
return tabSpecific
}
return button(in: app.buttons, for: key)
}
private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let bar = app.navigationBars[candidate]
if bar.exists {
return bar
}
}
return app.navigationBars.element(boundBy: 0)
}
private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let button = app.buttons[candidate]
if button.waitForExistence(timeout: 2) {
button.tap()
return
}
}
}
private func openBillOfMaterials(app: XCUIApplication) {
let bomButton = button(in: app.buttons, for: .billOfMaterials)
XCTAssertTrue(bomButton.waitForExistence(timeout: 6))
bomButton.tap()
let bomView = app.otherElements["system-bom-view"]
XCTAssertTrue(bomView.waitForExistence(timeout: 8))
waitForStability(long: true)
}
private func closeBillOfMaterials(app: XCUIApplication) {
tapButtonIfPresent(app: app, key: .close)
}
private func navigateBack(app: XCUIApplication) {
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists {
backButton.tap()
} else {
app.swipeRight()
}
}
private func openSettings(app: XCUIApplication) {
let systemsBar = navigationBar(in: app, key: .systemsTitle)
let settingsButton = button(in: systemsBar.buttons, for: .settings)
if settingsButton.exists {
settingsButton.tap()
} else {
systemsBar.buttons.element(boundBy: 0).tap()
}
}
private func ensureDoNotDisturbEnabled() {
springboard.activate()
let pullStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.02))
let pullEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.30))
pullStart.press(forDuration: 0.1, thenDragTo: pullEnd)
let focusTile = springboard.otherElements["Focus"]
let focusButton = springboard.buttons["Focus"]
if focusTile.waitForExistence(timeout: 2) {
focusTile.press(forDuration: 1.0)
} else if focusButton.waitForExistence(timeout: 2) {
focusButton.press(forDuration: 1.0)
} else {
return
}
let dndButton = springboard.buttons["Do Not Disturb"]
if dndButton.waitForExistence(timeout: 1) {
if !dndButton.isSelected {
dndButton.tap()
}
} else {
let dndCell = springboard.cells["Do Not Disturb"]
if dndCell.waitForExistence(timeout: 1) && !dndCell.isSelected {
dndCell.tap()
} else {
let dndLabel = springboard.staticTexts["Do Not Disturb"]
if dndLabel.waitForExistence(timeout: 1) {
dndLabel.tap()
}
}
}
let dismissStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
let dismissEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
}
private func dismissSystemOverlays() {
let app = XCUIApplication()
let alertButtons = [
"OK", "Allow", "Later", "Not Now", "Close",
"Continue", "Remind Me Later", "Maybe Later",
]
if app.alerts.firstMatch.exists {
handleAlerts(in: app, buttons: alertButtons)
}
if springboard.alerts.firstMatch.exists || springboard.scrollViews.firstMatch.exists {
handleAlerts(in: springboard, buttons: alertButtons + ["Enable Later"])
}
}
private func handleAlerts(in application: XCUIApplication, buttons: [String]) {
for buttonLabel in buttons {
let button = application.buttons[buttonLabel]
if button.waitForExistence(timeout: 0.5) {
button.tap()
return
}
}
let closeButton = application.buttons.matching(NSPredicate(format: "identifier CONTAINS[c] %@", "Close")).firstMatch
if closeButton.exists {
closeButton.tap()
}
}
}

View File

@@ -1,53 +1,6 @@
//
// CableUITestsScreenshotLaunchTests.swift
// CableUITestsScreenshot
//
// Created by Stefan Lange-Hegermann on 06.10.25.
//
import XCTest
final class CableUITestsScreenshotLaunchTests: XCTestCase {
private func launchApp(arguments: [String] = []) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
return app
}
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["systems-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["loads-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["loads-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func takeScreenshot(name: String,
lifetime: XCTAttachment.Lifetime = .keepAlways) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = lifetime
add(attachment)
}
override class var runsForEachTargetApplicationUIConfiguration: Bool {
false
@@ -57,110 +10,15 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
continueAfterFailure = false
}
@MainActor
func testOnboardingLoadsView() throws {
let app = launchApp(arguments: ["--uitest-reset-data"])
takeScreenshot(name: "01-OnboardingSystemsView")
func testLaunch() throws {
let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
createSystemButton.tap()
takeScreenshot(name: "02-OnboardingLoadsView")
let componentsTab = app.buttons["components-tab"]
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
componentsTab.tap()
let libraryCloseButton = app.buttons["library-view-close-button"]
let browseLibraryButton = onboardingSecondaryButton(in: app)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 5))
browseLibraryButton.tap()
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 10)
takeScreenshot(name: "04-ComponentSelectorView")
libraryCloseButton.tap()
let createComponentButton = onboardingPrimaryButton(in: app)
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
createComponentButton.tap()
takeScreenshot(name: "03-LoadEditorView")
}
func testWithSampleData() throws {
let app = launchApp(arguments: ["--uitest-sample-data"])
let systemsList = resolvedSystemsList(in: app)
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
let systemName = firstSystemCell.staticTexts.firstMatch.label
takeScreenshot(name: "05-SystemsWithSampleData")
let rowButton = firstSystemCell.buttons.firstMatch
if rowButton.waitForExistence(timeout: 2) {
rowButton.tap()
} else {
firstSystemCell.tap()
}
let navButton = app.navigationBars.buttons[systemName]
if !navButton.waitForExistence(timeout: 3) {
let coordinate = firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()
XCTAssertTrue(navButton.waitForExistence(timeout: 3))
}
tapComponentsTab(in: app)
let loadsElement = resolvedLoadsList(in: app)
XCTAssertTrue(loadsElement.waitForExistence(timeout: 6))
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
Thread.sleep(forTimeInterval: 1)
takeScreenshot(name: "06-AdventureVanLoads")
let bomButton = app.buttons["system-bom-button"]
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
bomButton.tap()
// let bomView = app.otherElements["system-bom-view"]
// XCTAssertTrue(bomView.waitForExistence(timeout: 3))
//
// Thread.sleep(forTimeInterval: 1)
// takeScreenshot(name: "07-AdventureVanBillOfMaterials")
}
private func tapComponentsTab(in app: XCUIApplication) {
let button = componentsTabButton(in: app)
XCTAssertTrue(button.waitForExistence(timeout: 3))
button.tap()
}
private func onboardingPrimaryButton(in app: XCUIApplication) -> XCUIElement {
let button = app.buttons["create-component-button"]
if button.exists { return button }
return app.buttons["onboarding-primary-button"]
}
private func onboardingSecondaryButton(in app: XCUIApplication) -> XCUIElement {
let button = app.buttons["select-component-button"]
if button.exists { return button }
return app.buttons["onboarding-secondary-button"]
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let idButton = app.buttons["components-tab"]
if idButton.exists {
return idButton
}
let labels = ["Components", "Verbraucher", "Componentes", "Composants", "Componenten"]
for label in labels {
let button = app.buttons[label]
if button.exists { return button }
}
return app.tabBars.buttons.element(boundBy: 1)
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}

5
android/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
set -euo pipefail
SCHEME="CableScreenshots"
# Kill all child processes on Ctrl-C
trap 'printf "\n\033[31m ✘\033[0m Interrupted — stopping all jobs...\n"; kill 0; exit 130' INT TERM
# ─── Configuration ────────────────────────────────────────────────────────────
# Override these via environment variables, a config file, or CLI argument.
#
# Config file format (shell):
# SCHEME="MyAppScreenshots"
# APP_BUNDLE_ID="com.example.myapp"
# UITEST_BUNDLE_ID="com.example.myapp.UITests"
# LANGUAGES=(en de fr)
# DEVICE_MATRIX=(
# "iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
# )
# OUTPUT_DIR="Shots/Screenshots"
CONFIG_FILE="${1:-./screenshot.config}"
if [[ -f "$CONFIG_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONFIG_FILE"
fi
# Required — must be set in config or environment
SCHEME="${SCHEME:?Set SCHEME in $CONFIG_FILE or environment}"
APP_BUNDLE_ID="${APP_BUNDLE_ID:?Set APP_BUNDLE_ID in $CONFIG_FILE or environment}"
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-}"
# Optional with defaults
OUTPUT_DIR="${OUTPUT_DIR:-Shots/Screenshots}"
DERIVED_DATA="${DERIVED_DATA:-DerivedData-Screenshots}"
RESET_SIMULATOR="${RESET_SIMULATOR:-1}"
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}"
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}"
PARALLEL="${PARALLEL:-1}"
VERBOSE="${VERBOSE:-0}"
STATUS_BAR_TIME="${STATUS_BAR_TIME:-9:41}"
if [[ -z "${LANGUAGES+x}" ]]; then
LANGUAGES=(en)
fi
if [[ -z "${DEVICE_MATRIX+x}" ]]; then
DEVICE_MATRIX=(
"iPhone 17 Pro Max|26.4|iphone-17-pro-max|com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max"
)
fi
# ─── Pretty output ────────────────────────────────────────────────────────────
BOLD="\033[1m"
DIM="\033[2m"
GREEN="\033[32m"
RED="\033[31m"
CYAN="\033[36m"
RST="\033[0m"
ok() { printf "${GREEN}${RST} %s\n" "$*"; }
fail() { printf "${RED}${RST} %s\n" "$*"; }
info() { printf "${CYAN} ${RST} %s\n" "$*"; }
step() { printf "\n${BOLD}%s${RST}\n" "$*"; }
is_truthy() {
case "$1" in
1|true|TRUE|yes|YES|on|ON) return 0 ;;
0|false|FALSE|no|NO|off|OFF|"") return 1 ;;
*) return 0 ;;
*) return 1 ;;
esac
}
DEVICE_MATRIX=(
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
)
# ─── Dependency check ─────────────────────────────────────────────────────────
command -v xcparse >/dev/null 2>&1 || {
echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2
fail "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse"
exit 1
}
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1)
# ─── Simulator helpers ────────────────────────────────────────────────────────
resolve_udid() {
local name="$1"; local os="$2"
if [[ -n "$os" ]]; then
# Prefer Shutdown state for a clean start
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}'
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' '
/^--.*--$/ { in_section = ($0 ~ o) }
in_section && $0 ~ n { print $2; exit }
'
else
xcrun simctl list devices | awk -v n="$name" -F '[()]' \
'$0 ~ n && /Shutdown/ {print $2; exit}'
xcrun simctl list devices | awk -v n="$name" -F '[()]' '
$0 ~ n { print $2; exit }
'
fi
}
for device_entry in "${DEVICE_MATRIX[@]}"; do
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry"
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ==="
for lang in de fr en es nl; do
echo "Resetting simulator for a clean start..."
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
if [[ -z "$UDID" ]]; then
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
fi
if [[ -z "$UDID" ]]; then
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
ensure_simulator() {
local name="$1"; local runtime="$2"; local device_type="$3"
local udid
udid=$(resolve_udid "$name" "$runtime")
if [[ -n "$udid" ]]; then
echo "$udid"
return 0
fi
xcrun simctl shutdown "$UDID" || true
if is_truthy "$RESET_SIMULATOR"; then
xcrun simctl erase "$UDID"
else
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do
if [[ -n "$bundle" ]]; then
xcrun simctl terminate "$UDID" "$bundle" || true
xcrun simctl uninstall "$UDID" "$bundle" || true
local runtime_id
runtime_id=$(xcrun simctl list runtimes | awk -v r="iOS $runtime" '$0 ~ r {for(i=1;i<=NF;i++) if($i ~ /com\.apple/) {print $i; exit}}')
if [[ -z "$runtime_id" ]]; then
fail "Runtime iOS $runtime not found" >&2
return 1
fi
done
fi
echo "Running screenshots for $lang"
info "Creating simulator: $name (iOS $runtime)" >&2
udid=$(xcrun simctl create "$name" "$device_type" "$runtime_id")
echo "$udid"
}
prepare_simulator() {
local udid="$1" lang="$2"
local region
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
if [[ -z "$UDID" ]]; then
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
fi
if [[ -z "$UDID" ]]; then
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
fi
xcrun simctl boot "$udid" 2>/dev/null || true
xcrun simctl boot "$UDID" || true
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
xcrun simctl shutdown "$UDID" || true
xcrun simctl boot "$UDID"
xcrun simctl status_bar booted override \
--time "9:41" \
# Language & locale
xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
xcrun simctl spawn "$udid" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
# Suppress notifications
xcrun simctl spawn "$udid" defaults write com.apple.springboard DoNotDisturb -bool true 2>/dev/null || true
xcrun simctl spawn "$udid" defaults write com.apple.generativeexperiences.corefollowup \
DateOfLastAppleIntelligenceReadinessCFU -date "2020-01-01T00:00:00Z" 2>/dev/null || true
xcrun simctl spawn "$udid" defaults write com.apple.corefollow DisableFollowUp -bool true 2>/dev/null || true
xcrun simctl spawn "$udid" defaults write com.apple.corefollowup DisableFollowUp -bool true 2>/dev/null || true
# Reboot for language change
xcrun simctl shutdown "$udid" 2>/dev/null || true
xcrun simctl boot "$udid"
# Clean status bar
xcrun simctl status_bar "$udid" override \
--time "$STATUS_BAR_TIME" \
--batteryState charged --batteryLevel 100 \
--wifiBars 3
--wifiBars 3 \
2>/dev/null || true
}
xcrun simctl spawn "$UDID" defaults write com.apple.springboard DoNotDisturb -bool true
# ─── Build ────────────────────────────────────────────────────────────────────
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
build_for_testing() {
local device_entry="$1"
IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry"
local udid
udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type")
if [[ -z "$udid" ]]; then
fail "Could not resolve or create simulator for $dev_name"; return 1
fi
info "Building $dev_name..."
local log_file="/tmp/shooter-build-${dev_slug}.log"
if xcodebuild build-for-testing \
-scheme "$SCHEME" \
-destination "id=$udid" \
-derivedDataPath "$DERIVED_DATA" \
-quiet \
> "$log_file" 2>&1; then
ok "Build succeeded ($dev_name)"
else
fail "Build failed ($dev_name)"
cat "$log_file"
return 1
fi
}
# ─── Test one language ────────────────────────────────────────────────────────
run_one_language() {
local device_entry="$1"
local lang="$2"
IFS='|' read -r dev_name dev_runtime dev_slug dev_type <<<"$device_entry"
local label="${dev_slug}/${lang}"
local udid
udid=$(resolve_udid "$dev_name" "$dev_runtime")
if [[ -z "$udid" ]]; then
udid=$(ensure_simulator "$dev_name" "$dev_runtime" "$dev_type")
fi
if [[ -z "$udid" ]]; then
fail "[$label] Could not resolve simulator"; return 1
fi
# Reset
xcrun simctl shutdown "$udid" 2>/dev/null || true
if is_truthy "$RESET_SIMULATOR"; then
xcrun simctl erase "$udid"
else
for bundle in "$APP_BUNDLE_ID" ${UITEST_BUNDLE_ID:+"$UITEST_BUNDLE_ID"}; do
xcrun simctl terminate "$udid" "$bundle" 2>/dev/null || true
xcrun simctl uninstall "$udid" "$bundle" 2>/dev/null || true
done
fi
udid=$(resolve_udid "$dev_name" "$dev_runtime")
prepare_simulator "$udid" "$lang"
local bundle="results-${dev_slug}-${lang}.xcresult"
local outdir="${OUTPUT_DIR}/${dev_slug}/$lang"
rm -rf "$bundle" "$outdir"
mkdir -p "$outdir"
xcodebuild test \
-scheme "$SCHEME" \
-destination "id=$UDID" \
-resultBundlePath "$bundle"
local log_file="/tmp/shooter-test-${dev_slug}-${lang}.log"
info "[$label] Testing..."
xcparse screenshots "$bundle" "$outdir"
echo "Exported screenshots to $outdir"
xcrun simctl shutdown "$UDID" || true
done
local test_exit=0
xcodebuild test-without-building \
-scheme "$SCHEME" \
-destination "id=$udid" \
-derivedDataPath "$DERIVED_DATA" \
-resultBundlePath "$bundle" \
> "$log_file" 2>&1 || test_exit=$?
if [[ $test_exit -eq 0 ]]; then
xcparse screenshots "$bundle" "$outdir" > /dev/null 2>&1
local count
count=$(find "$outdir" -name '*.png' 2>/dev/null | wc -l | tr -d ' ')
ok "[$label] ${count} screenshots"
else
fail "[$label] Tests failed"
grep -E '(error:|FAIL|failed)' "$log_file" | head -20
if is_truthy "$VERBOSE"; then
printf "${DIM}"; cat "$log_file"; printf "${RST}"
else
printf " ${DIM}Full log: $log_file${RST}\n"
fi
fi
xcrun simctl shutdown "$udid" 2>/dev/null || true
return $test_exit
}
# ─── Main ─────────────────────────────────────────────────────────────────────
step "Configuration"
info "Scheme: $SCHEME"
info "Bundle ID: $APP_BUNDLE_ID"
info "Output: $OUTPUT_DIR"
info "Languages: ${LANGUAGES[*]}"
info "Devices: ${#DEVICE_MATRIX[@]}"
step "Building for testing"
for device_entry in "${DEVICE_MATRIX[@]}"; do
build_for_testing "$device_entry"
done
step "Running screenshot tests"
total_runs=$((${#DEVICE_MATRIX[@]} * ${#LANGUAGES[@]}))
info "${#DEVICE_MATRIX[@]} devices × ${#LANGUAGES[@]} languages = ${total_runs} runs"
failed=0
if is_truthy "$PARALLEL"; then
info "Devices run in parallel"
pids=()
for device_entry in "${DEVICE_MATRIX[@]}"; do
(
for lang in "${LANGUAGES[@]}"; do
run_one_language "$device_entry" "$lang" || true
done
) &
pids+=($!)
done
for pid in "${pids[@]}"; do
wait "$pid" || failed=$((failed + 1))
done
else
for device_entry in "${DEVICE_MATRIX[@]}"; do
for lang in "${LANGUAGES[@]}"; do
run_one_language "$device_entry" "$lang" || failed=$((failed + 1))
done
done
fi
# ─── Summary ──────────────────────────────────────────────────────────────────
step "Done"
total_screenshots=$(find "$OUTPUT_DIR" -name '*.png' 2>/dev/null | wc -l | tr -d ' ')
if [[ $failed -eq 0 ]]; then
ok "All runs passed — ${total_screenshots} screenshots in ${OUTPUT_DIR}/"
else
fail "${failed} run(s) had errors — ${total_screenshots} screenshots in ${OUTPUT_DIR}/"
printf " ${DIM}Re-run with VERBOSE=1 for full logs${RST}\n"
exit 1
fi