Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
16 changes: 16 additions & 0 deletions V2er.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -184,6 +186,8 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
0B1A2B3C4D5E6F7081920A04 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
0B1A2B3C4D5E6F7081920A08 /* TypewriterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypewriterView.swift; sourceTree = "<group>"; };
28B24CA82EA3460D00F82B2A /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = "<group>"; };
28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStatsInfo.swift; sourceTree = "<group>"; };
31C4B81E79369CDE4880B773 /* RichContentView+Preview.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "RichContentView+Preview.swift"; path = "V2er/Sources/RichView/Views/RichContentView+Preview.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -718,6 +722,15 @@
path = FeedDetail;
sourceTree = "<group>";
};
0B1A2B3C4D5E6F7081920A06 /* Splash */ = {
isa = PBXGroup;
children = (
0B1A2B3C4D5E6F7081920A04 /* SplashView.swift */,
0B1A2B3C4D5E6F7081920A08 /* TypewriterView.swift */,
);
path = Splash;
sourceTree = "<group>";
};
5DE5B4C826845F4F00569684 /* View */ = {
isa = PBXGroup;
children = (
Expand All @@ -734,6 +747,7 @@
5D1D7B8526FC9AF6008E0C08 /* Login */,
5D843E9626A46CB800C47D95 /* Message */,
5D179BFD2496F6EC00E40E90 /* Widget */,
0B1A2B3C4D5E6F7081920A06 /* Splash */,
4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */,
);
path = View;
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 16 additions & 0 deletions V2er/Assets.xcassets/SplashLogo.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Binary file not shown.
34 changes: 22 additions & 12 deletions V2er/General/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
1 change: 1 addition & 0 deletions V2er/State/DataFlow/State/GlobalState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
64 changes: 64 additions & 0 deletions V2er/View/Splash/SplashView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// 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
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showSlogan = true
}

// Hide splash after animation completes
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
withAnimation(.easeOut(duration: 0.3)) {
store.appState.globalState.launchFinished = true
}
}
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using the existing runInMain(delay:execute:) utility function instead of DispatchQueue.main.asyncAfter for consistency with the rest of the codebase. This pattern is used throughout the project (e.g., in Utils.swift, SearchPage.swift, Toast.swift).

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct mutation of global state violates the Redux-like architecture pattern described in the project guidelines. State updates should be dispatched through actions and processed by reducers. Consider creating a GlobalActions.LaunchFinished action and dispatching it via the store instead.

Copilot uses AI. Check for mistakes.
}
}
}

#Preview {
SplashView()
.environmentObject(Store.shared)
}
82 changes: 82 additions & 0 deletions V2er/View/Splash/TypewriterView.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Error>?
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task is typed as Task<Void, Error>? but never throws errors. Consider using Task<Void, Never>? to accurately reflect that this task doesn't throw, or add proper error handling for the async operations.

Suggested change
@State private var typingTask: Task<Void, Error>?
@State private var typingTask: Task<Void, Never>?

Copilot uses AI. Check for mistakes.
@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 {
try Task.checkCancellation()

// 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()
}
Loading