Compare commits
No commits in common. "411bf440d4f6a288dad8aa642b26ed2f424a86fb" and "1fa7d113fe9e125602c31fe2b858f5787d329bc1" have entirely different histories.
411bf440d4
...
1fa7d113fe
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
@ -1,20 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// 盲盒动画阶段
|
||||
enum BlindBoxAnimationPhase {
|
||||
case loading
|
||||
case ready
|
||||
case opening
|
||||
case none
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
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) {
|
||||
// 单次播放,不需要在更新时重复触发
|
||||
}
|
||||
}
|
||||
@ -1,98 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
90
wake/Features/BlindBox/Components/Card.swift
Normal file
90
wake/Features/BlindBox/Components/Card.swift
Normal file
@ -0,0 +1,90 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
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,11 +8,66 @@ 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?
|
||||
@ -21,18 +76,60 @@ 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 承担加载与轮询逻辑
|
||||
|
||||
// 计算尺寸逻辑已迁移至 BlindBoxMediaOverlay 组件(已不再使用)
|
||||
// 已迁移至 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
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -41,7 +138,41 @@ 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()
|
||||
@ -78,12 +209,12 @@ struct BlindBoxView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.videoURL) { _, url in
|
||||
if !url.isEmpty && self.animationPhase != .opening {
|
||||
if !url.isEmpty {
|
||||
withAnimation { self.animationPhase = .ready }
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.imageURL) { _, url in
|
||||
if !url.isEmpty && self.animationPhase != .opening {
|
||||
if !url.isEmpty {
|
||||
withAnimation { self.animationPhase = .ready }
|
||||
}
|
||||
}
|
||||
@ -101,144 +232,395 @@ struct BlindBoxView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 原 overlay 分支已移除,直接展示内容
|
||||
// Original content
|
||||
VStack {
|
||||
VStack(spacing: 20) {
|
||||
if mediaType == .all {
|
||||
BlindBoxHeaderBar(
|
||||
onMenuTap: showUserProfile,
|
||||
remainPoints: viewModel.memberProfile?.remainPoints ?? 0,
|
||||
showLogin: $showLogin
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// 标题
|
||||
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)
|
||||
// 返回按钮
|
||||
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()
|
||||
}
|
||||
VStack(spacing: 20) {
|
||||
BlindBoxAnimationView(
|
||||
phase: $animationPhase,
|
||||
onTapReady: {
|
||||
Perf.event("BlindBox_Open_Tapped")
|
||||
print("点击了盲盒")
|
||||
.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
|
||||
)
|
||||
.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 {
|
||||
// 准备就绪点击,开启盲盒
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpeningCompleted: {
|
||||
navigateToOutcome()
|
||||
}
|
||||
)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: UIScreen.main.bounds.height * 0.65
|
||||
)
|
||||
|
||||
|
||||
// 打开 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)")
|
||||
}
|
||||
withAnimation {
|
||||
animationPhase = .opening
|
||||
}
|
||||
} else if animationPhase == .none {
|
||||
Router.shared.navigate(to: .mediaUpload)
|
||||
}
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
},
|
||||
onGoToBuy: {
|
||||
Router.shared.navigate(to: .mediaUpload)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
}
|
||||
|
||||
// 用户资料弹窗
|
||||
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)
|
||||
}
|
||||
@ -253,7 +635,7 @@ struct BlindBoxView: View {
|
||||
for (index, item) in login.enumerated() {
|
||||
print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
|
||||
}
|
||||
showModal.toggle()
|
||||
showModal.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,6 +645,70 @@ 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() {
|
||||
@ -270,58 +716,6 @@ 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: - 预览
|
||||
@ -355,3 +749,13 @@ 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
21
wake/Media.xcassets/Empty.imageset/Contents.json
vendored
@ -1,21 +0,0 @@
|
||||
{
|
||||
"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
BIN
wake/Media.xcassets/Empty.imageset/Empty.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@ -1,110 +0,0 @@
|
||||
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