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;
|
||||
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 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Cable-iOS-Default-1024x1024@1x.png",
|
||||
"filename" : "Icon1024_opaque.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"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 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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 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
BIN
Icon1024_opaque.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Reference in New Issue
Block a user