Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
568 changes: 306 additions & 262 deletions DashWallet.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

25 changes: 23 additions & 2 deletions DashWallet/Sources/Models/CrowdNode/CrowdNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,15 @@ extension CrowdNode {
do {
try tryRestoreLinkedOnlineAccount(state: onlineState, address: address)
refreshWithdrawalLimits()
// Linked online accounts don't go through `setFinished`, so their balance was
// never fetched at restore — only when the user opened the CrowdNode portal.
// Refresh it proactively so the balance reminder (banner/sheet) can appear after
// sync without requiring the user to enter CrowdNode. `refreshBalance` self-guards
// on `signUpState`, so it no-ops for accounts that aren't linked yet.
// Don't seed from cache: a stale positive `lastKnownBalance` would otherwise drive
// the balance reminder before the first fresh fetch, surfacing/consuming it even
// when the server returns 0.
refreshBalance(seedFromCache: false)
} catch {
DSLogger.log("Failure while restoring linked CrowdNode account: \(error.localizedDescription)")
}
Expand Down Expand Up @@ -529,13 +538,20 @@ extension CrowdNode {

// MARK: Balance
extension CrowdNode {
func refreshBalance(retries: Int = 3, afterWithdrawal: Bool = false) {
/// - Parameter seedFromCache: When `true` (default) the cached `lastKnownBalance` is published
/// immediately so UI (e.g. the CrowdNode portal) shows a value while the fetch is in flight.
/// Pass `false` for silent/background refreshes (e.g. restore) where a stale positive cache
/// must not drive balance-derived UI such as `CrowdNodeBalanceReminder` before the first
/// fresh fetch completes.
func refreshBalance(retries: Int = 3, afterWithdrawal: Bool = false, seedFromCache: Bool = true) {
guard !accountAddress.isEmpty && signUpState != .notStarted else { return }

Task {
let lastBalance = prefs.lastKnownBalance
var currentBalance = lastBalance
balance = currentBalance
if seedFromCache {
balance = currentBalance
}
isBalanceLoading = true

do {
Expand All @@ -551,6 +567,11 @@ extension CrowdNode {
let plainAmount = dashNumber * duffsNumber
currentBalance = NSDecimalNumber(decimal: plainAmount).uint64Value
prefs.lastKnownBalance = currentBalance
// Publish the fresh value as soon as it arrives. The loop keeps polling (with
// escalating backoff) until the balance *changes* vs. the cached baseline, which
// can take minutes; without this, a non-seeded refresh (e.g. restore) wouldn't
// surface the real balance — and the balance reminder — until the loop ends.
balance = currentBalance

var breakDifference: UInt64 = 0

Expand Down
79 changes: 79 additions & 0 deletions DashWallet/Sources/Models/CrowdNode/CrowdNodeBalanceReminder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// Created by OpenAI Codex
// Copyright © 2026 Dash Core Group. All rights reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Combine
import Foundation

@MainActor
final class CrowdNodeBalanceReminder: ObservableObject {
static let shared = CrowdNodeBalanceReminder()

@Published private(set) var hasBalance: Bool
@Published private(set) var balance: UInt64
@Published private(set) var activeScreenReminderDismissed: Bool = false
/// Set once the active-screen reminder has been shown this session. Never reset, so the
/// reminder sheet appears at most once per app launch (unlike the dismissed flag, which
/// resets when the balance clears).
@Published private(set) var didShowActiveScreenReminder: Bool = false

private let crowdNode: CrowdNode
private var cancellableBag = Set<AnyCancellable>()

var formattedBalance: String {
balance.formattedDashAmount
}

var shouldShowOnActiveScreen: Bool {
hasBalance && !activeScreenReminderDismissed && !didShowActiveScreenReminder
}

var shouldShowOnExplore: Bool {
hasBalance
}

private init(crowdNode: CrowdNode = .shared) {
self.crowdNode = crowdNode
self.balance = crowdNode.balance
self.hasBalance = crowdNode.balance > 0

crowdNode.$balance
.receive(on: DispatchQueue.main)
.sink { [weak self] balance in
self?.update(balance: balance)
}
.store(in: &cancellableBag)
}

func dismissActiveScreenReminder() {
// TODO(product): persist dismissal per account if this should survive app restarts.
activeScreenReminderDismissed = true
}

/// Mark the active-screen reminder as shown so it isn't presented again this session.
func markActiveScreenReminderShown() {
didShowActiveScreenReminder = true
}

private func update(balance: UInt64) {
self.balance = balance
hasBalance = balance > 0

if balance == 0 {
activeScreenReminderDismissed = false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// Created by OpenAI Codex
// Copyright © 2026 Dash Core Group. All rights reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import SwiftUI
import DashUIKit

struct CrowdNodeBalanceReminderBanner: View {
var onWithdraw: () -> Void

var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(dash: .custom("warning_triangle", bundle: .dashUIKit))
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)

VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 1) {
Text(NSLocalizedString("You have a balance on CrowdNode", comment: "CrowdNode"))
.font(Font.dash.subheadMedium)
.foregroundColor(Color.dash.primaryText)

Text(NSLocalizedString("These funds should be withdrawn from CrowdNode. You can transfer these funds to this wallet or via your online account on some other device.", comment: "CrowdNode"))
.font(Font.dash.subhead)
.foregroundColor(Color.dash.secondaryText)
.multilineTextAlignment(.leading)
}

DashUIKit.DashButton(
text: NSLocalizedString("Withdraw funds", comment: "CrowdNode"),
size: .small,
style: .filledBlue,
action: onWithdraw
)
}
.padding(.trailing, 20)
.padding(.top, 5)
}
.padding(16)
.background(Color.dash.gray300Alpha10)
.clipShape(.rect(cornerRadius: 20))
}
}

#Preview {
CrowdNodeBalanceReminderBanner(onWithdraw: {})
.padding(20)
.background(Color.dash.primaryBackground)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//
// Created by OpenAI Codex
// Copyright © 2026 Dash Core Group. All rights reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import SwiftUI
import DashUIKit

struct CrowdNodeBalanceReminderSheet: View {
var onWithdraw: () -> Void
var onDismiss: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 0) {
Image(dash: .custom("crowdnode.warning", bundle: .dashUIKit))
.resizable()
.scaledToFit()
.frame(width: 90, height: 90)
.padding(.top, 20)
.padding(.bottom, 10)
.frame(maxWidth: .infinity, alignment: .center)


VStack(alignment: .leading, spacing: 6) {
Text(NSLocalizedString("You have a balance on CrowdNode", comment: "CrowdNode"))
.font(Font.dash.title1)
.foregroundStyle(Color.dash.primaryText)
.multilineTextAlignment(.leading)

Text(NSLocalizedString("These funds should be withdrawn from CrowdNode. You can transfer these funds to this wallet or via your online account on some other device.", comment: "CrowdNode"))
.font(Font.dash.subhead)
.foregroundStyle(Color.dash.secondaryText)
.multilineTextAlignment(.leading)
}
.padding(.horizontal, 40 + 20)
.padding(.top, 20)
.padding(.bottom, 32)


VStack(alignment: .center, spacing: 16) {
DashUIKit.DashButton(
text: NSLocalizedString("Withdraw funds", comment: "CrowdNode"),
fillsWidth: true,
size: .large,
style: .filledBlue,
action: onWithdraw
)

DashUIKit.DashButton(
text: NSLocalizedString("Close", comment: "CrowdNode"),
fillsWidth: true,
size: .large,
style: .tintedGray,
action: onDismiss
)
}
.padding(.horizontal, 60)
.padding(.vertical, 20)
}
}
}

#Preview {
CrowdNodeBalanceReminderSheet(
onWithdraw: {},
onDismiss: {}
)
.background(Color.dash.primaryBackground)
}

#Preview {
Color.dash.primaryBackground
.ignoresSafeArea()
.sheet(isPresented: .constant(true)) {
// Use the lib's qualified factory: it sets `fillsHeight: false`, self-sizes to content,
// and (via `cornerRadius`) fills the sheet background + rounds the corners. Being fully
// qualified by type, it also avoids the ambiguity with the project's `selfSizingSheet`.
// `fallback` avoids the `.medium` flash before the first measurement.
DashUIKit.BottomSheet.selfSizing(
showBackButton: .constant(false),
fallback: 540,
cornerRadius: 24
) {
CrowdNodeBalanceReminderSheet(
onWithdraw: {},
onDismiss: {}
)
}
}
}
Loading
Loading