refactor: 盲盒流程重构
This commit is contained in:
parent
0cde8d0c32
commit
6eea4cf717
@ -6,8 +6,8 @@ struct ContentView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $router.path) {
|
||||
// Home entry: show the BlindBox home (all)
|
||||
BlindBoxView(mediaType: .all)
|
||||
// Home entry: gate decides destination by querying blind boxes first
|
||||
GateView()
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
route.view
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import SwiftUI
|
||||
@MainActor
|
||||
enum AppRoute: Hashable {
|
||||
case login
|
||||
case avatarBox
|
||||
case feedbackView
|
||||
case feedbackDetail(type: FeedbackView.FeedbackType)
|
||||
case mediaUpload
|
||||
@ -16,14 +15,13 @@ enum AppRoute: Hashable {
|
||||
case about
|
||||
case permissionManagement
|
||||
case feedback
|
||||
case secondOnboarding
|
||||
|
||||
@ViewBuilder
|
||||
var view: some View {
|
||||
switch self {
|
||||
case .login:
|
||||
LoginView()
|
||||
case .avatarBox:
|
||||
AvatarBoxView()
|
||||
case .feedbackView:
|
||||
FeedbackView()
|
||||
case .feedbackDetail(let type):
|
||||
@ -48,6 +46,8 @@ enum AppRoute: Hashable {
|
||||
PermissionManagementView()
|
||||
case .feedback:
|
||||
FeedbackView()
|
||||
case .secondOnboarding:
|
||||
SecondOnboardingView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,229 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct JoinModal: View {
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Semi-transparent background
|
||||
if isPresented {
|
||||
Color.black.opacity(0.4)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modal content
|
||||
if isPresented {
|
||||
VStack(spacing: 0) {
|
||||
// IP Image peeking from top
|
||||
HStack {
|
||||
// Make sure you have an image named "IP" in your assets
|
||||
SVGImageHtml(svgName: "IP1")
|
||||
.frame(width: 116, height: 65)
|
||||
.offset(x: 30)
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 65)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Close button on the right
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
Router.shared.navigate(to: .blindBox(mediaType: .all))
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.padding(12)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
|
||||
// 文本
|
||||
VStack(spacing: 8) {
|
||||
Text("Join us!")
|
||||
.font(Typography.font(for: .headline1, family: .quicksandBold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text("Join us to get more exclusive benefits.")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
// List content
|
||||
VStack (alignment: .leading) {
|
||||
HStack {
|
||||
SVGImage(svgName: "JoinList")
|
||||
.frame(width: 32, height: 32)
|
||||
HStack (alignment: .top){
|
||||
Text("Unlimited")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(" blind box purchases.")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.leading,12)
|
||||
HStack (alignment: .center) {
|
||||
SVGImage(svgName: "JoinList")
|
||||
.frame(width: 32, height: 32)
|
||||
VStack (alignment: .leading,spacing: 4) {
|
||||
HStack {
|
||||
Text("Freely")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(" upload image and video")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
Text(" materials.")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.leading,12)
|
||||
|
||||
HStack(alignment: .top) {
|
||||
SVGImage(svgName: "JoinList")
|
||||
.frame(width: 32, height: 32)
|
||||
VStack (alignment: .leading,spacing: 4) {
|
||||
HStack {
|
||||
Text("500")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(" credits daily,")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
HStack(alignment: .top) {
|
||||
VStack (alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("5000")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(" permanent credits on your first")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
Text(" purchase!")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
.padding(.leading,12)
|
||||
HStack {
|
||||
Spacer() // This will push the button to the right
|
||||
Button(action: {
|
||||
// 点击跳转到会员页面
|
||||
Router.shared.navigate(to: .subscribe)
|
||||
}) {
|
||||
HStack {
|
||||
Text("See More")
|
||||
.font(.system(size: 16))
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 24)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16) // Add some right padding to match the design
|
||||
Button(action: {
|
||||
// 点击跳转到会员页面
|
||||
Router.shared.navigate(to: .subscribe)
|
||||
}) {
|
||||
HStack {
|
||||
Text("Subscribe")
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
Spacer()
|
||||
Text("$1.00/Mon")
|
||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
||||
}
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.horizontal, 30)
|
||||
.background(Color.themePrimary)
|
||||
.cornerRadius(20)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
// 协议条款
|
||||
HStack(alignment: .center) {
|
||||
Button(action: {
|
||||
// Action for Terms of Service
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("Terms of Service")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
.underline() // Add underline
|
||||
}
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.5))
|
||||
.frame(width: 1, height: 16)
|
||||
.padding(.vertical, 4)
|
||||
Button(action: {
|
||||
// 打开网页
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("Privacy Policy")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
.underline() // Add underline
|
||||
}
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.5))
|
||||
.frame(width: 1, height: 16)
|
||||
.padding(.vertical, 4)
|
||||
Button(action: {
|
||||
// Action for Restore Purchase
|
||||
if let url = URL(string: "https://memorywake.com/privacy-policy") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("AI Usage Guidelines")
|
||||
.font(.system(size: 12, weight: .regular))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
.underline() // Add underline
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(Color.white)
|
||||
.cornerRadius(20, corners: [.topLeft, .topRight])
|
||||
}
|
||||
.frame(height: nil)
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.animation(.easeInOut, value: isPresented)
|
||||
}
|
||||
}
|
||||
|
||||
struct JoinModal_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
JoinModal(isPresented: .constant(true))
|
||||
}
|
||||
}
|
||||
@ -50,6 +50,46 @@ struct BlindBoxView: View {
|
||||
self.mediaType = mediaType
|
||||
}
|
||||
|
||||
// MARK: - 第三种盲盒(RetrievalGeneration)列表轮询
|
||||
private func startListPolling() {
|
||||
// 复用 pollingTimer,每5秒更新一次列表状态
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
|
||||
self.checkListStatus()
|
||||
}
|
||||
// 立即检查一次
|
||||
checkListStatus()
|
||||
}
|
||||
|
||||
private func checkListStatus() {
|
||||
NetworkService.shared.get(
|
||||
path: "/blind_boxs/query",
|
||||
parameters: nil
|
||||
) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let list = response.data ?? []
|
||||
self.blindList = list
|
||||
let lowered = list.map { ($0.boxType).lowercased() }
|
||||
let statuses = list.map { $0.status }
|
||||
let hasRetrievalUnopened = zip(lowered, statuses).contains { (t, s) in
|
||||
t == "retrievalgeneration" && s == "Unopened"
|
||||
}
|
||||
if hasRetrievalUnopened {
|
||||
withAnimation { self.animationPhase = .ready }
|
||||
} else if statuses.contains("Preparing") {
|
||||
withAnimation { self.animationPhase = .loading }
|
||||
} else {
|
||||
withAnimation { self.animationPhase = .none }
|
||||
}
|
||||
case .failure(let error):
|
||||
print("❌ 列表轮询失败: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 倒计时
|
||||
private func startCountdown() {
|
||||
// 重置为36:50:20
|
||||
@ -144,6 +184,8 @@ struct BlindBoxView: View {
|
||||
self.animationPhase = .none
|
||||
}
|
||||
print("✅ 成功获取 \(self.blindList.count) 个盲盒")
|
||||
// 启动第三种盲盒的列表轮询
|
||||
self.startListPolling()
|
||||
case .failure(let error):
|
||||
self.blindList = []
|
||||
self.animationPhase = .none
|
||||
@ -151,8 +193,9 @@ struct BlindBoxView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询接口
|
||||
private func startPolling() {
|
||||
stopPolling()
|
||||
|
||||
104
wake/View/BlindBox/SecondOnboardingView.swift
Normal file
104
wake/View/BlindBox/SecondOnboardingView.swift
Normal file
@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A dedicated page to explain the "second" blind box requirement
|
||||
/// and guide the user to upload 20 images and 5 videos without mixing
|
||||
/// with the generic MediaUploadView responsibilities.
|
||||
struct SecondOnboardingView: View {
|
||||
@EnvironmentObject private var router: Router
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Simple header
|
||||
SimpleNaviHeader(title: "Create Your Second Blind Box") {
|
||||
router.pop()
|
||||
}
|
||||
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
|
||||
|
||||
VStack(spacing: 20) {
|
||||
// Hero / Illustration
|
||||
SVGImageHtml(svgName: "IP1")
|
||||
.frame(width: 140, height: 80)
|
||||
.padding(.top, 24)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Upload Requirements")
|
||||
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text("To generate your next blind box, please upload:")
|
||||
.font(Typography.font(for: .body))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
SVGImage(svgName: "Upload").frame(width: 20, height: 20)
|
||||
Text("20 Images")
|
||||
.font(Typography.font(for: .body))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
SVGImage(svgName: "Upload").frame(width: 20, height: 20)
|
||||
Text("5 Videos")
|
||||
.font(Typography.font(for: .body))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Tips
|
||||
HStack(spacing: 6) {
|
||||
SVGImage(svgName: "Tips").frame(width: 16, height: 16)
|
||||
Text("Higher quality media helps create better memories.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.black)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(3)
|
||||
}
|
||||
.background(Color.themeTextWhite.cornerRadius(6))
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Primary CTAs
|
||||
VStack(spacing: 12) {
|
||||
Button(action: {
|
||||
router.navigate(to: .mediaUpload)
|
||||
}) {
|
||||
Text("Start Uploading")
|
||||
.font(.headline)
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(Color.themePrimary)
|
||||
.cornerRadius(28)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
router.navigate(to: .userInfo)
|
||||
}) {
|
||||
Text("Upload Avatar First")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.themeTextMessage)
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.white)
|
||||
.cornerRadius(16)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SecondOnboardingView().environmentObject(Router.shared)
|
||||
}
|
||||
108
wake/View/Gate/GateView.swift
Normal file
108
wake/View/Gate/GateView.swift
Normal file
@ -0,0 +1,108 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GateView: View {
|
||||
@EnvironmentObject private var router: Router
|
||||
@State private var isNavigated = false
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||
|
||||
if isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading...")
|
||||
.font(Typography.font(for: .body))
|
||||
.foregroundColor(.themeTextMessage)
|
||||
}
|
||||
} else if let error = errorMessage {
|
||||
// Briefly inform the user, then auto-navigate to avatar page
|
||||
VStack(spacing: 12) {
|
||||
Text("Network error, going to Avatar setup...")
|
||||
.font(Typography.font(for: .subtitle, family: .quicksandBold))
|
||||
.foregroundColor(.themeTextMessageMain)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.themeTextMessage)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else {
|
||||
// Should not be seen because we auto-route when done
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.onAppear(perform: queryAndRoute)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
|
||||
private func queryAndRoute() {
|
||||
guard !isNavigated else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
print("[Gate] Start queryAndRoute")
|
||||
|
||||
NetworkService.shared.get(
|
||||
path: "/blind_boxs/query",
|
||||
parameters: nil
|
||||
) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in
|
||||
print("Query result: \(result)")
|
||||
DispatchQueue.main.async {
|
||||
isLoading = false
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let list = response.data ?? []
|
||||
print("[Gate] Success, list count: \(list.count)")
|
||||
let lowered = list.map { ($0.boxType).lowercased() }
|
||||
let hasFirst = lowered.contains(where: { $0 == "first" })
|
||||
let hasSecond = lowered.contains(where: { $0 == "second" })
|
||||
let hasRetrieval = lowered.contains(where: { $0 == "retrievalgeneration" })
|
||||
|
||||
print("hasFirst: \(hasFirst), hasSecond: \(hasSecond), hasRetrieval: \(hasRetrieval)")
|
||||
// Routing rules (updated):
|
||||
// - If has 'second' OR 'retrievalgeneration' -> normal home
|
||||
// - Else if only 'first' exists -> second onboarding page
|
||||
// - Else -> avatar upload page
|
||||
if hasSecond || hasRetrieval {
|
||||
print("[Gate] Route -> BlindBox .all")
|
||||
navigateOnce(to: .blindBox(mediaType: .all))
|
||||
} else if hasFirst {
|
||||
print("[Gate] Route -> SecondOnboardingView")
|
||||
navigateOnce(to: .secondOnboarding)
|
||||
} else {
|
||||
print("[Gate] Route -> UserInfo (no first/second/retrieval)")
|
||||
navigateOnce(to: .userInfo)
|
||||
}
|
||||
|
||||
// Defensive fallback: if for any reason not navigated, push avatar after small delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
if !isNavigated {
|
||||
print("[Gate] Fallback routing -> UserInfo")
|
||||
navigateOnce(to: .userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
// Show an error briefly, then route to avatar page automatically
|
||||
errorMessage = error.localizedDescription
|
||||
print("Error: \(error.localizedDescription)")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
navigateOnce(to: .userInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func navigateOnce(to destination: AppRoute) {
|
||||
guard !isNavigated else { return }
|
||||
isNavigated = true
|
||||
router.navigate(to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GateView().environmentObject(Router.shared)
|
||||
}
|
||||
@ -15,6 +15,7 @@ struct UserInfo: View {
|
||||
@State private var uploadedFileId: String? // Add state for file ID
|
||||
@State private var errorMessage: String = ""
|
||||
@State private var showError: Bool = false
|
||||
@State private var hasTriggeredFirstBox: Bool = false // 防止重复触发首个盲盒
|
||||
|
||||
// 添加一个状态来跟踪键盘是否显示
|
||||
@State private var isKeyboardVisible = false
|
||||
@ -260,6 +261,13 @@ struct UserInfo: View {
|
||||
// Clean up observers
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
// 当头像文件上传完成后,自动触发首个盲盒创建
|
||||
.onChange(of: uploadedFileId) { newValue in
|
||||
guard let fileId = newValue, !fileId.isEmpty else { return }
|
||||
guard !hasTriggeredFirstBox else { return }
|
||||
hasTriggeredFirstBox = true
|
||||
generateFirstBlindBox(with: fileId)
|
||||
}
|
||||
.onReceive(keyboardPublisher) { isVisible in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isKeyboardVisible = isVisible
|
||||
@ -273,6 +281,85 @@ struct UserInfo: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 首个盲盒自动创建
|
||||
extension UserInfo {
|
||||
private struct GenerateFileInfo: Codable {
|
||||
let id: String
|
||||
let fileName: String?
|
||||
let url: String?
|
||||
let metadata: [String: String]?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case fileName = "file_name"
|
||||
case url
|
||||
case metadata
|
||||
}
|
||||
}
|
||||
|
||||
private struct GenerateData: Codable {
|
||||
let id: Int64?
|
||||
let boxType: String?
|
||||
let status: String?
|
||||
let resultFile: GenerateFileInfo?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case boxType = "box_type"
|
||||
case status
|
||||
case resultFile = "result_file"
|
||||
}
|
||||
}
|
||||
|
||||
private struct GenerateResponse: Codable {
|
||||
let code: Int
|
||||
let data: GenerateData?
|
||||
}
|
||||
|
||||
private func generateFirstBlindBox(with fileId: String) {
|
||||
let params: [String: Any] = [
|
||||
"box_type": "First",
|
||||
"material_ids": [fileId]
|
||||
]
|
||||
NetworkService.shared.postWithToken(
|
||||
path: "/blind_box/generate",
|
||||
parameters: params
|
||||
) { (result: Result<GenerateResponse, NetworkError>) in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
guard response.code == 0, let urlStr = response.data?.resultFile?.url, let url = URL(string: urlStr) else {
|
||||
self.errorMessage = "Create first blind box failed: invalid response"
|
||||
self.showError = true
|
||||
return
|
||||
}
|
||||
// 下载图片并跳转到盲盒结果页
|
||||
URLSession.shared.dataTask(with: url) { data, _, error in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
self.errorMessage = "Load result image failed: \(error.localizedDescription)"
|
||||
self.showError = true
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let data = data, let image = UIImage(data: data) else {
|
||||
DispatchQueue.main.async {
|
||||
self.errorMessage = "Invalid image data"
|
||||
self.showError = true
|
||||
}
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
Router.shared.navigate(to: .blindOutcome(media: .image(image), time: nil, description: nil))
|
||||
}
|
||||
}.resume()
|
||||
case .failure(let error):
|
||||
self.errorMessage = "Create first blind box failed: \(error.localizedDescription)"
|
||||
self.showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings Row View
|
||||
struct SettingsRow: View {
|
||||
let icon: String
|
||||
|
||||
@ -7,6 +7,82 @@ import CoreImage.CIFilterBuiltins
|
||||
extension Notification.Name {
|
||||
static let didAddFirstMedia = Notification.Name("didAddFirstMedia")
|
||||
}
|
||||
|
||||
// MARK: - Second Box Generate + Polling Models & Logic
|
||||
private struct GenerateFileInfo: Codable {
|
||||
let id: String
|
||||
let fileName: String?
|
||||
let url: String?
|
||||
let metadata: [String: String]?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case fileName = "file_name"
|
||||
case url
|
||||
case metadata
|
||||
}
|
||||
}
|
||||
|
||||
private struct GenerateData: Codable {
|
||||
let id: Int64?
|
||||
let boxType: String?
|
||||
let status: String?
|
||||
let resultFile: GenerateFileInfo?
|
||||
let videoGenerateTime: String?
|
||||
let description: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case boxType = "box_type"
|
||||
case status
|
||||
case resultFile = "result_file"
|
||||
case videoGenerateTime = "video_generate_time"
|
||||
case description
|
||||
}
|
||||
}
|
||||
|
||||
private struct GenerateResponse: Codable {
|
||||
let code: Int
|
||||
let data: GenerateData?
|
||||
}
|
||||
|
||||
private struct QueryResponse: Codable {
|
||||
let code: Int
|
||||
let data: GenerateData?
|
||||
}
|
||||
|
||||
extension MediaUploadView {
|
||||
private func startPollingSecondBox(id: Int64) {
|
||||
pollingTimer?.invalidate()
|
||||
// 每2秒轮询一次查询接口
|
||||
pollingTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { _ in
|
||||
querySecondBox(id: id)
|
||||
}
|
||||
// 立即查询一次
|
||||
querySecondBox(id: id)
|
||||
}
|
||||
|
||||
private func querySecondBox(id: Int64) {
|
||||
NetworkService.shared.get(
|
||||
path: "/blind_box/query/\(id)",
|
||||
parameters: nil
|
||||
) { (result: Result<QueryResponse, NetworkError>) in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
guard response.code == 0 else { return }
|
||||
if let urlStr = response.data?.resultFile?.url, let url = URL(string: urlStr) {
|
||||
// 检测到结果,停止轮询并跳转结果页(视频)
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = nil
|
||||
isGeneratingSecond = false
|
||||
Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: response.data?.videoGenerateTime, description: response.data?.description))
|
||||
}
|
||||
case .failure(let error):
|
||||
print("❌ 查询第二个盲盒失败: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 主上传视图
|
||||
/// 提供媒体选择、预览和上传功能
|
||||
@MainActor
|
||||
@ -26,6 +102,10 @@ struct MediaUploadView: View {
|
||||
@State private var uploadComplete = false
|
||||
/// 上传完成的文件ID列表
|
||||
@State private var uploadedFileIds: [[String: String]] = []
|
||||
/// 生成第二个盲盒时的加载与轮询
|
||||
@State private var isGeneratingSecond = false
|
||||
@State private var generatedSecondBoxId: Int64? = nil
|
||||
@State private var pollingTimer: Timer? = nil
|
||||
|
||||
// MARK: - 视图主体
|
||||
|
||||
@ -88,6 +168,28 @@ struct MediaUploadView: View {
|
||||
.onChange(of: uploadManager.uploadResults) { newResults in
|
||||
handleUploadCompletion(results: newResults)
|
||||
}
|
||||
.overlay(
|
||||
Group {
|
||||
if isGeneratingSecond {
|
||||
ZStack {
|
||||
Color.black.opacity(0.2).ignoresSafeArea()
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Generating your blind box...")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.onDisappear {
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 子视图
|
||||
@ -320,30 +422,33 @@ struct MediaUploadView: View {
|
||||
print("⚠️ 没有可用的文件ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 准备请求参数
|
||||
let files = uploadResults.map { (_, result) -> [String: String] in
|
||||
return [
|
||||
"file_id": result.fileId,
|
||||
"preview_file_id": result.thumbnailId ?? result.fileId
|
||||
]
|
||||
}
|
||||
|
||||
// 发送POST请求到/material接口
|
||||
// 收集素材 file_id 列表
|
||||
let materialIds: [String] = uploadResults.map { $0.value.fileId }
|
||||
// 调用创建第二个盲盒接口
|
||||
let params: [String: Any] = [
|
||||
"box_type": "Second",
|
||||
"material_ids": materialIds
|
||||
]
|
||||
isGeneratingSecond = true
|
||||
NetworkService.shared.postWithToken(
|
||||
path: "/material",
|
||||
parameters: files
|
||||
) { (result: Result<EmptyResponse, NetworkError>) in
|
||||
switch result {
|
||||
case .success:
|
||||
print("✅ 素材提交成功")
|
||||
// 跳转到盲盒页面
|
||||
DispatchQueue.main.async {
|
||||
Router.shared.navigate(to: .blindBox(mediaType: .video))
|
||||
path: "/blind_box/generate",
|
||||
parameters: params
|
||||
) { (result: Result<GenerateResponse, NetworkError>) in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
guard response.code == 0, let id = response.data?.id else {
|
||||
print("❌ 创建第二个盲盒失败:响应无效")
|
||||
isGeneratingSecond = false
|
||||
return
|
||||
}
|
||||
generatedSecondBoxId = id
|
||||
// 开始轮询结果
|
||||
startPollingSecondBox(id: id)
|
||||
case .failure(let error):
|
||||
print("❌ 创建第二个盲盒失败: \(error.localizedDescription)")
|
||||
isGeneratingSecond = false
|
||||
}
|
||||
case .failure(let error):
|
||||
print("❌ 素材提交失败: \(error.localizedDescription)")
|
||||
// 这里可以添加错误处理逻辑,比如显示错误提示
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,13 +44,8 @@ struct WakeApp: App {
|
||||
} else {
|
||||
// 根据登录状态显示不同视图
|
||||
if authState.isAuthenticated {
|
||||
// 已登录:显示主页面
|
||||
NavigationStack(path: $router.path) {
|
||||
BlindBoxView(mediaType: .all)
|
||||
.navigationDestination(for: AppRoute.self) { route in
|
||||
route.view
|
||||
}
|
||||
}
|
||||
// 已登录:通过 ContentView 进入(其内部会展示 GateView 并打印 [Gate] 日志)
|
||||
ContentView()
|
||||
} else {
|
||||
// 未登录:显示登录界面
|
||||
NavigationStack(path: $router.path) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user