medmap-ios/medmap/Views/SplashScreenView.swift
2026-08-27 23:26:14 +01:00

170 lines
6.0 KiB
Swift

//
// SplashScreenView.swift
// FluxRead
//
// Created by RSA on 29/11/2025.
//
import Foundation
import SwiftUI
import Combine
struct SplashScreenView: View {
@ObservedObject var viewModel: SplashScreenViewModel
@State private var glowPulse = false
@State private var logoScale = 0.85
@State private var logoOpacity = 0.0
@State private var ringRotate = 0.0
@State private var ringOpacity = 0.0
var body: some View {
ZStack {
// ── Deep black base ──────────────────────────────────────────
Color.black.ignoresSafeArea()
// ── Bottom-half warm particle field ──────────────────────────
SplashParticleField()
.ignoresSafeArea()
.opacity(0.90)
// ── Radial glow anchored to centre top ───────────────────────
VStack(spacing: 0) {
RadialGradient(
colors: [
Color.orange.opacity(glowPulse ? 0.22 : 0.10),
Color.yellow.opacity(glowPulse ? 0.10 : 0.04),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 340
)
.frame(width: 680, height: 680)
.blur(radius: 48)
.scaleEffect(glowPulse ? 1.12 : 1.0)
.animation(
.easeInOut(duration: 3.8).repeatForever(autoreverses: true),
value: glowPulse
)
Spacer()
}
.ignoresSafeArea()
// ── Orbiting arc ring around logo ────────────────────────────
ZStack {
// slow outer arc
Circle()
.trim(from: 0.0, to: 0.28)
.stroke(
LinearGradient(
colors: [Color.orange.opacity(0.45), Color.clear],
startPoint: .leading, endPoint: .trailing
),
style: StrokeStyle(lineWidth: 1.0, lineCap: .round)
)
.frame(width: 210, height: 210)
.rotationEffect(.degrees(ringRotate))
.opacity(ringOpacity)
// faster inner arc
Circle()
.trim(from: 0.55, to: 0.72)
.stroke(
LinearGradient(
colors: [Color.yellow.opacity(0.30), Color.clear],
startPoint: .leading, endPoint: .trailing
),
style: StrokeStyle(lineWidth: 0.6, lineCap: .round)
)
.frame(width: 175, height: 175)
.rotationEffect(.degrees(-ringRotate * 1.4))
.opacity(ringOpacity)
}
// ── Logo centred ─────────────────────────────────────────────
VStack(spacing: 0) {
Spacer()
Spacer()
}
}
.ignoresSafeArea()
.onAppear {
// Logo entrance
withAnimation(.spring(response: 0.75, dampingFraction: 0.72).delay(0.1)) {
logoScale = 1.0
logoOpacity = 1.0
}
// Glow breath
glowPulse = true
// Ring fade-in then continuous rotation
withAnimation(.easeIn(duration: 0.8).delay(0.4)) {
ringOpacity = 1.0
}
withAnimation(.linear(duration: 12).repeatForever(autoreverses: false).delay(0.4)) {
ringRotate = 360
}
}
}
}
// MARK: - Splash Particle Field
// Warm, slow-drifting particles — denser than the reader version, tinted orange/gold
struct SplashParticleField: View {
@State private var tick: Double = 0
private let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect()
private struct Particle {
let x, y, speed, phase: Double
let radius: CGFloat
let opacity: Double
let warm: Bool // warm (orange/gold) vs cool (white)
}
private let particles: [Particle] = (0..<120).map { i in
let seed = Double(i) * 1.618033
return Particle(
x: (seed * 0.7369).truncatingRemainder(dividingBy: 1.0),
y: (seed * 1.2345).truncatingRemainder(dividingBy: 1.0),
speed: Double.random(in: 8...28),
phase: Double.random(in: 0...Double.pi * 2),
radius: CGFloat(Double.random(in: 0.7...2.8)),
opacity: Double.random(in: 0.04...0.28),
warm: i % 3 != 0
)
}
var body: some View {
Canvas { ctx, size in
let t = tick
for p in particles {
let x = (p.x + t / p.speed).truncatingRemainder(dividingBy: 1.0) * size.width
let drift = sin(t / (p.speed * 0.6) + p.phase) * 22
let y = p.y * size.height + CGFloat(drift)
let pulse = 0.65 + 0.35 * sin(t / 2.5 + p.phase)
let alpha = p.opacity * pulse
let color: Color = p.warm
? Color(red: 1.0, green: 0.62, blue: 0.18).opacity(alpha)
: Color.white.opacity(alpha * 0.55)
ctx.fill(
Path(ellipseIn: CGRect(
x: x - p.radius, y: y - p.radius,
width: p.radius * 2, height: p.radius * 2
)),
with: .color(color)
)
}
}
.onReceive(timer) { _ in tick += 1.0 / 60.0 }
}
}