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
54 changes: 26 additions & 28 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ struct AppScene: View {
.onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) }
.onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) }
.onChange(of: scenePhase) { _, newValue in handleScenePhaseChange(newValue) }
.onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) }
.onChange(of: migrations.isShowingMigrationLoading) { _, isLoading in
if !isLoading {
SettingsViewModel.shared.updatePinEnabledState()
Expand All @@ -112,12 +113,6 @@ struct AppScene: View {
}
}
}
.onChange(of: network.isConnected) { _, isConnected in
// Retry starting wallet when network comes back online
if isConnected {
handleNetworkRestored()
}
}
.environmentObject(app)
.environmentObject(navigation)
.environmentObject(network)
Expand Down Expand Up @@ -543,16 +538,13 @@ struct AppScene: View {
}

private func handleScenePhaseChange(_ newPhase: ScenePhase) {
Logger.debug("Scene phase changed: \(newPhase)")
Logger.info("Scene phase changed: \(newPhase)", context: "AppScene")

if newPhase == .background {
if settings.pinEnabled {
// If PIN is enabled, lock the app when the app goes to the background
isPinVerified = false
}
if wallet.walletExists == true {
app.resetAppStatusInit()
}
}

if newPhase == .active {
Expand All @@ -573,28 +565,34 @@ struct AppScene: View {
center.removeDeliveredNotifications(withIdentifiers: deliveredNotifications.map(\.request.identifier))
}

private func handleNetworkRestored() {
// Refresh currency rates when network is restored - critical for UI
// to display balances (MoneyText returns "0" if rates are nil)
Task {
await currency.refresh()
}
private func handleNetworkChange(_ isConnected: Bool) {
Logger.info("Network changed: \(isConnected ? "connected" : "disconnected")", context: "AppScene")

guard wallet.walletExists == true,
scenePhase == .active
else {
return
}
app.toast(
type: isConnected ? .success : .warning,
title: isConnected ? t("other__connection_back_title") : t("other__connection_issue"),
description: isConnected ? t("other__connection_back_msg") : t("other__connection_issue_explain")
)

if isConnected {
guard wallet.walletExists == true else { return }

// If node is stopped/failed, restart it
switch wallet.nodeLifecycleState {
case .stopped, .errorStarting:
Logger.info("Network restored, retrying wallet start...", context: "AppScene")
// Refresh currency rates when network is restored - critical for UI
// to display balances (MoneyText returns "0" if rates are nil)
Task {
await startWallet()
await currency.refresh()
}

// Restart node if necessary (e.g. create/restore was skipped due to offline)
switch wallet.nodeLifecycleState {
case .stopped, .initializing, .errorStarting:
Logger.info("Network restored, retrying wallet start...", context: "AppScene")
Task {
await startWallet()
}
default:
break
}
default:
break
}
}

Expand Down
12 changes: 2 additions & 10 deletions Bitkit/Components/ActivityIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ struct ActivityIndicator: View {
}

var body: some View {
let strokeWidth = size / 12
let color = theme == .light ? Color.white : Color.black

ZStack {
Expand All @@ -31,18 +30,11 @@ struct ActivityIndicator: View {
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(
lineWidth: strokeWidth,
lineCap: .round
)
style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
)
.frame(width: size, height: size)
.rotationEffect(.degrees(isRotating ? 360 : 0))
.animation(
.linear(duration: 1.2)
.repeatForever(autoreverses: false),
value: isRotating
)
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: isRotating)
}
.opacity(opacity)
.onAppear {
Expand Down
5 changes: 4 additions & 1 deletion Bitkit/Components/AppStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ struct AppStatus: View {
private var appStatus: HealthStatus {
let realStatus = AppStatusHelper.combinedAppStatus(from: wallet, network: network)

// During init, hide error state but show pending (sync animation)
// During init, hide error state but show pending (sync animation).
// Always show error when offline so the header reflects no network.
if !app.appStatusInit && realStatus == .error {
let internet = AppStatusHelper.internetStatus(network: network)
if internet == .error { return .error }
return .ready
}

Expand Down
130 changes: 130 additions & 0 deletions Bitkit/Components/EllipseLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import SwiftUI

/// Variants of the ellipse loader: ellipse colors, animation, and center content are defined per variant.
enum EllipseLoaderVariant {
case sync
case quickpay
case transfer
case hardware

var accentColor: String {
switch self {
case .sync, .quickpay, .transfer: return "purple"
case .hardware: return "blue"
}
}

var centerScale: CGFloat {
switch self {
case .sync, .transfer, .hardware: return 0.85
case .quickpay: return 1
}
}

var ellipseAnimation: Animation {
switch self {
case .sync:
return Animation.easeOut(duration: 1.5).repeatForever(autoreverses: true)
case .quickpay:
return Animation.easeOut(duration: 1.6).repeatForever(autoreverses: true)
case .transfer:
return Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
case .hardware:
return Animation.linear(duration: 1).repeatForever(autoreverses: true)
}
}
}

/// Center content for transfer variant: transfer figure with its own rotation animation.
private struct AnimatedTransferFigure: View {
@State private var rotation: Double = 0

var body: some View {
Image("transfer-figure")
.resizable()
.aspectRatio(contentMode: .fit)
.rotationEffect(.degrees(rotation))
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
rotation = 90
}
}
}
}

/// Center content for quickpay variant: coin stack with subtle rotation.
private struct AnimatedCoinStack: View {
@State private var rotation: Double = 0

var body: some View {
Image("coin-stack-4")
.resizable()
.aspectRatio(contentMode: .fit)
.rotationEffect(.degrees(rotation))
.onAppear {
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
rotation = 20
}
}
}
}

/// Animated loading view with rotating ellipses and variant-specific center content.
/// Sizes to the available space so it can shrink on small screens and leave room for text.
struct EllipseLoader: View {
let variant: EllipseLoaderVariant

@State private var outerRotation: Double = 0
@State private var innerRotation: Double = 0

var body: some View {
GeometryReader { geo in
let container = min(geo.size.width, geo.size.height)
let figure = container * variant.centerScale
let inner = container * 0.7

ZStack(alignment: .center) {
Image("ellipse-outer-\(variant.accentColor)")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: container, height: container)
.rotationEffect(.degrees(outerRotation))

Image("ellipse-inner-\(variant.accentColor)")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: inner, height: inner)
.rotationEffect(.degrees(innerRotation))

centerContent
.frame(width: figure, height: figure)
}
.frame(width: container, height: container)
.clipped()
}
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity)
.onAppear {
withAnimation(variant.ellipseAnimation) { outerRotation = -180 }
withAnimation(variant.ellipseAnimation) { innerRotation = 180 }
}
}

@ViewBuilder private var centerContent: some View {
switch variant {
case .sync:
Image("lightning")
.resizable()
.aspectRatio(contentMode: .fit)
case .quickpay:
AnimatedCoinStack()
case .transfer:
AnimatedTransferFigure()
case .hardware:
// TODO: change to hardware figure
Image("shield-figure")
.resizable()
.aspectRatio(contentMode: .fit)
}
}
}
65 changes: 65 additions & 0 deletions Bitkit/Components/ProgressSteps.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import SwiftUI

/// A view that displays a list of steps with circles and numbers.
struct ProgressSteps: View {
let steps: [String]
let currentStep: Int

private let size: CGFloat = 32

var body: some View {
VStack(spacing: 0) {
// Steps with circles and separators
GeometryReader { geometry in
ZStack(alignment: .center) {
// Dashed line background
Path { path in
let y = geometry.size.height / 2
let padding = 36.0 * 2.5 // Account for circle radius (16) + horizontal padding (20)
path.move(to: CGPoint(x: padding, y: y))
path.addLine(to: CGPoint(x: geometry.size.width - padding, y: y))
}
.stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
.foregroundColor(Color.white32)

// Circles with numbers
HStack(spacing: 0) {
ForEach(Array(steps.enumerated()), id: \.0) { index, _ in
// Circle with number or checkmark
ZStack {
Circle()
.fill(index < currentStep ? Color.purpleAccent : Color.black)
.frame(width: size, height: size)

if index < currentStep {
// Checkmark for completed steps
Image("check-mark")
.foregroundColor(.black)
} else {
// Number for current and upcoming steps
Text("\(index + 1)")
.foregroundColor(index == currentStep ? Color.purpleAccent : .white32)
.font(.custom(Fonts.regular, size: 17))
Comment thread
pwltr marked this conversation as resolved.
Comment thread
pwltr marked this conversation as resolved.
Comment thread
pwltr marked this conversation as resolved.
}

// Border for uncompleted steps
if index >= currentStep {
Circle()
.stroke(index == currentStep ? Color.purpleAccent : Color.white32, lineWidth: 1)
.frame(width: size, height: size)
}
}
.padding(.horizontal, 16)
}
}
}
}
.frame(height: size)

VStack {
BodySSBText(steps[currentStep], textColor: .white32)
}
.frame(height: 56)
}
}
}
Loading
Loading