Files
aliasvault/apps/mobile-app/ios/VaultUI/Components/BrandedLoadingView.swift
2026-01-31 20:25:41 +00:00

129 lines
5.3 KiB
Swift

import SwiftUI
import UIKit
/// A reusable branded loading/placeholder view with the AliasVault logo.
/// The logo is positioned in the upper portion of the screen to avoid overlap with Face ID prompts.
/// Supports both light and dark mode automatically.
public struct BrandedLoadingView: View {
/// Optional loading message to display below the logo
let message: String?
/// Whether to show the animated loading dots
let showLoadingAnimation: Bool
@Environment(\.colorScheme) private var colorScheme
@State private var animatingDots: [Bool] = [false, false, false, false]
@State private var textDots = ""
@State private var timer: Timer?
/// Creates a branded loading view with optional message and loading animation
/// - Parameters:
/// - message: Optional message to display below the logo
/// - showLoadingAnimation: Whether to show the animated loading dots (default: true)
public init(message: String? = nil, showLoadingAnimation: Bool = true) {
self.message = message
self.showLoadingAnimation = showLoadingAnimation
}
/// Determine the effective color scheme, falling back to UIKit if SwiftUI environment is unavailable
private var effectiveColorScheme: ColorScheme {
// Use UIKit's trait collection as fallback since extension contexts may not
// properly propagate the SwiftUI colorScheme environment
let uiStyle = UITraitCollection.current.userInterfaceStyle
return uiStyle == .dark ? .dark : .light
}
private var colors: ColorConstants.Colors.Type {
ColorConstants.colors(for: effectiveColorScheme)
}
public var body: some View {
GeometryReader { geometry in
ZStack {
colors.background
.ignoresSafeArea()
VStack(spacing: 0) {
// Position content at 15% from top to avoid Face ID prompt obstruction
Spacer()
.frame(height: geometry.size.height * 0.15)
VStack(spacing: 16) {
// AliasVault logo
Image("Logo", bundle: .vaultUI)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
// Loading animation - four pulsing dots
if showLoadingAnimation {
HStack(spacing: 10) {
ForEach(0..<4) { index in
Circle()
.fill(colors.tertiary)
.frame(width: 8, height: 8)
.opacity(animatingDots[index] ? 1.0 : 0.3)
.animation(
Animation.easeInOut(duration: 0.7)
.repeatForever(autoreverses: true)
.delay(Double(index) * 0.2),
value: animatingDots[index]
)
}
}
.padding(12)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(effectiveColorScheme == .dark ? Color.clear : Color.white)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(colors.tertiary, lineWidth: 5)
)
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
)
}
// Loading message with animated dots
if let message = message, !message.isEmpty {
Text(message + textDots)
.font(.body)
.foregroundColor(colors.text)
.multilineTextAlignment(.center)
.padding(.horizontal)
.padding(.top, showLoadingAnimation ? 0 : 8)
}
}
.padding(20)
.frame(maxWidth: .infinity)
Spacer()
}
.frame(maxWidth: .infinity)
}
}
.onAppear {
if showLoadingAnimation {
// Start dot animations
for index in 0..<4 {
animatingDots[index] = true
}
// Start text dots animation
let textTimer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in
if textDots.count >= 3 {
textDots = ""
} else {
textDots += "."
}
}
timer = textTimer
}
}
.onDisappear {
timer?.invalidate()
timer = nil
}
}
}