diff --git a/V2er.xcodeproj/project.pbxproj b/V2er.xcodeproj/project.pbxproj index 6c6530e..7ab68e0 100644 --- a/V2er.xcodeproj/project.pbxproj +++ b/V2er.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 0B1A2B3C4D5E6F7081920A03 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B1A2B3C4D5E6F7081920A04 /* SplashView.swift */; }; + 0B1A2B3C4D5E6F7081920A07 /* TypewriterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B1A2B3C4D5E6F7081920A08 /* TypewriterView.swift */; }; 1AEBC3AC5DAA63523F5448F5 /* RichContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E205F350A3537A3E41B1AFC3 /* RichContentView.swift */; }; 28B24CA92EA3460D00F82B2A /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B24CA82EA3460D00F82B2A /* BalanceView.swift */; }; 28B24CAB2EA3561400F82B2A /* OnlineStatsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */; }; @@ -184,6 +186,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0B1A2B3C4D5E6F7081920A04 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; + 0B1A2B3C4D5E6F7081920A08 /* TypewriterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypewriterView.swift; sourceTree = ""; }; 28B24CA82EA3460D00F82B2A /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = ""; }; 28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStatsInfo.swift; sourceTree = ""; }; 31C4B81E79369CDE4880B773 /* RichContentView+Preview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "RichContentView+Preview.swift"; path = "V2er/Sources/RichView/Views/RichContentView+Preview.swift"; sourceTree = ""; }; @@ -718,6 +722,15 @@ path = FeedDetail; sourceTree = ""; }; + 0B1A2B3C4D5E6F7081920A06 /* Splash */ = { + isa = PBXGroup; + children = ( + 0B1A2B3C4D5E6F7081920A04 /* SplashView.swift */, + 0B1A2B3C4D5E6F7081920A08 /* TypewriterView.swift */, + ); + path = Splash; + sourceTree = ""; + }; 5DE5B4C826845F4F00569684 /* View */ = { isa = PBXGroup; children = ( @@ -734,6 +747,7 @@ 5D1D7B8526FC9AF6008E0C08 /* Login */, 5D843E9626A46CB800C47D95 /* Message */, 5D179BFD2496F6EC00E40E90 /* Widget */, + 0B1A2B3C4D5E6F7081920A06 /* Splash */, 4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */, ); path = View; @@ -975,6 +989,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0B1A2B3C4D5E6F7081920A03 /* SplashView.swift in Sources */, + 0B1A2B3C4D5E6F7081920A07 /* TypewriterView.swift in Sources */, 5D73FBDA27284ADB004558E9 /* RichText.swift in Sources */, 5D2DD00A26FB443D0001C85A /* GlobalActions.swift in Sources */, 5D71DF57247C153C00B53ED4 /* ExplorePage.swift in Sources */, diff --git a/V2er/Assets.xcassets/Colors/SplashBackground.colorset/Contents.json b/V2er/Assets.xcassets/Colors/SplashBackground.colorset/Contents.json new file mode 100644 index 0000000..903bdcd --- /dev/null +++ b/V2er/Assets.xcassets/Colors/SplashBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.078", + "green" : "0.071", + "red" : "0.067" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/V2er/Assets.xcassets/SplashLogo.imageset/Contents.json b/V2er/Assets.xcassets/SplashLogo.imageset/Contents.json new file mode 100644 index 0000000..b8db323 --- /dev/null +++ b/V2er/Assets.xcassets/SplashLogo.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "splash_logo.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/V2er/Assets.xcassets/SplashLogo.imageset/splash_logo.pdf b/V2er/Assets.xcassets/SplashLogo.imageset/splash_logo.pdf new file mode 100644 index 0000000..f7aedae Binary files /dev/null and b/V2er/Assets.xcassets/SplashLogo.imageset/splash_logo.pdf differ diff --git a/V2er/General/RootView.swift b/V2er/General/RootView.swift index 8b90fa2..c7fed0f 100644 --- a/V2er/General/RootView.swift +++ b/V2er/General/RootView.swift @@ -98,20 +98,30 @@ struct RootHostView: View { $store.appState.loginState } + var launchFinished: Bool { + store.appState.globalState.launchFinished + } + var body: some View { - MainPage() - .buttonStyle(.plain) - .toast(isPresented: toast.isPresented) { - DefaultToastView(title: toast.title.raw, icon: toast.icon.raw) - } - .sheet(isPresented: loginState.showLoginView) { - LoginPage() - } - .overlay { - if loginState.raw.showTwoStepDialog { - TwoStepLoginPage() + ZStack { + MainPage() + .buttonStyle(.plain) + .toast(isPresented: toast.isPresented) { + DefaultToastView(title: toast.title.raw, icon: toast.icon.raw) + } + .sheet(isPresented: loginState.showLoginView) { + LoginPage() + } + .overlay { + if loginState.raw.showTwoStepDialog { + TwoStepLoginPage() + } } - } + if !launchFinished { + SplashView() + .transition(.opacity) + } + } } } diff --git a/V2er/State/DataFlow/Actions/GlobalActions.swift b/V2er/State/DataFlow/Actions/GlobalActions.swift index 5248d35..557f343 100644 --- a/V2er/State/DataFlow/Actions/GlobalActions.swift +++ b/V2er/State/DataFlow/Actions/GlobalActions.swift @@ -40,6 +40,10 @@ struct ShowToastAction: Action { var icon: String = .empty } +struct LaunchFinishedAction: Action { + var target: Reducer = R +} + func globalStateReducer(_ state: GlobalState, _ action: Action?) -> (GlobalState, Action?) { var state = state @@ -50,6 +54,9 @@ func globalStateReducer(_ state: GlobalState, _ action: Action?) -> (GlobalState state.toast.icon = action.icon state.toast.isPresented = true break + case _ as LaunchFinishedAction: + state.launchFinished = true + break default: break } diff --git a/V2er/State/DataFlow/State/GlobalState.swift b/V2er/State/DataFlow/State/GlobalState.swift index 95901a1..4e0ce0c 100644 --- a/V2er/State/DataFlow/State/GlobalState.swift +++ b/V2er/State/DataFlow/State/GlobalState.swift @@ -19,6 +19,7 @@ struct GlobalState: FluxState { var lastSelectedTab: TabId = .none var scrollTopTab: TabId = .none var toast = Toast() + var launchFinished: Bool = false static var account: AccountInfo? { AccountState.getAccount() diff --git a/V2er/View/Splash/SplashView.swift b/V2er/View/Splash/SplashView.swift new file mode 100644 index 0000000..f3a01c9 --- /dev/null +++ b/V2er/View/Splash/SplashView.swift @@ -0,0 +1,62 @@ +// +// SplashView.swift +// V2er +// +// Created by Claude on 2024/12/1. +// + +import SwiftUI + +struct SplashView: View { + @EnvironmentObject private var store: Store + @Environment(\.colorScheme) private var colorScheme + + @State private var showSlogan = false + + private let slogan = "Way to explore" + + // Logo color adapts to color scheme (matches Android) + private var logoColor: Color { + colorScheme == .dark ? .white : Color(red: 0.067, green: 0.071, blue: 0.078) + } + + var body: some View { + ZStack { + // Background color - matches Android implementation + Color("SplashBackground") + .ignoresSafeArea() + + // Logo - vector PDF with template rendering (fixed position) + Image("SplashLogo") + .renderingMode(.template) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 200, height: 200) + .foregroundColor(logoColor) + + // Slogan with typewriter effect (fixed position below logo) + if showSlogan { + TypewriterView(text: slogan, typingDelay: .milliseconds(35)) + .font(.system(size: 17, weight: .semibold, design: .default)) + .foregroundColor(logoColor.opacity(0.85)) + .offset(y: 74) + } + } + .onAppear { + // Show slogan after a short delay + runInMain(delay: 300) { + showSlogan = true + } + + // Hide splash after animation completes + runInMain(delay: 1200) { + store.dispatch(LaunchFinishedAction(), animation: .easeOut(duration: 0.3)) + } + } + } +} + +#Preview { + SplashView() + .environmentObject(Store.shared) +} diff --git a/V2er/View/Splash/TypewriterView.swift b/V2er/View/Splash/TypewriterView.swift new file mode 100644 index 0000000..f3b52a8 --- /dev/null +++ b/V2er/View/Splash/TypewriterView.swift @@ -0,0 +1,82 @@ +// +// TypewriterView.swift +// V2er +// +// Created by Claude on 2024/12/1. +// + +import SwiftUI + +struct TypewriterView: View { + var text: String + var typingDelay: Duration = .milliseconds(50) + var easeIn: Bool = true + + @State private var animatedText: AttributedString = "" + @State private var typingTask: Task? + @State private var hasAppeared = false + + var body: some View { + Text(animatedText) + .onChange(of: text) { _ in + if hasAppeared { + animateText() + } + } + .onAppear() { + animateText() + hasAppeared = true + } + } + + private func animateText() { + typingTask?.cancel() + + typingTask = Task { + let defaultAttributes = AttributeContainer() + animatedText = AttributedString(text, + attributes: defaultAttributes.foregroundColor(.clear) + ) + + let totalChars = text.count + var charIndex = 0 + var index = animatedText.startIndex + + while index < animatedText.endIndex { + guard !Task.isCancelled else { return } + + // Update the style + animatedText[animatedText.startIndex...index] + .setAttributes(defaultAttributes) + + // Calculate delay with ease-out effect (starts fast, slows down) + let delay: Duration + if easeIn && totalChars > 1 { + // Ease-out: start fast, end slow - more natural typing feel + let progress = Double(charIndex) / Double(totalChars - 1) + let easeOutProgress = 1 - pow(1 - progress, 2) // quadratic ease-out + let baseDelay = Double(typingDelay.components.attoseconds) / 1_000_000_000_000_000_000 + let minDelay = baseDelay * 0.6 + let maxDelay = baseDelay * 1.5 + let currentDelay = minDelay + (maxDelay - minDelay) * easeOutProgress + delay = .milliseconds(Int(currentDelay * 1000)) + } else { + delay = typingDelay + } + + // Wait + try? await Task.sleep(for: delay) + + // Advance the index, character by character + index = animatedText.index(afterCharacter: index) + charIndex += 1 + } + } + } +} + +#Preview { + TypewriterView(text: "Way to explore") + .font(.title) + .padding() +}