Compare commits

..

9 Commits

Author SHA1 Message Date
88e79d79bf Add Play Store listing metadata for fastlane supply
Title, short description, localised full descriptions and the 1.8.0
release notes for en-US, de-DE, es-ES, fr-FR and nl-NL, ready for
`fastlane supply`. The service account key stays out of the repo.
2026-08-12 13:58:09 +02:00
8541130fa3 Generate a baseline profile for release builds
Cold starts ran unoptimised: no profileinstaller dependency and no
profile in the bundle, so ART interpreted Compose on first launch
(class verification measured at 128 bytecodes/s in an emulator ANR).

Add the androidx.baselineprofile producer module with an AOSP managed
device, a generator covering startup plus the four detail tabs, and
commit the generated baseline and startup profiles.
2026-08-12 13:58:01 +02:00
4c0524618d Add missing Gradle wrapper
android/gradle/wrapper only held the properties file, so the project
could not be built from the command line at all. Generate the wrapper
for the pinned 8.11.1 distribution and ignore the .kotlin scratch dir.
2026-08-12 12:27:28 +02:00
40c887a61a Bump build to 87 2026-08-12 11:49:04 +02:00
695d2ccd75 Show action buttons in export previews
QLPreviewController only renders its share, print and open-in actions
when it is inside a navigation controller, so diagram and report
previews were dead ends. Wrap it in one, add an explicit Done button
and assert both in a UI test.
2026-08-12 11:48:39 +02:00
a714a0d0d5 Bump version to 1.8.0 (build 86)
iOS MARKETING_VERSION/CURRENT_PROJECT_VERSION and Android
versionName/versionCode.
2026-08-12 11:26:08 +02:00
ab50728f07 Disable export until a system has components
The Overview share menu offered a wiring diagram and a full report even
for an empty system. Gate it on loads, batteries or chargers being
present (iOS + Android) and add UI tests for both states.
2026-08-12 11:26:03 +02:00
345c8b3ac7 Fix component library tests and cover 0.0 V case
ComponentLibraryItem gained componentCategory, which left the test
target uncompilable. Add the argument to all call sites and a
regression test for displayVoltage skipping a zero voltageIn.
2026-08-12 11:25:57 +02:00
5cf4a2e5b9 Fix Quick Look preview on iOS
.quickLookPreview() is a macOS-only SwiftUI modifier, so the app target
did not compile for iOS at all. Present QLPreviewController through a
UIViewControllerRepresentable in a sheet instead; clearing previewURL
still triggers cleanup and the review prompt.
2026-08-12 11:25:52 +02:00
600 changed files with 181956 additions and 8 deletions

View File

@@ -418,7 +418,7 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 87;
DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
@@ -436,7 +436,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -454,7 +454,7 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 85;
CURRENT_PROJECT_VERSION = 87;
DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
@@ -472,7 +472,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.7.0;
MARKETING_VERSION = 1.8.0;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -56,6 +56,10 @@ struct LoadsView: View {
allChargers.filter { $0.system == system }
}
private var hasComponents: Bool {
!savedLoads.isEmpty || !savedBatteries.isEmpty || !savedChargers.isEmpty
}
var body: some View {
VStack(spacing: 0) {
TabView(selection: $selectedComponentTab) {
@@ -174,6 +178,7 @@ struct LoadsView: View {
} label: {
Image(systemName: "square.and.arrow.up")
}
.disabled(!hasComponents)
.accessibilityIdentifier("system-overview-share-button")
}
} else if showPrimary || showEditLoads || showEditBatteries || showEditChargers {
@@ -220,7 +225,19 @@ struct LoadsView: View {
}
)
}
.quickLookPreview($previewURL)
.sheet(
isPresented: Binding(
get: { previewURL != nil },
set: { if !$0 { previewURL = nil } }
)
) {
if let previewURL {
QuickLookPreview(url: previewURL) {
self.previewURL = nil
}
.ignoresSafeArea()
}
}
.onChange(of: previewURL) { _, newValue in
if newValue == nil {
cleanupPreview()

View File

@@ -0,0 +1,67 @@
//
// QuickLookPreview.swift
// Cable
//
import QuickLook
import SwiftUI
import UIKit
/// Presents a single exported file (PDF or PNG) in Quick Look so the user can
/// inspect it before sharing.
///
/// SwiftUI's `quickLookPreview(_:)` modifier is macOS-only, so iOS wraps
/// `QLPreviewController` directly. The controller only shows its action
/// buttons — share, print, open in another app — when it sits inside a
/// navigation controller, so it is wrapped in one and gets an explicit Done
/// button to close the sheet.
struct QuickLookPreview: UIViewControllerRepresentable {
let url: URL
var onDone: () -> Void
func makeUIViewController(context: Context) -> UINavigationController {
let preview = QLPreviewController()
preview.dataSource = context.coordinator
preview.navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .done,
target: context.coordinator,
action: #selector(Coordinator.done)
)
preview.navigationItem.leftBarButtonItem?.accessibilityIdentifier = "quick-look-done-button"
return UINavigationController(rootViewController: preview)
}
func updateUIViewController(_ controller: UINavigationController, context: Context) {
context.coordinator.onDone = onDone
guard context.coordinator.url != url as NSURL else { return }
context.coordinator.url = url as NSURL
(controller.viewControllers.first as? QLPreviewController)?.reloadData()
}
func makeCoordinator() -> Coordinator {
Coordinator(url: url as NSURL, onDone: onDone)
}
// MARK: - Coordinator
final class Coordinator: NSObject, QLPreviewControllerDataSource {
var url: NSURL
var onDone: () -> Void
init(url: NSURL, onDone: @escaping () -> Void) {
self.url = url
self.onDone = onDone
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
url
}
@objc func done() {
onDone()
}
}
}

View File

@@ -14,6 +14,7 @@ struct ComponentLibraryItemTests {
watt: nil,
dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil,
componentCategory: nil,
iconURL: nil,
)
@@ -31,6 +32,7 @@ struct ComponentLibraryItemTests {
watt: nil,
dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil,
componentCategory: nil,
iconURL: nil,
)
@@ -51,6 +53,7 @@ struct ComponentLibraryItemTests {
watt: nil,
dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil,
componentCategory: nil,
iconURL: nil,
)
@@ -68,6 +71,7 @@ struct ComponentLibraryItemTests {
watt: nil,
dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil,
componentCategory: nil,
iconURL: nil,
)
@@ -85,6 +89,7 @@ struct ComponentLibraryItemTests {
watt: nil,
dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil,
componentCategory: nil,
iconURL: nil,
)
@@ -102,6 +107,7 @@ struct ComponentLibraryItemTests {
watt: nil,
dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil,
componentCategory: nil,
iconURL: nil,
)
@@ -119,6 +125,7 @@ struct ComponentLibraryItemTests {
watt: nil,
dutyCyclePercent: 0,
defaultUtilizationFactorPercent: nil,
componentCategory: nil,
iconURL: nil,
)
@@ -135,9 +142,27 @@ struct ComponentLibraryItemTests {
watt: nil,
dutyCyclePercent: nil,
defaultUtilizationFactorPercent: 50,
componentCategory: nil,
iconURL: nil,
)
#expect(item.defaultDailyUsageHours == 12)
}
@Test func displayVoltageIgnoresZeroInputVoltage() async throws {
let item = ComponentLibraryItem(
id: "component-9",
name: "Library Battery",
translations: [:],
voltageIn: 0,
voltageOut: 12.8,
watt: nil,
dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil,
componentCategory: nil,
iconURL: nil,
)
#expect(item.displayVoltage == 12.8)
}
}

View File

@@ -0,0 +1,115 @@
import XCTest
/// Verifies that the Overview export/share menu is only available once the
/// system actually contains something worth exporting.
final class SystemExportButtonUITests: XCTestCase {
override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
}
@MainActor
func testExportIsDisabledForSystemWithoutComponents() throws {
let app = launch(arguments: ["--uitest-reset-data"])
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 15))
createSystemButton.tap()
let shareButton = app.buttons["system-overview-share-button"]
XCTAssertTrue(shareButton.waitForExistence(timeout: 15))
XCTAssertFalse(
shareButton.isEnabled,
"Export must stay disabled while the system has no loads, batteries or chargers"
)
}
@MainActor
func testExportIsEnabledOnceComponentsExist() throws {
let app = launch(arguments: ["--uitest-reset-data", "--uitest-sample-data"])
openFirstSystem(in: app)
let shareButton = app.buttons["system-overview-share-button"]
XCTAssertTrue(shareButton.waitForExistence(timeout: 15))
XCTAssertTrue(
shareButton.isEnabled,
"Export must be available for a system that has components"
)
}
@MainActor
func testReportPreviewOffersActionButtons() throws {
let app = launch(arguments: ["--uitest-reset-data", "--uitest-sample-data"])
openFirstSystem(in: app)
let shareButton = app.buttons["system-overview-share-button"]
XCTAssertTrue(shareButton.waitForExistence(timeout: 15))
shareButton.tap()
let reportItem = app.buttons.matching(
NSPredicate(format: "label CONTAINS[c] 'PDF'")
).firstMatch
XCTAssertTrue(reportItem.waitForExistence(timeout: 10))
reportItem.tap()
// Quick Look lives in its own navigation stack; the Done button proves
// the navigation bar exists, the share item proves the file can leave
// the preview.
let doneButton = app.buttons["quick-look-done-button"]
XCTAssertTrue(
doneButton.waitForExistence(timeout: 60),
"Quick Look preview must be embedded in a navigation bar"
)
// Scope the action lookup to the preview's own navigation bar so the
// Overview share button underneath can never satisfy it.
let previewBar = app.navigationBars.containing(
.button,
identifier: "quick-look-done-button"
).firstMatch
XCTAssertTrue(previewBar.waitForExistence(timeout: 10))
let actionButtons = previewBar.buttons.allElementsBoundByIndex
.filter { $0.identifier != "quick-look-done-button" }
XCTAssertFalse(
actionButtons.isEmpty,
"Quick Look preview must offer an action button to share or open the file"
)
doneButton.tap()
XCTAssertTrue(shareButton.waitForExistence(timeout: 10))
}
// MARK: - Helpers
private func launch(arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = arguments
app.launch()
return app
}
private func openFirstSystem(in app: XCUIApplication) {
let list: XCUIElement
if app.collectionViews["systems-list"].waitForExistence(timeout: 15) {
list = app.collectionViews["systems-list"]
} else {
list = app.collectionViews.firstMatch
}
XCTAssertTrue(list.waitForExistence(timeout: 15))
let firstCell = list.cells.element(boundBy: 0)
XCTAssertTrue(firstCell.waitForExistence(timeout: 10))
let cellButton = firstCell.buttons.firstMatch
if cellButton.exists {
cellButton.tap()
} else {
firstCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
}
}

7
android/.gitignore vendored
View File

@@ -16,3 +16,10 @@
keystore.properties
*.jks
*.keystore
# Gradle build scratch
.kotlin/
# Play service account (never commit)
fastlane/play-service-account.json
fastlane/report.xml

View File

@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.baselineprofile)
}
// Release signing credentials, loaded from android/keystore.properties (gitignored).
@@ -25,8 +26,8 @@ android {
applicationId = "app.voltplan.cable"
minSdk = 26
targetSdk = 35
versionCode = 85
versionName = "1.7.0"
versionCode = 87
versionName = "1.8.0"
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
@@ -117,4 +118,10 @@ dependencies {
implementation(libs.coil.compose)
implementation(libs.play.review.ktx)
// Installs the bundled baseline profile on devices that do not get it from Play.
implementation(libs.androidx.profileinstaller)
// Consumes the profile produced by :baselineprofile.
baselineProfile(project(":baselineprofile"))
}

View File

@@ -157,7 +157,7 @@ fun SystemDetailScreen(
strokeWidth = 2.dp,
)
} else {
IconButton(onClick = { showOverviewMenu = true }) {
IconButton(onClick = { showOverviewMenu = true }, enabled = state.hasComponents) {
Icon(Icons.Outlined.IosShare, contentDescription = stringResource(R.string.overview_share_pdf))
}
}

View File

@@ -22,6 +22,9 @@ data class DetailState(
val chargers: List<SavedCharger> = emptyList(),
) {
val metrics: SystemMetrics get() = SystemMetrics(loads, batteries, chargers)
val hasComponents: Boolean
get() = loads.isNotEmpty() || batteries.isNotEmpty() || chargers.isNotEmpty()
}
class SystemDetailViewModel(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.baselineprofile)
}
android {
namespace = "app.voltplan.cable.baselineprofile"
compileSdk = 35
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
defaultConfig {
// Baseline profile capture needs API 33+ on an unrooted device.
minSdk = 33
targetSdk = 35
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
targetProjectPath = ":app"
// Profiles must be generated on an AOSP image: Play Store images are user
// builds where the benchmark cannot reset the compilation state.
testOptions.managedDevices.allDevices {
create<com.android.build.api.dsl.ManagedVirtualDevice>("pixel6Api34") {
device = "Pixel 6"
apiLevel = 34
systemImageSource = "aosp"
}
}
}
baselineProfile {
managedDevices += "pixel6Api34"
useConnectedDevices = false
}
dependencies {
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}

View File

@@ -0,0 +1,2 @@
#- File Locator -
listingFile=../../../../outputs/apk/nonMinifiedRelease/output-metadata.json

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="androidx.benchmark:benchmark-macro:1.4.1" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets"><file name="trace_processor_shell_x86" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets/trace_processor_shell_x86"/><file name="trace_processor_shell_arm" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets/trace_processor_shell_arm"/><file name="trace_processor_shell_aarch64" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets/trace_processor_shell_aarch64"/><file name="trace_processor_shell_x86_64" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/f271377597a5382a6d80241136ca586d/transformed/benchmark-macro-1.4.1/assets/trace_processor_shell_x86_64"/></source></dataSet><dataSet config="androidx.benchmark:benchmark-common:1.4.1" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets"><file name="tracebox_x86" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets/tracebox_x86"/><file name="tracebox_arm" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets/tracebox_arm"/><file name="tracebox_x86_64" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets/tracebox_x86_64"/><file name="tracebox_aarch64" path="/Users/lange-hegermann/.gradle/caches/8.11.1/transforms/1e8a3a9d48c6a6250f9534cccde84f8d/transformed/benchmark-common-1.4.1/assets/tracebox_aarch64"/></source></dataSet><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/main/assets"/></dataSet><dataSet config="nonMinifiedRelease" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/nonMinifiedRelease/assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/build/intermediates/shader_assets/nonMinifiedRelease/compileNonMinifiedReleaseShaders/out"/></dataSet></merger>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/main/jniLibs"/></dataSet><dataSet config="nonMinifiedRelease" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/nonMinifiedRelease/jniLibs"/></dataSet></merger>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/main/shaders"/></dataSet><dataSet config="nonMinifiedRelease" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/lange-hegermann/Documents/Development/ios-macos/Cable-Swift/Cable/android/baselineprofile/src/nonMinifiedRelease/shaders"/></dataSet></merger>

Some files were not shown because too many files have changed in this diff Show More