feat: add launch splash screen with calendar and kisaniCAL. wordmark
Calendar card animates in with spring, wordmark fades in 280ms later. Splash fades out at 1.65s and is removed from the view tree at 2s.
This commit is contained in:
@@ -3,10 +3,18 @@ import SwiftUI
|
||||
@main
|
||||
struct KisaniCalApp: App {
|
||||
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
|
||||
@State private var splashDone = false
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ZStack {
|
||||
ContentView()
|
||||
if !splashDone {
|
||||
SplashView { splashDone = true }
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeIn(duration: 0.2), value: splashDone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
KisaniCal/Views/SplashView.swift
Normal file
150
KisaniCal/Views/SplashView.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Splash View
|
||||
|
||||
struct SplashView: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
var onDismiss: () -> Void
|
||||
|
||||
@State private var calendarScale: CGFloat = 0.88
|
||||
@State private var calendarOpacity: Double = 0
|
||||
@State private var wordmarkOpacity: Double = 0
|
||||
@State private var splashOpacity: Double = 1
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppColors.background(cs).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 22) {
|
||||
SplashCalendarCard()
|
||||
.scaleEffect(calendarScale)
|
||||
.opacity(calendarOpacity)
|
||||
|
||||
// Wordmark: kisaniCAL.
|
||||
VStack(spacing: 1) {
|
||||
Text("kisani")
|
||||
.font(AppFonts.mono(11, weight: .regular))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.kerning(4)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
Text("CAL")
|
||||
.font(AppFonts.mono(38, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text(".")
|
||||
.font(AppFonts.mono(38, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
}
|
||||
.opacity(wordmarkOpacity)
|
||||
}
|
||||
}
|
||||
.opacity(splashOpacity)
|
||||
.onAppear {
|
||||
withAnimation(.spring(response: 0.55, dampingFraction: 0.82).delay(0.05)) {
|
||||
calendarScale = 1
|
||||
calendarOpacity = 1
|
||||
}
|
||||
withAnimation(.easeOut(duration: 0.35).delay(0.28)) {
|
||||
wordmarkOpacity = 1
|
||||
}
|
||||
withAnimation(.easeIn(duration: 0.32).delay(1.65)) {
|
||||
splashOpacity = 0
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar Card
|
||||
|
||||
private struct SplashCalendarCard: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
private let cal = Calendar.current
|
||||
private let today = Calendar.current.component(.day, from: Date())
|
||||
private let monthName: String = {
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "MMMM"
|
||||
return fmt.string(from: Date())
|
||||
}()
|
||||
|
||||
// Flat day array: nil = empty cell, Int = day number
|
||||
private var days: [Int?] {
|
||||
let now = Date()
|
||||
let range = cal.range(of: .day, in: .month, for: now)!.count
|
||||
let first = cal.date(from: cal.dateComponents([.year, .month], from: now))!
|
||||
let lead = cal.component(.weekday, from: first) - 1
|
||||
var grid: [Int?] = Array(repeating: nil, count: lead)
|
||||
grid += (1...range).map { Optional($0) }
|
||||
while grid.count % 7 != 0 { grid.append(nil) }
|
||||
return grid
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Month label
|
||||
Text(monthName.uppercased())
|
||||
.font(AppFonts.mono(9, weight: .bold))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.kerning(2)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
// Day-of-week header
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(["S","M","T","W","T","F","S"].enumerated()), id: \.offset) { _, d in
|
||||
Text(d)
|
||||
.font(AppFonts.mono(7.5, weight: .bold))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
|
||||
// Day grid
|
||||
let rowCount = days.count / 7
|
||||
ForEach(0..<rowCount, id: \.self) { row in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0..<7, id: \.self) { col in
|
||||
let day = days[row * 7 + col]
|
||||
DayCell(day: day, isToday: day == today, cs: cs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 16)
|
||||
.frame(width: 244)
|
||||
.background(AppColors.surface(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(AppColors.border(cs), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(cs == .dark ? 0.3 : 0.06), radius: 24, y: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayCell: View {
|
||||
let day: Int?
|
||||
let isToday: Bool
|
||||
let cs: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if isToday {
|
||||
Circle()
|
||||
.fill(AppColors.accent)
|
||||
.frame(width: 26, height: 26)
|
||||
}
|
||||
if let d = day {
|
||||
Text("\(d)")
|
||||
.font(AppFonts.mono(isToday ? 10 : 9.5, weight: isToday ? .bold : .regular))
|
||||
.foregroundColor(isToday ? .white : AppColors.text(cs).opacity(0.72))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 28)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user