Compare commits

...

4 Commits

Author SHA1 Message Date
jinyaqiu
55255bf0f8 feat: yangshi 2025-09-03 15:43:52 +08:00
jinyaqiu
b1cd957d0c feat: 样式 2025-09-03 14:53:03 +08:00
jinyaqiu
36b95abc37 feat: blind接口解析问题 2025-09-03 14:12:50 +08:00
jinyaqiu
df32ea71bb feat: 样式优化 2025-09-03 11:42:14 +08:00
19 changed files with 757 additions and 93 deletions

View File

@ -1,5 +1,5 @@
{
"originHash" : "e8f130fe30ac6cdc940ef06ee1e8535e9f46ffee6aeead1722b9525562f6ce08",
"originHash" : "7ea295cc5e3eb8ef644b89ce2b47a7600994b67c8582ee354b643cd63250740d",
"pins" : [
{
"identity" : "alamofire",
@ -9,6 +9,51 @@
"revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5",
"version" : "5.10.2"
}
},
{
"identity" : "cocoalumberjack",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git",
"state" : {
"revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114",
"version" : "3.9.0"
}
},
{
"identity" : "lottie-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/airbnb/lottie-spm.git",
"state" : {
"revision" : "04f2fd18cc9404a0a0917265a449002674f24ec9",
"version" : "4.5.2"
}
},
{
"identity" : "svgkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SVGKit/SVGKit.git",
"state" : {
"revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666",
"version" : "3.0.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4"
}
},
{
"identity" : "waterfallgrid",
"kind" : "remoteSourceControl",
"location" : "https://github.com/paololeonardi/WaterfallGrid.git",
"state" : {
"revision" : "c7c08652c3540adf8e48409c351879b4caea7e89",
"version" : "1.1.0"
}
}
],
"version" : 3

View File

@ -1,4 +1,4 @@
<svg width="106" height="66" viewBox="0 0 106 66" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.666667 60C0.666667 62.9455 3.05448 65.3333 6 65.3333C8.94552 65.3333 11.3333 62.9455 11.3333 60C11.3333 57.0545 8.94552 54.6667 6 54.6667C3.05448 54.6667 0.666667 57.0545 0.666667 60ZM22.2802 31.0204L23.152 31.5102L22.2802 31.0204ZM97.5663 30V29H24.0239V30V31H97.5663V30ZM22.2802 31.0204L21.4083 30.5306L5.12816 59.5102L6 60L6.87184 60.4898L23.152 31.5102L22.2802 31.0204ZM24.0239 30V29C22.9395 29 21.9395 29.5852 21.4083 30.5306L22.2802 31.0204L23.152 31.5102C23.3291 31.1951 23.6624 31 24.0239 31V30Z" fill="black"/>
<rect x="16.8433" width="89.1566" height="30" rx="15" fill="black"/>
<svg width="90" height="66" viewBox="0 0 90 66" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.666667 60C0.666667 62.9455 3.05448 65.3333 6 65.3333C8.94552 65.3333 11.3333 62.9455 11.3333 60C11.3333 57.0545 8.94552 54.6667 6 54.6667C3.05448 54.6667 0.666667 57.0545 0.666667 60ZM28.9006 28.8162L29.7066 29.4081L28.9006 28.8162ZM75.5 28V27H30.5126V28V29H75.5V28ZM28.9006 28.8162L28.0946 28.2243L5.194 59.4081L6 60L6.806 60.5919L29.7066 29.4081L28.9006 28.8162ZM30.5126 28V27C29.5577 27 28.6598 27.4546 28.0946 28.2243L28.9006 28.8162L29.7066 29.4081C29.895 29.1515 30.1943 29 30.5126 29V28Z" fill="black"/>
<rect x="15.5" width="74" height="30" rx="15" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 687 B

View File

@ -74,7 +74,7 @@ struct BlindBoxView: View {
case all
}
//
struct BlindList: Codable, Identifiable {
struct BlindList: Codable, Identifiable, Equatable, Hashable {
let id: Int64
let boxCode: String
let userId: Int64
@ -104,6 +104,40 @@ struct BlindBoxView: View {
case coverFileId = "cover_file_id"
case description
}
// Implement Equatable
static func == (lhs: BlindBoxView.BlindList, rhs: BlindBoxView.BlindList) -> Bool {
return lhs.id == rhs.id &&
lhs.boxCode == rhs.boxCode &&
lhs.userId == rhs.userId &&
lhs.name == rhs.name &&
lhs.boxType == rhs.boxType &&
lhs.features == rhs.features &&
lhs.resultFileId == rhs.resultFileId &&
lhs.status == rhs.status &&
lhs.workflowInstanceId == rhs.workflowInstanceId &&
lhs.videoGenerateTime == rhs.videoGenerateTime &&
lhs.createTime == rhs.createTime &&
lhs.coverFileId == rhs.coverFileId &&
lhs.description == rhs.description
}
// Implement Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(boxCode)
hasher.combine(userId)
hasher.combine(name)
hasher.combine(boxType)
hasher.combine(features)
hasher.combine(resultFileId)
hasher.combine(status)
hasher.combine(workflowInstanceId)
hasher.combine(videoGenerateTime)
hasher.combine(createTime)
hasher.combine(coverFileId)
hasher.combine(description)
}
}
//
struct BlindCount: Codable {
@ -114,22 +148,35 @@ struct BlindBoxView: View {
}
}
// MARK: - BlindBox Response Model
// MARK: - API Response Wrapper
struct APIResponse<T: Codable>: Codable {
let code: Int
let data: T
let message: String?
enum CodingKeys: String, CodingKey {
case code
case data
case message
}
}
struct BlindBoxData: Codable {
// MARK: - BlindBox Response Model
struct BlindBoxData: Codable, Equatable {
let id: Int64
let boxCode: String
let userId: Int64
let name: String
let boxType: String
let features: String?
let url: String?
let resultFileId: Int64?
let status: String
let workflowInstanceId: String?
//
let workflowInstanceId: String
let videoGenerateTime: String?
let createTime: String
let description: String?
let coverFileId: Int64?
let url: String?
let description: String
enum CodingKeys: String, CodingKey {
case id
@ -138,29 +185,59 @@ struct BlindBoxView: View {
case name
case boxType = "box_type"
case features
case url
case resultFileId = "result_file_id"
case status
case workflowInstanceId = "workflow_instance_id"
case videoGenerateTime = "video_generate_time"
case createTime = "create_time"
case coverFileId = "cover_file_id"
case url
case description
}
init(id: Int64, boxCode: String, userId: Int64, name: String, boxType: String, features: String?, url: String?, status: String, workflowInstanceId: String?, videoGenerateTime: String?, createTime: String, description: String?) {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Required fields with default values
id = try container.decode(Int64.self, forKey: .id)
boxCode = try container.decodeIfPresent(String.self, forKey: .boxCode) ?? ""
userId = try container.decode(Int64.self, forKey: .userId)
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
boxType = try container.decode(String.self, forKey: .boxType)
status = try container.decode(String.self, forKey: .status)
workflowInstanceId = try container.decode(String.self, forKey: .workflowInstanceId)
createTime = try container.decode(String.self, forKey: .createTime)
// Optional fields
features = try container.decodeIfPresent(String.self, forKey: .features)
resultFileId = try container.decodeIfPresent(Int64.self, forKey: .resultFileId)
videoGenerateTime = try container.decodeIfPresent(String.self, forKey: .videoGenerateTime)
coverFileId = try container.decodeIfPresent(Int64.self, forKey: .coverFileId)
url = try container.decodeIfPresent(String.self, forKey: .url)
description = try container.decodeIfPresent(String.self, forKey: .description) ?? ""
}
// Initializer for creating instances manually
init(id: Int64, boxCode: String, userId: Int64, name: String, boxType: String, features: String?,
resultFileId: Int64?, status: String, workflowInstanceId: String, videoGenerateTime: String?,
createTime: String, coverFileId: Int64?, url: String?, description: String) {
self.id = id
self.boxCode = boxCode
self.userId = userId
self.name = name
self.boxType = boxType
self.features = features
self.url = url
self.resultFileId = resultFileId
self.status = status
self.workflowInstanceId = workflowInstanceId
self.videoGenerateTime = videoGenerateTime
self.createTime = createTime
self.coverFileId = coverFileId
self.url = url
self.description = description
}
// Initializer for creating from BlindList
init(from listItem: BlindList) {
self.init(
id: listItem.id,
@ -169,17 +246,28 @@ struct BlindBoxView: View {
name: listItem.name,
boxType: listItem.boxType,
features: listItem.features,
url: nil,
resultFileId: listItem.resultFileId,
status: listItem.status,
workflowInstanceId: listItem.workflowInstanceId,
workflowInstanceId: listItem.workflowInstanceId ?? "",
videoGenerateTime: listItem.videoGenerateTime,
createTime: listItem.createTime,
coverFileId: listItem.coverFileId,
url: nil,
description: listItem.description
)
}
// Equatable conformance
static func == (lhs: BlindBoxData, rhs: BlindBoxData) -> Bool {
return lhs.id == rhs.id &&
lhs.workflowInstanceId == rhs.workflowInstanceId &&
lhs.status == rhs.status
}
}
let mediaType: BlindBoxMediaType
private var ids: [String]?
@State private var blindBoxItems: [BlindList]?
@State private var showModal = false //
@State private var showSettings = false //
@State private var isMember = false //
@ -217,8 +305,9 @@ struct BlindBoxView: View {
// -
@Query private var login: [Login]
init(mediaType: BlindBoxMediaType) {
init(mediaType: BlindBoxMediaType, ids: [String]? = nil) {
self.mediaType = mediaType
self.ids = ids
}
//
@ -313,6 +402,8 @@ struct BlindBoxView: View {
// none
if self.blindList.isEmpty {
self.animationPhase = .none
}else{
self.animationPhase = .loading
}
print("✅ 成功获取 \(self.blindList.count) 个盲盒")
case .failure(let error):
@ -343,9 +434,19 @@ struct BlindBoxView: View {
return
}
//
var parameters: [String: Any] = ["box_type": currentBoxType=="Video" ? "Second" : "First"]
//
if let ids = ids, !ids.isEmpty {
let fileIds = ids.compactMap { Int64($0) } // Convert string to Int64
print("material_ids: \(fileIds)")
parameters["material_ids"] = fileIds
}
NetworkService.shared.postWithToken(
path: "/blind_box/generate/mock",
parameters: ["box_type": currentBoxType]
path: "/blind_box/generate",
parameters: parameters
) { (result: Result<APIResponse<BlindBoxData>, NetworkError>) in
DispatchQueue.main.async {
switch result {
@ -534,7 +635,7 @@ struct BlindBoxView: View {
default:
//
withAnimation {
self.animationPhase = .ready
self.animationPhase = .loading
}
break
}
@ -645,7 +746,7 @@ struct BlindBoxView: View {
Button(action: showUserProfile) {
SVGImage(svgName: "User")
.frame(width: 24, height: 24)
.padding(13) // Increases tap area while keeping visual size
.padding(.trailing, 13) // Increases tap area while keeping visual size
.contentShape(Rectangle()) // Makes the padded area tappable
}
.buttonStyle(PlainButtonStyle()) // Prevents the button from affecting the layout
@ -686,18 +787,25 @@ struct BlindBoxView: View {
.foregroundColor(.white)
.cornerRadius(16)
}
.padding(.trailing)
.fullScreenCover(isPresented: $showLogin) {
LoginView()
}
}
.padding(.horizontal)
.padding(.top, 20)
.padding(.horizontal)
}
//
VStack(alignment: .leading, spacing: 4) {
Text("Hi! Click And")
Text("Open Your First Box~")
HStack {
Text("Open Your")
if mediaType == .video {
Text("Second")
} else if mediaType == .image {
Text("First")
}
Text("Box~")
}
}
.font(Typography.font(for: .smallLargeTitle))
.fontWeight(.bold)
@ -809,7 +917,7 @@ struct BlindBoxView: View {
.frame(width: 300, height: 300)
case .none:
SVGImage(svgName: "BlindNone")
SVGImageHtml(svgName: "BlindNone")
.frame(width: 300, height: 300)
}
}
@ -839,9 +947,9 @@ struct BlindBoxView: View {
maxHeight: UIScreen.main.bounds.height * 0.65
)
.opacity(showScalingOverlay ? 0 : 1)
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
.animation(.easeOut(duration: 0.5), value: showScalingOverlay)
.offset(y: showScalingOverlay ? -100 : 0)
.animation(.easeInOut(duration: 1.5), value: showScalingOverlay)
.animation(.easeInOut(duration: 0.5), value: showScalingOverlay)
//
if mediaType == .all {
Button(action: {

View File

@ -7,7 +7,7 @@ enum AppRoute: Hashable {
case feedbackView
case feedbackDetail(type: FeedbackView.FeedbackType)
case mediaUpload
case blindBox(mediaType: BlindBoxView.BlindBoxMediaType)
case blindBox(mediaType: BlindBoxView.BlindBoxMediaType, ids: [String]? = nil)
case blindOutcome(media: MediaType, time: String? = nil, description: String? = nil)
case memories
case subscribe
@ -16,6 +16,7 @@ enum AppRoute: Hashable {
case about
case permissionManagement
case feedback
case blindList
@ViewBuilder
var view: some View {
@ -30,8 +31,8 @@ enum AppRoute: Hashable {
FeedbackDetailView(feedbackType: type)
case .mediaUpload:
MediaUploadView()
case .blindBox(let mediaType):
BlindBoxView(mediaType: mediaType)
case .blindBox(let mediaType, let ids):
BlindBoxView(mediaType: mediaType, ids: ids)
case .blindOutcome(let media, let time, let description):
BlindOutcomeView(media: media, time: time, description: description)
case .memories:
@ -48,6 +49,8 @@ enum AppRoute: Hashable {
PermissionManagementView()
case .feedback:
FeedbackView()
case .blindList:
BlindListView()
}
}
}

View File

@ -66,7 +66,9 @@ struct BlindOutcomeView: View {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white)
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2)
Spacer()
.frame(height: 16)
VStack(spacing: 0) {
switch media {
case .image(let uiImage):
@ -75,7 +77,6 @@ struct BlindOutcomeView: View {
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.cornerRadius(10)
.padding(4)
.onTapGesture {
withAnimation {
isFullscreen.toggle()
@ -84,7 +85,7 @@ struct BlindOutcomeView: View {
case .video(let url, _):
VideoPlayerView(url: url, isPlaying: $isPlaying, player: $player)
.frame(width: UIScreen.main.bounds.width - 40)
.frame(width: UIScreen.main.bounds.width)
.background(Color.clear)
.cornerRadius(10)
.clipped()

View File

@ -154,10 +154,10 @@ struct JoinModal: View {
.font(Typography.font(for: .body, family: .quicksandBold))
}
.foregroundColor(.themeTextMessageMain)
.padding(.vertical, 12)
.padding(.vertical, 16)
.padding(.horizontal, 30)
.background(Color.themePrimary)
.cornerRadius(20)
.cornerRadius(32)
}
.padding(.top, 16)
//

View File

@ -196,27 +196,27 @@ struct UserProfileModal: View {
}
.buttonStyle(PlainButtonStyle())
// Box
// Button(action: {
// Router.shared.navigate(to: .mediaUpload)
// }) {
// HStack(spacing: 16) {
// SVGImage(svgName: "Box")
// .foregroundColor(.orange)
// .frame(width: 20, height: 20)
//Box
Button(action: {
Router.shared.navigate(to: .blindList)
}) {
HStack(spacing: 16) {
SVGImage(svgName: "Box")
.foregroundColor(.orange)
.frame(width: 20, height: 20)
// Text("My Blind Box")
// .font(Typography.font(for: .body))
// .fontWeight(.bold)
// .foregroundColor(.themeTextMessageMain)
Text("My Blind Box")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.foregroundColor(.themeTextMessageMain)
// Spacer()
// }
// .padding()
// .cornerRadius(10)
// .contentShape(Rectangle())
// }
// .buttonStyle(PlainButtonStyle())
Spacer()
}
.padding()
.cornerRadius(10)
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
// setting
Button(action: {

View File

@ -39,7 +39,7 @@ struct CreditsInfoCard: View {
// MARK: -
private var mainCreditsSection: some View {
HStack(spacing: Theme.Spacing.md) {
HStack(spacing: Theme.Spacing.sm) {
//
HStack(spacing: Theme.Spacing.sm) {
Text("Credits:")

View File

@ -25,7 +25,7 @@ struct AboutUsView: View {
VStack(spacing: 0) {
// IP Address Section
VStack(spacing: 12) {
SVGImage(svgName: "AboutIP")
SVGImageHtml(svgName: "AboutIP")
.frame(width: 102, height: 102)
}
.frame(maxWidth: .infinity)
@ -33,11 +33,11 @@ struct AboutUsView: View {
// Version & ICP Info
VStack(spacing: 12) {
Text("Version : 1.1.1")
Text("Version : 2.0.0")
.font(.system(size: 12))
.foregroundColor(.themeTextMessageMain)
Text("ICP 备案号: 京ICP备XXXXXXXX号")
Text("ICP 备案号: 沪ICP备2025133004号-2A")
.font(.system(size: 12))
.foregroundColor(.themeTextMessageMain)
}

View File

@ -121,7 +121,7 @@ struct AccountView: View {
}) {
Text("Confirm")
.foregroundColor(.themeTextWhite)
.foregroundColor(.themeTextMessage)
.font(.system(size: 12))
.frame(maxWidth: .infinity)
.padding()

View File

@ -0,0 +1,483 @@
import SwiftUI
import AVKit
import WaterfallGrid
// MARK: - API Response Models for BlindList
struct BlindListMaterialResponse: Decodable {
let code: Int
let data: [BlindBoxItem]
}
struct BlindBoxItem: Identifiable, Decodable {
let id: Int64
let boxCode: String
let userId: Int64
let name: String
let boxType: String
let features: String?
let resultFile: FileInfo?
let status: String
let workflowInstanceId: Int64?
let videoGenerateTime: String?
let createTime: String
let coverFile: FileInfo?
let description: String
struct FileInfo: Decodable {
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
}
}
enum CodingKeys: String, CodingKey {
case id
case boxCode = "box_code"
case userId = "user_id"
case name
case boxType = "box_type"
case features
case resultFile = "result_file"
case status
case workflowInstanceId = "workflow_instance_id"
case videoGenerateTime = "video_generate_time"
case createTime = "create_time"
case coverFile = "cover_file"
case description
}
}
enum BlindListMemoryMediaType: Equatable {
case image(String)
case video(url: String, previewUrl: String)
var isVideo: Bool {
if case .video = self { return true }
return false
}
var url: String {
switch self {
case .image(let url):
return url
case .video(let url, _):
return url
}
}
}
// MARK: - View Models
struct BlindListFileInfo {
let id: String
let fileName: String
let url: String
}
struct BlindListMemoryItem: Identifiable {
let id: String
let name: String
let description: String
let fileInfo: BlindListFileInfo
let previewFileInfo: BlindListFileInfo
var title: String { name }
var subtitle: String { description }
var mediaType: BlindListMemoryMediaType {
// Determine media type based on file extension or other criteria
// For now, default to image
return .image(fileInfo.url)
}
var aspectRatio: CGFloat { 1.0 }
}
struct BlindListView: View {
@Environment(\.dismiss) private var dismiss
@State private var memories: [BlindListMemoryItem] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var selectedMemory: BlindListMemoryItem? = nil
let columns = [
GridItem(.flexible(), spacing: 1),
GridItem(.flexible(), spacing: 1)
]
var body: some View {
NavigationView {
ZStack {
VStack(spacing: 0) {
//
HStack {
Button(action: {
self.dismiss()
}) {
Image(systemName: "chevron.left")
.foregroundColor(.themeTextMessageMain)
.font(.system(size: 20))
}
Spacer()
Text("我的盲盒")
.foregroundColor(.themeTextMessageMain)
.font(Typography.font(for: .body, family: .quicksandBold))
Spacer()
}
.padding()
.background(Color.themeTextWhiteSecondary)
//
ZStack {
Color.themeTextWhiteSecondary.ignoresSafeArea()
ScrollView {
WaterfallGrid(memories) { memory in
BlindListMemoryCard(memory: memory)
.onTapGesture {
withAnimation(.spring()) {
selectedMemory = memory
}
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
}
}
//
if let memory = selectedMemory {
BlindListFullScreenMediaView(memory: memory, isPresented: $selectedMemory)
.transition(.opacity)
.zIndex(1)
}
}
}
.navigationBarBackButtonHidden(true)
.onAppear {
fetchList()
}
}
private func fetchList() {
isLoading = true
errorMessage = nil
NetworkService.shared.get(path: "/blind_boxs/query", parameters: nil) { [self] (result: Result<BlindListMaterialResponse, NetworkError>) in
DispatchQueue.main.async { [self] in
self.isLoading = false
switch result {
case .success(let response):
print("✅ Successfully fetched \(response.data.count) blind box items")
// Convert BlindBoxItem to BlindListMemoryItem
self.memories = response.data
.filter { $0.status == "Opened" }
.map { item in
BlindListMemoryItem(
id: String(item.id),
name: item.name,
description: item.description,
fileInfo: BlindListFileInfo(
id: item.resultFile?.id ?? "",
fileName: item.resultFile?.fileName ?? "",
url: item.resultFile?.url ?? ""
),
previewFileInfo: BlindListFileInfo(
id: item.coverFile?.id ?? "",
fileName: item.coverFile?.fileName ?? "",
url: item.coverFile?.url ?? ""
)
)
}
case .failure(let error):
self.errorMessage = error.localizedDescription
print("❌ Failed to fetch blind box items: \(error.localizedDescription)")
}
}
}
}
}
struct BlindListFullScreenMediaView: View {
let memory: BlindListMemoryItem
@Binding var isPresented: BlindListMemoryItem?
@State private var isVideoPlaying = false
@State private var showControls = true
@State private var controlsTimer: Timer? = nil
@State private var imageAspectRatio: CGFloat = 1.0
@State private var isLoading = true
private func loadAspectRatio(from url: URL) {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
height > 0 else {
imageAspectRatio = 16/9
isLoading = false
return
}
imageAspectRatio = width / height
isLoading = false
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
ZStack {
GeometryReader { geometry in
switch memory.mediaType {
case .image(let url):
if let imageURL = URL(string: url) {
AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
GeometryReader { geometry in
ZStack {
Color.black
image
.resizable()
.scaledToFit()
.frame(
width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear {
if let uiImage = image.asUIImage() {
let size = uiImage.size
imageAspectRatio = size.width / size.height
isLoading = false
}
}
case .failure(_):
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
case .empty:
ProgressView()
@unknown default:
EmptyView()
}
}
}
case .video(_, let previewUrl):
GeometryReader { geometry in
ZStack {
Color.clear
BlindListVideoPlayer(url: memory.mediaType.url, isPlaying: $isVideoPlaying)
.aspectRatio(imageAspectRatio, contentMode: .fit)
.frame(
width: min(geometry.size.width, geometry.size.height * imageAspectRatio),
height: min(geometry.size.height, geometry.size.width / imageAspectRatio)
)
.onAppear {
if let previewUrl = URL(string: previewUrl) {
loadAspectRatio(from: previewUrl)
}
isVideoPlaying = true
}
.onDisappear {
isVideoPlaying = false
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
}
VStack {
HStack {
Button(action: {
withAnimation(.spring()) {
isPresented = nil
}
}) {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
.padding(12)
.background(Circle().fill(Color.black.opacity(0.4)))
}
.padding(.leading, 16)
.padding(.top, UIApplication.shared.windows.first?.safeAreaInsets.top ?? 0)
Spacer()
}
Spacer()
}
.zIndex(2)
}
.ignoresSafeArea()
.statusBar(hidden: true)
}
.onTapGesture {
if case .video = memory.mediaType {
withAnimation(.easeInOut) {
showControls.toggle()
}
}
}
.statusBar(hidden: true)
.onAppear {
UIApplication.shared.isIdleTimerDisabled = true
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
controlsTimer?.invalidate()
}
}
}
struct BlindListVideoPlayer: UIViewControllerRepresentable {
let url: String
@Binding var isPlaying: Bool
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
let player = AVPlayer(url: URL(string: url)!)
controller.player = player
controller.showsPlaybackControls = true
controller.videoGravity = .resizeAspect
controller.view.backgroundColor = .clear
controller.view.isOpaque = false
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
if isPlaying {
uiViewController.player?.play()
} else {
uiViewController.player?.pause()
}
}
}
struct BlindListMemoryCard: View {
let memory: BlindListMemoryItem
@State private var aspectRatio: CGFloat = 1.0
@State private var isLoading = true
private func loadAspectRatio(from url: URL) {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat,
height > 0 else {
aspectRatio = 16/9
isLoading = false
return
}
aspectRatio = width / height
isLoading = false
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ZStack {
Group {
switch memory.mediaType {
case .image(let url):
if let url = URL(string: url) {
AsyncImage(url: url) { phase in
Group {
if let image = phase.image {
GeometryReader { geometry in
ZStack {
Color.black
image
.resizable()
.scaledToFit()
.frame(
width: min(geometry.size.width, geometry.size.height * aspectRatio),
height: min(geometry.size.height, geometry.size.width / aspectRatio)
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(aspectRatio, contentMode: aspectRatio > 1 ? .fit : .fill)
.onAppear {
if let uiImage = image.asUIImage() {
let size = uiImage.size
aspectRatio = size.width / size.height
isLoading = false
}
}
} else if phase.error != nil {
Color.gray.opacity(0.3)
} else {
ProgressView()
}
}
}
}
case .video(_, let previewUrl):
if let previewUrl = URL(string: previewUrl) {
AsyncImage(url: previewUrl) { phase in
Group {
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.onAppear {
loadAspectRatio(from: previewUrl)
}
} else if phase.error != nil {
Color.gray.opacity(0.3)
} else {
ProgressView()
}
}
}
} else {
Color.gray.opacity(0.3)
}
}
}
.frame(
width: (UIScreen.main.bounds.width / 2) - 24,
height: (UIScreen.main.bounds.width / 2 - 24) / (isLoading ? 1 : aspectRatio)
)
.clipped()
.cornerRadius(12)
if case .video = memory.mediaType {
Image(systemName: "play.circle.fill")
.font(.system(size: 40))
.foregroundColor(.white.opacity(0.9))
.shadow(radius: 3)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(memory.title)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.themeTextMessageMain)
.lineLimit(1)
Text(memory.subtitle)
.font(.system(size: 14))
.foregroundColor(.themeTextMessageMain)
.lineLimit(2)
}
.padding(.horizontal, 2)
.padding(.bottom, 8)
}
}
}

View File

@ -26,18 +26,24 @@ struct PermissionManagementView: View {
// 1.
PermissionRow(
title: "Gallery Permissions",
isEnabled: photoLibraryStatus == .authorized
) {
requestPhotoLibraryPermission() //
}
isEnabled: photoLibraryStatus == .authorized,
action: {
requestPhotoLibraryPermission()
},
openSettings: openAppSettings
)
.background(Color.white)
// 2.
PermissionRow(
title: "Notification Permissions",
isEnabled: notificationStatus == .authorized
) {
requestNotificationPermission() //
}
isEnabled: notificationStatus == .authorized,
action: {
requestNotificationPermission()
},
openSettings: openAppSettings
)
.background(Color.white)
}
.background(Color.white)
.cornerRadius(16)
@ -120,9 +126,11 @@ struct PermissionManagementView: View {
///
private func openAppSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
guard let settingsUrl = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(settingsUrl) else {
return
}
UIApplication.shared.open(settingsUrl)
}
}
@ -131,6 +139,14 @@ struct PermissionRow: View {
let title: String //
let isEnabled: Bool //
let action: () -> Void //
let openSettings: () -> Void //
init(title: String, isEnabled: Bool, action: @escaping () -> Void, openSettings: @escaping () -> Void) {
self.title = title
self.isEnabled = isEnabled
self.action = action
self.openSettings = openSettings
}
var body: some View {
Button(action: action) {
@ -143,29 +159,27 @@ struct PermissionRow: View {
Spacer()
//
Toggle("", isOn: .constant(isEnabled))
.labelsHidden()
.tint(Color.themePrimary)
.disabled(true)
.onAppear {
// 使
let themeColor = UIColor(Color.themePrimary)
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).onTintColor = themeColor
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).thumbTintColor = .white
// 使
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).backgroundColor = UIColor.clear
Toggle("", isOn: .init(
get: { isEnabled },
set: { newValue in
//
openSettings()
}
))
.labelsHidden()
.tint(Color.themePrimary)
.onAppear {
// 使
let themeColor = UIColor(Color.themePrimary)
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).onTintColor = themeColor
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).thumbTintColor = .white
// 使
UISwitch.appearance(whenContainedInInstancesOf: [UIView.self]).backgroundColor = UIColor.clear
}
}
.padding()
}
.buttonStyle(PlainButtonStyle())
//
.overlay(
Rectangle()
.frame(height: 1)
.foregroundColor(Color(.systemGray6)),
alignment: .bottom
)
}
}

View File

@ -60,7 +60,11 @@ struct SettingsView: View {
settingRow(
icon: "Suport",
title: "Support & Service",
action: {}
action: {
if let url = URL(string: "https://work.weixin.qq.com/kfid/kfca0ac87f4e05e8bfd") {
UIApplication.shared.open(url)
}
}
)
//

View File

@ -116,7 +116,7 @@ struct UserInfo: View {
// Content VStack
VStack(spacing: 20) {
// Title
Text(showUsername ? "Add Your Avatar" : "What's Your Name?")
Text(showUsername ? "What's Your Name?" : "Add Your Avatar")
.font(Typography.font(for: .body, family: .quicksandBold))
.frame(maxWidth: .infinity, alignment: .center)

View File

@ -97,6 +97,9 @@ struct SubscriptionStatusBar: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
.frame(height: height)
.onTapGesture {
onSubscribeTap?()
}
}
// MARK: -

View File

@ -94,7 +94,7 @@ struct SubscribeView: View {
//
legalLinks
Spacer(minLength: 100)
Spacer(minLength: 10)
}
}
.background(Theme.Colors.background)

View File

@ -328,7 +328,10 @@ struct MediaUploadView: View {
"preview_file_id": result.thumbnailId ?? result.fileId
]
}
let filesID = uploadResults.map { (_, result) -> String in
return result.fileId
}
// POST/material
NetworkService.shared.postWithToken(
path: "/material",
@ -337,9 +340,9 @@ struct MediaUploadView: View {
switch result {
case .success:
print("✅ 素材提交成功")
//
//
DispatchQueue.main.async {
Router.shared.navigate(to: .blindBox(mediaType: .video))
Router.shared.navigate(to: .blindBox(mediaType: .video, ids: filesID))
}
case .failure(let error):
print("❌ 素材提交失败: \(error.localizedDescription)")