Compare commits
2 Commits
165827c4d4
...
61e0cb061f
| Author | SHA1 | Date | |
|---|---|---|---|
| 61e0cb061f | |||
| 9673bde107 |
@@ -351,3 +351,5 @@
|
|||||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — for non-critical loads such as cabin lighting.";
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — for non-critical loads such as cabin lighting.";
|
||||||
"settings.voltage_drop.label" = "Voltage drop budget";
|
"settings.voltage_drop.label" = "Voltage drop budget";
|
||||||
"settings.voltage_drop.footnote" = "Used by the calculator and for new systems. Existing systems keep their own budget.";
|
"settings.voltage_drop.footnote" = "Used by the calculator and for new systems. Existing systems keep their own budget.";
|
||||||
|
"settings.rate.title" = "Rate Cable";
|
||||||
|
"settings.rate.footnote" = "Ratings are how other installers find Cable in the App Store.";
|
||||||
|
|||||||
@@ -242,7 +242,9 @@ struct LoadsView: View {
|
|||||||
.onChange(of: previewURL) { _, newValue in
|
.onChange(of: previewURL) { _, newValue in
|
||||||
if newValue == nil {
|
if newValue == nil {
|
||||||
cleanupPreview()
|
cleanupPreview()
|
||||||
ReviewPrompt.registerSuccessfulExport()
|
ReviewPrompt.record(.exported)
|
||||||
|
// The QuickLook sheet is still animating away; `promptIfEligible` waits it out.
|
||||||
|
ReviewPrompt.promptIfEligible()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
@@ -333,6 +335,11 @@ struct LoadsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
LoadCableSync.synchronize(loads: savedLoads, for: system)
|
LoadCableSync.synchronize(loads: savedLoads, for: system)
|
||||||
|
|
||||||
|
if !savedLoads.isEmpty && loadStatus == nil {
|
||||||
|
ReviewPrompt.record(.systemPlanned)
|
||||||
|
}
|
||||||
|
ReviewPrompt.promptIfEligible()
|
||||||
|
|
||||||
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
||||||
hasPresentedSystemEditorOnAppear = true
|
hasPresentedSystemEditorOnAppear = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -922,6 +929,7 @@ struct LoadsView: View {
|
|||||||
"system": system.name
|
"system": system.name
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
ReviewPrompt.record(.billOfMaterials)
|
||||||
showingSystemBOM = true
|
showingSystemBOM = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
// Decides when to ask the user for an App Store rating via StoreKit's
|
// 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
|
// `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
|
// 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.
|
// moments. Recording a milestone and asking for the rating are deliberately separate:
|
||||||
|
// milestones are reached in the middle of a task (a sheet opens, a share sheet closes),
|
||||||
|
// which is exactly when StoreKit drops the request. `promptIfEligible()` is therefore only
|
||||||
|
// called from calm screens. Mirrors the Android `ReviewPrompt` object.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -13,97 +16,125 @@ import StoreKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
enum ReviewPrompt {
|
enum ReviewPrompt {
|
||||||
|
/// Distinct proofs that the user got real value out of the app. Each one counts at most
|
||||||
|
/// once per install, so navigating in circles cannot inflate the gate.
|
||||||
|
enum Milestone: String {
|
||||||
|
/// A system whose loads are all fully configured — the app's own definition of a finished plan.
|
||||||
|
case systemPlanned
|
||||||
|
/// Opened a system's bill of materials.
|
||||||
|
case billOfMaterials
|
||||||
|
/// Completed an export/share (Overview PDF, BOM PDF, diagram image).
|
||||||
|
case exported
|
||||||
|
}
|
||||||
|
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let migrationDone = "review.migrationDone"
|
static let gateVersion = "review.gateVersion"
|
||||||
static let firstLaunchDate = "review.firstLaunchDate"
|
static let milestones = "review.milestones"
|
||||||
static let exportCount = "review.successfulExportCount"
|
|
||||||
static let lastPromptDate = "review.lastPromptDate"
|
static let lastPromptDate = "review.lastPromptDate"
|
||||||
static let lastPromptedVersion = "review.lastPromptedVersion"
|
static let lastPromptedVersion = "review.lastPromptedVersion"
|
||||||
static let userType = "review.userType"
|
static let userType = "review.userType"
|
||||||
|
static let legacyExportCount = "review.successfulExportCount"
|
||||||
|
static let legacyFirstLaunchDate = "review.firstLaunchDate"
|
||||||
|
static let legacyMigrationDone = "review.migrationDone"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gate thresholds — see CLAUDE-discussed spec.
|
/// Two *different* milestones — a single one (only opening the parts list, say) is not enough.
|
||||||
private static let minExports = 2
|
private static let minMilestones = 2
|
||||||
private static let minDaysSinceInstall: TimeInterval = 3
|
private static let minDaysBetweenPrompts: TimeInterval = 90
|
||||||
private static let minDaysBetweenPrompts: TimeInterval = 120
|
|
||||||
private static let day: TimeInterval = 86_400
|
private static let day: TimeInterval = 86_400
|
||||||
|
/// Bump when the milestone semantics change so `migrateIfNeeded` runs again.
|
||||||
|
private static let gateVersion = 2
|
||||||
|
/// Lets a sheet dismissal finish before StoreKit tries to present on the same scene.
|
||||||
|
private static let presentationDelay: TimeInterval = 0.7
|
||||||
|
|
||||||
private static var defaults: UserDefaults { .standard }
|
/// Deep link for the manual entry point in Settings. Unlike `AppStore.requestReview`, this
|
||||||
|
/// is never throttled or suppressed, so it is the only path a willing user can always take.
|
||||||
|
static let writeReviewURL = URL(string: "https://apps.apple.com/app/id6752443870?action=write-review")!
|
||||||
|
|
||||||
/// One-time setup distinguishing fresh installs from users updating into this feature.
|
/// Injection seam for tests; production always uses `.standard`.
|
||||||
/// Existing users are backdated and pre-seeded so the prompt can fire on their *first*
|
static var store: UserDefaults = .standard
|
||||||
/// successful export after updating. Pass the `isFirstLaunch` value already computed in
|
|
||||||
/// `AppDelegate` (the existing `hasLaunchedBefore` flag).
|
/// One-time setup, called on every launch. Existing installs keep the credit they earned
|
||||||
|
/// under the previous export-only gate: the legacy counter was pre-seeded on update, so any
|
||||||
|
/// non-zero value means "knows the app already" and counts as the export milestone. They
|
||||||
|
/// still need a second, real milestone before we ask.
|
||||||
static func migrateIfNeeded(isFirstLaunch: Bool) {
|
static func migrateIfNeeded(isFirstLaunch: Bool) {
|
||||||
guard !defaults.bool(forKey: Key.migrationDone) else { return }
|
guard store.integer(forKey: Key.gateVersion) < gateVersion else { return }
|
||||||
let now = Date().timeIntervalSince1970
|
|
||||||
if isFirstLaunch {
|
if store.object(forKey: Key.userType) == nil {
|
||||||
// Genuine new install: normal flow — needs 2 exports and 3 days.
|
store.set(isFirstLaunch ? "new" : "existing", forKey: Key.userType)
|
||||||
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)
|
if store.integer(forKey: Key.legacyExportCount) > 0 {
|
||||||
|
record(.exported)
|
||||||
|
}
|
||||||
|
for key in [Key.legacyExportCount, Key.legacyFirstLaunchDate, Key.legacyMigrationDone] {
|
||||||
|
store.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
store.set(gateVersion, forKey: Key.gateVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
|
/// Books a success moment. Never presents anything — safe to call from anywhere.
|
||||||
/// Increments the shared counter, then requests a review if every gate condition holds.
|
static func record(_ milestone: Milestone) {
|
||||||
|
var reached = Set(store.stringArray(forKey: Key.milestones) ?? [])
|
||||||
|
guard reached.insert(milestone.rawValue).inserted else { return }
|
||||||
|
store.set(reached.sorted(), forKey: Key.milestones)
|
||||||
|
|
||||||
|
AnalyticsTracker.log("Review Milestone Reached", properties: [
|
||||||
|
"milestone": milestone.rawValue,
|
||||||
|
"reached": reached.count,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for a rating if the gate allows it. Call only from a screen at rest — never while a
|
||||||
|
/// sheet is being presented or dismissed.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func registerSuccessfulExport() {
|
static func promptIfEligible() {
|
||||||
// Guard against an export that races ahead of migration.
|
guard isEligible else { return }
|
||||||
if defaults.object(forKey: Key.firstLaunchDate) == nil {
|
DispatchQueue.main.asyncAfter(deadline: .now() + presentationDelay) { present() }
|
||||||
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 {
|
/// Internal rather than private so the gate can be tested without a foreground scene.
|
||||||
// A: enough successful exports
|
static var isEligible: Bool {
|
||||||
guard exportCount >= minExports else { return false }
|
// A: enough distinct milestones
|
||||||
|
let reached = store.stringArray(forKey: Key.milestones) ?? []
|
||||||
|
guard reached.count >= minMilestones else { return false }
|
||||||
|
|
||||||
|
// B: not prompted too recently
|
||||||
let now = Date().timeIntervalSince1970
|
let now = Date().timeIntervalSince1970
|
||||||
|
let lastPrompt = store.double(forKey: Key.lastPromptDate)
|
||||||
// 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 }
|
if lastPrompt > 0, now - lastPrompt < minDaysBetweenPrompts * day { return false }
|
||||||
|
|
||||||
// D: at most once per app version
|
// C: at most once per app version
|
||||||
if defaults.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
|
if store.string(forKey: Key.lastPromptedVersion) == currentVersion { return false }
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private static func request() {
|
private static func present() {
|
||||||
// Mark as requested up front — the OS may suppress the dialog, but we still
|
// Re-check after the delay: the app may have been backgrounded, or another trigger
|
||||||
// count it against our own throttle so we don't ask again immediately.
|
// may have gotten there first. Only mark the throttle once we really can present —
|
||||||
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
|
// otherwise a suppressed request would burn this version's single slot.
|
||||||
defaults.set(currentVersion, forKey: Key.lastPromptedVersion)
|
guard isEligible, let scene = activeScene else { return }
|
||||||
|
|
||||||
|
store.set(Date().timeIntervalSince1970, forKey: Key.lastPromptDate)
|
||||||
|
store.set(currentVersion, forKey: Key.lastPromptedVersion)
|
||||||
|
|
||||||
AnalyticsTracker.log("Review Prompt Requested", properties: [
|
AnalyticsTracker.log("Review Prompt Requested", properties: [
|
||||||
"version": currentVersion,
|
"version": currentVersion,
|
||||||
"userType": defaults.string(forKey: Key.userType) ?? "unknown",
|
"userType": store.string(forKey: Key.userType) ?? "unknown",
|
||||||
|
"milestones": (store.stringArray(forKey: Key.milestones) ?? []).joined(separator: ","),
|
||||||
])
|
])
|
||||||
|
|
||||||
guard let scene = UIApplication.shared.connectedScenes
|
|
||||||
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene else { return }
|
|
||||||
AppStore.requestReview(in: scene)
|
AppStore.requestReview(in: scene)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static var activeScene: UIWindowScene? {
|
||||||
|
UIApplication.shared.connectedScenes
|
||||||
|
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene
|
||||||
|
}
|
||||||
|
|
||||||
private static var currentVersion: String {
|
private static var currentVersion: String {
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
AnalyticsTracker.log("Rate App Tapped")
|
||||||
|
openURL(ReviewPrompt.writeReviewURL)
|
||||||
|
} label: {
|
||||||
|
Label {
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
localized: "settings.rate.title",
|
||||||
|
defaultValue: "Rate Cable"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings-rate-app")
|
||||||
|
} footer: {
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
localized: "settings.rate.footnote",
|
||||||
|
defaultValue: "Ratings are how other installers find Cable in the App Store."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
|||||||
@@ -303,7 +303,8 @@ struct SystemBillOfMaterialsView: View {
|
|||||||
.accessibilityIdentifier("system-bom-view")
|
.accessibilityIdentifier("system-bom-view")
|
||||||
.sheet(item: $activeShareItem, onDismiss: {
|
.sheet(item: $activeShareItem, onDismiss: {
|
||||||
cleanupShareItem()
|
cleanupShareItem()
|
||||||
ReviewPrompt.registerSuccessfulExport()
|
ReviewPrompt.record(.exported)
|
||||||
|
ReviewPrompt.promptIfEligible()
|
||||||
}) { item in
|
}) { item in
|
||||||
ShareSheet(items: [item.url])
|
ShareSheet(items: [item.url])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ struct SystemsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("systems-settings")
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -416,3 +416,5 @@
|
|||||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — für unkritische Verbraucher wie Innenbeleuchtung.";
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — für unkritische Verbraucher wie Innenbeleuchtung.";
|
||||||
"settings.voltage_drop.label" = "Spannungsfall-Budget";
|
"settings.voltage_drop.label" = "Spannungsfall-Budget";
|
||||||
"settings.voltage_drop.footnote" = "Gilt für den Rechner und neue Systeme. Bestehende Systeme behalten ihr eigenes Budget.";
|
"settings.voltage_drop.footnote" = "Gilt für den Rechner und neue Systeme. Bestehende Systeme behalten ihr eigenes Budget.";
|
||||||
|
"settings.rate.title" = "Cable bewerten";
|
||||||
|
"settings.rate.footnote" = "Bewertungen helfen anderen Monteuren, Cable im App Store zu finden.";
|
||||||
|
|||||||
@@ -417,3 +417,5 @@
|
|||||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — para consumos no críticos como la iluminación interior.";
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — para consumos no críticos como la iluminación interior.";
|
||||||
"settings.voltage_drop.label" = "Caída de tensión admisible";
|
"settings.voltage_drop.label" = "Caída de tensión admisible";
|
||||||
"settings.voltage_drop.footnote" = "Se aplica a la calculadora y a los sistemas nuevos. Los sistemas existentes conservan su valor.";
|
"settings.voltage_drop.footnote" = "Se aplica a la calculadora y a los sistemas nuevos. Los sistemas existentes conservan su valor.";
|
||||||
|
"settings.rate.title" = "Valorar Cable";
|
||||||
|
"settings.rate.footnote" = "Las valoraciones ayudan a que otros instaladores encuentren Cable en el App Store.";
|
||||||
|
|||||||
@@ -417,3 +417,5 @@
|
|||||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — pour les charges non critiques comme l’éclairage intérieur.";
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — pour les charges non critiques comme l’éclairage intérieur.";
|
||||||
"settings.voltage_drop.label" = "Chute de tension admissible";
|
"settings.voltage_drop.label" = "Chute de tension admissible";
|
||||||
"settings.voltage_drop.footnote" = "S’applique au calculateur et aux nouvelles installations. Les installations existantes gardent leur valeur.";
|
"settings.voltage_drop.footnote" = "S’applique au calculateur et aux nouvelles installations. Les installations existantes gardent leur valeur.";
|
||||||
|
"settings.rate.title" = "Noter Cable";
|
||||||
|
"settings.rate.footnote" = "Les avis aident les autres installateurs à trouver Cable sur l’App Store.";
|
||||||
|
|||||||
@@ -417,3 +417,5 @@
|
|||||||
"editor.system.voltage_drop.hint.noncritical" = "10 % — voor niet-kritische verbruikers zoals binnenverlichting.";
|
"editor.system.voltage_drop.hint.noncritical" = "10 % — voor niet-kritische verbruikers zoals binnenverlichting.";
|
||||||
"settings.voltage_drop.label" = "Toegestane spanningsval";
|
"settings.voltage_drop.label" = "Toegestane spanningsval";
|
||||||
"settings.voltage_drop.footnote" = "Geldt voor de rekenhulp en nieuwe systemen. Bestaande systemen houden hun eigen waarde.";
|
"settings.voltage_drop.footnote" = "Geldt voor de rekenhulp en nieuwe systemen. Bestaande systemen houden hun eigen waarde.";
|
||||||
|
"settings.rate.title" = "Cable beoordelen";
|
||||||
|
"settings.rate.footnote" = "Beoordelingen helpen andere installateurs om Cable in de App Store te vinden.";
|
||||||
|
|||||||
114
CableTests/ReviewPromptTests.swift
Normal file
114
CableTests/ReviewPromptTests.swift
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Cable
|
||||||
|
|
||||||
|
/// The rating gate is invisible in the app — the OS may swallow the dialog — so its rules are
|
||||||
|
/// only observable here. Serialized because `ReviewPrompt.store` is process-wide state.
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct ReviewPromptTests {
|
||||||
|
|
||||||
|
private func withFreshStore(_ body: (UserDefaults) -> Void) {
|
||||||
|
let name = "review.tests.\(UUID().uuidString)"
|
||||||
|
guard let defaults = UserDefaults(suiteName: name) else {
|
||||||
|
Issue.record("could not create a test defaults suite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let previous = ReviewPrompt.store
|
||||||
|
ReviewPrompt.store = defaults
|
||||||
|
defer {
|
||||||
|
ReviewPrompt.store = previous
|
||||||
|
defaults.removePersistentDomain(forName: name)
|
||||||
|
}
|
||||||
|
body(defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var appVersion: String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func oneMilestoneIsNotEnough() {
|
||||||
|
withFreshStore { _ in
|
||||||
|
ReviewPrompt.record(.billOfMaterials)
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func twoDistinctMilestonesOpenTheGate() {
|
||||||
|
withFreshStore { _ in
|
||||||
|
ReviewPrompt.record(.systemPlanned)
|
||||||
|
ReviewPrompt.record(.billOfMaterials)
|
||||||
|
#expect(ReviewPrompt.isEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func repeatingOneMilestoneNeverOpensTheGate() {
|
||||||
|
withFreshStore { store in
|
||||||
|
for _ in 0..<5 { ReviewPrompt.record(.systemPlanned) }
|
||||||
|
#expect(store.stringArray(forKey: "review.milestones") == ["systemPlanned"])
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aRecentPromptBlocksTheGate() {
|
||||||
|
withFreshStore { store in
|
||||||
|
ReviewPrompt.record(.systemPlanned)
|
||||||
|
ReviewPrompt.record(.exported)
|
||||||
|
store.set(Date().timeIntervalSince1970 - 10 * 86_400, forKey: "review.lastPromptDate")
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
|
||||||
|
store.set(Date().timeIntervalSince1970 - 91 * 86_400, forKey: "review.lastPromptDate")
|
||||||
|
#expect(ReviewPrompt.isEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func theCurrentVersionIsOnlyAskedOnce() {
|
||||||
|
withFreshStore { store in
|
||||||
|
ReviewPrompt.record(.systemPlanned)
|
||||||
|
ReviewPrompt.record(.exported)
|
||||||
|
store.set(appVersion, forKey: "review.lastPromptedVersion")
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
|
||||||
|
store.set("0.0.0-old", forKey: "review.lastPromptedVersion")
|
||||||
|
#expect(ReviewPrompt.isEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs that already exported under the previous gate keep that credit, but still need a
|
||||||
|
/// second, genuine milestone before we ask.
|
||||||
|
@Test func migrationCarriesLegacyExportCreditAndDropsOldKeys() {
|
||||||
|
withFreshStore { store in
|
||||||
|
store.set(1, forKey: "review.successfulExportCount")
|
||||||
|
store.set(true, forKey: "review.migrationDone")
|
||||||
|
store.set(Date().timeIntervalSince1970, forKey: "review.firstLaunchDate")
|
||||||
|
|
||||||
|
ReviewPrompt.migrateIfNeeded(isFirstLaunch: false)
|
||||||
|
|
||||||
|
#expect(store.stringArray(forKey: "review.milestones") == ["exported"])
|
||||||
|
#expect(store.string(forKey: "review.userType") == "existing")
|
||||||
|
#expect(store.object(forKey: "review.successfulExportCount") == nil)
|
||||||
|
#expect(store.object(forKey: "review.migrationDone") == nil)
|
||||||
|
#expect(store.object(forKey: "review.firstLaunchDate") == nil)
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
|
||||||
|
ReviewPrompt.record(.billOfMaterials)
|
||||||
|
#expect(ReviewPrompt.isEligible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func aFreshInstallStartsWithNoCredit() {
|
||||||
|
withFreshStore { store in
|
||||||
|
ReviewPrompt.migrateIfNeeded(isFirstLaunch: true)
|
||||||
|
|
||||||
|
#expect(store.stringArray(forKey: "review.milestones") == nil)
|
||||||
|
#expect(store.string(forKey: "review.userType") == "new")
|
||||||
|
#expect(ReviewPrompt.isEligible == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func theManualReviewLinkTargetsTheStoreListing() {
|
||||||
|
#expect(
|
||||||
|
ReviewPrompt.writeReviewURL.absoluteString
|
||||||
|
== "https://apps.apple.com/app/id6752443870?action=write-review"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
CableUITestsScreenshot/SettingsRateAppUITests.swift
Normal file
37
CableUITestsScreenshot/SettingsRateAppUITests.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// The manual rating entry point is the only path a willing user can always take — StoreKit's
|
||||||
|
/// dialog may be suppressed by the OS. If this row disappears, ratings stop entirely.
|
||||||
|
final class SettingsRateAppUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
try super.setUpWithError()
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSettingsOffersARateAppRow() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = [
|
||||||
|
"--uitest-reset-data",
|
||||||
|
"--uitest-sample-data",
|
||||||
|
"-AppleLanguages", "(en)",
|
||||||
|
"-AppleLocale", "en_US",
|
||||||
|
]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
let settingsButton = app.buttons["systems-settings"]
|
||||||
|
XCTAssertTrue(settingsButton.waitForExistence(timeout: 15))
|
||||||
|
settingsButton.tap()
|
||||||
|
|
||||||
|
let rateRow = app.buttons["settings-rate-app"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
rateRow.waitForExistence(timeout: 10),
|
||||||
|
"Settings no longer offers a way to rate the app"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
rateRow.label.contains("Rate Cable"),
|
||||||
|
"Rate row shows \"\(rateRow.label)\" instead of the localized title"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,14 +20,14 @@ val hasReleaseSigning = keystoreProps.getProperty("storeFile")?.let { file(it).e
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "app.voltplan.cable"
|
namespace = "app.voltplan.cable"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.voltplan.cable"
|
applicationId = "app.voltplan.cable"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = 87
|
versionCode = 88
|
||||||
versionName = "1.8.0"
|
versionName = "1.8.1"
|
||||||
|
|
||||||
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
|
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
|
||||||
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
|
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import app.voltplan.cable.BuildConfig
|
import app.voltplan.cable.BuildConfig
|
||||||
@@ -20,109 +21,144 @@ import kotlinx.coroutines.flow.first
|
|||||||
/**
|
/**
|
||||||
* Decides when to ask the user for a Play Store rating via the Play In-App Review API.
|
* 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
|
* 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.
|
* keeps requests rare and tied to genuine success moments. Booking a milestone and asking for the
|
||||||
* Mirrors the iOS `ReviewPrompt` enum, sharing the same gate thresholds and the `cable_settings`
|
* rating are deliberately separate: milestones are reached mid-task (a screen opens, a share
|
||||||
* DataStore so both platforms behave identically.
|
* intent fires), which is exactly when the review flow gets dropped. [promptIfEligible] is
|
||||||
|
* therefore only called from calm screens.
|
||||||
|
* Mirrors the iOS `ReviewPrompt` enum, sharing the same milestones and thresholds and the
|
||||||
|
* `cable_settings` DataStore so both platforms behave identically.
|
||||||
*/
|
*/
|
||||||
object ReviewPrompt {
|
object ReviewPrompt {
|
||||||
private val MIGRATION_DONE = stringPreferencesKey("review.migrationDone")
|
/**
|
||||||
private val FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate")
|
* Distinct proofs that the user got real value out of the app. Each one counts at most once
|
||||||
private val EXPORT_COUNT = intPreferencesKey("review.successfulExportCount")
|
* per install, so navigating in circles cannot inflate the gate.
|
||||||
|
*/
|
||||||
|
enum class Milestone(val key: String) {
|
||||||
|
/** A system whose loads are all fully configured — the app's own definition of a finished plan. */
|
||||||
|
SYSTEM_PLANNED("systemPlanned"),
|
||||||
|
|
||||||
|
/** Opened a system's bill of materials. */
|
||||||
|
BILL_OF_MATERIALS("billOfMaterials"),
|
||||||
|
|
||||||
|
/** Completed an export/share (Overview PDF, BOM PDF, diagram image). */
|
||||||
|
EXPORTED("exported"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private val GATE_VERSION = intPreferencesKey("review.gateVersion")
|
||||||
|
private val MILESTONES = stringSetPreferencesKey("review.milestones")
|
||||||
private val LAST_PROMPT_DATE = longPreferencesKey("review.lastPromptDate")
|
private val LAST_PROMPT_DATE = longPreferencesKey("review.lastPromptDate")
|
||||||
private val LAST_PROMPTED_VERSION = stringPreferencesKey("review.lastPromptedVersion")
|
private val LAST_PROMPTED_VERSION = stringPreferencesKey("review.lastPromptedVersion")
|
||||||
private val USER_TYPE = stringPreferencesKey("review.userType")
|
private val USER_TYPE = stringPreferencesKey("review.userType")
|
||||||
|
private val LEGACY_EXPORT_COUNT = intPreferencesKey("review.successfulExportCount")
|
||||||
|
private val LEGACY_FIRST_LAUNCH_DATE = longPreferencesKey("review.firstLaunchDate")
|
||||||
|
private val LEGACY_MIGRATION_DONE = stringPreferencesKey("review.migrationDone")
|
||||||
|
|
||||||
private const val MIN_EXPORTS = 2
|
/** Two *different* milestones — a single one (only opening the parts list, say) is not enough. */
|
||||||
private const val MIN_DAYS_SINCE_INSTALL = 3L
|
private const val MIN_MILESTONES = 2
|
||||||
private const val MIN_DAYS_BETWEEN_PROMPTS = 120L
|
private const val MIN_DAYS_BETWEEN_PROMPTS = 90L
|
||||||
private const val DAY_MS = 24L * 60 * 60 * 1000
|
private const val DAY_MS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Bump when the milestone semantics change so [migrateIfNeeded] runs again. */
|
||||||
|
private const val CURRENT_GATE_VERSION = 2
|
||||||
|
|
||||||
|
/** Play listing, used by the manual entry point in Settings. Unlike the in-app review flow this
|
||||||
|
* is never throttled or suppressed, so it is the only path a willing user can always take. */
|
||||||
|
const val PLAY_STORE_URI = "market://details?id=app.voltplan.cable"
|
||||||
|
const val PLAY_STORE_WEB_URL = "https://play.google.com/store/apps/details?id=app.voltplan.cable"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time setup distinguishing fresh installs from users updating into this feature.
|
* One-time setup, called on every launch. Existing installs keep the credit they earned under
|
||||||
* Existing users are backdated and pre-seeded so the prompt can fire on their *first*
|
* the previous export-only gate: the legacy counter was pre-seeded on update, so any non-zero
|
||||||
* successful export after updating. Pass the value returned by [UnitSystemSettings.consumeFirstLaunch].
|
* value means "knows the app already" and counts as the export milestone. They still need a
|
||||||
|
* second, real milestone before we ask. Pass the value returned by
|
||||||
|
* [UnitSystemSettings.consumeFirstLaunch].
|
||||||
*/
|
*/
|
||||||
suspend fun migrateIfNeeded(context: Context, isFirstLaunch: Boolean) {
|
suspend fun migrateIfNeeded(context: Context, isFirstLaunch: Boolean) {
|
||||||
if (context.dataStore.data.first()[MIGRATION_DONE] != null) return
|
val prefs = context.dataStore.data.first()
|
||||||
val now = System.currentTimeMillis()
|
if ((prefs[GATE_VERSION] ?: 0) >= CURRENT_GATE_VERSION) return
|
||||||
|
|
||||||
|
if ((prefs[LEGACY_EXPORT_COUNT] ?: 0) > 0) {
|
||||||
|
record(context, Milestone.EXPORTED)
|
||||||
|
}
|
||||||
context.dataStore.edit {
|
context.dataStore.edit {
|
||||||
if (isFirstLaunch) {
|
if (it[USER_TYPE] == null) it[USER_TYPE] = if (isFirstLaunch) "new" else "existing"
|
||||||
// Genuine new install: normal flow — needs 2 exports and 3 days.
|
it.remove(LEGACY_EXPORT_COUNT)
|
||||||
it[FIRST_LAUNCH_DATE] = now
|
it.remove(LEGACY_FIRST_LAUNCH_DATE)
|
||||||
it[EXPORT_COUNT] = 0
|
it.remove(LEGACY_MIGRATION_DONE)
|
||||||
it[USER_TYPE] = "new"
|
it[GATE_VERSION] = CURRENT_GATE_VERSION
|
||||||
} 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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Books a success moment. Never shows anything — safe to call from anywhere. */
|
||||||
|
suspend fun record(context: Context, milestone: Milestone) {
|
||||||
|
var reached = emptySet<String>()
|
||||||
|
var added = false
|
||||||
|
context.dataStore.edit {
|
||||||
|
val current = it[MILESTONES] ?: emptySet()
|
||||||
|
added = milestone.key !in current
|
||||||
|
reached = current + milestone.key
|
||||||
|
if (added) it[MILESTONES] = reached
|
||||||
|
}
|
||||||
|
if (!added) return
|
||||||
|
|
||||||
|
Analytics.log(
|
||||||
|
"Review Milestone Reached",
|
||||||
|
mapOf("milestone" to milestone.key, "reached" to reached.size),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call after any successful export/share (Overview PDF, BOM PDF, Diagram image).
|
* Asks for a rating if the gate allows it. Call only from a screen at rest — never right
|
||||||
* Increments the shared counter, then requests a review if every gate condition holds.
|
* before starting an activity.
|
||||||
*/
|
*/
|
||||||
suspend fun registerSuccessfulExport(context: Context) {
|
suspend fun promptIfEligible(context: Context) {
|
||||||
var count = 0
|
if (!isEligible(context)) return
|
||||||
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 {
|
val activity = context.findActivity() ?: return
|
||||||
// A: enough successful exports
|
val manager = ReviewManagerFactory.create(context)
|
||||||
if (exportCount < MIN_EXPORTS) return false
|
// Play decides whether there is a flow to show at all; asking first means a suppressed
|
||||||
|
// request never burns this version's single slot.
|
||||||
|
val reviewInfo = runCatching { manager.requestReview() }.getOrNull() ?: return
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
// Wait until the activity is resumed so the dialog doesn't overlap a share sheet that was
|
||||||
// B: installed long enough
|
// just launched (startActivity returns immediately, so we may still be paused).
|
||||||
if (now - firstLaunch < MIN_DAYS_SINCE_INSTALL * DAY_MS) return false
|
(activity as? LifecycleOwner)?.lifecycle?.currentStateFlow
|
||||||
|
?.filter { state: Lifecycle.State -> state.isAtLeast(Lifecycle.State.RESUMED) }
|
||||||
|
?.first()
|
||||||
|
|
||||||
val prefs = context.dataStore.data.first()
|
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 {
|
context.dataStore.edit {
|
||||||
it[LAST_PROMPT_DATE] = System.currentTimeMillis()
|
it[LAST_PROMPT_DATE] = System.currentTimeMillis()
|
||||||
it[LAST_PROMPTED_VERSION] = BuildConfig.VERSION_NAME
|
it[LAST_PROMPTED_VERSION] = BuildConfig.VERSION_NAME
|
||||||
}
|
}
|
||||||
val userType = context.dataStore.data.first()[USER_TYPE] ?: "unknown"
|
|
||||||
Analytics.log(
|
Analytics.log(
|
||||||
"Review Prompt Requested",
|
"Review Prompt Requested",
|
||||||
mapOf("version" to BuildConfig.VERSION_NAME, "userType" to userType),
|
mapOf(
|
||||||
|
"version" to BuildConfig.VERSION_NAME,
|
||||||
|
"userType" to (prefs[USER_TYPE] ?: "unknown"),
|
||||||
|
"milestones" to (prefs[MILESTONES] ?: emptySet()).sorted().joinToString(","),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val activity = context.findActivity() ?: return
|
runCatching { manager.launchReview(activity, reviewInfo) }
|
||||||
runCatching {
|
|
||||||
val manager = ReviewManagerFactory.create(context)
|
|
||||||
val reviewInfo = manager.requestReview()
|
|
||||||
// Wait until the activity is resumed so the dialog doesn't overlap a share sheet
|
|
||||||
// that was just launched (startActivity returns immediately, so we may still be paused).
|
|
||||||
(activity as? LifecycleOwner)?.lifecycle?.currentStateFlow
|
|
||||||
?.filter { state: Lifecycle.State -> state.isAtLeast(Lifecycle.State.RESUMED) }
|
|
||||||
?.first()
|
|
||||||
manager.launchReview(activity, reviewInfo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Internal rather than private so the gate can be tested without an Activity. */
|
||||||
|
internal suspend fun isEligible(context: Context): Boolean {
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
|
||||||
|
// A: enough distinct milestones
|
||||||
|
if ((prefs[MILESTONES] ?: emptySet()).size < MIN_MILESTONES) return false
|
||||||
|
|
||||||
|
// B: not prompted too recently
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastPrompt = prefs[LAST_PROMPT_DATE] ?: 0L
|
||||||
|
if (lastPrompt > 0 && now - lastPrompt < MIN_DAYS_BETWEEN_PROMPTS * DAY_MS) return false
|
||||||
|
|
||||||
|
// C: at most once per app version
|
||||||
|
if (prefs[LAST_PROMPTED_VERSION] == BuildConfig.VERSION_NAME) return false
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Context.findActivity(): Activity? {
|
private fun Context.findActivity(): Activity? {
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) {
|
|||||||
vm.logPdfExported()
|
vm.logPdfExported()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
SystemBomPdf.exportAndShare(context, state, unit)
|
SystemBomPdf.exportAndShare(context, state, unit)
|
||||||
ReviewPrompt.registerSuccessfulExport(context)
|
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
|
||||||
|
ReviewPrompt.promptIfEligible(context)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }
|
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package app.voltplan.cable.ui.settings
|
package app.voltplan.cable.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -9,9 +13,11 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.outlined.Warning
|
import androidx.compose.material.icons.outlined.Warning
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
@@ -25,18 +31,24 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
|
import app.voltplan.cable.analytics.Analytics
|
||||||
|
import app.voltplan.cable.data.ReviewPrompt
|
||||||
import app.voltplan.cable.data.UnitSystem
|
import app.voltplan.cable.data.UnitSystem
|
||||||
import app.voltplan.cable.ui.LocalUnitSettings
|
import app.voltplan.cable.ui.LocalUnitSettings
|
||||||
import app.voltplan.cable.ui.theme.SysOrange
|
import app.voltplan.cable.ui.theme.SysOrange
|
||||||
|
import app.voltplan.cable.ui.theme.SysYellow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBack: () -> Unit) {
|
fun SettingsScreen(onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
val settings = LocalUnitSettings.current
|
val settings = LocalUnitSettings.current
|
||||||
val unit by settings.unitSystem.collectAsStateWithLifecycle()
|
val unit by settings.unitSystem.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -68,6 +80,33 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable {
|
||||||
|
Analytics.log("Rate App Tapped")
|
||||||
|
openPlayStoreListing(context)
|
||||||
|
}
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Star, contentDescription = null, tint = SysYellow, modifier = Modifier.size(20.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_rate_title),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_rate_footnote),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Icon(Icons.Outlined.Warning, contentDescription = null, tint = SysOrange, modifier = Modifier.size(18.dp))
|
Icon(Icons.Outlined.Warning, contentDescription = null, tint = SysOrange, modifier = Modifier.size(18.dp))
|
||||||
Text(stringResource(R.string.settings_disclaimer_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.settings_disclaimer_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
@@ -77,3 +116,12 @@ fun SettingsScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the Play listing, falling back to the web listing on devices without the Play app. */
|
||||||
|
private fun openPlayStoreListing(context: Context) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ReviewPrompt.PLAY_STORE_URI)))
|
||||||
|
}.recoverCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ReviewPrompt.PLAY_STORE_WEB_URL)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.outlined.Bolt
|
|||||||
import androidx.compose.material.icons.outlined.IosShare
|
import androidx.compose.material.icons.outlined.IosShare
|
||||||
import androidx.compose.material.icons.outlined.PictureAsPdf
|
import androidx.compose.material.icons.outlined.PictureAsPdf
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -110,6 +111,15 @@ fun SystemDetailScreen(
|
|||||||
var diagramBitmapPreview by remember { mutableStateOf<Bitmap?>(null) }
|
var diagramBitmapPreview by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
val system = state.system
|
val system = state.system
|
||||||
|
|
||||||
|
// A finished plan is the app's own definition of success, and the overview is the calm moment
|
||||||
|
// where a rating request can actually be presented.
|
||||||
|
val systemPlanned = state.loads.isNotEmpty() &&
|
||||||
|
state.loads.all { it.length > 0 && it.current > 0 && it.crossSection > 0 }
|
||||||
|
LaunchedEffect(systemPlanned) {
|
||||||
|
if (systemPlanned) ReviewPrompt.record(context, ReviewPrompt.Milestone.SYSTEM_PLANNED)
|
||||||
|
ReviewPrompt.promptIfEligible(context)
|
||||||
|
}
|
||||||
|
|
||||||
// Switch to the matching tab before opening an editor, so returning from the
|
// Switch to the matching tab before opening an editor, so returning from the
|
||||||
// editor lands on that tab with the newly created component visible.
|
// editor lands on that tab with the newly created component visible.
|
||||||
val newLoad = { tab = ComponentTab.COMPONENTS; onNewLoad() }
|
val newLoad = { tab = ComponentTab.COMPONENTS; onNewLoad() }
|
||||||
@@ -184,7 +194,8 @@ fun SystemDetailScreen(
|
|||||||
exporting = true
|
exporting = true
|
||||||
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
|
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
|
||||||
exporting = false
|
exporting = false
|
||||||
ReviewPrompt.registerSuccessfulExport(context)
|
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
|
||||||
|
ReviewPrompt.promptIfEligible(context)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -221,7 +232,11 @@ fun SystemDetailScreen(
|
|||||||
onAddBattery = newBattery,
|
onAddBattery = newBattery,
|
||||||
onAddCharger = newCharger,
|
onAddCharger = newCharger,
|
||||||
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
|
onOpenLibrary = { onOpenLibrary(ComponentLibraryType.LOAD) },
|
||||||
onOpenBom = { vm.logBomOpened(); onOpenBom() },
|
onOpenBom = {
|
||||||
|
vm.logBomOpened()
|
||||||
|
scope.launch { ReviewPrompt.record(context, ReviewPrompt.Milestone.BILL_OF_MATERIALS) }
|
||||||
|
onOpenBom()
|
||||||
|
},
|
||||||
onSelectLoads = { tab = ComponentTab.COMPONENTS; vm.logTabChange(ComponentTab.COMPONENTS.analytics) },
|
onSelectLoads = { tab = ComponentTab.COMPONENTS; vm.logTabChange(ComponentTab.COMPONENTS.analytics) },
|
||||||
onSelectBatteries = { tab = ComponentTab.BATTERIES; vm.logTabChange(ComponentTab.BATTERIES.analytics) },
|
onSelectBatteries = { tab = ComponentTab.BATTERIES; vm.logTabChange(ComponentTab.BATTERIES.analytics) },
|
||||||
onSelectChargers = { tab = ComponentTab.CHARGERS; vm.logTabChange(ComponentTab.CHARGERS.analytics) },
|
onSelectChargers = { tab = ComponentTab.CHARGERS; vm.logTabChange(ComponentTab.CHARGERS.analytics) },
|
||||||
@@ -261,7 +276,8 @@ fun SystemDetailScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
SystemDiagram.share(context, bmp, state.system?.name ?: "System")
|
SystemDiagram.share(context, bmp, state.system?.name ?: "System")
|
||||||
diagramBitmapPreview = null
|
diagramBitmapPreview = null
|
||||||
ReviewPrompt.registerSuccessfulExport(context)
|
ReviewPrompt.record(context, ReviewPrompt.Milestone.EXPORTED)
|
||||||
|
ReviewPrompt.promptIfEligible(context)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDismiss = { diagramBitmapPreview = null },
|
onDismiss = { diagramBitmapPreview = null },
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Einheiten</string>
|
<string name="settings_units_section">Einheiten</string>
|
||||||
<string name="units_metric_display">Metrisch (mm², m)</string>
|
<string name="units_metric_display">Metrisch (mm², m)</string>
|
||||||
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Cable bewerten</string>
|
||||||
|
<string name="settings_rate_footnote">Bewertungen helfen anderen Monteuren, Cable im Play Store zu finden.</string>
|
||||||
<string name="settings_disclaimer_title">Sicherheitshinweis</string>
|
<string name="settings_disclaimer_title">Sicherheitshinweis</string>
|
||||||
<string name="settings_disclaimer_body">Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.</string>
|
<string name="settings_disclaimer_body">Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.</string>
|
||||||
<string name="settings_disclaimer_points">• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu\n• Beachte alle örtlichen Vorschriften und Normen\n• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden\n• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren\n• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen</string>
|
<string name="settings_disclaimer_points">• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu\n• Beachte alle örtlichen Vorschriften und Normen\n• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden\n• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren\n• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen</string>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Unidades</string>
|
<string name="settings_units_section">Unidades</string>
|
||||||
<string name="units_metric_display">Métrico (mm², m)</string>
|
<string name="units_metric_display">Métrico (mm², m)</string>
|
||||||
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Valorar Cable</string>
|
||||||
|
<string name="settings_rate_footnote">Las valoraciones ayudan a que otros instaladores encuentren Cable en Play Store.</string>
|
||||||
<string name="settings_disclaimer_title">Aviso de seguridad</string>
|
<string name="settings_disclaimer_title">Aviso de seguridad</string>
|
||||||
<string name="settings_disclaimer_body">Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.</string>
|
<string name="settings_disclaimer_body">Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.</string>
|
||||||
<string name="settings_disclaimer_points">• Consulta siempre a electricistas calificados para las instalaciones reales\n• Cumple todas las normativas y códigos eléctricos locales\n• Los trabajos eléctricos solo deben realizarlos profesionales autorizados\n• Estos cálculos pueden no tener en cuenta todos los factores ambientales\n• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas</string>
|
<string name="settings_disclaimer_points">• Consulta siempre a electricistas calificados para las instalaciones reales\n• Cumple todas las normativas y códigos eléctricos locales\n• Los trabajos eléctricos solo deben realizarlos profesionales autorizados\n• Estos cálculos pueden no tener en cuenta todos los factores ambientales\n• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas</string>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Unités</string>
|
<string name="settings_units_section">Unités</string>
|
||||||
<string name="units_metric_display">Métrique (mm², m)</string>
|
<string name="units_metric_display">Métrique (mm², m)</string>
|
||||||
<string name="units_imperial_display">Impérial (AWG, ft)</string>
|
<string name="units_imperial_display">Impérial (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Noter Cable</string>
|
||||||
|
<string name="settings_rate_footnote">Les avis aident les autres installateurs à trouver Cable sur le Play Store.</string>
|
||||||
<string name="settings_disclaimer_title">Avertissement de sécurité</string>
|
<string name="settings_disclaimer_title">Avertissement de sécurité</string>
|
||||||
<string name="settings_disclaimer_body">Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d\'estimation.</string>
|
<string name="settings_disclaimer_body">Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d\'estimation.</string>
|
||||||
<string name="settings_disclaimer_points">• Faites toujours appel à des électriciens qualifiés pour les installations réelles\n• Respectez toutes les normes et réglementations électriques locales\n• Les travaux électriques doivent être réalisés uniquement par des professionnels certifiés\n• Ces calculs peuvent ne pas prendre en compte tous les facteurs environnementaux\n• Les développeurs de l\'application déclinent toute responsabilité quant aux installations électriques</string>
|
<string name="settings_disclaimer_points">• Faites toujours appel à des électriciens qualifiés pour les installations réelles\n• Respectez toutes les normes et réglementations électriques locales\n• Les travaux électriques doivent être réalisés uniquement par des professionnels certifiés\n• Ces calculs peuvent ne pas prendre en compte tous les facteurs environnementaux\n• Les développeurs de l\'application déclinent toute responsabilité quant aux installations électriques</string>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Eenheden</string>
|
<string name="settings_units_section">Eenheden</string>
|
||||||
<string name="units_metric_display">Metrisch (mm², m)</string>
|
<string name="units_metric_display">Metrisch (mm², m)</string>
|
||||||
<string name="units_imperial_display">Imperiaal (AWG, ft)</string>
|
<string name="units_imperial_display">Imperiaal (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Cable beoordelen</string>
|
||||||
|
<string name="settings_rate_footnote">Beoordelingen helpen andere installateurs om Cable in de Play Store te vinden.</string>
|
||||||
<string name="settings_disclaimer_title">Veiligheidswaarschuwing</string>
|
<string name="settings_disclaimer_title">Veiligheidswaarschuwing</string>
|
||||||
<string name="settings_disclaimer_body">Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen.</string>
|
<string name="settings_disclaimer_body">Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen.</string>
|
||||||
<string name="settings_disclaimer_points">• Raadpleeg voor echte installaties altijd een gekwalificeerde elektricien\n• Volg alle lokale elektrische voorschriften en regels\n• Elektrisch werk mag alleen worden uitgevoerd door bevoegde professionals\n• Deze berekeningen houden mogelijk niet met alle omgevingsfactoren rekening\n• De ontwikkelaars van de app aanvaarden geen aansprakelijkheid voor elektrische installaties</string>
|
<string name="settings_disclaimer_points">• Raadpleeg voor echte installaties altijd een gekwalificeerde elektricien\n• Volg alle lokale elektrische voorschriften en regels\n• Elektrisch werk mag alleen worden uitgevoerd door bevoegde professionals\n• Deze berekeningen houden mogelijk niet met alle omgevingsfactoren rekening\n• De ontwikkelaars van de app aanvaarden geen aansprakelijkheid voor elektrische installaties</string>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@
|
|||||||
<string name="settings_units_section">Units</string>
|
<string name="settings_units_section">Units</string>
|
||||||
<string name="units_metric_display">Metric (mm², m)</string>
|
<string name="units_metric_display">Metric (mm², m)</string>
|
||||||
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
<string name="units_imperial_display">Imperial (AWG, ft)</string>
|
||||||
|
<string name="settings_rate_title">Rate Cable</string>
|
||||||
|
<string name="settings_rate_footnote">Ratings are how other installers find Cable in the Play Store.</string>
|
||||||
<string name="settings_disclaimer_title">Safety Disclaimer</string>
|
<string name="settings_disclaimer_title">Safety Disclaimer</string>
|
||||||
<string name="settings_disclaimer_body">This application provides electrical calculations for educational and estimation purposes only.</string>
|
<string name="settings_disclaimer_body">This application provides electrical calculations for educational and estimation purposes only.</string>
|
||||||
<string name="settings_disclaimer_points">• Always consult qualified electricians for actual installations\n• Follow all local electrical codes and regulations\n• Electrical work should only be performed by licensed professionals\n• These calculations may not account for all environmental factors\n• The app developers assume no liability for electrical installations</string>
|
<string name="settings_disclaimer_points">• Always consult qualified electricians for actual installations\n• Follow all local electrical codes and regulations\n• Electrical work should only be performed by licensed professionals\n• These calculations may not account for all environmental factors\n• The app developers assume no liability for electrical installations</string>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "app.voltplan.cable.baselineprofile"
|
namespace = "app.voltplan.cable.baselineprofile"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
@@ -19,7 +19,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
// Baseline profile capture needs API 33+ on an unrooted device.
|
// Baseline profile capture needs API 33+ on an unrooted device.
|
||||||
minSdk = 33
|
minSdk = 33
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.7.3"
|
agp = "8.10.1"
|
||||||
kotlin = "2.1.0"
|
kotlin = "2.1.0"
|
||||||
ksp = "2.1.0-1.0.29"
|
ksp = "2.1.0-1.0.29"
|
||||||
coreKtx = "1.15.0"
|
coreKtx = "1.15.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user