Includes buy now button
This commit is contained in:
BIN
Cable.icon/Assets/cablebyvoltplan-logomark copy.png
Normal file
BIN
Cable.icon/Assets/cablebyvoltplan-logomark copy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
39
Cable.icon/icon.json
Normal file
39
Cable.icon/icon.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */ = {isa = PBXBuildFile; fileRef = 3E4BC9B72E7F5E9E0052324A /* Cable.icon */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
3E5C0BDE2E72C0FE00247EC8 /* PBXContainerItemProxy */ = {
|
3E5C0BDE2E72C0FE00247EC8 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
@@ -24,6 +28,7 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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; };
|
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; };
|
3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -92,6 +97,7 @@
|
|||||||
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
|
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
|
||||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||||
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
||||||
|
3E4BC9B72E7F5E9E0052324A /* Cable.icon */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -225,6 +231,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "Cable-iOS-Default-1024x1024@1x.png",
|
"filename" : "Icon1024_opaque.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
BIN
Cable/Assets.xcassets/AppIcon.appiconset/Icon1024_opaque.png
Normal file
BIN
Cable/Assets.xcassets/AppIcon.appiconset/Icon1024_opaque.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -134,8 +134,10 @@ class SavedLoad {
|
|||||||
var isWattMode: Bool = false
|
var isWattMode: Bool = false
|
||||||
var system: ElectricalSystem?
|
var system: ElectricalSystem?
|
||||||
var remoteIconURLString: String? = nil
|
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.name = name
|
||||||
self.voltage = voltage
|
self.voltage = voltage
|
||||||
self.current = current
|
self.current = current
|
||||||
@@ -148,5 +150,7 @@ class SavedLoad {
|
|||||||
self.isWattMode = isWattMode
|
self.isWattMode = isWattMode
|
||||||
self.system = system
|
self.system = system
|
||||||
self.remoteIconURLString = remoteIconURLString
|
self.remoteIconURLString = remoteIconURLString
|
||||||
|
self.affiliateURLString = affiliateURLString
|
||||||
|
self.affiliateCountryCode = affiliateCountryCode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ struct CalculatorView: View {
|
|||||||
case voltage, current, power, length
|
case voltage, current, power, length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct AffiliateLinkInfo {
|
||||||
|
let url: URL
|
||||||
|
let buttonTitle: String
|
||||||
|
let regionName: String?
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
badgesSection
|
badgesSection
|
||||||
@@ -191,6 +197,28 @@ struct CalculatorView: View {
|
|||||||
savedLoad?.remoteIconURLString
|
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 {
|
private var navigationTitle: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingLoadEditor = true
|
showingLoadEditor = true
|
||||||
@@ -432,10 +460,49 @@ struct CalculatorView: View {
|
|||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
Divider().padding(.horizontal)
|
Divider().padding(.horizontal)
|
||||||
slidersSection
|
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 {
|
private var slidersSection: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
voltageSlider
|
voltageSlider
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ComponentLibraryItem: Identifiable, Equatable {
|
struct ComponentLibraryItem: Identifiable, Equatable {
|
||||||
|
struct AffiliateLink: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let url: URL
|
||||||
|
let country: String?
|
||||||
|
}
|
||||||
|
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
let voltageIn: Double?
|
let voltageIn: Double?
|
||||||
let voltageOut: Double?
|
let voltageOut: Double?
|
||||||
let watt: Double?
|
let watt: Double?
|
||||||
let iconURL: URL?
|
let iconURL: URL?
|
||||||
|
let affiliateLinks: [AffiliateLink]
|
||||||
|
|
||||||
var displayVoltage: Double? {
|
var displayVoltage: Double? {
|
||||||
voltageIn ?? voltageOut
|
voltageIn ?? voltageOut
|
||||||
@@ -31,6 +38,32 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
|||||||
guard let current else { return nil }
|
guard let current else { return nil }
|
||||||
return String(format: "%.1fA", current)
|
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
|
@MainActor
|
||||||
@@ -89,16 +122,130 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let decoded = try JSONDecoder().decode(PocketBaseResponse.self, from: data)
|
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(
|
ComponentLibraryItem(
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
voltageIn: record.voltageIn,
|
voltageIn: record.voltageIn,
|
||||||
voltageOut: record.voltageOut,
|
voltageOut: record.voltageOut,
|
||||||
watt: record.watt,
|
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? {
|
private func iconURL(for record: PocketBaseRecord) -> URL? {
|
||||||
@@ -112,6 +259,10 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
.appendingPathComponent(icon)
|
.appendingPathComponent(icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func escapeFilterValue(_ value: String) -> String {
|
||||||
|
value.replacingOccurrences(of: "'", with: "\\'")
|
||||||
|
}
|
||||||
|
|
||||||
private struct PocketBaseResponse: Decodable {
|
private struct PocketBaseResponse: Decodable {
|
||||||
let items: [PocketBaseRecord]
|
let items: [PocketBaseRecord]
|
||||||
}
|
}
|
||||||
@@ -135,6 +286,19 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
case watt
|
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 {
|
struct ComponentLibraryView: View {
|
||||||
@@ -246,39 +410,12 @@ private struct ComponentRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var iconView: some View {
|
private var iconView: some View {
|
||||||
Group {
|
LoadIconView(
|
||||||
if let url = item.iconURL {
|
remoteIconURLString: item.iconURL?.absoluteString,
|
||||||
AsyncImage(url: url) { phase in
|
fallbackSystemName: "bolt",
|
||||||
switch phase {
|
fallbackColor: Color.blue.opacity(0.15),
|
||||||
case .empty:
|
size: 44
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct SystemsView: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||||
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
|
@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 systemNavigationTarget: SystemNavigationTarget?
|
||||||
@State private var showingComponentLibrary = false
|
@State private var showingComponentLibrary = false
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@@ -50,24 +51,24 @@ struct SystemsView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(system.name)
|
Text(system.name)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
|
||||||
if !system.location.isEmpty {
|
if !system.location.isEmpty {
|
||||||
Text(system.location)
|
Text(system.location)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(system.timestamp, format: .dateTime.month().day().hour().minute())
|
Text(componentSummary(for: system))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteSystems)
|
.onDelete(perform: deleteSystems)
|
||||||
@@ -301,6 +302,8 @@ struct SystemsView: View {
|
|||||||
current = 0
|
current = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let affiliateLink = item.primaryAffiliateLink
|
||||||
|
|
||||||
let newLoad = SavedLoad(
|
let newLoad = SavedLoad(
|
||||||
name: loadName,
|
name: loadName,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
@@ -312,7 +315,9 @@ struct SystemsView: View {
|
|||||||
colorName: "blue",
|
colorName: "blue",
|
||||||
isWattMode: item.watt != nil,
|
isWattMode: item.watt != nil,
|
||||||
system: system,
|
system: system,
|
||||||
remoteIconURLString: item.iconURL?.absoluteString
|
remoteIconURLString: item.iconURL?.absoluteString,
|
||||||
|
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||||
|
affiliateCountryCode: affiliateLink?.country
|
||||||
)
|
)
|
||||||
|
|
||||||
modelContext.insert(newLoad)
|
modelContext.insert(newLoad)
|
||||||
@@ -358,6 +363,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 {
|
private func colorForName(_ colorName: String) -> Color {
|
||||||
switch colorName {
|
switch colorName {
|
||||||
case "blue": return .blue
|
case "blue": return .blue
|
||||||
@@ -700,6 +726,8 @@ struct LoadsView: View {
|
|||||||
current = 0
|
current = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let affiliateLink = item.primaryAffiliateLink
|
||||||
|
|
||||||
let newLoad = SavedLoad(
|
let newLoad = SavedLoad(
|
||||||
name: loadName,
|
name: loadName,
|
||||||
voltage: voltage,
|
voltage: voltage,
|
||||||
@@ -711,7 +739,9 @@ struct LoadsView: View {
|
|||||||
colorName: "blue",
|
colorName: "blue",
|
||||||
isWattMode: item.watt != nil,
|
isWattMode: item.watt != nil,
|
||||||
system: system,
|
system: system,
|
||||||
remoteIconURLString: item.iconURL?.absoluteString
|
remoteIconURLString: item.iconURL?.absoluteString,
|
||||||
|
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||||
|
affiliateCountryCode: affiliateLink?.country
|
||||||
)
|
)
|
||||||
|
|
||||||
modelContext.insert(newLoad)
|
modelContext.insert(newLoad)
|
||||||
@@ -811,6 +841,7 @@ struct SystemView: View {
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -878,6 +909,13 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Close") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
Cable/IconCache.swift
Normal file
57
Cable/IconCache.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct LoadIconView: View {
|
struct LoadIconView: View {
|
||||||
let remoteIconURLString: String?
|
let remoteIconURLString: String?
|
||||||
@@ -11,32 +12,33 @@ struct LoadIconView: View {
|
|||||||
max(6, size / 4)
|
max(6, size / 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@State private var cachedImage: Image?
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var hasAttemptedLoad = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let urlString = remoteIconURLString,
|
if let cachedImage {
|
||||||
let url = URL(string: urlString) {
|
cachedImage
|
||||||
AsyncImage(url: url) { phase in
|
.resizable()
|
||||||
switch phase {
|
.scaledToFit()
|
||||||
case .empty:
|
.frame(width: size, height: size)
|
||||||
ProgressView()
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
.frame(width: size, height: size)
|
} else if let url = remoteURL, !hasAttemptedLoad {
|
||||||
case .success(let image):
|
ProgressView()
|
||||||
image
|
.frame(width: size, height: size)
|
||||||
.resizable()
|
.task(id: url) {
|
||||||
.scaledToFit()
|
await loadImage(from: url)
|
||||||
.frame(width: size, height: size)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
|
||||||
case .failure:
|
|
||||||
fallbackView
|
|
||||||
@unknown default:
|
|
||||||
fallbackView
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
fallbackView
|
fallbackView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
|
.onChange(of: remoteIconURLString) { _ in
|
||||||
|
cachedImage = nil
|
||||||
|
hasAttemptedLoad = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fallbackView: some View {
|
private var fallbackView: some View {
|
||||||
@@ -48,4 +50,27 @@ struct LoadIconView: View {
|
|||||||
.foregroundColor(.white)
|
.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
BIN
Icon1024_opaque.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Reference in New Issue
Block a user