diff --git a/Cable.icon/Assets/cablebyvoltplan-logomark copy.png b/Cable.icon/Assets/cablebyvoltplan-logomark copy.png new file mode 100644 index 0000000..85395f1 Binary files /dev/null and b/Cable.icon/Assets/cablebyvoltplan-logomark copy.png differ diff --git a/Cable.icon/icon.json b/Cable.icon/icon.json new file mode 100644 index 0000000..2478855 --- /dev/null +++ b/Cable.icon/icon.json @@ -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" + } +} \ No newline at end of file diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj index ead92c6..073868c 100644 --- a/Cable.xcodeproj/project.pbxproj +++ b/Cable.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; @@ -225,6 +231,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png b/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png deleted file mode 100644 index 0d2b1ec..0000000 Binary files a/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png and /dev/null differ diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json b/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json index fc4e57c..a02c04c 100644 --- a/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Cable-iOS-Default-1024x1024@1x.png", + "filename" : "Icon1024_opaque.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/Icon1024_opaque.png b/Cable/Assets.xcassets/AppIcon.appiconset/Icon1024_opaque.png new file mode 100644 index 0000000..0d178e6 Binary files /dev/null and b/Cable/Assets.xcassets/AppIcon.appiconset/Icon1024_opaque.png differ diff --git a/Cable/CableCalculator.swift b/Cable/CableCalculator.swift index 6a14544..5f7afd7 100644 --- a/Cable/CableCalculator.swift +++ b/Cable/CableCalculator.swift @@ -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 } } diff --git a/Cable/CalculatorView.swift b/Cable/CalculatorView.swift index 5e49cc9..be2f538 100644 --- a/Cable/CalculatorView.swift +++ b/Cable/CalculatorView.swift @@ -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) { diff --git a/Cable/ComponentLibraryView.swift b/Cable/ComponentLibraryView.swift index 662b40b..504264b 100644 --- a/Cable/ComponentLibraryView.swift +++ b/Cable/ComponentLibraryView.swift @@ -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.. 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 diff --git a/Cable/ContentView.swift b/Cable/ContentView.swift index ced2f4d..4e7361e 100644 --- a/Cable/ContentView.swift +++ b/Cable/ContentView.swift @@ -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() + } + } + } } } } diff --git a/Cable/IconCache.swift b/Cable/IconCache.swift new file mode 100644 index 0000000..51a304c --- /dev/null +++ b/Cable/IconCache.swift @@ -0,0 +1,57 @@ +import CryptoKit +import Foundation +import UIKit + +actor IconCache { + static let shared = IconCache() + + private let memoryCache = NSCache() + 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) + } +} + diff --git a/Cable/LoadIconView.swift b/Cable/LoadIconView.swift index 1a56f77..4169180 100644 --- a/Cable/LoadIconView.swift +++ b/Cable/LoadIconView.swift @@ -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 + } + } + } } diff --git a/Icon1024_opaque.png b/Icon1024_opaque.png new file mode 100644 index 0000000..0d178e6 Binary files /dev/null and b/Icon1024_opaque.png differ