Smart Banner draft

- SmartBanner object for the Home page basic logic prepared

Smart banner on a home page

- UI done for the widget itself
- Sheets done for the help for the states
- Business logic done for all except auto-shielding and shield button
- Local notifications dependency implemented
- Network checker dependency implemented
This commit is contained in:
Lukas Korba 2025-04-03 15:20:48 +02:00
parent 971a44317f
commit b2adf66ef2
41 changed files with 1851 additions and 274 deletions

View File

@ -40,9 +40,11 @@ let package = Package(
.library(name: "Home", targets: ["Home"]),
.library(name: "KeystoneHandler", targets: ["KeystoneHandler"]),
.library(name: "LocalAuthenticationHandler", targets: ["LocalAuthenticationHandler"]),
.library(name: "LocalNotification", targets: ["LocalNotification"]),
.library(name: "LogsHandler", targets: ["LogsHandler"]),
.library(name: "MnemonicClient", targets: ["MnemonicClient"]),
.library(name: "Models", targets: ["Models"]),
.library(name: "NetworkMonitor", targets: ["NetworkMonitor"]),
.library(name: "NotEnoughFreeSpace", targets: ["NotEnoughFreeSpace"]),
.library(name: "NumberFormatter", targets: ["NumberFormatter"]),
.library(name: "OnboardingFlow", targets: ["OnboardingFlow"]),
@ -67,6 +69,7 @@ let package = Package(
.library(name: "SendForm", targets: ["SendForm"]),
.library(name: "ServerSetup", targets: ["ServerSetup"]),
.library(name: "Settings", targets: ["Settings"]),
.library(name: "SmartBanner", targets: ["SmartBanner"]),
.library(name: "SupportDataGenerator", targets: ["SupportDataGenerator"]),
.library(name: "SyncProgress", targets: ["SyncProgress"]),
.library(name: "ReadTransactionsStorage", targets: ["ReadTransactionsStorage"]),
@ -414,6 +417,7 @@ let package = Package(
"Scan",
"Settings",
"SDKSynchronizer",
"SmartBanner",
"SyncProgress",
"TransactionList",
"UIComponents",
@ -442,6 +446,14 @@ let package = Package(
],
path: "Sources/Dependencies/LocalAuthenticationHandler"
),
.target(
name: "LocalNotification",
dependencies: [
"Generated",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
],
path: "Sources/Dependencies/LocalNotification"
),
.target(
name: "LogsHandler",
dependencies: [
@ -468,6 +480,14 @@ let package = Package(
],
path: "Sources/Models"
),
.target(
name: "NetworkMonitor",
dependencies: [
"Utils",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
],
path: "Sources/Dependencies/NetworkMonitor"
),
.target(
name: "NotEnoughFreeSpace",
dependencies: [
@ -655,6 +675,7 @@ let package = Package(
"Generated",
"Home",
"LocalAuthenticationHandler",
"LocalNotification",
"MnemonicClient",
"Models",
"NotEnoughFreeSpace",
@ -838,6 +859,24 @@ let package = Package(
],
path: "Sources/Features/Settings"
),
.target(
name: "SmartBanner",
dependencies: [
"Generated",
"LocalNotification",
"Models",
"NetworkMonitor",
"SDKSynchronizer",
"UIComponents",
"UserPreferencesStorage",
"Utils",
"WalletStorage",
"ZcashSDKEnvironment",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "ZcashLightClientKit", package: "ZcashLightClientKit"),
],
path: "Sources/Features/SmartBanner"
),
.target(
name: "SupportDataGenerator",
dependencies: [

View File

@ -0,0 +1,29 @@
//
// LocalNotificationInterface.swift
// Zashi
//
// Created by Lukáš Korba on 04-07-2025.
//
import ComposableArchitecture
extension DependencyValues {
public var localNotification: LocalNotificationClient {
get { self[LocalNotificationClient.self] }
set { self[LocalNotificationClient.self] = newValue }
}
}
@DependencyClient
public struct LocalNotificationClient {
public enum Constants {
public static let walletBackupUUID = "com.zashi.wallet-backup-local-notification"
public static let shieldingUUID = "com.zashi.shielding-local-notification"
}
public let clearNotifications: () -> Void
public let isShieldingScheduled: () async -> Bool
public let isWalletBackupScheduled: () async -> Bool
public let scheduleShielding: () -> Void
public let scheduleWalletBackup: () -> Void
}

View File

@ -0,0 +1,100 @@
//
// LocalNotificationLiveKey.swift
// Zashi
//
// Created by Lukáš Korba on 04-07-2025.
//
import Foundation
import UserNotifications
import ComposableArchitecture
import Generated
extension LocalNotificationClient: DependencyKey {
public static let liveValue: LocalNotificationClient = Self.live()
public static func live() -> Self {
return LocalNotificationClient(
clearNotifications: {
UNUserNotificationCenter.current().removePendingNotificationRequests(
withIdentifiers: [
Constants.walletBackupUUID,
Constants.shieldingUUID
]
)
},
isShieldingScheduled: {
await withCheckedContinuation { continuation in
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let res = requests.contains { $0.identifier == Constants.shieldingUUID }
continuation.resume(returning: res)
}
}
},
isWalletBackupScheduled: {
await withCheckedContinuation { continuation in
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let res = requests.contains { $0.identifier == Constants.walletBackupUUID }
continuation.resume(returning: res)
}
}
},
scheduleShielding: {
guard let futureDate = Calendar.current.date(byAdding: .hour, value: 48, to: Date()) else {
return
}
scheduleNotification(
title: L10n.SmartBanner.Content.Shield.title,
body: "",
date: futureDate,
identifier: Constants.shieldingUUID
)
},
scheduleWalletBackup: {
guard let futureDate = Calendar.current.date(byAdding: .hour, value: 48, to: Date()) else {
return
}
scheduleNotification(
title: L10n.SmartBanner.Content.Backup.title,
body: L10n.SmartBanner.Content.Backup.info,
date: futureDate,
identifier: Constants.walletBackupUUID
)
}
)
}
}
private extension LocalNotificationClient {
static func scheduleNotification(
title: String,
body: String,
date: Date,
identifier: String
) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in
if !granted {
return
}
}
let dateComponents = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute, .second],
from: date
)
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.userInfo = ["customData": "fizzbuzz"]
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.add(request)
}
}

View File

@ -0,0 +1,21 @@
//
// NetworkMonitorInterface.swift
// Zashi
//
// Created by Lukáš Korba on 04-07-2025.
//
import ComposableArchitecture
import Combine
extension DependencyValues {
public var networkMonitor: NetworkMonitorClient {
get { self[NetworkMonitorClient.self] }
set { self[NetworkMonitorClient.self] = newValue }
}
}
@DependencyClient
public struct NetworkMonitorClient {
public let networkMonitorStream: () -> AnyPublisher<Bool, Never>
}

View File

@ -0,0 +1,33 @@
//
// NetworkMonitorLiveKey.swift
// Zashi
//
// Created by Lukáš Korba on 04-07-2025.
//
import Foundation
import Network
import Combine
import ComposableArchitecture
extension NetworkMonitorClient: DependencyKey {
public static let liveValue: NetworkMonitorClient = Self.live()
public static func live() -> Self {
let monitor = NWPathMonitor()
let queue = DispatchQueue.global(qos: .background)
let subject = PassthroughSubject<Bool, Never>()
return NetworkMonitorClient(
networkMonitorStream: {
monitor.pathUpdateHandler = {
print("__LD status \($0)")
subject.send($0.status == .satisfied)
}
monitor.start(queue: queue)
return subject.eraseToAnyPublisher()
}
)
}
}

View File

@ -47,8 +47,7 @@ extension SDKSynchronizerClient: DependencyKey {
let synchronizer = SDKSynchronizer(initializer: initializer)
return SDKSynchronizerClient(
stateStream: { synchronizer.stateStream
},
stateStream: { synchronizer.stateStream },
eventStream: { synchronizer.eventStream },
exchangeRateUSDStream: { synchronizer.exchangeRateUSDStream },
latestState: { synchronizer.latestState },

View File

@ -166,8 +166,6 @@ public struct Balances {
let storedWallet = try walletStorage.exportWallet()
let seedBytes = try mnemonic.toSeed(storedWallet.seedPhrase.value())
let spendingKey = try derivationTool.deriveSpendingKey(seedBytes, zip32AccountIndex, zcashSDKEnvironment.network.networkType)
guard let uAddress = try await sdkSynchronizer.getUnifiedAddress(account.id) else { throw "sdkSynchronizer.getUnifiedAddress" }
let proposal = try await sdkSynchronizer.proposeShielding(account.id, zcashSDKEnvironment.shieldingThreshold, .empty, nil)

View File

@ -128,13 +128,13 @@ public struct CurrencyConversionSetupView: View {
note()
.padding(.bottom, 20)
primaryButton(L10n.CurrencyConversion.enable) {
store.send(.enableTapped)
}
secondaryButton(L10n.CurrencyConversion.skipBtn) {
store.send(.skipTapped)
}
primaryButton(L10n.CurrencyConversion.enable) {
store.send(.enableTapped)
}
}
.padding(.bottom, 24)
}

View File

@ -15,6 +15,7 @@ import PartnerKeys
import UserPreferencesStorage
import Utils
import BalanceBreakdown
import SmartBanner
@Reducer
public struct Home {
@ -36,6 +37,7 @@ public struct Home {
public var isRateTooltipEnabled = false
public var migratingDatabase = true
public var moreRequest = false
public var smartBannerState = SmartBanner.State.initial
public var syncProgressState: SyncProgress.State
public var walletConfig: WalletConfig
// public var scanBinding = false
@ -67,7 +69,7 @@ public struct Home {
public var inAppBrowserURLKeystone: String {
"https://keyst.one/shop/products/keystone-3-pro?discount=Zashi"
}
public init(
canRequestReview: Bool = false,
migratingDatabase: Bool = true,
@ -86,6 +88,8 @@ public struct Home {
}
public enum Action: BindableAction, Equatable {
case debug
case accountSwitchTapped
case addKeystoneHWWalletTapped
case alert(PresentationAction<Action>)
@ -112,6 +116,7 @@ public struct Home {
case sendTapped
case settingsTapped
case showSynchronizerErrorAlert(ZcashError)
case smartBanner(SmartBanner.Action)
case synchronizerStateChanged(RedactableSynchronizerState)
case syncFailed(ZcashError)
case syncProgress(SyncProgress.Action)
@ -156,8 +161,15 @@ public struct Home {
Balances()
}
Scope(state: \.smartBannerState, action: \.smartBanner) {
SmartBanner()
}
Reduce { state, action in
switch action {
case .debug:
return .send(.smartBanner(.debug))
case .onAppear:
// state.scanState.checkers = [.zcashAddressScanChecker, .requestZecScanChecker]
state.appId = PartnerKeys.cbProjectId
@ -334,6 +346,9 @@ public struct Home {
// )
return .none
case .smartBanner(.currencyConversionScreenRequested):
return .send(.currencyConversionSetupTapped)
// More actions
case .coinbaseTapped:
state.moreRequest = false
@ -356,6 +371,9 @@ public struct Home {
case .walletBalances:
return .none
case .smartBanner:
return .none
}
}
}

View File

@ -11,6 +11,7 @@ import Models
import WalletBalances
import Scan
import BalanceBreakdown
import SmartBanner
public struct HomeView: View {
@Environment(\.colorScheme) var colorScheme
@ -42,21 +43,24 @@ public struct HomeView: View {
couldBeHidden: true
)
.padding(.top, 1)
if walletStatus == .restoring {
SyncProgressView(
store: store.scope(
state: \.syncProgressState,
action: \.syncProgress
)
)
.frame(height: 94)
.frame(maxWidth: .infinity)
.background(Asset.Colors.syncProgresBcg.color)
.padding(.top, 7)
.padding(.bottom, 20)
.onTapGesture {
store.send(.debug)
}
// if walletStatus == .restoring {
// SyncProgressView(
// store: store.scope(
// state: \.syncProgressState,
// action: \.syncProgress
// )
// )
// .frame(height: 94)
// .frame(maxWidth: .infinity)
// .background(Asset.Colors.syncProgresBcg.color)
// .padding(.top, 7)
// .padding(.bottom, 20)
// }
HStack(spacing: 8) {
button(
L10n.Tabs.receive,
@ -88,9 +92,15 @@ public struct HomeView: View {
}
.zFont(.medium, size: 12, style: Design.Text.primary)
.padding(.top, 24)
.padding(.bottom, 32)
// .padding(.bottom, 32)
.screenHorizontalPadding()
SmartBannerView(
store: store.scope(
state: \.smartBannerState,
action: \.smartBanner
)
)
// SmartBanner(isOpen: true) {
//// EmptyView()
// HStack(spacing: 0) {
@ -156,6 +166,7 @@ public struct HomeView: View {
}
//.padding(.top, 12)
}
// .popover(
// isPresented:
// Binding(
@ -223,75 +234,75 @@ public struct HomeView: View {
}
}
}
.overlayPreferenceValue(ExchangeRateFeaturePreferenceKey.self) { preferences in
WithPerceptionTracking {
if store.isRateEducationEnabled {
GeometryReader { geometry in
preferences.map {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
Asset.Assets.coinsSwap.image
.zImage(size: 20, style: Design.Text.primary)
.padding(10)
.background {
Circle()
.fill(Design.Surfaces.bgTertiary.color(colorScheme))
}
.padding(.trailing, 16)
VStack(alignment: .leading, spacing: 5) {
Text(L10n.CurrencyConversion.cardTitle)
.zFont(size: 14, style: Design.Text.tertiary)
Text(L10n.CurrencyConversion.title)
.zFont(.semiBold, size: 16, style: Design.Text.primary)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
.padding(.trailing, 16)
Spacer(minLength: 0)
Button {
store.send(.currencyConversionCloseTapped)
} label: {
Asset.Assets.buttonCloseX.image
.zImage(size: 20, style: Design.HintTooltips.defaultFg)
}
.padding(20)
.offset(x: 20, y: -20)
}
Button {
store.send(.currencyConversionSetupTapped)
} label: {
Text(L10n.CurrencyConversion.cardButton)
.zFont(.semiBold, size: 16, style: Design.Btns.Tertiary.fg)
.frame(height: 24)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Design.Btns.Tertiary.bg.color(colorScheme))
}
}
}
.padding(24)
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Design.Surfaces.bgPrimary.color(colorScheme))
.background {
RoundedRectangle(cornerRadius: 12)
.stroke(Design.Surfaces.strokeSecondary.color(colorScheme))
}
}
.frame(width: geometry.size.width - 40)
.offset(x: 20, y: geometry[$0].minY + geometry[$0].height)
}
}
}
}
}
// .overlayPreferenceValue(ExchangeRateFeaturePreferenceKey.self) { preferences in
// WithPerceptionTracking {
// if store.isRateEducationEnabled {
// GeometryReader { geometry in
// preferences.map {
// VStack(alignment: .leading, spacing: 0) {
// HStack(alignment: .top, spacing: 0) {
// Asset.Assets.Icons.coinsSwap.image
// .zImage(size: 20, style: Design.Text.primary)
// .padding(10)
// .background {
// Circle()
// .fill(Design.Surfaces.bgTertiary.color(colorScheme))
// }
// .padding(.trailing, 16)
//
// VStack(alignment: .leading, spacing: 5) {
// Text(L10n.CurrencyConversion.cardTitle)
// .zFont(size: 14, style: Design.Text.tertiary)
//
// Text(L10n.CurrencyConversion.title)
// .zFont(.semiBold, size: 16, style: Design.Text.primary)
// .lineLimit(1)
// .minimumScaleFactor(0.5)
// }
// .padding(.trailing, 16)
//
// Spacer(minLength: 0)
//
// Button {
// store.send(.currencyConversionCloseTapped)
// } label: {
// Asset.Assets.buttonCloseX.image
// .zImage(size: 20, style: Design.HintTooltips.defaultFg)
// }
// .padding(20)
// .offset(x: 20, y: -20)
// }
//
// Button {
// store.send(.currencyConversionSetupTapped)
// } label: {
// Text(L10n.CurrencyConversion.cardButton)
// .zFont(.semiBold, size: 16, style: Design.Btns.Tertiary.fg)
// .frame(height: 24)
// .frame(maxWidth: .infinity)
// .padding(.vertical, 12)
// .background {
// RoundedRectangle(cornerRadius: 12)
// .fill(Design.Btns.Tertiary.bg.color(colorScheme))
// }
// }
// }
// .padding(24)
// .background {
// RoundedRectangle(cornerRadius: 12)
// .fill(Design.Surfaces.bgPrimary.color(colorScheme))
// .background {
// RoundedRectangle(cornerRadius: 12)
// .stroke(Design.Surfaces.strokeSecondary.color(colorScheme))
// }
// }
// .frame(width: geometry.size.width - 40)
// .offset(x: 20, y: geometry[$0].minY + geometry[$0].height)
// }
// }
// }
// }
// }
//..walletstatusPanel()
.applyScreenBackground()
.onAppear {

View File

@ -1,153 +0,0 @@
//
// SmartBanner.swift
// Zashi
//
// Created by Lukáš Korba on 2025-03-14.
//
import SwiftUI
import Generated
struct BottomRoundedRectangle: Shape {
var radius: CGFloat
func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
return Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: width, y: 0))
path.addLine(to: CGPoint(x: width, y: height - radius))
path.addQuadCurve(
to: CGPoint(x: width - radius, y: height),
control: CGPoint(x: width, y: height)
)
path.addLine(to: CGPoint(x: radius, y: height))
path.addQuadCurve(
to: CGPoint(x: 0, y: height - radius),
control: CGPoint(x: 0, y: height)
)
path.addLine(to: CGPoint(x: 0, y: 0))
}
}
}
struct TopRoundedRectangle: Shape {
var radius: CGFloat
var animatableData: CGFloat {
get { radius }
set { radius = newValue }
}
func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
return Path { path in
path.move(to: CGPoint(x: 0, y: height))
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: width, y: radius))
path.addQuadCurve(
to: CGPoint(x: width - radius, y: 0),
control: CGPoint(x: width, y: 0)
)
path.addLine(to: CGPoint(x: radius, y: 0))
path.addQuadCurve(
to: CGPoint(x: 0, y: radius),
control: CGPoint(x: 0, y: 0)
)
path.addLine(to: CGPoint(x: 0, y: 0))
}
}
}
enum Constants {
static let fixedHeight: CGFloat = 32
static let fixedHeightWithShadow: CGFloat = 36
static let shadowHeight: CGFloat = 4
}
public struct SmartBanner<Content: View>: View {
@Environment(\.colorScheme) var colorScheme
@State private var realHeight: CGFloat = 100
@State private var isOpen = false
@State private var isUnhidden = false
@State private var height: CGFloat = 0
let content: () -> Content?
var test = false
public init(isOpen: Bool = false, content: @escaping () -> Content?) {
self.content = content
// if isOpen {
// withAnimation {
test = isOpen
// }
// }
}
public var body: some View {
ZStack(alignment: .top) {
BottomRoundedRectangle(radius: Constants.fixedHeight)
.frame(height: Constants.fixedHeight)
.foregroundColor(Design.screenBackground.color(colorScheme))
.shadow(color: Design.Text.primary.color(colorScheme).opacity(0.25), radius: 1)
.zIndex(1)
VStack(spacing: 0) {
if isOpen {
content()
.padding(.vertical, 24)
.padding(.top, Constants.fixedHeight)
.screenHorizontalPadding()
}
TopRoundedRectangle(radius: isOpen ? Constants.fixedHeight : 0)
.frame(height: Constants.fixedHeightWithShadow)
.foregroundColor(Design.screenBackground.color(colorScheme))
.shadow(color: Design.Text.primary.color(colorScheme).opacity(0.1), radius: isOpen ? 1 : 0, y: -1)
}
.frame(minHeight: Constants.fixedHeight + Constants.shadowHeight)
}
.background {
LinearGradient(
stops: [
Gradient.Stop(
color: colorScheme == .dark
? Color(UIColor(red: 0.06, green: 0.06, blue: 0.06, alpha: 1))
: Design.Utility.Gray._300.color(colorScheme),
location: 0.00
),
Gradient.Stop(color: Design.screenBackground.color(colorScheme), location: 1.00),
],
startPoint: UnitPoint(x: 0.5, y: 0.0),
endPoint: UnitPoint(x: 0.5, y: 0.8)
)
}
.clipShape( Rectangle() )
.onTapGesture {
withAnimation {
isOpen.toggle()
}
}
// .task { @MainActor in
// try? await Task.sleep(for: .seconds(2))
// if test {
// withAnimation(.easeInOut(duration: 0.5)) {
// isOpen.toggle()
// }
// }
// }
}
}

View File

@ -79,7 +79,7 @@ public struct ReceiveView: View {
if let transparentAddress = store.selectedWalletAccount?.transparentAddress {
addressBlock(
prefixIcon: Asset.Assets.Brandmarks.brandmarkLow.image,
prefixIcon: Asset.Assets.Brandmarks.brandmarkKeystone.image,
title: L10n.Accounts.Keystone.transparentAddress,
address: transparentAddress,
iconFg: Design.Text.primary,
@ -120,9 +120,9 @@ public struct ReceiveView: View {
.padding(.top, 24)
addressBlock(
prefixIcon: Asset.Assets.Brandmarks.brandmarkKeystone.image,
title: L10n.Accounts.Keystone.transparentAddress,
address: transparentAddress,
prefixIcon: Asset.Assets.Brandmarks.brandmarkLow.image,
title: L10n.Accounts.Zashi.transparentAddress,
address: store.transparentAddress,
iconFg: Design.Text.primary,
iconBg: Design.Surfaces.bgTertiary,
bcgColor: Design.Utility.Gray._50.color(colorScheme),

View File

@ -75,8 +75,8 @@ extension Root {
case .currencyConversionSetup(.skipTapped), .currencyConversionSetup(.enableTapped):
state.path = nil
state.homeState.isRateEducationEnabled = false
return .none
return .send(.home(.smartBanner(.closeAndCleanupBanner)))
// MARK: - Home
case .home(.settingsTapped):

View File

@ -195,13 +195,16 @@ extension Root {
}
case .initialization(.registerForSynchronizersUpdate):
return .publisher {
sdkSynchronizer.stateStream()
.throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true)
.map { $0.redacted }
.map(Root.Action.synchronizerStateChanged)
}
.cancellable(id: CancelStateId, cancelInFlight: true)
return .merge(
.publisher {
sdkSynchronizer.stateStream()
.throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true)
.map { $0.redacted }
.map(Root.Action.synchronizerStateChanged)
}
.cancellable(id: CancelStateId, cancelInFlight: true),
.send(.home(.smartBanner(.evaluatePriority1)))
)
case .initialization(.checkWalletConfig):
return .publisher {
@ -494,6 +497,7 @@ extension Root {
}
state.splashAppeared = true
state.isRestoringWallet = false
localNotification.clearNotifications()
userDefaults.remove(Constants.udIsRestoringWallet)
userDefaults.remove(Constants.udLeavesScreenOpen)
flexaHandler.signOut()

View File

@ -30,6 +30,7 @@ import OSStatusError
import AddressBookClient
import UserMetadataProvider
import AudioServices
import LocalNotification
// Screens
//import About
@ -238,6 +239,7 @@ public struct Root {
case flexaTransactionFailed(String)
case home(Home.Action)
case initialization(InitializationAction)
case localNotificationTapped(String)
case notEnoughFreeSpace(NotEnoughFreeSpace.Action)
// case path(StackActionOf<Path>)
case resetZashiFinishProcessing
@ -299,6 +301,7 @@ public struct Root {
@Dependency(\.exchangeRate) var exchangeRate
@Dependency(\.flexaHandler) var flexaHandler
@Dependency(\.localAuthentication) var localAuthentication
@Dependency(\.localNotification) var localNotification
@Dependency(\.mainQueue) var mainQueue
@Dependency(\.mnemonic) var mnemonic
@Dependency(\.numberFormatter) var numberFormatter
@ -484,6 +487,9 @@ public struct Root {
//return
// return .none
case .localNotificationTapped(let identifier):
return .send(.home(.smartBanner(.localNotificationTapped(identifier))))
case .serverSetup:
return .none

View File

@ -0,0 +1,232 @@
//
// SmartBannerContent.swift
// modules
//
// Created by Lukáš Korba on 04-03-2025.
//
import SwiftUI
import ComposableArchitecture
import Generated
import UIComponents
extension SmartBannerView {
func titleStyle() -> Color {
Design.Utility.Purple._50.color(.light)
}
func infoStyle() -> Color {
Design.Utility.Purple._200.color(.light)
}
@ViewBuilder func priorityContent() -> some View {
WithPerceptionTracking {
switch store.priorityContent {
case .priority1: disconnectedContent()
case .priority2: syncingErrorContent()
case .priority3: restoringContent()
case .priority4: syncingContent()
case .priority5: updatingBalanceContent()
case .priority6: walletBackupContent()
case .priority7: shieldingContent()
case .priority8: currencyConversionContent()
case .priority9: autoShieldingContent()
default: EmptyView()
}
}
}
@ViewBuilder func disconnectedContent() -> some View {
HStack(spacing: 0) {
Asset.Assets.Icons.wifiOff.image
.zImage(size: 20, color: titleStyle())
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.Disconnected.title)
.zFont(.medium, size: 14, color: titleStyle())
Text(L10n.SmartBanner.Content.Disconnected.info)
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
}
}
@ViewBuilder func syncingErrorContent() -> some View {
HStack(spacing: 0) {
Asset.Assets.Icons.alertTriangle.image
.zImage(size: 20, color: titleStyle())
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.SyncingError.title)
.zFont(.medium, size: 14, color: titleStyle())
Text(L10n.SmartBanner.Content.SyncingError.info)
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
}
}
@ViewBuilder func restoringContent() -> some View {
HStack(spacing: 0) {
CircularProgressView(progress: store.lastKnownSyncPercentage)
.frame(width: 20, height: 20)
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.Restore.title(String(format: "%0.1f%%", store.lastKnownSyncPercentage * 100)))
.zFont(.medium, size: 14, color: titleStyle())
Text(L10n.SmartBanner.Content.Restore.info)
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
}
}
@ViewBuilder func syncingContent() -> some View {
HStack(spacing: 0) {
CircularProgressView(progress: store.lastKnownSyncPercentage)
.frame(width: 20, height: 20)
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.Sync.title(String(format: "%0.1f%%", store.lastKnownSyncPercentage * 100)))
.zFont(.medium, size: 14, color: titleStyle())
Text(L10n.SmartBanner.Content.Sync.info)
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
}
}
@ViewBuilder func updatingBalanceContent() -> some View {
HStack(spacing: 0) {
Asset.Assets.Icons.loading.image
.zImage(size: 20, color: titleStyle())
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.UpdatingBalance.title)
.zFont(.medium, size: 14, color: titleStyle())
Text(L10n.SmartBanner.Content.UpdatingBalance.info)
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
}
}
@ViewBuilder func walletBackupContent() -> some View {
HStack(spacing: 0) {
Asset.Assets.Icons.alertTriangle.image
.zImage(size: 20, color: titleStyle())
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.Backup.title)
.zFont(.medium, size: 14, color: titleStyle())
Text(L10n.SmartBanner.Content.Backup.info)
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
ZashiButton(
L10n.SmartBanner.Content.Backup.button,
type: .ghost,
infinityWidth: false
) {
store.send(.walletBackupTapped)
}
}
}
@ViewBuilder func shieldingContent() -> some View {
HStack(spacing: 0) {
Asset.Assets.Icons.shieldOff.image
.zImage(size: 20, color: titleStyle())
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.Shield.title)
.zFont(.medium, size: 14, color: titleStyle())
Text("\(store.transparentBalance.decimalString()) \(store.tokenName)")
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
ZashiButton(
L10n.SmartBanner.Content.Shield.button,
type: .ghost,
infinityWidth: false
) {
store.send(.shieldTapped)
}
}
}
@ViewBuilder func currencyConversionContent() -> some View {
HStack(spacing: 0) {
Asset.Assets.Icons.coinsSwap.image
.zImage(size: 20, color: titleStyle())
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.CurrencyConversion.title)
.zFont(.medium, size: 14, color: titleStyle())
Text(L10n.SmartBanner.Content.CurrencyConversion.info)
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
ZashiButton(
L10n.SmartBanner.Content.CurrencyConversion.button,
type: .ghost,
infinityWidth: false
) {
store.send(.currencyConversionTapped)
}
}
}
@ViewBuilder func autoShieldingContent() -> some View {
HStack(spacing: 0) {
Asset.Assets.Icons.shieldZap.image
.zImage(size: 20, color: titleStyle())
.padding(.trailing, 12)
VStack(alignment: .leading, spacing: 2) {
Text(L10n.SmartBanner.Content.AutoShielding.title)
.zFont(.medium, size: 14, color: titleStyle())
Text(L10n.SmartBanner.Content.AutoShielding.info)
.zFont(.medium, size: 12, color: infoStyle())
}
Spacer()
ZashiButton(
L10n.SmartBanner.Content.AutoShielding.button,
type: .ghost,
infinityWidth: false
) {
store.send(.autoShieldingTapped)
}
}
}
}

View File

@ -0,0 +1,322 @@
//
// SmartBannerHelpSheet.swift
// modules
//
// Created by Lukáš Korba on 04-03-2025.
//
import SwiftUI
import ComposableArchitecture
import Generated
import UIComponents
extension SmartBannerView {
@ViewBuilder func helpSheetContent() -> some View {
WithPerceptionTracking {
switch store.priorityContent {
case .priority1: disconnectedHelpContent()
case .priority2: syncingErrorHelpContent()
case .priority3: restoringHelpContent()
case .priority4: syncingHelpContent()
case .priority5: updatingBalanceHelpContent()
case .priority6: walletBackupHelpContent()
case .priority7: shieldingHelpContent()
case .priority9: autoShieldingHelpContent()
default: EmptyView()
}
}
}
@ViewBuilder func disconnectedHelpContent() -> some View {
VStack(alignment: .leading, spacing: 0) {
Asset.Assets.Icons.wifiOff.image
.zImage(size: 20, color: Design.Text.primary.color(colorScheme))
.padding(10)
.background {
Circle()
.fill(Design.Surfaces.bgTertiary.color(colorScheme))
.frame(width: 40, height: 40)
}
.padding(.top, 32)
.padding(.bottom, 12)
Text(L10n.SmartBanner.Help.Diconnected.title)
.zFont(.semiBold, size: 20, style: Design.Text.primary)
.padding(.bottom, 4)
Text(L10n.SmartBanner.Help.Diconnected.info)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 32)
.fixedSize(horizontal: false, vertical: true)
ZashiButton(L10n.General.ok.uppercased()) {
store.send(.closeSheetTapped)
}
.padding(.bottom, 32)
}
}
@ViewBuilder func syncingErrorHelpContent() -> some View {
VStack(alignment: .leading, spacing: 0) {
Text("Error during sync")
.zFont(.semiBold, size: 20, style: Design.Text.primary)
.padding(.top, 32)
.padding(.bottom, 12)
Text(store.lastKnownErrorMessage)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 32)
.fixedSize(horizontal: false, vertical: true)
ZashiButton(
"Report",
type: .ghost
) {
}
.padding(.bottom, 12)
ZashiButton(L10n.General.ok.uppercased()) {
store.send(.closeSheetTapped)
}
.padding(.bottom, 32)
}
}
@ViewBuilder func restoringHelpContent() -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(L10n.SmartBanner.Help.Restore.title)
.zFont(.semiBold, size: 20, style: Design.Text.primary)
.padding(.top, 32)
.padding(.bottom, 12)
Text(L10n.SmartBanner.Help.Restore.info)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 12)
.fixedSize(horizontal: false, vertical: true)
bulletpoint(L10n.SmartBanner.Help.Restore.point1)
bulletpoint(L10n.SmartBanner.Help.Restore.point2)
.padding(.bottom, 32)
note(L10n.SmartBanner.Help.Restore.warning)
.padding(.bottom, 24)
ZashiButton(L10n.General.ok.uppercased()) {
store.send(.closeSheetTapped)
}
.padding(.bottom, 32)
}
}
@ViewBuilder func syncingHelpContent() -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(L10n.SmartBanner.Help.Sync.title)
.zFont(.semiBold, size: 20, style: Design.Text.primary)
.padding(.top, 32)
.padding(.bottom, 12)
Text(L10n.SmartBanner.Help.Sync.info)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 32)
.fixedSize(horizontal: false, vertical: true)
ZashiButton(L10n.General.ok.uppercased()) {
store.send(.closeSheetTapped)
}
.padding(.bottom, 32)
}
}
@ViewBuilder func updatingBalanceHelpContent() -> some View {
VStack(alignment: .leading, spacing: 0) {
Asset.Assets.Icons.loading.image
.zImage(size: 20, color: Design.Text.primary.color(colorScheme))
.padding(10)
.background {
Circle()
.fill(Design.Surfaces.bgTertiary.color(colorScheme))
.frame(width: 40, height: 40)
}
.padding(.top, 32)
.padding(.bottom, 12)
Text(L10n.SmartBanner.Help.UpdatingBalance.title)
.zFont(.semiBold, size: 20, style: Design.Text.primary)
.padding(.bottom, 4)
Text(L10n.SmartBanner.Help.UpdatingBalance.info)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 32)
.fixedSize(horizontal: false, vertical: true)
ZashiButton(L10n.General.ok.uppercased()) {
store.send(.closeSheetTapped)
}
.padding(.bottom, 32)
}
}
@ViewBuilder func walletBackupHelpContent() -> some View {
VStack(alignment: .leading, spacing: 0) {
Asset.Assets.Icons.alertTriangle.image
.zImage(size: 20, color: Design.Text.primary.color(colorScheme))
.padding(10)
.background {
Circle()
.fill(Design.Surfaces.bgTertiary.color(colorScheme))
.frame(width: 40, height: 40)
}
.padding(.top, 32)
.padding(.bottom, 12)
Text(L10n.SmartBanner.Help.Backup.title)
.zFont(.semiBold, size: 20, style: Design.Text.primary)
.padding(.bottom, 4)
Text(L10n.SmartBanner.Help.Backup.info1)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 12)
.fixedSize(horizontal: false, vertical: true)
Text(L10n.SmartBanner.Help.Backup.info2)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 12)
.fixedSize(horizontal: false, vertical: true)
bulletpoint(L10n.SmartBanner.Help.Backup.point1)
bulletpoint(L10n.SmartBanner.Help.Backup.point2)
.padding(.bottom, 12)
Text(L10n.SmartBanner.Help.Backup.info3)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 24)
.fixedSize(horizontal: false, vertical: true)
Text(L10n.SmartBanner.Help.Backup.info4)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 32)
.fixedSize(horizontal: false, vertical: true)
ZashiButton(
L10n.SmartBanner.Help.remindMe,
type: .ghost
) {
store.send(.remindMeLaterTapped(.priority6))
}
.padding(.bottom, 12)
ZashiButton(L10n.SmartBanner.Content.Backup.button) {
store.send(.walletBackupTapped)
}
.padding(.bottom, 32)
}
}
@ViewBuilder func shieldingHelpContent() -> some View {
VStack(alignment: .leading, spacing: 0) {
Asset.Assets.shieldTick.image
.zImage(size: 20, color: Design.Text.primary.color(colorScheme))
.padding(10)
.background {
Circle()
.fill(Design.Surfaces.bgTertiary.color(colorScheme))
.frame(width: 40, height: 40)
}
.padding(.top, 32)
.padding(.bottom, 12)
Text(L10n.SmartBanner.Help.Shield.title)
.zFont(.semiBold, size: 20, style: Design.Text.primary)
.padding(.bottom, 4)
Text(L10n.SmartBanner.Help.Shield.info1)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 12)
.fixedSize(horizontal: false, vertical: true)
Text(L10n.SmartBanner.Help.Shield.info2)
.zFont(size: 16, style: Design.Text.tertiary)
.padding(.bottom, 32)
.fixedSize(horizontal: false, vertical: true)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
Text(L10n.SmartBanner.Help.Shield.transparent)
.zFont(.medium, size: 16, style: Design.Text.primary)
.padding(.trailing, 4)
Asset.Assets.Icons.shieldOff.image
.zImage(size: 16, style: Design.Text.primary)
Spacer()
}
.padding(.bottom, 4)
Text("\(store.transparentBalance.decimalString()) \(store.tokenName)")
.zFont(.semiBold, size: 20, style: Design.Text.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.padding(.horizontal, 20)
.background {
RoundedRectangle(cornerRadius: 16)
.fill(Design.Surfaces.bgSecondary.color(colorScheme))
.background {
RoundedRectangle(cornerRadius: 16)
.stroke(Design.Surfaces.strokeSecondary.color(colorScheme))
}
}
.padding(.bottom, 24)
ZashiButton(
L10n.SmartBanner.Help.remindMe,
type: .ghost
) {
store.send(.remindMeLaterTapped(.priority7))
}
.padding(.bottom, 12)
ZashiButton(L10n.SmartBanner.Content.Shield.button) {
store.send(.shieldTapped)
}
.padding(.bottom, 32)
}
}
@ViewBuilder func autoShieldingHelpContent() -> some View {
Text("autoShieldingHelpContent")
.zFont(size: 14, style: Design.Text.primary)
.padding(.vertical, 50)
}
@ViewBuilder private func bulletpoint(_ text: String) -> some View {
HStack(alignment: .top) {
Circle()
.fill(Design.Text.tertiary.color(colorScheme))
.frame(width: 4, height: 4)
.padding(.top, 7)
.padding(.leading, 8)
Text(text)
.zFont(size: 14, style: Design.Text.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 5)
}
@ViewBuilder private func note(_ text: String) -> some View {
VStack {
HStack(alignment: .top, spacing: 0) {
Asset.Assets.infoCircle.image
.zImage(size: 20, style: Design.Text.tertiary)
.padding(.trailing, 12)
Text(text)
.zFont(size: 12, style: Design.Text.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity)
}
}

View File

@ -0,0 +1,371 @@
//
// SmartBannerStore.swift
// modules
//
// Created by Lukáš Korba on 03.04.2025.
//
import Foundation
import ComposableArchitecture
import ZcashLightClientKit
import Generated
import SDKSynchronizer
import Utils
import Models
import WalletStorage
import UserPreferencesStorage
import UIComponents
import NetworkMonitor
import ZcashSDKEnvironment
import LocalNotification
@Reducer
public struct SmartBanner {
enum Constants: Equatable {
static let easeInOutDuration = 0.85
}
@ObservableState
public struct State: Equatable {
public enum PriorityContent: Int {
case priority1 = 0 // disconnected
case priority2 // syncing error
case priority3 // restoring
case priority4 // syncing
case priority5 // updating balance
case priority6 // wallet backup
case priority7 // shielding
case priority8 // currency conversion
case priority9 // auto-shielding
public func next() -> PriorityContent {
PriorityContent.init(rawValue: self.rawValue - 1) ?? .priority9
}
}
public var CancelNetworkMonitorId = UUID()
public var CancelStateStreamId = UUID()
public var delay = 1.5
public var isOpen = false
public var isSmartBannerSheetPresented = false
public var lastKnownErrorMessage = ""
public var lastKnownSyncPercentage = 0.0
public var priorityContent: PriorityContent? = nil
public var priorityContentRequested: PriorityContent? = nil
@Shared(.inMemory(.selectedWalletAccount)) public var selectedWalletAccount: WalletAccount? = nil
public var synchronizerStatusSnapshot: SyncStatusSnapshot = .snapshotFor(state: .unprepared)
public var tokenName = "ZEC"
public var transparentBalance = Zatoshi(0)
@Shared(.inMemory(.walletStatus)) public var walletStatus: WalletStatus = .none
public init() { }
}
public enum Action: BindableAction, Equatable {
case debug
case binding(BindingAction<SmartBanner.State>)
case closeAndCleanupBanner
case closeBanner(Bool)
case closeSheetTapped
case onAppear
case onDisappear
case evaluatePriority1
case evaluatePriority2
case evaluatePriority3
case evaluatePriority4
case evaluatePriority5
case evaluatePriority6
case evaluatePriority7
case evaluatePriority8
case evaluatePriority9
case localNotificationTapped(String)
case networkMonitorChanged(Bool)
case openBanner
case openBannerRequest
case remindMeLaterTapped(State.PriorityContent)
case smartBannerContentTapped
case synchronizerStateChanged(RedactableSynchronizerState)
case transparentBalanceUpdated(Zatoshi)
case triggerPriority(State.PriorityContent)
// Action buttons
case autoShieldingTapped
case currencyConversionScreenRequested
case currencyConversionTapped
case shieldTapped
case walletBackupTapped
}
@Dependency(\.localNotification) var localNotification
@Dependency(\.mainQueue) var mainQueue
@Dependency(\.networkMonitor) var networkMonitor
@Dependency(\.sdkSynchronizer) var sdkSynchronizer
@Dependency(\.userStoredPreferences) var userStoredPreferences
@Dependency(\.walletStorage) var walletStorage
@Dependency(\.zcashSDKEnvironment) var zcashSDKEnvironment
public init() { }
public var body: some Reducer<State, Action> {
BindingReducer()
Reduce { state, action in
switch action {
case .debug:
if state.priorityContentRequested == nil {
state.priorityContentRequested = .priority9
} else {
state.priorityContentRequested = state.priorityContentRequested?.next()
if state.priorityContentRequested == .priority9 {
state.priorityContent = nil
}
}
return .send(.openBannerRequest)
case .onAppear:
state.tokenName = zcashSDKEnvironment.tokenName
return .merge(
.publisher {
networkMonitor.networkMonitorStream()
.map(Action.networkMonitorChanged)
.receive(on: mainQueue)
}
.cancellable(id: state.CancelNetworkMonitorId, cancelInFlight: true),
.publisher {
sdkSynchronizer.stateStream()
.throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true)
.map { $0.redacted }
.map(Action.synchronizerStateChanged)
}
.cancellable(id: state.CancelStateStreamId, cancelInFlight: true)
)
case .onDisappear:
return .merge(
.cancel(id: state.CancelNetworkMonitorId),
.cancel(id: state.CancelStateStreamId)
)
case .binding:
return .none
case .networkMonitorChanged(let isConnected):
if state.priorityContent == .priority1 && isConnected {
return .run { send in
await send(.closeAndCleanupBanner)
try? await mainQueue.sleep(for: .seconds(2))
await send(.evaluatePriority2)
}
} else if state.priorityContent != .priority1 && !isConnected {
return .send(.triggerPriority(.priority1))
}
return .none
case .smartBannerContentTapped:
if state.priorityContent == .priority8 {
return .send(.currencyConversionScreenRequested)
}
state.isSmartBannerSheetPresented = true
return .none
case .closeSheetTapped:
state.isSmartBannerSheetPresented = false
return .none
case .remindMeLaterTapped(let priority):
state.isSmartBannerSheetPresented = false
state.priorityContentRequested = nil
if priority == .priority6 {
localNotification.scheduleWalletBackup()
} else if priority == .priority7 {
localNotification.scheduleShielding()
}
return .run { send in
try? await mainQueue.sleep(for: .seconds(1))
await send(.closeBanner(false), animation: .easeInOut(duration: Constants.easeInOutDuration))
}
case .synchronizerStateChanged(let latestState):
let snapshot = SyncStatusSnapshot.snapshotFor(state: latestState.data.syncStatus)
if snapshot.syncStatus != state.synchronizerStatusSnapshot.syncStatus {
state.synchronizerStatusSnapshot = snapshot
if case .syncing(let progress) = snapshot.syncStatus {
state.lastKnownSyncPercentage = Double(progress)
if state.priorityContent == .priority2 {
return .send(.closeAndCleanupBanner)
}
}
// error syncing check
switch snapshot.syncStatus {
case .upToDate:
if state.priorityContent == .priority3 {
return .send(.closeAndCleanupBanner)
}
case .error, .unprepared:
if state.lastKnownErrorMessage != snapshot.message {
state.lastKnownErrorMessage = snapshot.message
return .send(.triggerPriority(.priority2))
}
default: break
}
// unavailable balance check
let accountsBalances = latestState.data.accountsBalances
if let account = state.selectedWalletAccount, let accountBalance = accountsBalances[account.id] {
state.transparentBalance = accountBalance.unshielded
if accountBalance.orchardBalance.spendableValue.amount == 0 && state.priorityContent != .priority5 {
return .send(.triggerPriority(.priority5))
} else if accountBalance.orchardBalance.spendableValue.amount > 0 && state.priorityContent == .priority5 {
return .send(.closeAndCleanupBanner)
}
}
}
return .none
case .localNotificationTapped(let identifier):
if LocalNotificationClient.Constants.walletBackupUUID == identifier {
return .send(.triggerPriority(.priority6))
} else if LocalNotificationClient.Constants.shieldingUUID == identifier {
return .send(.triggerPriority(.priority7))
}
return .none
// disconnected
case .evaluatePriority1:
return .send(.evaluatePriority2)
// syncing error
case .evaluatePriority2:
return .send(.evaluatePriority3)
// restoring
case .evaluatePriority3:
if state.walletStatus == .restoring {
return .send(.triggerPriority(.priority3))
}
return .send(.evaluatePriority4)
// syncing
case .evaluatePriority4:
return .send(.evaluatePriority5)
// updating balance
case .evaluatePriority5:
return .send(.evaluatePriority6)
// wallet backup
case .evaluatePriority6:
if let storedWallet = try? walletStorage.exportWallet(), !storedWallet.hasUserPassedPhraseBackupTest {
return .run { send in
guard await !localNotification.isWalletBackupScheduled() else {
await send(.evaluatePriority7)
return
}
await send(.triggerPriority(.priority6))
}
}
return .send(.evaluatePriority7)
// shielding
case .evaluatePriority7:
guard let account = state.selectedWalletAccount else {
return .none
}
return .run { send in
if let accountBalance = try? await sdkSynchronizer.getAccountsBalances()[account.id],
accountBalance.unshielded > zcashSDKEnvironment.shieldingThreshold {
await send(.transparentBalanceUpdated(accountBalance.unshielded))
if await !localNotification.isShieldingScheduled() {
await send(.triggerPriority(.priority7))
}
} else {
await send(.evaluatePriority8)
}
}
// currency conversion
case .evaluatePriority8:
if userStoredPreferences.exchangeRate() == nil {
return .send(.triggerPriority(.priority8))
}
return .send(.evaluatePriority9)
// auto-shielding
case .evaluatePriority9:
return .none
case .triggerPriority(let priority):
state.priorityContentRequested = priority
return .send(.openBannerRequest)
case .transparentBalanceUpdated(let balance):
state.transparentBalance = balance
return .none
case .openBannerRequest:
guard let priorityContentRequested = state.priorityContentRequested else {
return .none
}
if let priorityContent = state.priorityContent, priorityContentRequested.rawValue >= priorityContent.rawValue {
return .none
}
if state.isOpen {
return .run { send in
await send(.closeBanner(false), animation: .easeInOut(duration: Constants.easeInOutDuration))
}
}
state.priorityContent = priorityContentRequested
return .run { [delay = state.delay] send in
try? await mainQueue.sleep(for: .seconds(delay))
await send(.openBanner, animation: .easeInOut(duration: Constants.easeInOutDuration))
}
case .closeBanner(let clean):
state.isOpen = false
if clean {
state.priorityContentRequested = nil
state.priorityContent = nil
}
return .send(.openBannerRequest)
case .closeAndCleanupBanner:
// state.priorityContentRequested = nil
// state.priorityContent = nil
return .run { send in
await send(.closeBanner(true), animation: .easeInOut(duration: Constants.easeInOutDuration))
}
case .openBanner:
state.delay = 1.0
state.isOpen = true
return .none
// MARK: - Actions
case .autoShieldingTapped:
return .none
case .currencyConversionScreenRequested:
return .none
case .currencyConversionTapped:
return .send(.smartBannerContentTapped)
case .shieldTapped:
return .none
case .walletBackupTapped:
return .none
}
}
}
}

View File

@ -0,0 +1,161 @@
//
// SmartBannerView.swift
// Zashi
//
// Created by Lukáš Korba on 2025-03-14.
//
import SwiftUI
import ComposableArchitecture
import Generated
import Generated
import UIComponents
enum SBConstants {
static let fixedHeight: CGFloat = 32
static let fixedHeightWithShadow: CGFloat = 36
static let shadowHeight: CGFloat = 4
}
public struct SmartBannerView: View {
@Environment(\.colorScheme) var colorScheme
@Perception.Bindable var store: StoreOf<SmartBanner>
@State private var realHeight: CGFloat = 100
@State private var isUnhidden = false
@State private var height: CGFloat = 0
public init(store: StoreOf<SmartBanner>) {
self.store = store
}
public var body: some View {
WithPerceptionTracking {
ZStack(alignment: .top) {
BottomRoundedRectangle(radius: SBConstants.fixedHeight)
.frame(height: SBConstants.fixedHeight)
.foregroundColor(Design.screenBackground.color(colorScheme))
.shadow(color: Design.Text.primary.color(colorScheme).opacity(0.25), radius: 1)
.zIndex(1)
VStack(spacing: 0) {
if store.isOpen {
priorityContent()
.padding(.vertical, 16)
.padding(.top, SBConstants.fixedHeight)
.onTapGesture {
store.send(.smartBannerContentTapped)
}
.screenHorizontalPadding()
}
TopRoundedRectangle(radius: store.isOpen ? SBConstants.fixedHeight : 0)
.frame(height: SBConstants.fixedHeightWithShadow)
.foregroundColor(Design.screenBackground.color(colorScheme))
.shadow(color: Design.Text.primary.color(colorScheme).opacity(0.1), radius: store.isOpen ? 1 : 0, y: -1)
}
.frame(minHeight: SBConstants.fixedHeight + SBConstants.shadowHeight)
}
.zashiSheet(isPresented: $store.isSmartBannerSheetPresented) {
helpSheetContent()
.screenHorizontalPadding()
}
.onAppear { store.send(.onAppear) }
.onDisappear { store.send(.onDisappear) }
.background {
LinearGradient(
stops: [
Gradient.Stop(color: Design.Utility.Purple._700.color(.light), location: 0.00),
Gradient.Stop(color: Design.Utility.Purple._950.color(.light), location: 1.00)
],
startPoint: UnitPoint(x: 0.5, y: 0.0),
endPoint: UnitPoint(x: 0.5, y: 1.0)
)
}
.clipShape( Rectangle() )
}
}
}
// MARK: - Store
extension SmartBanner {
public static var initial = StoreOf<SmartBanner>(
initialState: .initial
) {
SmartBanner()
}
}
// MARK: - Placeholders
extension SmartBanner.State {
public static let initial = SmartBanner.State()
}
// MARK: - Helpers
struct BottomRoundedRectangle: Shape {
var radius: CGFloat
func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
return Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: width, y: 0))
path.addLine(to: CGPoint(x: width, y: height - radius))
path.addQuadCurve(
to: CGPoint(x: width - radius, y: height),
control: CGPoint(x: width, y: height)
)
path.addLine(to: CGPoint(x: radius, y: height))
path.addQuadCurve(
to: CGPoint(x: 0, y: height - radius),
control: CGPoint(x: 0, y: height)
)
path.addLine(to: CGPoint(x: 0, y: 0))
}
}
}
struct TopRoundedRectangle: Shape {
var radius: CGFloat
var animatableData: CGFloat {
get { radius }
set { radius = newValue }
}
func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
return Path { path in
path.move(to: CGPoint(x: 0, y: height))
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: width, y: radius))
path.addQuadCurve(
to: CGPoint(x: width - radius, y: 0),
control: CGPoint(x: width, y: 0)
)
path.addLine(to: CGPoint(x: radius, y: 0))
path.addQuadCurve(
to: CGPoint(x: 0, y: radius),
control: CGPoint(x: 0, y: 0)
)
path.addLine(to: CGPoint(x: 0, y: 0))
}
}
}

View File

@ -281,7 +281,7 @@ public struct TabsView: View {
preferences.map {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
Asset.Assets.coinsSwap.image
Asset.Assets.Icons.coinsSwap.image
.zImage(size: 20, style: Design.Text.primary)
.padding(10)
.background {

View File

@ -303,6 +303,7 @@ public enum Design {
case _700
case _800
case _900
case _950
}
public enum Brand: Colorable {
@ -702,6 +703,7 @@ public extension Design.Utility.Purple {
case ._700: return Design.col(Asset.Colors.ZDesign.purple700.color, Asset.Colors.ZDesign.purple300.color, colorScheme)
case ._800: return Design.col(Asset.Colors.ZDesign.purple800.color, Asset.Colors.ZDesign.purple200.color, colorScheme)
case ._900: return Design.col(Asset.Colors.ZDesign.purple900.color, Asset.Colors.ZDesign.purple100.color, colorScheme)
case ._950: return Design.col(Asset.Colors.ZDesign.purple950.color, Asset.Colors.ZDesign.purple50.color, colorScheme)
}
}
}

View File

@ -203,8 +203,8 @@ public enum L10n {
public static let saveBtn = L10n.tr("Localizable", "currencyConversion.saveBtn", fallback: "Save changes")
/// Display your balance and payment amounts in USD. Zashis currency conversion feature protects your IP address at all times.
public static let settingsDesc = L10n.tr("Localizable", "currencyConversion.settingsDesc", fallback: "Display your balance and payment amounts in USD. Zashis currency conversion feature protects your IP address at all times.")
/// Skip for now
public static let skipBtn = L10n.tr("Localizable", "currencyConversion.skipBtn", fallback: "Skip for now")
/// Skip
public static let skipBtn = L10n.tr("Localizable", "currencyConversion.skipBtn", fallback: "Skip")
/// Currency Conversion
public static let title = L10n.tr("Localizable", "currencyConversion.title", fallback: "Currency Conversion")
}
@ -1095,6 +1095,134 @@ public enum L10n {
}
}
}
public enum SmartBanner {
public enum Content {
public enum AutoShielding {
/// Start
public static let button = L10n.tr("Localizable", "smartBanner.content.autoShielding.button", fallback: "Start")
/// Enable automatic shielding
public static let info = L10n.tr("Localizable", "smartBanner.content.autoShielding.info", fallback: "Enable automatic shielding")
/// Auto-Shielding
public static let title = L10n.tr("Localizable", "smartBanner.content.autoShielding.title", fallback: "Auto-Shielding")
}
public enum Backup {
/// Start
public static let button = L10n.tr("Localizable", "smartBanner.content.backup.button", fallback: "Start")
/// Prevent potential loss of funds
public static let info = L10n.tr("Localizable", "smartBanner.content.backup.info", fallback: "Prevent potential loss of funds")
/// Wallet Backup Required
public static let title = L10n.tr("Localizable", "smartBanner.content.backup.title", fallback: "Wallet Backup Required")
}
public enum CurrencyConversion {
/// Start
public static let button = L10n.tr("Localizable", "smartBanner.content.currencyConversion.button", fallback: "Start")
/// Enable to display USD values
public static let info = L10n.tr("Localizable", "smartBanner.content.currencyConversion.info", fallback: "Enable to display USD values")
/// Currency Conversion
public static let title = L10n.tr("Localizable", "smartBanner.content.currencyConversion.title", fallback: "Currency Conversion")
}
public enum Disconnected {
/// Check your connection and reload Zashi
public static let info = L10n.tr("Localizable", "smartBanner.content.disconnected.info", fallback: "Check your connection and reload Zashi")
/// Wallet Disconnected
public static let title = L10n.tr("Localizable", "smartBanner.content.disconnected.title", fallback: "Wallet Disconnected")
}
public enum Restore {
/// Keep Zashi open on active phone screen
public static let info = L10n.tr("Localizable", "smartBanner.content.restore.info", fallback: "Keep Zashi open on active phone screen")
/// Restoring Wallet %@ Complete
public static func title(_ p1: Any) -> String {
return L10n.tr("Localizable", "smartBanner.content.restore.title", String(describing: p1), fallback: "Restoring Wallet • %@ Complete")
}
}
public enum Shield {
/// Shield
public static let button = L10n.tr("Localizable", "smartBanner.content.shield.button", fallback: "Shield")
/// Transparent Balance Detected
public static let title = L10n.tr("Localizable", "smartBanner.content.shield.title", fallback: "Transparent Balance Detected")
}
public enum Sync {
/// Your wallet is getting updated
public static let info = L10n.tr("Localizable", "smartBanner.content.sync.info", fallback: "Your wallet is getting updated")
/// Syncing %@ Complete
public static func title(_ p1: Any) -> String {
return L10n.tr("Localizable", "smartBanner.content.sync.title", String(describing: p1), fallback: "Syncing • %@ Complete")
}
}
public enum SyncingError {
/// Attempting to resolve
public static let info = L10n.tr("Localizable", "smartBanner.content.syncingError.info", fallback: "Attempting to resolve")
/// Error encountered while syncing
public static let title = L10n.tr("Localizable", "smartBanner.content.syncingError.title", fallback: "Error encountered while syncing")
}
public enum UpdatingBalance {
/// Waiting for last transaction to process
public static let info = L10n.tr("Localizable", "smartBanner.content.updatingBalance.info", fallback: "Waiting for last transaction to process")
/// Updating Balance...
public static let title = L10n.tr("Localizable", "smartBanner.content.updatingBalance.title", fallback: "Updating Balance...")
}
}
public enum Help {
/// Remind me later
public static let remindMe = L10n.tr("Localizable", "smartBanner.help.remindMe", fallback: "Remind me later")
public enum Backup {
/// Prevent potential loss of funds by securely backing up your wallet.
public static let info1 = L10n.tr("Localizable", "smartBanner.help.backup.info1", fallback: "Prevent potential loss of funds by securely backing up your wallet.")
/// Back up access to your funds by backing up:
public static let info2 = L10n.tr("Localizable", "smartBanner.help.backup.info2", fallback: "Back up access to your funds by backing up:")
/// In case you lose access to the app or to your device, knowing the Secret Recovery Phrase is the only way to recover your funds. We cannot see it and cannot help you recover it.
public static let info3 = L10n.tr("Localizable", "smartBanner.help.backup.info3", fallback: "In case you lose access to the app or to your device, knowing the Secret Recovery Phrase is the only way to recover your funds. We cannot see it and cannot help you recover it.")
/// Anyone with access to your Secret Recovery Phrase will have full control of your wallet, so keep it secure and never show it to anyone.
public static let info4 = L10n.tr("Localizable", "smartBanner.help.backup.info4", fallback: "Anyone with access to your Secret Recovery Phrase will have full control of your wallet, so keep it secure and never show it to anyone.")
/// Secret Recovery Phrase (a unique set of 24 words in a precise order)
public static let point1 = L10n.tr("Localizable", "smartBanner.help.backup.point1", fallback: "Secret Recovery Phrase (a unique set of 24 words in a precise order) ")
/// Wallet Birthday Height (block height at which your wallet was created)
public static let point2 = L10n.tr("Localizable", "smartBanner.help.backup.point2", fallback: "Wallet Birthday Height (block height at which your wallet was created)")
/// Backup Required
public static let title = L10n.tr("Localizable", "smartBanner.help.backup.title", fallback: "Backup Required")
}
public enum Diconnected {
/// You appear to be offline. Please restore your internet connection to use Zashi wallet.
public static let info = L10n.tr("Localizable", "smartBanner.help.diconnected.info", fallback: "You appear to be offline. Please restore your internet connection to use Zashi wallet.")
/// Wallet Disconnected
public static let title = L10n.tr("Localizable", "smartBanner.help.diconnected.title", fallback: "Wallet Disconnected")
}
public enum Restore {
/// Zashi is scanning the blockchain to retrieve your transactions. Older wallets can take hours to restore. Follow these steps to prevent interruption:
public static let info = L10n.tr("Localizable", "smartBanner.help.restore.info", fallback: "Zashi is scanning the blockchain to retrieve your transactions. Older wallets can take hours to restore. Follow these steps to prevent interruption:")
/// Keep the Zashi app open on an active phone screen.
public static let point1 = L10n.tr("Localizable", "smartBanner.help.restore.point1", fallback: "Keep the Zashi app open on an active phone screen.")
/// To prevent your phone screen from going dark, turn off power-saving mode and keep your phone plugged in.
public static let point2 = L10n.tr("Localizable", "smartBanner.help.restore.point2", fallback: "To prevent your phone screen from going dark, turn off power-saving mode and keep your phone plugged in.")
/// Wallet Restore in Progress
public static let title = L10n.tr("Localizable", "smartBanner.help.restore.title", fallback: "Wallet Restore in Progress")
/// Funds cannot be spent until your wallet is restored.
public static let warning = L10n.tr("Localizable", "smartBanner.help.restore.warning", fallback: "Funds cannot be spent until your wallet is restored.")
}
public enum Shield {
/// To protect user privacy, Zashi doesn't support spending transparent ZEC. Tap the "Shield" button below to make your transparent funds spendable and your total Zashi balance private.
public static let info1 = L10n.tr("Localizable", "smartBanner.help.shield.info1", fallback: "To protect user privacy, Zashi doesn't support spending transparent ZEC. Tap the \"Shield\" button below to make your transparent funds spendable and your total Zashi balance private. ")
/// This will create a shielding in-wallet transaction, consolidating your transparent and shielded funds. (Typical fee: .001 ZEC)
public static let info2 = L10n.tr("Localizable", "smartBanner.help.shield.info2", fallback: "This will create a shielding in-wallet transaction, consolidating your transparent and shielded funds. (Typical fee: .001 ZEC)")
/// Always Shield Transparent Funds
public static let title = L10n.tr("Localizable", "smartBanner.help.shield.title", fallback: "Always Shield Transparent Funds")
/// Transparent
public static let transparent = L10n.tr("Localizable", "smartBanner.help.shield.transparent", fallback: "Transparent")
}
public enum Sync {
/// Zashi is scanning the blockchain for your latest transactions to make sure your balances are displayed correctly. Keep the Zashi app open on an active phone screen to avoid interruptions.
public static let info = L10n.tr("Localizable", "smartBanner.help.sync.info", fallback: "Zashi is scanning the blockchain for your latest transactions to make sure your balances are displayed correctly. Keep the Zashi app open on an active phone screen to avoid interruptions.")
/// Wallet Sync in Progress
public static let title = L10n.tr("Localizable", "smartBanner.help.sync.title", fallback: "Wallet Sync in Progress")
}
public enum UpdatingBalance {
/// Your last transaction is getting mined and confirmed.
public static let info = L10n.tr("Localizable", "smartBanner.help.updatingBalance.info", fallback: "Your last transaction is getting mined and confirmed.")
/// Updating Balance
public static let title = L10n.tr("Localizable", "smartBanner.help.updatingBalance.title", fallback: "Updating Balance")
}
}
}
public enum Splash {
/// Tap the face icon to use Face ID and unlock it.
public static let authFaceID = L10n.tr("Localizable", "splash.authFaceID", fallback: "Tap the face icon to use Face ID and unlock it.")

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "alertTriangle.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "loading.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "shieldOff.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "shieldZap.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "wifiOff.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -428,7 +428,6 @@
"currencyConversion.ipDesc" = "Zashis currency conversion feature doesnt compromise your IP address.";
"currencyConversion.refresh" = "Rate Refresh";
"currencyConversion.refreshDesc" = "The rate is refreshed automatically and can also be refreshed manually.";
"currencyConversion.skipBtn" = "Skip for now";
// MARK: - Deeplink Warning
"deeplinkWarning.title" = "Looks like you used a third-party app to scan for payment.";
@ -578,3 +577,66 @@
"restoreWallet.birthday.estimated.title" = "Estimated Block Height";
"restoreWallet.birthday.estimated.info" = "Zashi will scan and recover all transactions made after the following block number.";
"restoreInfo.checkbox" = "Keep screen on while restoring";
// Smart Banner
"smartBanner.help.diconnected.title" = "Wallet Disconnected";
"smartBanner.help.diconnected.info" = "You appear to be offline. Please restore your internet connection to use Zashi wallet.";
"smartBanner.help.restore.title" = "Wallet Restore in Progress";
"smartBanner.help.restore.info" = "Zashi is scanning the blockchain to retrieve your transactions. Older wallets can take hours to restore. Follow these steps to prevent interruption:";
"smartBanner.help.restore.point1" = "Keep the Zashi app open on an active phone screen.";
"smartBanner.help.restore.point2" = "To prevent your phone screen from going dark, turn off power-saving mode and keep your phone plugged in.";
"smartBanner.help.restore.warning" = "Funds cannot be spent until your wallet is restored.";
"smartBanner.help.sync.title" = "Wallet Sync in Progress";
"smartBanner.help.sync.info" = "Zashi is scanning the blockchain for your latest transactions to make sure your balances are displayed correctly. Keep the Zashi app open on an active phone screen to avoid interruptions.";
"smartBanner.help.updatingBalance.title" = "Updating Balance";
"smartBanner.help.updatingBalance.info" = "Your last transaction is getting mined and confirmed.";
"smartBanner.help.backup.title" = "Backup Required";
"smartBanner.help.backup.info1" = "Prevent potential loss of funds by securely backing up your wallet.";
"smartBanner.help.backup.info2" = "Back up access to your funds by backing up:";
"smartBanner.help.backup.point1" = "Secret Recovery Phrase (a unique set of 24 words in a precise order) ";
"smartBanner.help.backup.point2" = "Wallet Birthday Height (block height at which your wallet was created)";
"smartBanner.help.backup.info3" = "In case you lose access to the app or to your device, knowing the Secret Recovery Phrase is the only way to recover your funds. We cannot see it and cannot help you recover it.";
"smartBanner.help.backup.info4" = "Anyone with access to your Secret Recovery Phrase will have full control of your wallet, so keep it secure and never show it to anyone.";
"smartBanner.help.shield.title" = "Always Shield Transparent Funds";
"smartBanner.help.shield.info1" = "To protect user privacy, Zashi doesn't support spending transparent ZEC. Tap the \"Shield\" button below to make your transparent funds spendable and your total Zashi balance private. ";
"smartBanner.help.shield.info2" = "This will create a shielding in-wallet transaction, consolidating your transparent and shielded funds. (Typical fee: .001 ZEC)";
"smartBanner.help.shield.transparent" = "Transparent";
"smartBanner.help.remindMe" = "Remind me later";
"smartBanner.content.disconnected.title" = "Wallet Disconnected";
"smartBanner.content.disconnected.info" = "Check your connection and reload Zashi";
"smartBanner.content.syncingError.title" = "Error encountered while syncing";
"smartBanner.content.syncingError.info" = "Attempting to resolve";
"smartBanner.content.restore.title" = "Restoring Wallet • %@ Complete";
"smartBanner.content.restore.info" = "Keep Zashi open on active phone screen";
"smartBanner.content.sync.title" = "Syncing • %@ Complete";
"smartBanner.content.sync.info" = "Your wallet is getting updated";
"smartBanner.content.updatingBalance.title" = "Updating Balance...";
"smartBanner.content.updatingBalance.info" = "Waiting for last transaction to process";
"smartBanner.content.backup.title" = "Wallet Backup Required";
"smartBanner.content.backup.info" = "Prevent potential loss of funds";
"smartBanner.content.backup.button" = "Start";
"smartBanner.content.shield.title" = "Transparent Balance Detected";
"smartBanner.content.shield.button" = "Shield";
"smartBanner.content.currencyConversion.title" = "Currency Conversion";
"smartBanner.content.currencyConversion.info" = "Enable to display USD values";
"smartBanner.content.currencyConversion.button" = "Start";
"smartBanner.content.autoShielding.title" = "Auto-Shielding";
"smartBanner.content.autoShielding.info" = "Enable automatic shielding";
"smartBanner.content.autoShielding.button" = "Start";
"currencyConversion.skipBtn" = "Skip";

View File

@ -67,7 +67,6 @@ public enum Asset {
public static let chevronDown = ImageAsset(name: "chevronDown")
public static let chevronRight = ImageAsset(name: "chevronRight")
public static let chevronUp = ImageAsset(name: "chevronUp")
public static let coinsSwap = ImageAsset(name: "coinsSwap")
public static let convertIcon = ImageAsset(name: "convertIcon")
public static let copy = ImageAsset(name: "copy")
public static let eyeOff = ImageAsset(name: "eyeOff")
@ -75,11 +74,13 @@ public enum Asset {
public static let flyReceivedFilled = ImageAsset(name: "flyReceivedFilled")
public enum Icons {
public static let alertCircle = ImageAsset(name: "alertCircle")
public static let alertTriangle = ImageAsset(name: "alertTriangle")
public static let arrowUp = ImageAsset(name: "arrowUp")
public static let authKey = ImageAsset(name: "authKey")
public static let bookmark = ImageAsset(name: "bookmark")
public static let bookmarkCheck = ImageAsset(name: "bookmarkCheck")
public static let coinsHand = ImageAsset(name: "coinsHand")
public static let coinsSwap = ImageAsset(name: "coinsSwap")
public static let connectWallet = ImageAsset(name: "connectWallet")
public static let currencyDollar = ImageAsset(name: "currencyDollar")
public static let currencyZec = ImageAsset(name: "currencyZec")
@ -94,6 +95,7 @@ public enum Asset {
public static let imageLibrary = ImageAsset(name: "imageLibrary")
public static let integrations = ImageAsset(name: "integrations")
public static let key = ImageAsset(name: "key")
public static let loading = ImageAsset(name: "loading")
public static let lockUnlocked = ImageAsset(name: "lockUnlocked")
public static let magicWand = ImageAsset(name: "magicWand")
public static let menu = ImageAsset(name: "menu")
@ -112,11 +114,14 @@ public enum Asset {
public static let server = ImageAsset(name: "server")
public static let settings = ImageAsset(name: "settings")
public static let share = ImageAsset(name: "share")
public static let shieldOff = ImageAsset(name: "shieldOff")
public static let shieldTickFilled = ImageAsset(name: "shieldTickFilled")
public static let shieldZap = ImageAsset(name: "shieldZap")
public static let switchHorizontal = ImageAsset(name: "switchHorizontal")
public static let textInput = ImageAsset(name: "textInput")
public static let user = ImageAsset(name: "user")
public static let userPlus = ImageAsset(name: "userPlus")
public static let wifiOff = ImageAsset(name: "wifiOff")
public static let xClose = ImageAsset(name: "xClose")
public static let zashiLogoSq = ImageAsset(name: "zashiLogoSq")
public static let zashiLogoSqBold = ImageAsset(name: "zashiLogoSqBold")

View File

@ -0,0 +1,32 @@
//
// CircularProgressView.swift
// Zashi
//
// Created by Lukáš Korba on 04-03-2025.
//
import SwiftUI
import Generated
public struct CircularProgressView: View {
@Environment(\.colorScheme) var colorScheme
var progress: Double
public init(progress: Double) {
self.progress = progress
}
public var body: some View {
ZStack {
Circle()
.stroke(Design.Utility.Purple._400.color(.light), lineWidth: 4)
Circle()
.trim(from: 0, to: progress)
.stroke(Design.Utility.Purple._50.color(.light), style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.2), value: progress)
}
}
}

View File

@ -34,12 +34,23 @@ public struct ZashiFontModifier: ViewModifier {
let weight: FontWeight
let addressFont: Bool
let size: CGFloat
let style: Colorable
let color: Color?
let style: Colorable?
public func body(content: Content) -> some View {
content
.font(.custom(fontName(weight, addressFont: addressFont), size: size))
.zForegroundColor(style)
if let color {
content
.font(.custom(fontName(weight, addressFont: addressFont), size: size))
//.zForegroundColor(style)
.foregroundColor(color)
} else if let style {
content
.font(.custom(fontName(weight, addressFont: addressFont), size: size))
.zForegroundColor(style)
// .foregroundColor(color)
} else {
EmptyView()
}
}
private func fontName(_ weight: FontWeight, addressFont: Bool = false) -> String {
@ -82,8 +93,21 @@ public extension View {
size: CGFloat,
style: Colorable
) -> some View {
// zFont(weight, addressFont: addressFont, size: size, color: style.color(.light))
self.modifier(
ZashiFontModifier(weight: weight, addressFont: addressFont, size: size, style: style)
ZashiFontModifier(weight: weight, addressFont: addressFont, size: size, color: nil, style: style)
)
}
func zFont(
_ weight: ZashiFontModifier.FontWeight = .regular,
addressFont: Bool = false,
size: CGFloat,
color: Color
) -> some View {
self.modifier(
ZashiFontModifier(weight: weight, addressFont: addressFont, size: size, color: color, style: nil)
)
}
}

View File

@ -460,12 +460,12 @@
}
},
{
"identity" : "zcash-swift-wallet-sdk",
"identity" : "zcashlightclientkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Electric-Coin-Company/zcash-swift-wallet-sdk",
"location" : "https://github.com/LukasKorba/ZcashLightClientKit",
"state" : {
"revision" : "e5e826d13349746eabf7b481ccf7c6adcbc32bce",
"version" : "2.2.11"
"branch" : "1537-Birthday-estimate-based-on-a-date",
"revision" : "04ca05428f469767725ba421383fca31cf225855"
}
}
],

View File

@ -13,9 +13,10 @@ import Network
import Utils
import Root
import BackgroundTasks
import UserNotifications
// swiftlint:disable indentation_width
final class AppDelegate: NSObject, UIApplicationDelegate {
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
private let bcgTaskId = "co.electriccoin.power_wifi_sync"
private let bcgSchedulerTaskId = "co.electriccoin.scheduler"
private var monitor: NWPathMonitor?
@ -33,6 +34,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let center = UNUserNotificationCenter.current()
center.delegate = self
#if DEBUG
// Short-circuit if running unit tests to avoid side-effects from the app running.
guard !_XCTIsTesting else { return true }
@ -54,6 +58,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
) -> Bool {
return extensionPointIdentifier != UIApplication.ExtensionPointIdentifier.keyboard
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
rootStore.send(.localNotificationTapped(response.notification.request.identifier))
completionHandler()
}
}
// MARK: - BackgroundTasks

View File

@ -428,7 +428,6 @@
"currencyConversion.ipDesc" = "Zashis currency conversion feature doesnt compromise your IP address.";
"currencyConversion.refresh" = "Rate Refresh";
"currencyConversion.refreshDesc" = "The rate is refreshed automatically and can also be refreshed manually.";
"currencyConversion.skipBtn" = "Skip for now";
// MARK: - Deeplink Warning
"deeplinkWarning.title" = "Looks like you used a third-party app to scan for payment.";
@ -578,3 +577,66 @@
"restoreWallet.birthday.estimated.title" = "Estimated Block Height";
"restoreWallet.birthday.estimated.info" = "Zashi will scan and recover all transactions made after the following block number.";
"restoreInfo.checkbox" = "Keep screen on while restoring";
// Smart Banner
"smartBanner.help.diconnected.title" = "Wallet Disconnected";
"smartBanner.help.diconnected.info" = "You appear to be offline. Please restore your internet connection to use Zashi wallet.";
"smartBanner.help.restore.title" = "Wallet Restore in Progress";
"smartBanner.help.restore.info" = "Zashi is scanning the blockchain to retrieve your transactions. Older wallets can take hours to restore. Follow these steps to prevent interruption:";
"smartBanner.help.restore.point1" = "Keep the Zashi app open on an active phone screen.";
"smartBanner.help.restore.point2" = "To prevent your phone screen from going dark, turn off power-saving mode and keep your phone plugged in.";
"smartBanner.help.restore.warning" = "Funds cannot be spent until your wallet is restored.";
"smartBanner.help.sync.title" = "Wallet Sync in Progress";
"smartBanner.help.sync.info" = "Zashi is scanning the blockchain for your latest transactions to make sure your balances are displayed correctly. Keep the Zashi app open on an active phone screen to avoid interruptions.";
"smartBanner.help.updatingBalance.title" = "Updating Balance";
"smartBanner.help.updatingBalance.info" = "Your last transaction is getting mined and confirmed.";
"smartBanner.help.backup.title" = "Backup Required";
"smartBanner.help.backup.info1" = "Prevent potential loss of funds by securely backing up your wallet.";
"smartBanner.help.backup.info2" = "Back up access to your funds by backing up:";
"smartBanner.help.backup.point1" = "Secret Recovery Phrase (a unique set of 24 words in a precise order) ";
"smartBanner.help.backup.point2" = "Wallet Birthday Height (block height at which your wallet was created)";
"smartBanner.help.backup.info3" = "In case you lose access to the app or to your device, knowing the Secret Recovery Phrase is the only way to recover your funds. We cannot see it and cannot help you recover it.";
"smartBanner.help.backup.info4" = "Anyone with access to your Secret Recovery Phrase will have full control of your wallet, so keep it secure and never show it to anyone.";
"smartBanner.help.shield.title" = "Always Shield Transparent Funds";
"smartBanner.help.shield.info1" = "To protect user privacy, Zashi doesn't support spending transparent ZEC. Tap the \"Shield\" button below to make your transparent funds spendable and your total Zashi balance private. ";
"smartBanner.help.shield.info2" = "This will create a shielding in-wallet transaction, consolidating your transparent and shielded funds. (Typical fee: .001 ZEC)";
"smartBanner.help.shield.transparent" = "Transparent";
"smartBanner.help.remindMe" = "Remind me later";
"smartBanner.content.disconnected.title" = "Wallet Disconnected";
"smartBanner.content.disconnected.info" = "Check your connection and reload Zashi";
"smartBanner.content.syncingError.title" = "Error encountered while syncing";
"smartBanner.content.syncingError.info" = "Attempting to resolve";
"smartBanner.content.restore.title" = "Restoring Wallet • %@ Complete";
"smartBanner.content.restore.info" = "Keep Zashi open on active phone screen";
"smartBanner.content.sync.title" = "Syncing • %@ Complete";
"smartBanner.content.sync.info" = "Your wallet is getting updated";
"smartBanner.content.updatingBalance.title" = "Updating Balance...";
"smartBanner.content.updatingBalance.info" = "Waiting for last transaction to process";
"smartBanner.content.backup.title" = "Wallet Backup Required";
"smartBanner.content.backup.info" = "Prevent potential loss of funds";
"smartBanner.content.backup.button" = "Start";
"smartBanner.content.shield.title" = "Transparent Balance Detected";
"smartBanner.content.shield.button" = "Shield";
"smartBanner.content.currencyConversion.title" = "Currency Conversion";
"smartBanner.content.currencyConversion.info" = "Enable to display USD values";
"smartBanner.content.currencyConversion.button" = "Start";
"smartBanner.content.autoShielding.title" = "Auto-Shielding";
"smartBanner.content.autoShielding.info" = "Enable automatic shielding";
"smartBanner.content.autoShielding.button" = "Start";
"currencyConversion.skipBtn" = "Skip";