Compare commits

..

5 Commits

Author SHA1 Message Date
411bf440d4 fix: 盲盒展示 2025-09-11 17:45:55 +08:00
0ab33cab47 refactor: 进一步拆解BlindBoxView 2025-09-11 17:06:12 +08:00
1e57f993c2 feat: Background Card 2025-09-11 16:43:26 +08:00
562c7aab88 feat: 卡片背景绘制 2025-09-11 13:37:00 +08:00
2487d7ebf7 chore: 移除积分详情 2025-09-11 12:57:24 +08:00
21 changed files with 758 additions and 675 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,20 @@
import SwiftUI
import AVKit
// AVPlayer
struct AVPlayerController: UIViewControllerRepresentable {
@Binding var player: AVPlayer?
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
controller.player = player
controller.showsPlaybackControls = false
controller.videoGravity = .resizeAspect
controller.view.backgroundColor = .clear
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
uiViewController.player = player
}
}

View File

@ -0,0 +1,77 @@
import SwiftUI
// MARK: - SwiftUI
struct CardBlindBackground: View {
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let h = geo.size.height
ZStack {
//
ScoopRoundedRect(cornerRadius: 20, scoopDepth: 20, scoopHalfWidth: 90, scoopCenterX: 0.5, convexDown: true, flatHalfWidth: 60)
.fill(Theme.Colors.primary)
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
.padding()
Rectangle()
.fill(
LinearGradient(
colors: [Color(hex: "FFFFFF"), Color(hex: "FFEFB2")],
startPoint: .topTrailing,
endPoint: .bottomLeading
)
)
.frame(width: w - 100 , height: h - 130)
.cornerRadius(20)
.padding(.top, Theme.Spacing.lg)
// var view = UIView()
// view.frame = CGRect(x: 0, y: 0, width: 320, height: 464)
// let layer0 = CAGradientLayer()
// layer0.colors = [
// UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor,
// UIColor(red: 1, green: 0.937, blue: 0.698, alpha: 1).cgColor
// ]
// layer0.locations = [0, 1]
// layer0.startPoint = CGPoint(x: 0.25, y: 0.5)
// layer0.endPoint = CGPoint(x: 0.75, y: 0.5)
// layer0.transform = CATransform3DMakeAffineTransform(CGAffineTransform(a: -1.13, b: 1.07, c: -1.08, d: -0.54, tx: 1.65, ty: 0.24))
// layer0.bounds = view.bounds.insetBy(dx: -0.5*view.bounds.size.width, dy: -0.5*view.bounds.size.height)
// layer0.position = view.center
// view.layer.addSublayer(layer0)
// view.layer.cornerRadius = 18
//
// Circle()
// .fill(Color.themePrimary.opacity(0.18))
// .blur(radius: 40)
// .frame(width: min(w, h) * 0.35, height: min(w, h) * 0.35)
// .position(x: w * 0.25, y: h * 0.25)
//
// Circle()
// .fill(Color.orange.opacity(0.14))
// .blur(radius: 50)
// .frame(width: min(w, h) * 0.40, height: min(w, h) * 0.40)
// .position(x: w * 0.75, y: h * 0.75)
//
// RoundedRectangle(cornerRadius: 28)
// .stroke(Color.white.opacity(0.35), lineWidth: 1)
// .frame(width: w * 0.88, height: h * 0.88)
// .position(x: w / 2, y: h / 2)
// .blendMode(.overlay)
// .opacity(0.7)
}
}
}
}
//
struct CardBlindBackground_Previews: PreviewProvider {
static var previews: some View {
CardBlindBackground()
.frame(width: 400, height: 600)
}
}

View File

@ -0,0 +1,53 @@
import SwiftUI
struct BlindBoxActionButton: View {
let phase: BlindBoxAnimationPhase
let countdownText: String
let onOpen: () -> Void
let onGoToBuy: () -> Void
var body: some View {
Button(action: {
switch phase {
case .ready:
onOpen()
case .none:
onGoToBuy()
default:
break
}
}) {
Group {
switch phase {
case .loading:
Text("Next: \(countdownText)")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.white)
.foregroundColor(.black)
.cornerRadius(32)
case .ready:
Text("Ready")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
default:
Text("Go to Buy")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
}
}
}
}
}

View File

@ -0,0 +1,9 @@
import Foundation
//
enum BlindBoxAnimationPhase {
case loading
case ready
case opening
case none
}

View File

@ -0,0 +1,36 @@
import SwiftUI
import Lottie
/// 4 loading / ready / opening / none
struct BlindBoxAnimationView: View {
@Binding var phase: BlindBoxAnimationPhase
let onTapReady: () -> Void
let onOpeningCompleted: () -> Void
var body: some View {
ZStack {
switch phase {
case .loading:
LottieView(name: "loading", isPlaying: true)
case .ready:
ZStack {
LottieView(name: "ready", isPlaying: true)
Color.clear
.contentShape(Rectangle())
.onTapGesture {
onTapReady()
}
}
case .opening:
BlindBoxLottieOnceView(name: "opening") {
onOpeningCompleted()
}
case .none:
Image("Empty")
.resizable()
.scaledToFit()
}
}
.frame(width: 300, height: 300)
}
}

View File

@ -0,0 +1,19 @@
import SwiftUI
struct BlindBoxDescriptionView: View {
let name: String
let description: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(name)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Color.themeTextMessageMain)
Text(description)
.font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain)
}
.frame(width: UIScreen.main.bounds.width * 0.70, alignment: .leading)
.padding()
}
}

View File

@ -0,0 +1,37 @@
import SwiftUI
struct BlindBoxHeaderBar: View {
let onMenuTap: () -> Void
let remainPoints: Int
@Binding var showLogin: Bool
var body: some View {
HStack {
Button(action: onMenuTap) {
Image(systemName: "line.3.horizontal")
.font(.system(size: 20, weight: .regular))
.foregroundColor(.primary)
.padding(13)
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
Spacer()
NavigationLink(destination: SubscribeView()) {
Text("\(remainPoints)")
.font(Typography.font(for: .subtitle))
.fontWeight(.bold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.black)
.foregroundColor(.white)
.cornerRadius(16)
}
.padding(.trailing)
.fullScreenCover(isPresented: $showLogin) {
LoginView()
}
}
.padding(.horizontal)
.padding(.top, 20)
}
}

View File

@ -0,0 +1,31 @@
import SwiftUI
import Lottie
/// Lottie
struct BlindBoxLottieOnceView: UIViewRepresentable {
let name: String
var animationSpeed: CGFloat = 1.0
let onCompleted: () -> Void
func makeUIView(context: Context) -> LottieAnimationView {
let animationView = LottieAnimationView()
if let animation = LottieAnimation.named(name) {
animationView.animation = animation
} else if let path = Bundle.main.path(forResource: name, ofType: "json") {
let animation = LottieAnimation.filepath(path)
animationView.animation = animation
}
animationView.loopMode = .playOnce
animationView.animationSpeed = animationSpeed
animationView.contentMode = .scaleAspectFit
animationView.backgroundBehavior = .pauseAndRestore
animationView.play { _ in
onCompleted()
}
return animationView
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
//
}
}

View File

@ -0,0 +1,98 @@
import SwiftUI
import UIKit
import AVKit
// /
struct BlindBoxMediaOverlay: View {
let mediaType: BlindBoxMediaType
@Binding var player: AVPlayer?
let displayImage: UIImage?
let isPortrait: Bool
let aspectRatio: CGFloat
@Binding var scale: CGFloat
let onBack: () -> Void
@State private var showControls: Bool = false
private var scaledWidth: CGFloat {
if isPortrait {
return UIScreen.main.bounds.height * scale * 1 / aspectRatio
} else {
return UIScreen.main.bounds.width * scale
}
}
private var scaledHeight: CGFloat {
if isPortrait {
return UIScreen.main.bounds.height * scale
} else {
return UIScreen.main.bounds.width * scale * 1 / aspectRatio
}
}
var body: some View {
ZStack {
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight))
.opacity(0.3)
.edgesIgnoringSafeArea(.all)
Group {
if mediaType == .all, player != nil {
AVPlayerController(player: $player)
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
.onAppear { player?.play() }
} else if mediaType == .image, let image = displayImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.1)) {
showControls.toggle()
}
}
if showControls {
VStack {
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.black)
}
Spacer()
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 50)
.padding(.leading, 20)
.zIndex(1000)
.transition(.opacity)
.onAppear {
// 2
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.easeInOut(duration: 0.3)) {
showControls = true
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(.easeInOut(duration: 1.0), value: scale)
.ignoresSafeArea()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.spring(response: 2.5, dampingFraction: 0.6, blendDuration: 1.0)) {
self.scale = 1.0
}
}
}
}
}

View File

@ -0,0 +1,15 @@
import SwiftUI
struct BlindBoxTitleView: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Hi! Click And")
Text("Open Your Box~")
}
.font(Typography.font(for: .smallLargeTitle))
.fontWeight(.bold)
.foregroundColor(Color.themeTextMessageMain)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
}

View File

@ -0,0 +1,19 @@
import SwiftUI
//
struct BlindCountBadge: View {
let text: String
var body: some View {
Text(text)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.black)
.shadow(color: Color.black.opacity(0.15), radius: 4, x: 0, y: 2)
)
}
}

View File

@ -1,90 +0,0 @@
import SwiftUI
struct CustomLightSequenceAnimation: View {
// "123321123321"
private let baseSequence: [Int] = [1, 2, 3, 3, 2, 1, 1, 2, 3, 3, 2, 1]
@State private var currentLight: Int = 1 //
@State private var sequenceIndex: Int = 0 //
//
@State private var currentOpacity: CGFloat = 1.0
@State private var nextOpacity: CGFloat = 0.0
//
private let screenWidth = UIScreen.main.bounds.width
private let squareSize: CGFloat
private let imageSize: CGFloat
init() {
self.squareSize = screenWidth * 1.8 //
self.imageSize = squareSize / 3 // 1/3
}
// MARK: - SwiftUI
private struct CardBlindBackground: View {
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let h = geo.size.height
ZStack {
//
RoundedRectangle(cornerRadius: 28)
.fill(
LinearGradient(
colors: [Color.white, Color.white.opacity(0.96)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.shadow(color: Color.black.opacity(0.06), radius: 16, x: 0, y: 8)
.frame(width: w * 0.88, height: h * 0.88)
.position(x: w / 2, y: h / 2)
//
Circle()
.fill(Color.themePrimary.opacity(0.18))
.blur(radius: 40)
.frame(width: min(w, h) * 0.35, height: min(w, h) * 0.35)
.position(x: w * 0.25, y: h * 0.25)
//
Circle()
.fill(Color.orange.opacity(0.14))
.blur(radius: 50)
.frame(width: min(w, h) * 0.40, height: min(w, h) * 0.40)
.position(x: w * 0.75, y: h * 0.75)
//
RoundedRectangle(cornerRadius: 28)
.stroke(Color.white.opacity(0.35), lineWidth: 1)
.frame(width: w * 0.88, height: h * 0.88)
.position(x: w / 2, y: h / 2)
.blendMode(.overlay)
.opacity(0.7)
}
}
}
}
//
private var centerPosition: CGPoint {
CGPoint(x: screenWidth / 2, y: squareSize * 0.325)
}
var body: some View {
ZStack {
// SwiftUI
CardBlindBackground()
.frame(width: squareSize, height: squareSize)
.position(centerPosition)
}
}
}
//
struct CustomLightSequenceAnimation_Previews: PreviewProvider {
static var previews: some View {
CustomLightSequenceAnimation()
}
}

View File

@ -0,0 +1,30 @@
import SwiftUI
import UIKit
//
struct VisualEffectView: UIViewRepresentable {
var effect: UIVisualEffect?
func makeUIView(context: Context) -> UIVisualEffectView {
let view = UIVisualEffectView(effect: nil)
let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialLight)
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.alpha = 0.3
let backgroundView = UIView()
backgroundView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.contentView.addSubview(backgroundView)
view.contentView.addSubview(blurView)
blurView.frame = view.bounds
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
return view
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
//
}
}

View File

@ -8,66 +8,11 @@ extension Notification.Name {
static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged")
}
private enum BlindBoxAnimationPhase {
case loading
case ready
case opening
case none
}
extension Notification.Name {
static let navigateToMediaViewer = Notification.Name("navigateToMediaViewer")
}
// MARK: -
struct VisualEffectView: UIViewRepresentable {
var effect: UIVisualEffect?
func makeUIView(context: Context) -> UIVisualEffectView {
let view = UIVisualEffectView(effect: nil)
// Use a simpler approach without animator
let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialLight)
// Create a custom blur effect with reduced intensity
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.alpha = 0.3 // Reduce intensity
// Add a white background with low opacity for better frosted effect
let backgroundView = UIView()
backgroundView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.contentView.addSubview(backgroundView)
view.contentView.addSubview(blurView)
blurView.frame = view.bounds
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
return view
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
// No need to update the effect
}
}
struct AVPlayerController: UIViewControllerRepresentable {
@Binding var player: AVPlayer?
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
controller.player = player
controller.showsPlaybackControls = false
controller.videoGravity = .resizeAspect
controller.view.backgroundColor = .clear
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
uiViewController.player = player
}
}
struct BlindBoxView: View {
let mediaType: BlindBoxMediaType
let currentBoxId: String?
@ -76,60 +21,18 @@ struct BlindBoxView: View {
@State private var showSettings = false //
@State private var showLogin = false
// ViewModel countdownText
//
@State private var showScalingOverlay = false
@State private var animationPhase: BlindBoxAnimationPhase = .none
@State private var scale: CGFloat = 0.1
@State private var showControls = false
@State private var isAnimating = true
@State private var showMedia = false
// -
// -
@Query private var login: [Login]
init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) {
self.mediaType = mediaType
self.currentBoxId = blindBoxId
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
}
// ViewModel
// ViewModel
// ViewModel
// ViewModel
// ViewModel
// ViewModel.prepareMedia()
private func startScalingAnimation() {
self.scale = 0.1
self.showScalingOverlay = true
withAnimation(.spring(response: 2.0, dampingFraction: 0.5, blendDuration: 0.8)) {
self.scale = 1.0
}
}
// MARK: - Computed Properties
private var scaledWidth: CGFloat {
if viewModel.isPortrait {
return UIScreen.main.bounds.height * scale * 1/viewModel.aspectRatio
} else {
return UIScreen.main.bounds.width * scale
}
}
private var scaledHeight: CGFloat {
if viewModel.isPortrait {
return UIScreen.main.bounds.height * scale
} else {
return UIScreen.main.bounds.width * scale * 1/viewModel.aspectRatio
}
}
// BlindBoxMediaOverlay 使
var body: some View {
ZStack {
@ -138,41 +41,7 @@ struct BlindBoxView: View {
Perf.event("BlindBox_Appear")
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
print("🎯 Current thread: \(Thread.current)")
//
// if mediaType == .all, let firstItem = blindList.first {
// displayData = BlindBoxData(from: firstItem)
// } else {
// displayData = blindGenerate
// }
//
// NotificationCenter.default.addObserver(
// forName: .blindBoxStatusChanged,
// object: nil,
// queue: .main
// ) { notification in
// if let status = notification.userInfo?["status"] as? String {
// switch status {
// case "Preparing":
// withAnimation {
// self.animationPhase = .loading
// }
// case "Unopened":
// withAnimation {
// self.animationPhase = .ready
// }
// default:
// //
// withAnimation {
// self.animationPhase = .ready
// }
// break
// }
// }
// }
//
Task {
await viewModel.load()
@ -209,12 +78,12 @@ struct BlindBoxView: View {
}
}
.onChange(of: viewModel.videoURL) { _, url in
if !url.isEmpty {
if !url.isEmpty && self.animationPhase != .opening {
withAnimation { self.animationPhase = .ready }
}
}
.onChange(of: viewModel.imageURL) { _, url in
if !url.isEmpty {
if !url.isEmpty && self.animationPhase != .opening {
withAnimation { self.animationPhase = .ready }
}
}
@ -232,395 +101,144 @@ struct BlindBoxView: View {
}
}
if showScalingOverlay {
ZStack {
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight))
.opacity(0.3)
.edgesIgnoringSafeArea(.all)
Group {
if mediaType == .all, viewModel.player != nil {
// Video Player
AVPlayerController(player: .init(get: { viewModel.player }, set: { viewModel.player = $0 }))
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
.onAppear { viewModel.player?.play() }
} else if mediaType == .image, let image = viewModel.displayImage {
// Image View
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
}
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.1)) {
showControls.toggle()
}
}
//
if showControls {
VStack {
HStack {
Button(action: {
// BlindOutcomeView
if mediaType == .all, !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) {
Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: viewModel.blindGenerate?.name ?? "Your box", description:viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember))
} else if mediaType == .image, let image = viewModel.displayImage {
Router.shared.navigate(to: .blindOutcome(media: .image(image), time: viewModel.blindGenerate?.name ?? "Your box", description:viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember))
}
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.black)
}
Spacer()
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 50)
.padding(.leading, 20)
.zIndex(1000)
.transition(.opacity)
.onAppear {
// 2
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.easeInOut(duration: 0.3)) {
showControls = true
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(.easeInOut(duration: 1.0), value: scale)
.ignoresSafeArea()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.spring(response: 2.5, dampingFraction: 0.6, blendDuration: 1.0)) {
self.scale = 1.0
}
}
}
} else {
// Original content
VStack {
VStack(spacing: 20) {
if mediaType == .all {
//
HStack {
//
Button(action: showUserProfile) {
Image(systemName: "line.3.horizontal")
.font(.system(size: 20, weight: .regular))
.foregroundColor(.primary)
.padding(13) // Increases tap area while keeping visual size
.contentShape(Rectangle()) // Makes the padded area tappable
}
.buttonStyle(PlainButtonStyle()) // Prevents the button from affecting the layout
Spacer()
// //
// NavigationLink(destination: TestView()) {
// Text("TestView")
// .font(.subheadline)
// .padding(.horizontal, 12)
// .padding(.vertical, 6)
// .background(Color.brown)
// .foregroundColor(.white)
// .cornerRadius(8)
// }
// //
// NavigationLink(destination: SubscribeView()) {
// Text("Subscribe")
// .font(.subheadline)
// .padding(.horizontal, 12)
// .padding(.vertical, 6)
// .background(Color.orange)
// .foregroundColor(.white)
// .cornerRadius(8)
// }
// .padding(.trailing)
// .fullScreenCover(isPresented: $showLogin) {
// LoginView()
// }
NavigationLink(destination: SubscribeView()) {
Text("\(viewModel.memberProfile?.remainPoints ?? 0)")
.font(Typography.font(for: .subtitle))
.fontWeight(.bold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.black)
.foregroundColor(.white)
.cornerRadius(16)
}
.padding(.trailing)
.fullScreenCover(isPresented: $showLogin) {
LoginView()
}
}
.padding(.horizontal)
.padding(.top, 20)
}
//
VStack(alignment: .leading, spacing: 4) {
Text("Hi! Click And")
Text("Open Your Box~")
}
.font(Typography.font(for: .smallLargeTitle))
.fontWeight(.bold)
.foregroundColor(Color.themeTextMessageMain)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.opacity(showScalingOverlay ? 0 : 1)
.offset(y: showScalingOverlay ? -UIScreen.main.bounds.height * 0.2 : 0)
.animation(.easeInOut(duration: 0.5), value: showScalingOverlay)
//
ZStack {
// 1. SwiftUI
if !showScalingOverlay {
BlindBackground()
.opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
}
if mediaType == .all && !showScalingOverlay {
BlindCountBadge(text: "\(viewModel.blindCount?.availableQuantity ?? 0) Boxes")
.position(x: UIScreen.main.bounds.width * 0.7,
y: UIScreen.main.bounds.height * 0.18)
.opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
}
if !showScalingOverlay {
VStack(spacing: 20) {
switch animationPhase {
case .loading:
LottieView(name: "ready", isPlaying: animationPhase == .loading && !showScalingOverlay)
.frame(width: 300, height: 300)
// .onAppear {
// DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
// withAnimation {
// animationPhase = .ready
// }
// }
// }
case .ready:
ZStack {
LottieView(name: "ready", isPlaying: animationPhase == .ready && !showScalingOverlay)
.frame(width: 300, height: 300)
// Add a transparent overlay to capture taps
Color.clear
.contentShape(Rectangle()) // Make the entire area tappable
.frame(width: 300, height: 300)
.onTapGesture {
Perf.event("BlindBox_Open_Tapped")
print("点击了盲盒")
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen {
Task {
do {
try await viewModel.openBlindBox(for: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
}
}
}
withAnimation {
animationPhase = .opening
}
}
}
.frame(width: 300, height: 300)
case .opening:
ZStack {
if !showMedia {
LottieView(name: "ready", loopMode: .playOnce, isPlaying: !showMedia)
.frame(width: 300, height: 300)
.scaleEffect(scale)
}
// GIFView
Color.clear
.onAppear {
Perf.event("BlindBox_Opening_Begin")
print("开始播放开启动画")
// 1
self.scale = 1.0
// 1
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
withAnimation(.spring(response: 1.0, dampingFraction: 0.7)) {
//
self.scale = max(
UIScreen.main.bounds.width / 300,
UIScreen.main.bounds.height / 300
) * 1.2
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
self.scale = 1.0
//
Perf.event("BlindBox_Opening_ShowMedia")
self.showScalingOverlay = true
Task { await viewModel.prepareMedia() }
// GIF
self.showMedia = true
}
}
}
}
}
}
.frame(width: 300, height: 300)
case .none:
//
LottieView(name: "ready", loopMode: .loop, isPlaying: true)
.frame(width: 300, height: 300)
.scaleEffect(scale)
// Color.clear
// .frame(width: 300, height: 300)
// SVGImage(svgName: "BlindNone")
// .frame(width: 300, height: 300)
}
}
.offset(y: -50)
.compositingGroup()
.padding()
}
//
if !showScalingOverlay && !showMedia {
VStack(alignment: .leading, spacing: 8) {
// blindGeneratedescription
Text(viewModel.blindGenerate?.name ?? "Some box")
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Color.themeTextMessageMain)
Text(viewModel.blindGenerate?.description ?? "")
.font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain)
}
.frame(width: UIScreen.main.bounds.width * 0.70, alignment: .leading)
.padding()
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
}
}
.padding()
.frame(
maxWidth: .infinity,
maxHeight: UIScreen.main.bounds.height * 0.65
// overlay
// Original content
VStack {
VStack(spacing: 20) {
if mediaType == .all {
BlindBoxHeaderBar(
onMenuTap: showUserProfile,
remainPoints: viewModel.memberProfile?.remainPoints ?? 0,
showLogin: $showLogin
)
.opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
.offset(y: showScalingOverlay ? -100 : 0)
.animation(.easeInOut(duration: 1.5), value: showScalingOverlay)
// TODO
if mediaType == .all, viewModel.didBootstrap {
Button(action: {
if animationPhase == .ready {
//
}
//
BlindBoxTitleView()
.opacity(animationPhase == .opening ? 0 : 1)
//
ZStack {
// 1. Card
CardBlindBackground()
if mediaType == .all {
BlindCountBadge(text: "\(viewModel.blindCount?.availableQuantity ?? 0) Boxes")
.position(x: UIScreen.main.bounds.width * 0.7,
y: UIScreen.main.bounds.height * 0.18)
}
VStack(spacing: 20) {
BlindBoxAnimationView(
phase: $animationPhase,
onTapReady: {
Perf.event("BlindBox_Open_Tapped")
print("点击了盲盒")
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen {
Task {
do {
try await viewModel.openBlindBox(for: boxId)
print("✅ 盲盒开启成功")
await viewModel.startPolling()
withAnimation {
animationPhase = .opening
}
} catch {
print("❌ 开启盲盒失败: \(error)")
}
}
}
withAnimation {
animationPhase = .opening
}
} else if animationPhase == .none {
Router.shared.navigate(to: .mediaUpload)
},
onOpeningCompleted: {
navigateToOutcome()
}
}) {
if animationPhase == .loading {
Text("Next: \(viewModel.countdownText)")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.white)
.foregroundColor(.black)
.cornerRadius(32)
} else if animationPhase == .ready {
Text("Ready")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
} else {
Text("Go to Buy")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
}
}
.padding(.horizontal)
)
}
.offset(y: -50)
.compositingGroup()
.padding()
// opening
if animationPhase != .opening {
BlindBoxDescriptionView(
name: viewModel.blindGenerate?.name ?? "Some box",
description: viewModel.blindGenerate?.description ?? ""
)
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.themeTextWhiteSecondary)
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
.edgesIgnoringSafeArea(.all)
}
//
SlideInModal(
isPresented: $showModal,
onDismiss: hideUserProfile
) {
UserProfileModal(
showModal: $showModal,
showSettings: $showSettings,
isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }),
memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 })
.padding()
.frame(
maxWidth: .infinity,
maxHeight: UIScreen.main.bounds.height * 0.65
)
}
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
//
ZStack {
if showSettings {
Color.black.opacity(0.3)
.edgesIgnoringSafeArea(.all)
.onTapGesture(perform: hideSettings)
.transition(.opacity)
}
if showSettings {
SettingsView(isPresented: $showSettings)
.transition(.move(edge: .leading))
.zIndex(1)
// TODO
if mediaType == .all, viewModel.didBootstrap {
BlindBoxActionButton(
phase: animationPhase,
countdownText: viewModel.countdownText,
onOpen: {
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen {
Task {
do {
try await viewModel.openBlindBox(for: boxId)
print("✅ 盲盒开启成功")
await viewModel.startPolling()
withAnimation {
animationPhase = .opening
}
} catch {
print("❌ 开启盲盒失败: \(error)")
}
}
}
},
onGoToBuy: {
Router.shared.navigate(to: .mediaUpload)
}
)
.padding(.horizontal)
}
}
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.themeTextWhiteSecondary)
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
.edgesIgnoringSafeArea(.all)
}
//
SlideInModal(
isPresented: $showModal,
onDismiss: hideUserProfile
) {
UserProfileModal(
showModal: $showModal,
showSettings: $showSettings,
isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }),
memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 })
)
}
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
//
ZStack {
if showSettings {
Color.black.opacity(0.3)
.edgesIgnoringSafeArea(.all)
.onTapGesture(perform: hideSettings)
.transition(.opacity)
}
if showSettings {
SettingsView(isPresented: $showSettings)
.transition(.move(edge: .leading))
.zIndex(1)
}
}
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
}
.navigationBarBackButtonHidden(true)
}
@ -635,7 +253,7 @@ struct BlindBoxView: View {
for (index, item) in login.enumerated() {
print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
}
showModal.toggle()
showModal.toggle()
}
}
@ -645,70 +263,6 @@ struct BlindBoxView: View {
showModal = false
}
}
// MARK: - SwiftUI
private struct BlindCountBadge: View {
let text: String
var body: some View {
Text(text)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.black)
.shadow(color: Color.black.opacity(0.15), radius: 4, x: 0, y: 2)
)
}
}
// MARK: - SwiftUI
private struct BlindBackground: View {
var body: some View {
GeometryReader { geo in
let w = geo.size.width
let h = geo.size.height
ZStack {
//
RoundedRectangle(cornerRadius: 28)
.fill(
LinearGradient(
colors: [Color.white, Color.white.opacity(0.96)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.shadow(color: Color.black.opacity(0.06), radius: 16, x: 0, y: 8)
.frame(width: min(w * 0.9, 360), height: min(h * 0.6, 260))
.position(x: w / 2, y: h * 0.35)
//
Circle()
.fill(Color.themePrimary.opacity(0.18))
.blur(radius: 40)
.frame(width: 160, height: 160)
.position(x: w * 0.22, y: h * 0.18)
//
Circle()
.fill(Color.orange.opacity(0.14))
.blur(radius: 50)
.frame(width: 180, height: 180)
.position(x: w * 0.78, y: h * 0.55)
//
RoundedRectangle(cornerRadius: 28)
.stroke(Color.white.opacity(0.35), lineWidth: 1)
.frame(width: min(w * 0.9, 360), height: min(h * 0.6, 260))
.position(x: w / 2, y: h * 0.35)
.blendMode(.overlay)
.opacity(0.7)
}
}
}
}
///
private func hideSettings() {
@ -716,6 +270,58 @@ struct BlindBoxView: View {
showSettings = false
}
}
///
private func navigateToOutcome() {
Perf.event("BlindBox_Opening_Completed")
Task { @MainActor in
let interval: UInt64 = 300_000_000 // 300ms
let timeout: UInt64 = 6_000_000_000 // 6s
var waited: UInt64 = 0
if mediaType == .all {
// URL
while viewModel.videoURL.isEmpty && waited < timeout {
try? await Task.sleep(nanoseconds: interval)
waited += interval
}
// URL player
if !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) {
Router.shared.navigate(
to: .blindOutcome(
media: .video(url, nil),
time: viewModel.blindGenerate?.name ?? "Your box",
description: viewModel.blindGenerate?.description ?? "",
isMember: viewModel.isMember
)
)
return
}
} else if mediaType == .image {
// imageURL UIImage
while viewModel.imageURL.isEmpty && waited < timeout {
try? await Task.sleep(nanoseconds: interval)
waited += interval
}
if viewModel.displayImage == nil && !viewModel.imageURL.isEmpty {
await viewModel.prepareMedia()
}
if let image = viewModel.displayImage {
Router.shared.navigate(
to: .blindOutcome(
media: .image(image),
time: viewModel.blindGenerate?.name ?? "Your box",
description: viewModel.blindGenerate?.description ?? "",
isMember: viewModel.isMember
)
)
return
}
}
// 便
print("⚠️ navigateToOutcome: 媒体尚未准备好videoURL=\(viewModel.videoURL), image=\(String(describing: viewModel.displayImage))")
}
}
}
// MARK: -
@ -749,13 +355,3 @@ struct BlindBoxView: View {
#endif
}
}
// struct TransparentVideoPlayer: UIViewRepresentable {
// func makeUIView(context: Context) -> UIView {
// let view = UIView()
// view.backgroundColor = .clear
// view.isOpaque = false
// return view
// }
// func updateUIView(_ uiView: UIView, context: Context) {}
// }

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Empty.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,110 @@
import SwiftUI
///
struct ScoopRoundedRect: Shape {
var cornerRadius: CGFloat = 20
/// >0 <0
var scoopDepth: CGFloat = 10
///
var scoopHalfWidth: CGFloat = 18
/// 0~10.5
var scoopCenterX: CGFloat = 0.33
/// false notch
var convexDown: Bool = true
/// /0
var flatHalfWidth: CGFloat = 8
func path(in rect: CGRect) -> Path {
let r = min(cornerRadius, min(rect.width, rect.height) * 0.5)
let topY = rect.minY
// 穿
let minX = rect.minX + r
let maxX = rect.maxX - r
let centerX = rect.minX + rect.width * scoopCenterX
let hw = min(scoopHalfWidth, (maxX - minX) * 0.45)
let flatHW = max(0, min(flatHalfWidth, hw * 0.8))
let shoulder = max(1, hw - flatHW) // 线
let startX = max(minX, centerX - (flatHW + shoulder))
let endX = min(maxX, centerX + (flatHW + shoulder))
let leftFlatX = max(minX, centerX - flatHW)
let rightFlatX = min(maxX, centerX + flatHW)
let depth = (convexDown ? 1 : -1) * scoopDepth
// 线P0 -> Lf线Rf -> P3Lf~Rf 线
let P0 = CGPoint(x: startX, y: topY)
let Lf = CGPoint(x: leftFlatX, y: topY + depth)
let Rf = CGPoint(x: rightFlatX, y: topY + depth)
let P3 = CGPoint(x: endX, y: topY)
// 使 shoulder
let k = shoulder * 0.5522847498
let C1 = CGPoint(x: P0.x + k, y: P0.y) // P0 线
let C2 = CGPoint(x: Lf.x - k, y: Lf.y) // Lf 线
let C3 = CGPoint(x: Rf.x + k, y: Rf.y) // Rf 线
let C4 = CGPoint(x: P3.x - k, y: P3.y) // P3 线
var p = Path()
//
p.move(to: CGPoint(x: rect.minX + r, y: topY))
// 线/
p.addLine(to: P0)
// 线
p.addCurve(to: Lf, control1: C1, control2: C2)
// 线
p.addLine(to: Rf)
// 线
p.addCurve(to: P3, control1: C3, control2: C4)
//
p.addLine(to: CGPoint(x: rect.maxX - r, y: topY))
//
p.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY + r),
control: CGPoint(x: rect.maxX, y: rect.minY))
// 线
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - r))
//
p.addQuadCurve(to: CGPoint(x: rect.maxX - r, y: rect.maxY),
control: CGPoint(x: rect.maxX, y: rect.maxY))
//
p.addLine(to: CGPoint(x: rect.minX + r, y: rect.maxY))
//
p.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.maxY - r),
control: CGPoint(x: rect.minX, y: rect.maxY))
//
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r))
//
p.addQuadCurve(to: CGPoint(x: rect.minX + r, y: rect.minY),
control: CGPoint(x: rect.minX, y: rect.minY))
p.closeSubpath()
return p
}
}
struct ScoopRoundedRect_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 30) {
//
ScoopRoundedRect(cornerRadius: 24, scoopDepth: 8, scoopHalfWidth: 26, scoopCenterX: 0.25, convexDown: true, flatHalfWidth: 12)
.fill(Color.orange)
.frame(height: 140)
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
.padding()
//
ScoopRoundedRect(cornerRadius: 28, scoopDepth: 12, scoopHalfWidth: 36, scoopCenterX: 0.5, convexDown: true, flatHalfWidth: 18)
.fill(Color.orange)
.frame(height: 140)
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
.padding()
// notch
ScoopRoundedRect(cornerRadius: 24, scoopDepth: 10, scoopHalfWidth: 22, scoopCenterX: 0.6, convexDown: false, flatHalfWidth: 10)
.fill(Color.orange)
.frame(height: 140)
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
.padding()
}
.background(Color(white: 0.96))
.previewLayout(.sizeThatFits)
}
}

View File

@ -77,9 +77,9 @@ struct CreditsInfoCard: View {
Spacer()
//
Image(systemName: "chevron.right")
.foregroundColor(Theme.Colors.textPrimary)
.font(.system(size: 14, weight: .medium))
// Image(systemName: "chevron.right")
// .foregroundColor(Theme.Colors.textPrimary)
// .font(.system(size: 14, weight: .medium))
}
}
.padding(Theme.Spacing.lg)