diff --git "a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Fran\303\247ais.xcscheme" "b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Fran\303\247ais.xcscheme" index f62c2999..98abcd95 100644 --- "a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Fran\303\247ais.xcscheme" +++ "b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Fran\303\247ais.xcscheme" @@ -88,7 +88,7 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" language = "fr" - region = "GB" + region = "FR" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" @@ -106,7 +106,7 @@ diff --git a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Indonesian.xcscheme b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Indonesian.xcscheme new file mode 100644 index 00000000..4b15534e --- /dev/null +++ b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Indonesian.xcscheme @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme index 150fa4b4..23c19487 100644 --- a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme +++ b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme @@ -106,7 +106,7 @@ referenceType = "1"> + identifier = "../Private/general-purpose/BrainwalletiOSPrivateGeneralPurpose/brainwallet-private-general-purpose.storekit"> ) { + init(shopBentoViewModel: ShopBentoViewModel, + newMViewModel: NewMainViewModel, + userPrefersDarkTheme: Binding) { _userPrefersDarkTheme = userPrefersDarkTheme - newMainViewModel = viewModel + shopViewModel = shopBentoViewModel + newMainViewModel = newMViewModel } + + var body: some View { GeometryReader { geometry in @@ -57,7 +65,7 @@ struct ShopBentoView: View { } .frame(width: width * 0.5) VStack(alignment: .trailing) { - GiftCardsView() + ShopCardsView(shopViewModel: shopViewModel) } .frame(maxWidth: width * 0.5) } diff --git a/brainwallet/Bento Views/Shop Bentos/ShopBentoViewModel.swift b/brainwallet/Bento Views/Shop Bentos/ShopBentoViewModel.swift index bcb7c33e..fe484d2a 100644 --- a/brainwallet/Bento Views/Shop Bentos/ShopBentoViewModel.swift +++ b/brainwallet/Bento Views/Shop Bentos/ShopBentoViewModel.swift @@ -5,60 +5,7 @@ //// Created by Kerry Washington on 5/8/26. //// Copyright © 2026 Grunt Software, LTD. All rights reserved. //// -//import Foundation -//import SwiftUI -// -//class ShopBentoViewModel: ObservableObject { -// -// // MARK: - Public Variables -// -// @Published -// var widgetURL: URL = URL(string: BrainwalletShop.bitrefillCode)! -// -// init() { -// fetchShopConfig() -// } -// -// // MARK: - Models -// -// private struct ShopConfig: Decodable { -// let shopData: [ShopItem] -// -// enum CodingKeys: String, CodingKey { -// case shopData = "shop_data" -// } -// } -// -// private struct ShopItem: Decodable { -// let widgetUrl: String -// -// enum CodingKeys: String, CodingKey { -// case widgetUrl = "widget_url" -// } -// } -// -// func fetchShopConfig() { -// let key = RemoteConfigKeys.PATH_SHOP_CONTENT.rawValue -// let shopJson = RemoteConfigHelper().getString(key: key) -// -// guard -// let data = shopJson.data(using: .utf8), -// let config = try? JSONDecoder().decode(ShopConfig.self, from: data), -// let firstItem = config.shopData.first, -// let resolved = URL(string: firstItem.widgetUrl) -// else { return } -// -// widgetURL = resolved -// } -//} -// -// ShopBentoViewModel.swift -// brainwallet -// -// Created by Kerry Washington on 5/8/26. -// Copyright © 2026 Grunt Software, LTD. All rights reserved. -// import Foundation import SwiftUI @@ -69,41 +16,71 @@ class ShopBentoViewModel: ObservableObject { @Published var widgetURL: URL = URL(string: BrainwalletShop.bitrefillCode)! + @Published + var cards: [ShopCard]? + + @Published + var cardImages: [UIImage]? + + @Published + var cardImagesVersion: Int = 0 + + @StateObject + private var loader = ImageLoader() + + init() { let key = RemoteConfigKeys.PATH_SHOP_CONTENT.rawValue let shopJson = RemoteConfigHelper().getString(key: key) fetchShopConfig(from: shopJson) } - - // MARK: - Models - private struct ShopConfig: Decodable { - let shopData: [ShopItem] - - enum CodingKeys: String, CodingKey { - case shopData = "shop_data" - } + func updateCardImages(_ images: [UIImage]) { + cardImages = images + cardImagesVersion += 1 + } - private struct ShopItem: Decodable { - let widgetUrl: String - - enum CodingKeys: String, CodingKey { - case widgetUrl = "widget_url" + private func loadCardImages(_ cards: [ShopCard]?) async { + guard let realCards = cards else { + return + } + var loaded: [UIImage] = [] + for card in realCards { + if let uiImage = await ImageLoader.loadImage(from: card.cardImageWebP) { + loaded.append(uiImage) + } else { + loaded.append(UIImage(named: "bw-placeholder")!) + } + } + await MainActor.run { + updateCardImages(loaded) } } - + // MARK: - Fetch - /// Internal entry point — accepts a raw JSON string so tests can inject directly. func fetchShopConfig(from jsonString: String) { - guard - let data = jsonString.data(using: .utf8), - let config = try? JSONDecoder().decode(ShopConfig.self, from: data), - let firstItem = config.shopData.first, - let resolved = URL(string: firstItem.widgetUrl) - else { return } + guard let jsonData = jsonString.data(using: .utf8) else { + assertionFailure("ShopConfig: invalid UTF-8 string") + return + } - widgetURL = resolved + do { + let shopConfig = try JSONDecoder().decode(ShopConfig.self, from: jsonData) + let data = shopConfig.shopData + widgetURL = data.widgetUrl + + if let regionCode = Locale.current.region?.identifier { + cards = data.cards.filter { $0.countryCode.contains(regionCode) } + Task { + await loadCardImages(cards) + } + } + + } catch { + assertionFailure("ShopConfig decode failed: \(error)") + } } } + diff --git a/brainwallet/Bento Views/Shop Bentos/GiftCardsView.swift b/brainwallet/Bento Views/Shop Bentos/ShopCardsView.swift similarity index 59% rename from brainwallet/Bento Views/Shop Bentos/GiftCardsView.swift rename to brainwallet/Bento Views/Shop Bentos/ShopCardsView.swift index 061361df..27e021e6 100644 --- a/brainwallet/Bento Views/Shop Bentos/GiftCardsView.swift +++ b/brainwallet/Bento Views/Shop Bentos/ShopCardsView.swift @@ -1,5 +1,5 @@ // -// GiftCardsView.swift +// ShopCardsView.swift // brainwallet // // Created by Kerry Washington on 4/26/26. @@ -9,23 +9,40 @@ import SwiftUI -struct GiftCardsView: View { +struct ShopCardsView: View { @State private var shouldAnimate: Bool = false + @State + private var image1 = UIImage(named: "bw-placeholder")! + + @State + private var image2 = UIImage(named: "bw-placeholder")! + + @State + private var image3 = UIImage(named: "bw-placeholder")! + + @ObservedObject + var shopViewModel: ShopBentoViewModel + + private func loadImages() { + let images = shopViewModel.cardImages + image1 = images?[safe: 0] ?? UIImage(named: "bw-placeholder")! + image2 = images?[safe: 1] ?? UIImage(named: "bw-placeholder")! + image3 = images?[safe: 2] ?? UIImage(named: "bw-placeholder")! + } var body: some View { GeometryReader { geometry in let width = geometry.size.width - let height = geometry.size.height ZStack { HStack { VStack(alignment: .trailing) { - SingleGiftCardView(giftCardImage: "visa_logo", + SingleShopCardView(cardUIImage: image1, gradient: BrainwalletGradient.blueCard, rotationAngle: 20.0) .frame(width: 100, height: 63.05) @@ -42,7 +59,7 @@ struct GiftCardsView: View { HStack { VStack(alignment: .trailing) { - SingleGiftCardView(giftCardImage: "je_logo", + SingleShopCardView(cardUIImage: image2, gradient: BrainwalletGradient.orangeCard, rotationAngle: 20.0) .frame(width: 100, height: 63.05) @@ -61,8 +78,8 @@ struct GiftCardsView: View { HStack { VStack(alignment: .trailing) { Spacer() - SingleGiftCardView(giftCardImage: "amazon_logo", - gradient: BrainwalletGradient.grayCard, + SingleShopCardView(cardUIImage: image3, + gradient: BrainwalletGradient.blueCard, rotationAngle: -20.0) .frame(width: 100, height: 63.05) .offset(x: shouldAnimate ? 25 : 105, y: 15.0) @@ -79,35 +96,42 @@ struct GiftCardsView: View { } } + .onChange(of: shopViewModel.cardImagesVersion) { _, newVersion in + loadImages() + withAnimation(.easeInOut(duration: 2.0)) { + shouldAnimate = true + } + } } } - - -struct SingleGiftCardView: View { - - let giftCardImage: String +struct SingleShopCardView: View { + let cardUIImage: UIImage let gradient: RadialGradient - let rotationAngle: Double - var body: some View { GeometryReader { geometry in - let width = geometry.size.width ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(gradient) - .frame(width: width, height: width * 0.6305) - .rotationEffect(Angle(degrees: rotationAngle)) - Image(giftCardImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: width * 0.6) - .rotationEffect(Angle(degrees: rotationAngle)) - } + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(gradient) + .frame(width: width, height: width * 0.6305) + .rotationEffect(Angle(degrees: rotationAngle)) + + Image(uiImage: cardUIImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: width, height: width * 0.6305) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .rotationEffect(Angle(degrees: rotationAngle)) + } } } } +extension Collection { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/brainwallet/Bento Views/Shop Bentos/ShopImages.swift b/brainwallet/Bento Views/Shop Bentos/ShopImages.swift new file mode 100644 index 00000000..63781882 --- /dev/null +++ b/brainwallet/Bento Views/Shop Bentos/ShopImages.swift @@ -0,0 +1,85 @@ +// +// ShopImages.swift +// brainwallet +// +// Created by Kerry Washington on 5/23/26. +// Copyright © 2026 Grunt Software, LTD. All rights reserved. +// + +import SwiftUI +import UIKit + +final class ImageLoader: ObservableObject { + + private static let cache = NSCache() + + + static func loadImage(from url: URL) async -> UIImage? { + + // Memory cache + if let cached = cache.object(forKey: url as NSURL) { + return cached + } + + var request = URLRequest(url: url) + + request.timeoutInterval = 20 + + // Important for CDNs + request.setValue( + "image/webp,image/*,*/*;q=0.8", + forHTTPHeaderField: "Accept" + ) + request.setValue( + "https://www.bitrefill.com/", + forHTTPHeaderField: "Referer" + ) + request.setValue( + "https://www.bitrefill.com", + forHTTPHeaderField: "Origin" + ) + + // Retry transient CDN failures + for attempt in 1...3 { + + do { + + let (data, response) = try await URLSession.shared.data(for: request) + + guard + let http = response as? HTTPURLResponse + else { + continue + } + + // Validate status code + guard (200...299).contains(http.statusCode) else { + let isRetryable = http.statusCode >= 500 || http.statusCode == 429 + if isRetryable && attempt < 3 { + try await Task.sleep(for: .seconds(Double(attempt))) + continue + } + return nil + } + + // Validate MIME type + guard (http.mimeType ?? "").starts(with: "image/") else { + return nil + } + + // Validate image decode + guard let uiImage = UIImage(data: data) else { + return nil + } + + cache.setObject(uiImage, forKey: url as NSURL) + return uiImage + + } catch { + if attempt == 3 { return nil } + try? await Task.sleep(for: .seconds(Double(attempt) * 1.5)) + } + } + return nil + } +} diff --git a/brainwallet/Bento Views/Shop Bentos/ShopStructs.swift b/brainwallet/Bento Views/Shop Bentos/ShopStructs.swift new file mode 100644 index 00000000..9b6813b5 --- /dev/null +++ b/brainwallet/Bento Views/Shop Bentos/ShopStructs.swift @@ -0,0 +1,46 @@ +// +// ShopStructs.swift +// brainwallet +// +// Created by Kerry Washington on 5/23/26. +// Copyright © 2026 Grunt Software, LTD. All rights reserved. +// + +// MARK: - Shop Structs +import SwiftUI + +struct ShopConfig: Codable { + let shopData: ShopData + + enum CodingKeys: String, CodingKey { + case shopData = "shop_data" + } +} + +struct ShopData: Codable { + let widgetUrl: URL + let cards: [ShopCard] + enum CodingKeys: String, CodingKey { + case widgetUrl = "widget_url" + case cards + } + } +struct ShopCard: Codable, Identifiable { + let countryCode: String + let countryName: String + let productSlug: String + let productName: String + let productURL: URL + let cardImageWebP: URL + + var id: String { productSlug } + + enum CodingKeys: String, CodingKey { + case countryCode = "country_code" + case countryName = "country_name" + case productSlug = "product_slug" + case productName = "product_name" + case productURL = "product_url" + case cardImageWebP = "card_image_webp" + } +} diff --git a/brainwallet/New Main Classes/NewMainView.swift b/brainwallet/New Main Classes/NewMainView.swift index 5d4bf877..2fc5a03c 100644 --- a/brainwallet/New Main Classes/NewMainView.swift +++ b/brainwallet/New Main Classes/NewMainView.swift @@ -23,7 +23,7 @@ struct NewMainView: View { @StateObject var gameHubViewModel = GameHubViewModel() - @StateObject + @ObservedObject var shopViewModel = ShopBentoViewModel() @State @@ -169,8 +169,9 @@ struct NewMainView: View { .frame(maxHeight: heightPadded * 0.7) .padding(bentoPadding) .accessibilityIdentifier("ltcPriceBentoView") - ShopBentoView(viewModel: newMainViewModel, - userPrefersDarkTheme: $userPrefersDarkTheme) + ShopBentoView(shopBentoViewModel: shopViewModel, + newMViewModel: newMainViewModel, + userPrefersDarkTheme: $userPrefersDarkTheme) .frame(maxHeight: heightPadded * 0.3) .padding(bentoPadding) .accessibilityIdentifier("shopBentoView") diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/Contents.json b/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/Contents.json new file mode 100644 index 00000000..6db91b9d --- /dev/null +++ b/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bw-logo-padding.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/bw-logo-padding.png b/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/bw-logo-padding.png new file mode 100644 index 00000000..485f8af4 Binary files /dev/null and b/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/bw-logo-padding.png differ