Includes buy now button

This commit is contained in:
Stefan Lange-Hegermann
2025-09-24 19:43:26 +02:00
parent 26d297f8ca
commit 769aa5d2a5
13 changed files with 453 additions and 79 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

39
Cable.icon/icon.json Normal file
View File

@@ -0,0 +1,39 @@
{
"fill" : {
"linear-gradient" : [
"srgb:0.66422,0.66424,0.66423,1.00000",
"extended-gray:1.00000,1.00000"
]
},
"groups" : [
{
"layers" : [
{
"image-name" : "cablebyvoltplan-logomark copy.png",
"name" : "cablebyvoltplan-logomark copy",
"position" : {
"scale" : 1,
"translation-in-points" : [
-483.8671875,
391.375
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -6,6 +6,10 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */ = {isa = PBXBuildFile; fileRef = 3E4BC9B72E7F5E9E0052324A /* Cable.icon */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
3E5C0BDE2E72C0FE00247EC8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
@@ -24,6 +28,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
3E4BC9B72E7F5E9E0052324A /* Cable.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Cable.icon; sourceTree = "<group>"; };
3E5C0BCC2E72C0FD00247EC8 /* Cable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cable.app; sourceTree = BUILT_PRODUCTS_DIR; };
3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -92,6 +97,7 @@
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
3E5C0BCD2E72C0FD00247EC8 /* Products */,
3E4BC9B72E7F5E9E0052324A /* Cable.icon */,
);
sourceTree = "<group>";
};
@@ -225,6 +231,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "Cable-iOS-Default-1024x1024@1x.png",
"filename" : "Icon1024_opaque.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -134,8 +134,10 @@ class SavedLoad {
var isWattMode: Bool = false
var system: ElectricalSystem?
var remoteIconURLString: String? = nil
var affiliateURLString: String? = nil
var affiliateCountryCode: String? = nil
init(name: String, voltage: Double, current: Double, power: Double, length: Double, crossSection: Double, iconName: String = "lightbulb", colorName: String = "blue", isWattMode: Bool = false, system: ElectricalSystem? = nil, remoteIconURLString: String? = nil) {
init(name: String, voltage: Double, current: Double, power: Double, length: Double, crossSection: Double, iconName: String = "lightbulb", colorName: String = "blue", isWattMode: Bool = false, system: ElectricalSystem? = nil, remoteIconURLString: String? = nil, affiliateURLString: String? = nil, affiliateCountryCode: String? = nil) {
self.name = name
self.voltage = voltage
self.current = current
@@ -148,5 +150,7 @@ class SavedLoad {
self.isWattMode = isWattMode
self.system = system
self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString
self.affiliateCountryCode = affiliateCountryCode
}
}

View File

@@ -28,7 +28,13 @@ struct CalculatorView: View {
enum EditingValue {
case voltage, current, power, length
}
private struct AffiliateLinkInfo {
let url: URL
let buttonTitle: String
let regionName: String?
}
var body: some View {
VStack(spacing: 0) {
badgesSection
@@ -190,6 +196,28 @@ struct CalculatorView: View {
private var loadRemoteIconURLString: String? {
savedLoad?.remoteIconURLString
}
private var affiliateLinkInfo: AffiliateLinkInfo? {
guard let savedLoad,
let urlString = savedLoad.affiliateURLString,
let url = URL(string: urlString) else { return nil }
let countryCode = savedLoad.affiliateCountryCode?.uppercased()
let regionName: String?
if let countryCode {
regionName = Locale.current.localizedString(forRegionCode: countryCode) ?? countryCode
} else {
regionName = nil
}
let buttonTitle = regionName.map { "Buy in \($0)" } ?? "Buy this component"
return AffiliateLinkInfo(
url: url,
buttonTitle: buttonTitle,
regionName: regionName
)
}
private var navigationTitle: some View {
Button(action: {
@@ -432,9 +460,48 @@ struct CalculatorView: View {
VStack(spacing: 20) {
Divider().padding(.horizontal)
slidersSection
if let info = affiliateLinkInfo {
affiliateLinkSection(info: info)
}
}
}
}
@ViewBuilder
private func affiliateLinkSection(info: AffiliateLinkInfo) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Need the hardware?")
.font(.caption)
.foregroundColor(.secondary)
Link(destination: info.url) {
Label(info.buttonTitle, systemImage: "cart")
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.padding(.horizontal, 16)
.foregroundColor(.accentColor)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.accentColor.opacity(0.12))
)
}
if let regionName = info.regionName {
Text("Available in \(regionName).")
.font(.caption2)
.foregroundColor(.secondary)
}
Text("Affiliate link purchases may support VoltPlan.")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal)
}
private var slidersSection: some View {
VStack(spacing: 30) {

View File

@@ -1,17 +1,24 @@
import SwiftUI
struct ComponentLibraryItem: Identifiable, Equatable {
struct AffiliateLink: Identifiable, Equatable {
let id: String
let url: URL
let country: String?
}
let id: String
let name: String
let voltageIn: Double?
let voltageOut: Double?
let watt: Double?
let iconURL: URL?
let affiliateLinks: [AffiliateLink]
var displayVoltage: Double? {
voltageIn ?? voltageOut
}
var current: Double? {
guard let power = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
return power / voltage
@@ -31,6 +38,32 @@ struct ComponentLibraryItem: Identifiable, Equatable {
guard let current else { return nil }
return String(format: "%.1fA", current)
}
var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.regionCode)
}
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
}
}
@MainActor
@@ -89,16 +122,130 @@ final class ComponentLibraryViewModel: ObservableObject {
}
let decoded = try JSONDecoder().decode(PocketBaseResponse.self, from: data)
return decoded.items.map { record in
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: decoded.items.map(\.id))
let mappedItems = decoded.items.map { record in
ComponentLibraryItem(
id: record.id,
name: record.name,
voltageIn: record.voltageIn,
voltageOut: record.voltageOut,
watt: record.watt,
iconURL: iconURL(for: record)
iconURL: iconURL(for: record),
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
)
}
for item in mappedItems {
if let url = item.iconURL {
Task.detached(priority: .background) {
await IconCache.shared.prefetch(url)
}
}
}
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? {
@@ -112,6 +259,10 @@ final class ComponentLibraryViewModel: ObservableObject {
.appendingPathComponent(icon)
}
private func escapeFilterValue(_ value: String) -> String {
value.replacingOccurrences(of: "'", with: "\\'")
}
private struct PocketBaseResponse: Decodable {
let items: [PocketBaseRecord]
}
@@ -135,6 +286,19 @@ final class ComponentLibraryViewModel: ObservableObject {
case watt
}
}
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 {
@@ -246,39 +410,12 @@ private struct ComponentRow: View {
}
private var iconView: some View {
Group {
if let url = item.iconURL {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView()
.frame(width: 44, height: 44)
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 10))
case .failure:
placeholder
@unknown default:
placeholder
}
}
} else {
placeholder
}
}
}
private var placeholder: some View {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue.opacity(0.1))
Image(systemName: "bolt")
.foregroundColor(.blue)
}
.frame(width: 44, height: 44)
LoadIconView(
remoteIconURLString: item.iconURL?.absoluteString,
fallbackSystemName: "bolt",
fallbackColor: Color.blue.opacity(0.15),
size: 44
)
}
@ViewBuilder

View File

@@ -11,6 +11,7 @@ struct SystemsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@State private var systemNavigationTarget: SystemNavigationTarget?
@State private var showingComponentLibrary = false
@State private var showingSettings = false
@@ -49,25 +50,25 @@ struct SystemsView: View {
.font(.title3)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
}
Text(system.timestamp, format: .dateTime.month().day().hour().minute())
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
}
Text(componentSummary(for: system))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
.onDelete(perform: deleteSystems)
@@ -301,6 +302,8 @@ struct SystemsView: View {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
@@ -312,7 +315,9 @@ struct SystemsView: View {
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
modelContext.insert(newLoad)
@@ -357,6 +362,27 @@ struct SystemsView: View {
}
}
}
private func loads(for system: ElectricalSystem) -> [SavedLoad] {
allLoads.filter { $0.system == system }
}
private func componentSummary(for system: ElectricalSystem) -> String {
let systemLoads = loads(for: system)
guard !systemLoads.isEmpty else { return "No components yet" }
let count = systemLoads.count
let totalPower = systemLoads.reduce(0.0) { $0 + $1.power }
let formattedPower: String
if totalPower >= 1000 {
formattedPower = String(format: "%.1fkW", totalPower / 1000)
} else {
formattedPower = String(format: "%.0fW", totalPower)
}
return "\(count) component\(count == 1 ? "" : "s")\(formattedPower) total"
}
private func colorForName(_ colorName: String) -> Color {
switch colorName {
@@ -700,6 +726,8 @@ struct LoadsView: View {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
@@ -711,7 +739,9 @@ struct LoadsView: View {
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
modelContext.insert(newLoad)
@@ -811,6 +841,7 @@ struct SystemView: View {
struct SettingsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
@@ -878,6 +909,13 @@ struct SettingsView: View {
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
}
}
}
}
}

57
Cable/IconCache.swift Normal file
View File

@@ -0,0 +1,57 @@
import CryptoKit
import Foundation
import UIKit
actor IconCache {
static let shared = IconCache()
private let memoryCache = NSCache<NSURL, UIImage>()
private let fileManager = FileManager.default
private let cacheDirectory: URL
init() {
let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory())
cacheDirectory = cachesURL.appendingPathComponent("ComponentIcons", isDirectory: true)
if !fileManager.fileExists(atPath: cacheDirectory.path) {
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
}
func image(for url: URL) async throws -> UIImage {
if let cached = memoryCache.object(forKey: url as NSURL) {
return cached
}
let fileURL = cachedFileURL(for: url)
if let data = try? Data(contentsOf: fileURL), let image = UIImage(data: data) {
memoryCache.setObject(image, forKey: url as NSURL)
return image
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
guard let image = UIImage(data: data) else {
throw URLError(.cannotDecodeContentData)
}
memoryCache.setObject(image, forKey: url as NSURL)
try? data.write(to: fileURL, options: .atomic)
return image
}
func prefetch(_ url: URL) async {
_ = try? await image(for: url)
}
private func cachedFileURL(for url: URL) -> URL {
let hash = SHA256.hash(data: Data(url.absoluteString.utf8)).compactMap { String(format: "%02x", $0) }.joined()
return cacheDirectory.appendingPathComponent(hash)
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
import UIKit
struct LoadIconView: View {
let remoteIconURLString: String?
@@ -11,32 +12,33 @@ struct LoadIconView: View {
max(6, size / 4)
}
@State private var cachedImage: Image?
@State private var isLoading = false
@State private var hasAttemptedLoad = false
var body: some View {
Group {
if let urlString = remoteIconURLString,
let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView()
.frame(width: size, height: size)
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
case .failure:
fallbackView
@unknown default:
fallbackView
if let cachedImage {
cachedImage
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
} else if let url = remoteURL, !hasAttemptedLoad {
ProgressView()
.frame(width: size, height: size)
.task(id: url) {
await loadImage(from: url)
}
}
} else {
fallbackView
}
}
.frame(width: size, height: size)
.onChange(of: remoteIconURLString) { _ in
cachedImage = nil
hasAttemptedLoad = false
}
}
private var fallbackView: some View {
@@ -48,4 +50,27 @@ struct LoadIconView: View {
.foregroundColor(.white)
}
}
private var remoteURL: URL? {
guard let remoteIconURLString, let url = URL(string: remoteIconURLString) else { return nil }
return url
}
private func loadImage(from url: URL) async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
if let uiImage = try? await IconCache.shared.image(for: url) {
await MainActor.run {
cachedImage = Image(uiImage: uiImage)
hasAttemptedLoad = true
}
} else {
await MainActor.run {
cachedImage = nil
hasAttemptedLoad = true
}
}
}
}

BIN
Icon1024_opaque.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB