refactor: 盲盒流程重构

This commit is contained in:
Junhui Chen 2025-09-06 14:01:20 +08:00
parent 0cde8d0c32
commit 6eea4cf717
9 changed files with 477 additions and 264 deletions

View File

@ -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
}

View File

@ -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()
}
}
}

View File

@ -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))
}
}

View File

@ -50,6 +50,46 @@ struct BlindBoxView: View {
self.mediaType = mediaType
}
// MARK: - RetrievalGeneration
private func startListPolling() {
// pollingTimer5
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()

View 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)
}

View 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)
}

View File

@ -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

View File

@ -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)")
//
}
}
}

View File

@ -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) {