Skip to content
Closed
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
121 changes: 121 additions & 0 deletions Bitkit/Components/ConnectionIssuesView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import SwiftUI

/// A full-screen overlay displayed when the device loses internet connectivity.
/// Shows a phone illustration with animated dashed gradient rings and a loading spinner.
struct ConnectionIssuesView: View {
let title: String

var body: some View {
VStack(alignment: .leading, spacing: 0) {
SheetHeader(title: title, showBackButton: false)

Spacer().frame(height: 24)

ZStack(alignment: .center) {
DashedRingsLayer(radii: [200])

Image("phone")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 311)

DashedRingsLayer(radii: [150, 100, 50])
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

DisplayText(
t("other__connection_issues_title"),
accentColor: .yellowAccent
)

Spacer().frame(height: 8)

BodyMText(
t("other__connection_issues_explain"),
textColor: .white64
)
.frame(maxWidth: .infinity, alignment: .leading)

Spacer().frame(height: 24)

ActivityIndicator()
.frame(maxWidth: .infinity)

Spacer().frame(height: 16)
}
.navigationBarHidden(true)
.padding(.horizontal, 16)
.sheetBackground()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("ConnectionIssuesView")
}
}

// MARK: - Dashed Gradient Rings

private struct DashedRingsLayer: View {
let radii: [CGFloat]

var body: some View {
Canvas { context, size in
let center = CGPoint(x: size.width * 0.25, y: size.height * 0.40)

for radius in radii {
let rect = CGRect(
x: center.x - radius,
y: center.y - radius,
width: radius * 2,
height: radius * 2
)

var path = Path()
path.addEllipse(in: rect)

let gradient = Gradient(colors: [.black, .yellowAccent])
let startPoint = CGPoint(x: rect.minX, y: rect.minY)
let endPoint = CGPoint(x: rect.maxX, y: rect.maxY)

context.stroke(
path,
with: .linearGradient(gradient, startPoint: startPoint, endPoint: endPoint),
style: StrokeStyle(lineWidth: 1, dash: [8, 6])
)
}
}
.allowsHitTesting(false)
}
}

// MARK: - View Modifier

private struct ConnectionIssuesOverlayModifier: ViewModifier {
let title: String
@EnvironmentObject private var network: NetworkMonitor

func body(content: Content) -> some View {
ZStack {
content

if !network.isConnected {
ConnectionIssuesView(title: title)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.3), value: network.isConnected)
}
}

extension View {
/// Overlays a `ConnectionIssuesView` when the device is offline.
/// The underlying content remains mounted so navigation state and inputs are preserved.
func connectionIssuesOverlay(title: String) -> some View {
modifier(ConnectionIssuesOverlayModifier(title: title))
}
}

// MARK: - Preview

#Preview {
ConnectionIssuesView(title: "Send Bitcoin")
.preferredColorScheme(.dark)
}
1 change: 0 additions & 1 deletion Bitkit/Components/Header.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ struct Header: View {
.padding(.trailing, 10)
}

@ViewBuilder
private var profileButton: some View {
Button {
if pubkyProfile.isAuthenticated || pubkyProfile.cachedName != nil {
Expand Down
6 changes: 0 additions & 6 deletions Bitkit/Components/ProfileEditFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ struct ProfileEditFormView<Avatar: View>: View {

// MARK: - Pubky Key Section

@ViewBuilder
private var pubkyKeySection: some View {
VStack(spacing: 8) {
CaptionMText(publicKeyLabel, textColor: .white64)
Expand All @@ -111,7 +110,6 @@ struct ProfileEditFormView<Avatar: View>: View {

// MARK: - Bio Section

@ViewBuilder
private var bioSection: some View {
VStack(alignment: .leading, spacing: 8) {
CaptionMText(t("profile__create_bio_label"), textColor: .white64)
Expand All @@ -129,7 +127,6 @@ struct ProfileEditFormView<Avatar: View>: View {

// MARK: - Links Section

@ViewBuilder
private var linksSection: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(links.indices, id: \.self) { index in
Expand Down Expand Up @@ -198,7 +195,6 @@ struct ProfileEditFormView<Avatar: View>: View {

// MARK: - Delete Section

@ViewBuilder
private func deleteSection(label: String, action: @escaping () -> Void) -> some View {
VStack(alignment: .leading, spacing: 8) {
CaptionMText(t("profile__edit_delete_section"), textColor: .white64)
Expand All @@ -221,7 +217,6 @@ struct ProfileEditFormView<Avatar: View>: View {

// MARK: - Tags Section

@ViewBuilder
private var tagsSection: some View {
VStack(alignment: .leading, spacing: 8) {
if !tags.isEmpty {
Expand All @@ -246,7 +241,6 @@ struct ProfileEditFormView<Avatar: View>: View {
}
}

@ViewBuilder
private var footerBar: some View {
VStack(spacing: 0) {
LinearGradient(
Expand Down
1 change: 0 additions & 1 deletion Bitkit/Components/PubkyImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
}
}

@ViewBuilder
private var placeholder: some View {
Circle()
.fill(Color.gray5)
Expand Down Expand Up @@ -141,12 +140,12 @@

/// Full lookup (memory + disk). Disk I/O runs on a dedicated queue to avoid blocking cooperative threads.
func image(for uri: String) async -> UIImage? {
lock.lock()

Check warning on line 143 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Tests

instance method 'lock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode

Check warning on line 143 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

instance method 'lock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode
if let memoryHit = memoryCache[uri] {
lock.unlock()

Check warning on line 145 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Tests

instance method 'unlock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode

Check warning on line 145 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

instance method 'unlock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode
return memoryHit
}
lock.unlock()

Check warning on line 148 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Tests

instance method 'unlock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode

Check warning on line 148 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

instance method 'unlock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode

return await withCheckedContinuation { continuation in
diskQueue.async { [self] in
Expand Down Expand Up @@ -179,9 +178,9 @@
}

func clear() async {
lock.lock()

Check warning on line 181 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Tests

instance method 'lock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode

Check warning on line 181 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

instance method 'lock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode
memoryCache.removeAll()
lock.unlock()

Check warning on line 183 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Tests

instance method 'unlock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode

Check warning on line 183 in Bitkit/Components/PubkyImage.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

instance method 'unlock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode

await withCheckedContinuation { continuation in
diskQueue.async { [diskDirectory] in
Expand Down
32 changes: 32 additions & 0 deletions Bitkit/Components/SyncNodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,35 @@ struct SyncNodeView: View {
}
}
}

// MARK: - View Modifier

private struct SyncNodeOverlayModifier: ViewModifier {
@EnvironmentObject private var wallet: WalletViewModel

private var shouldShowSyncOverlay: Bool {
guard wallet.nodeLifecycleState == .running else { return true }
let hasAnyChannels = (wallet.channels?.isEmpty == false) || wallet.channelCount > 0
guard hasAnyChannels else { return false }
return !wallet.hasUsableChannels
}

func body(content: Content) -> some View {
ZStack {
content

if shouldShowSyncOverlay {
SyncNodeView()
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.3), value: shouldShowSyncOverlay)
}
}

extension View {
/// Overlays a `SyncNodeView` when the node is not running or channels aren't usable yet.
func syncNodeOverlay() -> some View {
modifier(SyncNodeOverlayModifier())
}
}
1 change: 0 additions & 1 deletion Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,6 @@ struct MainNavView: View {
}
}

@ViewBuilder
private func missingPendingImportView(fallbackRoute: Route) -> some View {
Color.customBlack
.task {
Expand Down
6 changes: 2 additions & 4 deletions Bitkit/Managers/ContactsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ enum ContactsManagerError: LocalizedError {

// MARK: - PubkyContact

struct PubkyContact: Identifiable, Hashable, Sendable {
struct PubkyContact: Identifiable, Hashable {
let id: String
let publicKey: String
let profile: PubkyProfile
Expand Down Expand Up @@ -656,13 +656,11 @@ class ContactsManager: ObservableObject {
}

let normalized = message.lowercased()
let indicatesMissingResource = normalized.contains("404")
return normalized.contains("404")
|| normalized.contains("no such file")
|| normalized.contains("does not exist")
|| normalized.contains("profile not found")
|| normalized.contains("profilenotfound")
|| (normalized.contains("fetch failed") && normalized.contains("not found"))

return indicatesMissingResource
}
}
13 changes: 12 additions & 1 deletion Bitkit/Managers/NetworkMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ final class NetworkMonitor: ObservableObject {
// Set the pathUpdateHandler
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
let wasConnected = self?.isConnected
let isNowConnected = path.status == .satisfied

// Check if the device is connected to the internet
self?.isConnected = path.status == .satisfied
self?.isConnected = isNowConnected

// Check if the network is expensive (e.g. cellular data)
self?.isExpensive = path.isExpensive
Expand All @@ -36,6 +39,14 @@ final class NetworkMonitor: ObservableObject {

// Update the network path
self?.nwPath = path

if wasConnected != isNowConnected {
let interfaceType = path.availableInterfaces.first?.type
Logger
.debug(
"Network connectivity changed: \(isNowConnected ? "connected" : "disconnected") (interface: \(String(describing: interfaceType)), status: \(path.status))"
)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Managers/PubkyProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

// MARK: - Initialization & Session Restoration

private enum InitResult: Sendable {
private enum InitResult {
case noSession
case restored(publicKey: String)
case restorationFailed
Expand Down Expand Up @@ -535,8 +535,8 @@
nonisolated static func resolveRemoteProfile(publicKey: String) async throws -> PubkyProfile {
try await resolveRemoteProfile(
publicKey: publicKey,
fetchBitkitProfile: fetchBitkitProfile,

Check warning on line 538 in Bitkit/Managers/PubkyProfileManager.swift

View workflow job for this annotation

GitHub Actions / Run Tests

converting non-sendable function value to '@sendable (String) async -> PubkyProfile?' may introduce data races

Check warning on line 538 in Bitkit/Managers/PubkyProfileManager.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

converting non-sendable function value to '@sendable (String) async -> PubkyProfile?' may introduce data races
fetchPubkyProfile: fetchPubkyProfile

Check warning on line 539 in Bitkit/Managers/PubkyProfileManager.swift

View workflow job for this annotation

GitHub Actions / Run Tests

converting non-sendable function value to '@sendable (String) async throws -> PubkyProfile' may introduce data races

Check warning on line 539 in Bitkit/Managers/PubkyProfileManager.swift

View workflow job for this annotation

GitHub Actions / Run Integration Tests

converting non-sendable function value to '@sendable (String) async throws -> PubkyProfile' may introduce data races
)
}

Expand Down
4 changes: 2 additions & 2 deletions Bitkit/Models/PubkyProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,15 @@ struct PubkyProfileData: Codable {

// MARK: - PubkyProfileLink

struct PubkyProfileLink: Identifiable, Sendable {
struct PubkyProfileLink: Identifiable {
let id = UUID()
let label: String
let url: String
}

// MARK: - PubkyProfile

struct PubkyProfile: Sendable {
struct PubkyProfile {
let publicKey: String
let name: String
let bio: String
Expand Down
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@
"other__connection_reconnect_msg" = "Lost connection to Electrum, trying to reconnect...";
"other__connection_back_title" = "Internet Connection Restored";
"other__connection_back_msg" = "Bitkit successfully reconnected to the Internet.";
"other__connection_issues_title" = "<accent>Connection</accent>\nIssues";
"other__connection_issues_explain" = "It appears you're disconnected. Please check your connection. Bitkit will try to reconnect every few seconds.";
"other__high_balance__nav_title" = "High Balance";
"other__high_balance__title" = "High\n<accent>Balance</accent>";
"other__high_balance__text" = "<accent>Your wallet balance exceeds $500.</accent>\nFor your security, consider moving some of your savings to an offline wallet.";
Expand Down
4 changes: 0 additions & 4 deletions Bitkit/Views/Contacts/AddContactView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ struct AddContactView: View {

@State private var dashedCircleRotation: Double = 0

@ViewBuilder
private var loadingContent: some View {
VStack(spacing: 0) {
CaptionMText(truncatedPublicKey, textColor: .white64)
Expand Down Expand Up @@ -83,7 +82,6 @@ struct AddContactView: View {
}
}

@ViewBuilder
private var retrievingAnimation: some View {
ZStack {
Image("ellipse-outer-green")
Expand All @@ -107,7 +105,6 @@ struct AddContactView: View {

// MARK: - Result State

@ViewBuilder
private func resultContent(_ profile: PubkyProfile) -> some View {
VStack(spacing: 0) {
ScrollView {
Expand Down Expand Up @@ -154,7 +151,6 @@ struct AddContactView: View {

// MARK: - Error State

@ViewBuilder
private var errorContent: some View {
VStack(spacing: 16) {
Spacer()
Expand Down
Loading
Loading