Skip to content

Commit cb5019c

Browse files
authored
feat(ui): add offline UI (#528)
1 parent 67a7bf5 commit cb5019c

28 files changed

Lines changed: 550 additions & 328 deletions

Bitkit/AppScene.swift

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ struct AppScene: View {
9292
.onChange(of: wallet.walletExists) { _, newValue in handleWalletExistsChange(newValue) }
9393
.onChange(of: wallet.nodeLifecycleState) { _, newValue in handleNodeLifecycleChange(newValue) }
9494
.onChange(of: scenePhase) { _, newValue in handleScenePhaseChange(newValue) }
95+
.onChange(of: network.isConnected) { _, isConnected in handleNetworkChange(isConnected) }
9596
.onChange(of: migrations.isShowingMigrationLoading) { _, isLoading in
9697
if !isLoading {
9798
SettingsViewModel.shared.updatePinEnabledState()
@@ -112,12 +113,6 @@ struct AppScene: View {
112113
}
113114
}
114115
}
115-
.onChange(of: network.isConnected) { _, isConnected in
116-
// Retry starting wallet when network comes back online
117-
if isConnected {
118-
handleNetworkRestored()
119-
}
120-
}
121116
.environmentObject(app)
122117
.environmentObject(navigation)
123118
.environmentObject(network)
@@ -543,16 +538,13 @@ struct AppScene: View {
543538
}
544539

545540
private func handleScenePhaseChange(_ newPhase: ScenePhase) {
546-
Logger.debug("Scene phase changed: \(newPhase)")
541+
Logger.info("Scene phase changed: \(newPhase)", context: "AppScene")
547542

548543
if newPhase == .background {
549544
if settings.pinEnabled {
550545
// If PIN is enabled, lock the app when the app goes to the background
551546
isPinVerified = false
552547
}
553-
if wallet.walletExists == true {
554-
app.resetAppStatusInit()
555-
}
556548
}
557549

558550
if newPhase == .active {
@@ -574,28 +566,34 @@ struct AppScene: View {
574566
center.removeDeliveredNotifications(withIdentifiers: deliveredNotifications.map(\.request.identifier))
575567
}
576568

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

584-
guard wallet.walletExists == true,
585-
scenePhase == .active
586-
else {
587-
return
588-
}
572+
app.toast(
573+
type: isConnected ? .success : .warning,
574+
title: isConnected ? t("other__connection_back_title") : t("other__connection_issue"),
575+
description: isConnected ? t("other__connection_back_msg") : t("other__connection_issue_explain")
576+
)
577+
578+
if isConnected {
579+
guard wallet.walletExists == true else { return }
589580

590-
// If node is stopped/failed, restart it
591-
switch wallet.nodeLifecycleState {
592-
case .stopped, .errorStarting:
593-
Logger.info("Network restored, retrying wallet start...", context: "AppScene")
581+
// Refresh currency rates when network is restored - critical for UI
582+
// to display balances (MoneyText returns "0" if rates are nil)
594583
Task {
595-
await startWallet()
584+
await currency.refresh()
585+
}
586+
587+
// Restart node if necessary (e.g. create/restore was skipped due to offline)
588+
switch wallet.nodeLifecycleState {
589+
case .stopped, .initializing, .errorStarting:
590+
Logger.info("Network restored, retrying wallet start...", context: "AppScene")
591+
Task {
592+
await startWallet()
593+
}
594+
default:
595+
break
596596
}
597-
default:
598-
break
599597
}
600598
}
601599

Bitkit/Components/ActivityIndicator.swift

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ struct ActivityIndicator: View {
1818
}
1919

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

2423
ZStack {
@@ -31,18 +30,11 @@ struct ActivityIndicator: View {
3130
startAngle: .degrees(0),
3231
endAngle: .degrees(360)
3332
),
34-
style: StrokeStyle(
35-
lineWidth: strokeWidth,
36-
lineCap: .round
37-
)
33+
style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
3834
)
3935
.frame(width: size, height: size)
4036
.rotationEffect(.degrees(isRotating ? 360 : 0))
41-
.animation(
42-
.linear(duration: 1.2)
43-
.repeatForever(autoreverses: false),
44-
value: isRotating
45-
)
37+
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: isRotating)
4638
}
4739
.opacity(opacity)
4840
.onAppear {

Bitkit/Components/AppStatus.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ struct AppStatus: View {
5656
private var appStatus: HealthStatus {
5757
let realStatus = AppStatusHelper.combinedAppStatus(from: wallet, network: network)
5858

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import SwiftUI
2+
3+
/// Variants of the ellipse loader: ellipse colors, animation, and center content are defined per variant.
4+
enum EllipseLoaderVariant {
5+
case sync
6+
case quickpay
7+
case transfer
8+
case hardware
9+
10+
var accentColor: String {
11+
switch self {
12+
case .sync, .quickpay, .transfer: return "purple"
13+
case .hardware: return "blue"
14+
}
15+
}
16+
17+
var centerScale: CGFloat {
18+
switch self {
19+
case .sync, .transfer, .hardware: return 0.85
20+
case .quickpay: return 1
21+
}
22+
}
23+
24+
var ellipseAnimation: Animation {
25+
switch self {
26+
case .sync:
27+
return Animation.easeOut(duration: 1.5).repeatForever(autoreverses: true)
28+
case .quickpay:
29+
return Animation.easeOut(duration: 1.6).repeatForever(autoreverses: true)
30+
case .transfer:
31+
return Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
32+
case .hardware:
33+
return Animation.linear(duration: 1).repeatForever(autoreverses: true)
34+
}
35+
}
36+
}
37+
38+
/// Center content for transfer variant: transfer figure with its own rotation animation.
39+
private struct AnimatedTransferFigure: View {
40+
@State private var rotation: Double = 0
41+
42+
var body: some View {
43+
Image("transfer-figure")
44+
.resizable()
45+
.aspectRatio(contentMode: .fit)
46+
.rotationEffect(.degrees(rotation))
47+
.onAppear {
48+
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
49+
rotation = 90
50+
}
51+
}
52+
}
53+
}
54+
55+
/// Center content for quickpay variant: coin stack with subtle rotation.
56+
private struct AnimatedCoinStack: View {
57+
@State private var rotation: Double = 0
58+
59+
var body: some View {
60+
Image("coin-stack-4")
61+
.resizable()
62+
.aspectRatio(contentMode: .fit)
63+
.rotationEffect(.degrees(rotation))
64+
.onAppear {
65+
withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) {
66+
rotation = 20
67+
}
68+
}
69+
}
70+
}
71+
72+
/// Animated loading view with rotating ellipses and variant-specific center content.
73+
/// Sizes to the available space so it can shrink on small screens and leave room for text.
74+
struct EllipseLoader: View {
75+
let variant: EllipseLoaderVariant
76+
77+
@State private var outerRotation: Double = 0
78+
@State private var innerRotation: Double = 0
79+
80+
var body: some View {
81+
GeometryReader { geo in
82+
let container = min(geo.size.width, geo.size.height)
83+
let figure = container * variant.centerScale
84+
let inner = container * 0.7
85+
86+
ZStack(alignment: .center) {
87+
Image("ellipse-outer-\(variant.accentColor)")
88+
.resizable()
89+
.aspectRatio(contentMode: .fit)
90+
.frame(width: container, height: container)
91+
.rotationEffect(.degrees(outerRotation))
92+
93+
Image("ellipse-inner-\(variant.accentColor)")
94+
.resizable()
95+
.aspectRatio(contentMode: .fit)
96+
.frame(width: inner, height: inner)
97+
.rotationEffect(.degrees(innerRotation))
98+
99+
centerContent
100+
.frame(width: figure, height: figure)
101+
}
102+
.frame(width: container, height: container)
103+
.clipped()
104+
}
105+
.aspectRatio(1, contentMode: .fit)
106+
.frame(maxWidth: .infinity)
107+
.onAppear {
108+
withAnimation(variant.ellipseAnimation) { outerRotation = -180 }
109+
withAnimation(variant.ellipseAnimation) { innerRotation = 180 }
110+
}
111+
}
112+
113+
@ViewBuilder private var centerContent: some View {
114+
switch variant {
115+
case .sync:
116+
Image("lightning")
117+
.resizable()
118+
.aspectRatio(contentMode: .fit)
119+
case .quickpay:
120+
AnimatedCoinStack()
121+
case .transfer:
122+
AnimatedTransferFigure()
123+
case .hardware:
124+
// TODO: change to hardware figure
125+
Image("shield-figure")
126+
.resizable()
127+
.aspectRatio(contentMode: .fit)
128+
}
129+
}
130+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import SwiftUI
2+
3+
/// A view that displays a list of steps with circles and numbers.
4+
struct ProgressSteps: View {
5+
let steps: [String]
6+
let currentStep: Int
7+
8+
private let size: CGFloat = 32
9+
10+
var body: some View {
11+
VStack(spacing: 0) {
12+
// Steps with circles and separators
13+
GeometryReader { geometry in
14+
ZStack(alignment: .center) {
15+
// Dashed line background
16+
Path { path in
17+
let y = geometry.size.height / 2
18+
let padding = 36.0 * 2.5 // Account for circle radius (16) + horizontal padding (20)
19+
path.move(to: CGPoint(x: padding, y: y))
20+
path.addLine(to: CGPoint(x: geometry.size.width - padding, y: y))
21+
}
22+
.stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
23+
.foregroundColor(Color.white32)
24+
25+
// Circles with numbers
26+
HStack(spacing: 0) {
27+
ForEach(Array(steps.enumerated()), id: \.0) { index, _ in
28+
// Circle with number or checkmark
29+
ZStack {
30+
Circle()
31+
.fill(index < currentStep ? Color.purpleAccent : Color.black)
32+
.frame(width: size, height: size)
33+
34+
if index < currentStep {
35+
// Checkmark for completed steps
36+
Image("check-mark")
37+
.foregroundColor(.black)
38+
} else {
39+
// Number for current and upcoming steps
40+
Text("\(index + 1)")
41+
.foregroundColor(index == currentStep ? Color.purpleAccent : .white32)
42+
.font(.custom(Fonts.regular, size: 17))
43+
}
44+
45+
// Border for uncompleted steps
46+
if index >= currentStep {
47+
Circle()
48+
.stroke(index == currentStep ? Color.purpleAccent : Color.white32, lineWidth: 1)
49+
.frame(width: size, height: size)
50+
}
51+
}
52+
.padding(.horizontal, 16)
53+
}
54+
}
55+
}
56+
}
57+
.frame(height: size)
58+
59+
VStack {
60+
BodySSBText(steps[currentStep], textColor: .white32)
61+
}
62+
.frame(height: 56)
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)