Compare commits
5 Commits
1fa7d113fe
...
411bf440d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 411bf440d4 | |||
| 0ab33cab47 | |||
| 1e57f993c2 | |||
| 562c7aab88 | |||
| 2487d7ebf7 |
1
wake/Assets/Lottie/opening.json
Normal file
1
wake/Assets/Lottie/opening.json
Normal file
File diff suppressed because one or more lines are too long
1
wake/Assets/Lottie/preparing.json
Normal file
1
wake/Assets/Lottie/preparing.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
20
wake/Features/BlindBox/Components/AVPlayerController.swift
Normal file
20
wake/Features/BlindBox/Components/AVPlayerController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
77
wake/Features/BlindBox/Components/BackgroundCard.swift
Normal file
77
wake/Features/BlindBox/Components/BackgroundCard.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
53
wake/Features/BlindBox/Components/BlindBoxActionButton.swift
Normal file
53
wake/Features/BlindBox/Components/BlindBoxActionButton.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
// 盲盒动画阶段
|
||||
enum BlindBoxAnimationPhase {
|
||||
case loading
|
||||
case ready
|
||||
case opening
|
||||
case none
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
37
wake/Features/BlindBox/Components/BlindBoxHeaderBar.swift
Normal file
37
wake/Features/BlindBox/Components/BlindBoxHeaderBar.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
// 单次播放,不需要在更新时重复触发
|
||||
}
|
||||
}
|
||||
98
wake/Features/BlindBox/Components/BlindBoxMediaOverlay.swift
Normal file
98
wake/Features/BlindBox/Components/BlindBoxMediaOverlay.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
wake/Features/BlindBox/Components/BlindBoxTitleView.swift
Normal file
15
wake/Features/BlindBox/Components/BlindBoxTitleView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
19
wake/Features/BlindBox/Components/BlindCountBadge.swift
Normal file
19
wake/Features/BlindBox/Components/BlindCountBadge.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
30
wake/Features/BlindBox/Components/VisualEffectView.swift
Normal file
30
wake/Features/BlindBox/Components/VisualEffectView.swift
Normal 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) {
|
||||
// 无需动态更新
|
||||
}
|
||||
}
|
||||
@ -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,15 +21,9 @@ 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) {
|
||||
@ -93,43 +32,7 @@ struct BlindBoxView: View {
|
||||
_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 {
|
||||
@ -139,40 +42,6 @@ struct BlindBoxView: View {
|
||||
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) {
|
||||
// 从变量blindGenerate中获取description
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -646,76 +264,64 @@ struct BlindBoxView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
|
||||
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) {}
|
||||
// }
|
||||
|
||||
21
wake/Media.xcassets/Empty.imageset/Contents.json
vendored
Normal file
21
wake/Media.xcassets/Empty.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
wake/Media.xcassets/Empty.imageset/Empty.png
vendored
Normal file
BIN
wake/Media.xcassets/Empty.imageset/Empty.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
110
wake/SharedUI/Graphics/ScoopRoundedRect.swift
Normal file
110
wake/SharedUI/Graphics/ScoopRoundedRect.swift
Normal 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~1,0.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 -> P3(水平);Lf~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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user