7 Commits

Author SHA1 Message Date
edwinQQQ
327d4fd218 feat: 实现动态详情页及相关功能
- 在MePage和MomentListHomePage中新增动态点击事件,支持打开动态详情页。
- 创建MomentDetailPage视图,展示动态详细信息,包括用户信息、动态内容和互动按钮。
- 实现MomentDetailViewModel,管理动态详情页的状态和点赞逻辑。
- 更新MomentListItem组件,添加整体点击回调,提升用户交互体验。
- 优化背景视图组件,确保一致的视觉效果。
2025-09-26 16:49:18 +08:00
edwinQQQ
d97de8455a feat: 优化底部导航栏组件及初始化逻辑
- 在CommonComponents中为BottomTabBar添加了便捷初始化和最简初始化方法,简化了外部使用。
- 新增内部默认items方法,确保底部导航栏的图标资源一致性。
- 在MainPage中更新BottomTabBar的使用方式,直接传入viewModel,提升代码可读性和维护性。
2025-09-26 15:23:33 +08:00
edwinQQQ
07265c01db feat: 更新视图组件及数据模型
- 在yanaApp中为SplashPage添加忽略安全区域的设置,确保全屏显示。
- 在DynamicsModels中更新MyMomentInfo结构,添加可选字段以兼容不同版本的服务器返回数据。
- 在CommonComponents中将LoginBackgroundView的背景图替换为蓝色,简化视图。
- 在MainPage中为内容添加忽略安全区域的设置,提升布局一致性。
- 在MePage中新增MePageViewModel,优化用户信息管理逻辑,支持动态列表的加载和错误处理。
- 在SplashPage中调整过渡动画时长,提升用户体验。
2025-09-26 14:57:34 +08:00
edwinQQQ
6b960f53b4 feat: 更新Splash视图及登录模型逻辑
- 将SplashV2替换为SplashPage,优化应用启动流程。
- 在LoginModels中将用户ID参数更改为加密后的ID,增强安全性。
- 更新AppConfig中的API基础URL,确保与生产环境一致。
- 在CommonComponents中添加底部Tab栏图标映射逻辑,提升用户体验。
- 新增NineGridImagePicker组件,支持多图选择功能,优化CreateFeedPage的图片选择逻辑。
- 移除冗余的BottomTabView组件,简化视图结构,提升代码整洁性。
- 在MainPage中整合创建动态页面的逻辑,优化导航体验。
- 新增MePage视图,提供用户信息管理功能,增强应用的可用性。
2025-09-26 10:53:00 +08:00
edwinQQQ
90a840c5f3 feat: 更新日志级别管理及底部导航栏组件化
- 在ContentView中根据编译模式初始化日志级别,确保调试信息的灵活性。
- 在APILogger中使用actor封装日志级别,增强并发安全性。
- 新增通用底部Tab栏组件,优化MainPage中的底部导航逻辑,提升代码可维护性。
- 移除冗余的AppRootView和MainView,简化视图结构,提升代码整洁性。
2025-09-18 16:12:18 +08:00
edwinQQQ
8b4eb9cb7e feat: 更新API相关逻辑及视图结构
- 在Info.plist中新增API签名密钥配置。
- 将Splash视图替换为SplashV2,优化启动逻辑和用户体验。
- 更新API请求中的User-Agent逻辑,使用UserAgentProvider提供的动态值。
- 在APILogger中添加敏感信息脱敏处理,增强安全性。
- 新增CreateFeedPage视图,支持用户发布动态功能。
- 更新MainPage和Splash视图的导航逻辑,整合统一的AppRoute管理。
- 移除冗余的SplashFeature视图,提升代码整洁性和可维护性。
2025-09-17 16:37:21 +08:00
edwinQQQ
c57bde4525 feat: 优化AppDelegate启动逻辑
- 修改application(_:didFinishLaunchingWithOptions:)方法,确保应用启动时不阻塞主线程。
- 使用Task异步预加载用户信息缓存,提升启动性能。
- 添加调试信息以便于监控应用启动过程。
2025-09-15 22:43:53 +08:00
38 changed files with 2032 additions and 1291 deletions

View File

@@ -16,7 +16,7 @@
| 环境 | 地址 | 说明 |
|------|------|------|
| 生产环境 | `https://api.epartylive.com` | 正式服务器 |
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
| 测试环境 | `http://beta.api.pekolive.com` | 开发测试服务器 |
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
**环境切换机制:**

View File

@@ -102,7 +102,7 @@ struct APIConfiguration {
"Accept-Encoding": "gzip, br",
"Accept-Language": Locale.current.language.languageCode?.identifier ?? "en",
"App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
"User-Agent": "YuMi/20.20.61 (iPhone; iOS 17+; Scale/2.00)"
"User-Agent": await UserAgentProvider.userAgent()
]
// headers
let authStatus = await UserInfoManager.checkAuthenticationStatus()

View File

@@ -1,7 +1,6 @@
import Foundation
// MARK: - API Logger
@MainActor
class APILogger {
enum LogLevel {
case none
@@ -9,11 +8,19 @@ class APILogger {
case detailed
}
#if DEBUG
static var logLevel: LogLevel = .detailed
#else
static var logLevel: LogLevel = .none
#endif
// 使 actor
actor Config {
static let shared = Config()
#if DEBUG
private var level: LogLevel = .detailed
#else
private var level: LogLevel = .none
#endif
func get() -> LogLevel { level }
func set(_ newLevel: LogLevel) { level = newLevel }
}
private static let logQueue = DispatchQueue(label: "com.yana.api.logger", qos: .utility)
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
@@ -21,198 +28,259 @@ class APILogger {
return formatter
}()
// MARK: - Redaction
///
private static let sensitiveKeys: Set<String> = [
"authorization", "token", "access-token", "access_token", "refresh-token", "refresh_token",
"password", "passwd", "secret", "pub_ticket", "ticket", "set-cookie", "cookie"
]
///
private static func maskString(_ value: String, keepPrefix: Int = 3, keepSuffix: Int = 2) -> String {
guard !value.isEmpty else { return value }
if value.count <= keepPrefix + keepSuffix { return String(repeating: "*", count: value.count) }
let start = value.startIndex
let prefixEnd = value.index(start, offsetBy: keepPrefix)
let suffixStart = value.index(value.endIndex, offsetBy: -keepSuffix)
let prefix = value[start..<prefixEnd]
let suffix = value[suffixStart..<value.endIndex]
return String(prefix) + String(repeating: "*", count: max(0, value.distance(from: prefixEnd, to: suffixStart))) + String(suffix)
}
/// headers
private static func maskHeaders(_ headers: [String: String]) -> [String: String] {
var masked: [String: String] = [:]
for (key, value) in headers {
if sensitiveKeys.contains(key.lowercased()) {
masked[key] = maskString(value)
} else {
masked[key] = value
}
}
return masked
}
/// JSON
private static func redactJSONObject(_ obj: Any) -> Any {
if let dict = obj as? [String: Any] {
var newDict: [String: Any] = [:]
for (k, v) in dict {
if sensitiveKeys.contains(k.lowercased()) {
if let str = v as? String { newDict[k] = maskString(str) }
else { newDict[k] = "<redacted>" }
} else {
newDict[k] = redactJSONObject(v)
}
}
return newDict
} else if let arr = obj as? [Any] {
return arr.map { redactJSONObject($0) }
} else {
return obj
}
}
/// Data Pretty JSON
private static func maskedBodyString(from body: Data?) -> String {
guard let body = body, !body.isEmpty else { return "No body" }
if let json = try? JSONSerialization.jsonObject(with: body, options: []) {
let redacted = redactJSONObject(json)
if let pretty = try? JSONSerialization.data(withJSONObject: redacted, options: [.prettyPrinted]),
let prettyString = String(data: pretty, encoding: .utf8) {
return prettyString
}
}
return "<non-json body> (\(body.count) bytes)"
}
// MARK: - Request Logging
@MainActor static func logRequest<T: APIRequestProtocol>(
static func logRequest<T: APIRequestProtocol>(
_ request: T,
url: URL,
body: Data?,
finalHeaders: [String: String]? = nil
) {
#if DEBUG
guard logLevel != .none else { return }
#else
#if !DEBUG
return
#endif
#else
Task {
let level = await Config.shared.get()
guard level != .none else { return }
logQueue.async {
let timestamp = dateFormatter.string(from: Date())
debugInfoSync("\n🚀 [API Request] [\(timestamp)] ==================")
debugInfoSync("📍 Endpoint: \(request.endpoint)")
debugInfoSync("🔗 Full URL: \(url.absoluteString)")
debugInfoSync("📝 Method: \(request.method.rawValue)")
debugInfoSync("⏰ Timeout: \(request.timeout)s")
let timestamp = dateFormatter.string(from: Date())
print("\n🚀 [API Request] [\(timestamp)] ==================")
print("📍 Endpoint: \(request.endpoint)")
print("🔗 Full URL: \(url.absoluteString)")
print("📝 Method: \(request.method.rawValue)")
print("⏰ Timeout: \(request.timeout)s")
// headers headers headers
if let headers = finalHeaders, !headers.isEmpty {
if logLevel == .detailed {
print("📋 Final Headers (包括默认 + 自定义):")
for (key, value) in headers.sorted(by: { $0.key < $1.key }) {
print(" \(key): \(value)")
}
} else if logLevel == .basic {
print("📋 Headers: \(headers.count) 个 headers")
// headers
let importantHeaders = ["Content-Type", "Accept", "User-Agent", "Authorization"]
for key in importantHeaders {
if let value = headers[key] {
print(" \(key): \(value)")
// headers headers headers
if let headers = finalHeaders, !headers.isEmpty {
if level == .detailed {
debugInfoSync("📋 Final Headers (包括默认 + 自定义):")
let masked = maskHeaders(headers)
for (key, value) in masked.sorted(by: { $0.key < $1.key }) {
debugInfoSync(" \(key): \(value)")
}
} else if level == .basic {
debugInfoSync("📋 Headers: \(headers.count) 个 headers")
// headers
let importantHeaders = ["Content-Type", "Accept", "User-Agent", "Authorization"]
let masked = maskHeaders(headers)
for key in importantHeaders {
if let value = masked[key] {
debugInfoSync(" \(key): \(value)")
}
}
}
}
} else if let customHeaders = request.headers, !customHeaders.isEmpty {
print("📋 Custom Headers:")
for (key, value) in customHeaders.sorted(by: { $0.key < $1.key }) {
print(" \(key): \(value)")
}
} else {
print("📋 Headers: 使用默认 headers")
}
if let queryParams = request.queryParameters, !queryParams.isEmpty {
print("🔍 Query Parameters:")
for (key, value) in queryParams.sorted(by: { $0.key < $1.key }) {
print(" \(key): \(value)")
}
}
if logLevel == .detailed {
if let body = body {
print("📦 Request Body (\(body.count) bytes):")
if let jsonObject = try? JSONSerialization.jsonObject(with: body, options: []),
let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted),
let prettyString = String(data: prettyData, encoding: .utf8) {
print(prettyString)
} else if let rawString = String(data: body, encoding: .utf8) {
print(rawString)
} else {
print("Binary data")
} else if let customHeaders = request.headers, !customHeaders.isEmpty {
debugInfoSync("📋 Custom Headers:")
let masked = maskHeaders(customHeaders)
for (key, value) in masked.sorted(by: { $0.key < $1.key }) {
debugInfoSync(" \(key): \(value)")
}
} else {
print("📦 Request Body: No body")
debugInfoSync("📋 Headers: 使用默认 headers")
}
//
if request.includeBaseParameters {
print("📱 Base Parameters: 自动注入设备和应用信息")
let baseParams = BaseRequest()
print(" Device: \(baseParams.model), OS: \(baseParams.os) \(baseParams.osVersion)")
print(" App: \(baseParams.app) v\(baseParams.appVersion)")
print(" Language: \(baseParams.acceptLanguage)")
}
} else if logLevel == .basic {
if let body = body {
print("📦 Request Body: \(formatBytes(body.count))")
} else {
print("📦 Request Body: No body")
if let queryParams = request.queryParameters, !queryParams.isEmpty {
debugInfoSync("🔍 Query Parameters:")
for (key, value) in queryParams.sorted(by: { $0.key < $1.key }) {
let masked = sensitiveKeys.contains(key.lowercased()) ? maskString(value) : value
debugInfoSync(" \(key): \(masked)")
}
}
//
if request.includeBaseParameters {
print("📱 Base Parameters: 已自动注入")
if level == .detailed {
let pretty = maskedBodyString(from: body)
debugInfoSync("📦 Request Body: \n\(pretty)")
// actor UIKit
if request.includeBaseParameters {
debugInfoSync("📱 Base Parameters: 已自动注入")
}
} else if level == .basic {
let size = body?.count ?? 0
debugInfoSync("📦 Request Body: \(formatBytes(size))")
//
if request.includeBaseParameters {
debugInfoSync("📱 Base Parameters: 已自动注入")
}
}
debugInfoSync("=====================================")
}
}
print("=====================================")
#endif
}
// MARK: - Response Logging
static func logResponse(data: Data, response: HTTPURLResponse, duration: TimeInterval) {
#if DEBUG
guard logLevel != .none else { return }
#else
#if !DEBUG
return
#endif
#else
Task {
let level = await Config.shared.get()
guard level != .none else { return }
logQueue.async {
let timestamp = dateFormatter.string(from: Date())
let statusEmoji = response.statusCode < 400 ? "" : ""
debugInfoSync("\n\(statusEmoji) [API Response] [\(timestamp)] ===================")
debugInfoSync("⏱️ Duration: \(String(format: "%.3f", duration))s")
debugInfoSync("📊 Status Code: \(response.statusCode)")
debugInfoSync("🔗 URL: \(response.url?.absoluteString ?? "Unknown")")
debugInfoSync("📏 Data Size: \(formatBytes(data.count))")
let timestamp = dateFormatter.string(from: Date())
let statusEmoji = response.statusCode < 400 ? "" : ""
if level == .detailed {
debugInfoSync("📋 Response Headers:")
// headers [String:String]
var headers: [String: String] = [:]
for (k, v) in response.allHeaderFields { headers["\(k)"] = "\(v)" }
let masked = maskHeaders(headers)
for (key, value) in masked.sorted(by: { $0.key < $1.key }) {
debugInfoSync(" \(key): \(value)")
}
print("\n\(statusEmoji) [API Response] [\(timestamp)] ===================")
print("⏱️ Duration: \(String(format: "%.3f", duration))s")
print("📊 Status Code: \(response.statusCode)")
print("🔗 URL: \(response.url?.absoluteString ?? "Unknown")")
print("📏 Data Size: \(formatBytes(data.count))")
if logLevel == .detailed {
print("📋 Response Headers:")
for (key, value) in response.allHeaderFields.sorted(by: { "\($0.key)" < "\($1.key)" }) {
print(" \(key): \(value)")
debugInfoSync("📦 Response Data:")
if data.isEmpty {
debugInfoSync(" Empty response")
} else if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []),
let prettyData = try? JSONSerialization.data(withJSONObject: redactJSONObject(jsonObject), options: .prettyPrinted),
let prettyString = String(data: prettyData, encoding: .utf8) {
debugInfoSync(prettyString)
} else if let _ = String(data: data, encoding: .utf8) {
// JSON
debugInfoSync("<non-json text> (\(data.count) bytes)")
} else {
debugInfoSync(" Binary data (\(data.count) bytes)")
}
}
print("📦 Response Data:")
if data.isEmpty {
print(" Empty response")
} else if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []),
let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted),
let prettyString = String(data: prettyData, encoding: .utf8) {
print(prettyString)
} else if let rawString = String(data: data, encoding: .utf8) {
print(rawString)
} else {
print(" Binary data (\(data.count) bytes)")
debugInfoSync("=====================================")
}
}
print("=====================================")
#endif
}
// MARK: - Error Logging
static func logError(_ error: Error, url: URL?, duration: TimeInterval) {
#if DEBUG
guard logLevel != .none else { return }
#else
#if !DEBUG
return
#endif
let timestamp = dateFormatter.string(from: Date())
print("\n❌ [API Error] [\(timestamp)] ======================")
print("⏱️ Duration: \(String(format: "%.3f", duration))s")
if let url = url {
print("🔗 URL: \(url.absoluteString)")
}
if let apiError = error as? APIError {
print("🚨 API Error: \(apiError.localizedDescription)")
} else {
print("🚨 System Error: \(error.localizedDescription)")
}
if logLevel == .detailed {
if let urlError = error as? URLError {
print("🔍 URLError Code: \(urlError.code.rawValue)")
print("🔍 URLError Localized: \(urlError.localizedDescription)")
//
switch urlError.code {
case .timedOut:
print("💡 建议:检查网络连接或增加超时时间")
case .notConnectedToInternet:
print("💡 建议:检查网络连接")
case .cannotConnectToHost:
print("💡 建议:检查服务器地址和端口")
case .resourceUnavailable:
print("💡 建议:检查 API 端点是否正确")
default:
break
}
#else
Task {
let level = await Config.shared.get()
guard level != .none else { return }
logQueue.async {
let timestamp = dateFormatter.string(from: Date())
debugErrorSync("\n❌ [API Error] [\(timestamp)] ======================")
debugErrorSync("⏱️ Duration: \(String(format: "%.3f", duration))s")
if let url = url {
debugErrorSync("🔗 URL: \(url.absoluteString)")
}
print("🔍 Full Error: \(error)")
}
print("=====================================\n")
if let apiError = error as? APIError {
debugErrorSync("🚨 API Error: \(apiError.localizedDescription)")
} else {
debugErrorSync("🚨 System Error: \(error.localizedDescription)")
}
if level == .detailed {
if let urlError = error as? URLError {
debugInfoSync("🔍 URLError Code: \(urlError.code.rawValue)")
debugInfoSync("🔍 URLError Localized: \(urlError.localizedDescription)")
//
switch urlError.code {
case .timedOut:
debugWarnSync("💡 建议:检查网络连接或增加超时时间")
case .notConnectedToInternet:
debugWarnSync("💡 建议:检查网络连接")
case .cannotConnectToHost:
debugWarnSync("💡 建议:检查服务器地址和端口")
case .resourceUnavailable:
debugWarnSync("💡 建议:检查 API 端点是否正确")
default:
break
}
}
debugInfoSync("🔍 Full Error: \(error)")
}
debugErrorSync("=====================================\n")
}
}
#endif
}
// MARK: - Decoded Response Logging
static func logDecodedResponse<T>(_ response: T, type: T.Type) {
#if DEBUG
guard logLevel == .detailed else { return }
#else
#if !DEBUG
return
#else
Task {
let level = await Config.shared.get()
guard level == .detailed else { return }
logQueue.async {
let timestamp = dateFormatter.string(from: Date())
debugInfoSync("🎯 [Decoded Response] [\(timestamp)] Type: \(type)")
debugInfoSync("=====================================\n")
}
}
#endif
let timestamp = dateFormatter.string(from: Date())
print("🎯 [Decoded Response] [\(timestamp)] Type: \(type)")
print("=====================================\n")
}
// MARK: - Helper Methods
@@ -225,16 +293,20 @@ class APILogger {
// MARK: - Performance Logging
static func logPerformanceWarning(duration: TimeInterval, threshold: TimeInterval = 5.0) {
#if DEBUG
guard logLevel != .none && duration > threshold else { return }
#else
#if !DEBUG
return
#else
Task {
let level = await Config.shared.get()
guard level != .none && duration > threshold else { return }
logQueue.async {
let timestamp = dateFormatter.string(from: Date())
debugWarnSync("\n⚠️ [Performance Warning] [\(timestamp)] ============")
debugWarnSync("🐌 Request took \(String(format: "%.3f", duration))s (threshold: \(threshold)s)")
debugWarnSync("💡 建议:检查网络条件或优化 API 响应")
debugWarnSync("================================================\n")
}
}
#endif
let timestamp = dateFormatter.string(from: Date())
print("\n⚠️ [Performance Warning] [\(timestamp)] ============")
print("🐌 Request took \(String(format: "%.3f", duration))s (threshold: \(threshold)s)")
print("💡 建议:检查网络条件或优化 API 响应")
print("================================================\n")
}
}

View File

@@ -1,5 +1,4 @@
import Foundation
import ComposableArchitecture
// MARK: - HTTP Method
@@ -205,8 +204,9 @@ struct BaseRequest: Codable {
"\(key)=\(String(describing: filteredParams[key] ?? ""))"
}.joined(separator: "&")
// 4.
let keyString = "key=rpbs6us1m8r2j9g6u06ff2bo18orwaya"
// 4.
let key = SigningKeyProvider.signingKey()
let keyString = "key=\(key)"
let finalString = paramString.isEmpty ? keyString : "\(paramString)&\(keyString)"
// 5. MD5
@@ -217,9 +217,8 @@ struct BaseRequest: Codable {
// MARK: - Network Type Detector
struct NetworkTypeDetector {
static func getCurrentNetworkType() -> Int {
// WiFi = 2, = 1
//
return 2 //
// WiFi = 2, = 1, / = 0
return NetworkMonitor.shared.currentType
}
}
@@ -238,7 +237,6 @@ struct CarrierInfoManager {
// MARK: - User Info Manager (for Headers)
struct UserInfoManager {
@MainActor
private static let keychain = KeychainManager.shared
// MARK: - Storage Keys
@@ -287,7 +285,7 @@ struct UserInfoManager {
// MARK: - User Info Management
static func saveUserInfo(_ userInfo: UserInfo) async {
do {
try await keychain.store(userInfo, forKey: StorageKeys.userInfo)
try keychain.store(userInfo, forKey: StorageKeys.userInfo)
await cacheActor.setUserInfo(userInfo)
debugInfoSync("💾 保存用户信息成功")
} catch {
@@ -302,7 +300,7 @@ struct UserInfoManager {
}
// Keychain
do {
let userInfo = try await keychain.retrieve(UserInfo.self, forKey: StorageKeys.userInfo)
let userInfo = try keychain.retrieve(UserInfo.self, forKey: StorageKeys.userInfo)
await cacheActor.setUserInfo(userInfo)
return userInfo
} catch {
@@ -377,7 +375,7 @@ struct UserInfoManager {
/// - Parameter accountModel:
static func saveAccountModel(_ accountModel: AccountModel) async {
do {
try await keychain.store(accountModel, forKey: StorageKeys.accountModel)
try keychain.store(accountModel, forKey: StorageKeys.accountModel)
await cacheActor.setAccountModel(accountModel)
// ticket
@@ -400,7 +398,7 @@ struct UserInfoManager {
}
// Keychain
do {
let accountModel = try await keychain.retrieve(
let accountModel = try keychain.retrieve(
AccountModel.self,
forKey: StorageKeys.accountModel
)
@@ -448,7 +446,7 @@ struct UserInfoManager {
/// AccountModel
static func clearAccountModel() async {
do {
try await keychain.delete(forKey: StorageKeys.accountModel)
try keychain.delete(forKey: StorageKeys.accountModel)
await cacheActor.clearAccountModel()
debugInfoSync("🗑️ AccountModel 已清除")
} catch {
@@ -459,7 +457,7 @@ struct UserInfoManager {
///
static func clearUserInfo() async {
do {
try await keychain.delete(forKey: StorageKeys.userInfo)
try keychain.delete(forKey: StorageKeys.userInfo)
await cacheActor.clearUserInfo()
debugInfoSync("🗑️ UserInfo 已清除")
} catch {

View File

@@ -1,5 +1,4 @@
import Foundation
import ComposableArchitecture
// MARK: - API Service Protocol
@@ -136,10 +135,7 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
requestBody = try JSONSerialization.data(withJSONObject: finalBody, options: [])
urlRequest.httpBody = requestBody
// urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
if let httpBody = urlRequest.httpBody,
let bodyString = String(data: httpBody, encoding: .utf8) {
debugInfoSync("HTTP Body: \(bodyString)")
}
// HTTP Body APILogger
} catch {
let encodingError = APIError.decodingError("请求体编码失败: \(error.localizedDescription)")
await APILoadingManager.shared.setError(loadingId, errorMessage: encodingError.localizedDescription)
@@ -148,8 +144,7 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
}
// headers
await APILogger
.logRequest(request, url: url, body: requestBody, finalHeaders: headers)
APILogger.logRequest(request, url: url, body: requestBody, finalHeaders: headers)
do {
//
@@ -165,18 +160,16 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
//
if data.count > APIConfiguration.maxDataSize {
await APILogger
.logError(APIError.resourceTooLarge, url: url, duration: duration)
APILogger.logError(APIError.resourceTooLarge, url: url, duration: duration)
await APILoadingManager.shared.setError(loadingId, errorMessage: APIError.resourceTooLarge.localizedDescription)
throw APIError.resourceTooLarge
}
//
await APILogger
.logResponse(data: data, response: httpResponse, duration: duration)
APILogger.logResponse(data: data, response: httpResponse, duration: duration)
//
await APILogger.logPerformanceWarning(duration: duration)
APILogger.logPerformanceWarning(duration: duration)
// HTTP
guard 200...299 ~= httpResponse.statusCode else {
@@ -196,7 +189,7 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
do {
let decoder = JSONDecoder()
let decodedResponse = try decoder.decode(T.Response.self, from: data)
await APILogger.logDecodedResponse(decodedResponse, type: T.Response.self)
APILogger.logDecodedResponse(decodedResponse, type: T.Response.self)
// loading
await APILoadingManager.shared.finishLoading(loadingId)
@@ -210,13 +203,13 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
} catch let error as APIError {
let duration = Date().timeIntervalSince(startTime)
await APILogger.logError(error, url: url, duration: duration)
APILogger.logError(error, url: url, duration: duration)
await APILoadingManager.shared.setError(loadingId, errorMessage: error.localizedDescription)
throw error
} catch {
let duration = Date().timeIntervalSince(startTime)
let apiError = mapSystemError(error)
await APILogger.logError(apiError, url: url, duration: duration)
APILogger.logError(apiError, url: url, duration: duration)
await APILoadingManager.shared.setError(loadingId, errorMessage: apiError.localizedDescription)
throw apiError
}
@@ -300,6 +293,14 @@ struct LiveAPIService: APIServiceProtocol, Sendable {
return error
} else if let msg = json["msg"] as? String {
return msg
} else if let detail = json["detail"] as? String {
return detail
} else if let errorDescription = json["error_description"] as? String {
return errorDescription
} else if let errorDict = json["error"] as? [String: Any], let nestedMsg = errorDict["message"] as? String {
return nestedMsg
} else if let errors = json["errors"] as? [[String: Any]], let firstMsg = errors.first? ["message"] as? String {
return firstMsg
}
return nil
@@ -349,7 +350,9 @@ actor MockAPIServiceActor: APIServiceProtocol, Sendable {
}
}
// MARK: - TCA Dependency Integration
// MARK: - TCA Dependency Integration (optional)
#if canImport(ComposableArchitecture)
import ComposableArchitecture
private enum APIServiceKey: DependencyKey {
static let liveValue: any APIServiceProtocol & Sendable = LiveAPIService()
static let testValue: any APIServiceProtocol & Sendable = MockAPIServiceActor()
@@ -361,6 +364,7 @@ extension DependencyValues {
set { self[APIServiceKey.self] = newValue }
}
}
#endif
// MARK: - BaseRequest Dictionary Conversion
extension BaseRequest {

View File

@@ -1,5 +1,4 @@
import Foundation
import ComposableArchitecture
// MARK: -
@@ -18,7 +17,7 @@ struct MomentsListData: Codable, Equatable, Sendable {
}
///
public struct MomentsInfo: Codable, Equatable, Sendable {
public struct MomentsInfo: Codable, Equatable, Sendable, Identifiable {
let dynamicId: Int
let uid: Int
let nick: String
@@ -52,6 +51,7 @@ public struct MomentsInfo: Codable, Equatable, Sendable {
let isCustomWord: Bool?
let labelList: [String]?
//
public var id: Int { dynamicId } // Identifiable
var isSquareTop: Bool { (squareTop ?? 0) != 0 }
var isTopicTop: Bool { (topicTop ?? 0) != 0 }
var formattedPublishTime: Date {
@@ -243,28 +243,40 @@ struct PublishFeedData: Codable, Equatable {
/// - /dynamic/getMyDynamic
struct MyMomentInfo: Codable, Equatable, Sendable {
let content: String
//
let dynamicId: Int?
let uid: Int
let publishTime: Int64
let nick: String?
let avatar: String?
let type: Int
let content: String
let likeCount: Int?
let isLike: Bool?
let commentCount: Int?
let publishTime: Int64
let worldId: Int?
let status: Int?
let playCount: Int?
let dynamicResList: [MomentsPicture]? // /
// MomentsInfo
func toMomentsInfo() -> MomentsInfo {
return MomentsInfo(
dynamicId: 0, // dynamicId
dynamicId: dynamicId ?? 0,
uid: uid,
nick: "", //
avatar: "", //
nick: nick ?? "",
avatar: avatar ?? "",
type: type,
content: content,
likeCount: 0, //
isLike: false, //
commentCount: 0, //
publishTime: Int(publishTime / 1000), //
worldId: 0, // worldId
status: 1, //
playCount: nil,
dynamicResList: nil,
likeCount: likeCount ?? 0,
isLike: isLike ?? false,
commentCount: commentCount ?? 0,
// UI formatDisplayTime /1000
publishTime: Int(publishTime),
worldId: worldId ?? 0,
status: status ?? 1,
playCount: playCount,
dynamicResList: dynamicResList,
gender: nil,
squareTop: nil,
topicTop: nil,

View File

@@ -392,7 +392,7 @@ struct LoginHelper {
debugInfoSync(" 加密后密码: \(encryptedPassword)")
return IDLoginAPIRequest(
phone: userID,
phone: encryptedID,
password: encryptedPassword
)
}

View File

@@ -2,12 +2,16 @@ import UIKit
//import NIMSDK
class AppDelegate: UIResponder, UIApplicationDelegate {
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
debugInfoSync("🚀 UIApplication didFinishLaunching")
//
await UserInfoManager.preloadCache()
// NIMConfigurationManager.setupNimSDK()
//
Task { @MainActor in
await UserInfoManager.preloadCache()
// IM/ SDK
// NIMConfigurationManager.setupNimSDK()
debugInfoSync("✅ App 启动预热完成")
}
return true
}

View File

@@ -5,17 +5,17 @@ enum AppEnvironment {
struct AppConfig {
static let current: AppEnvironment = {
// #if DEBUG
// return .development
// #else
#if DEBUG
return .development
#else
return .production
// #endif
#endif
}()
static var baseURL: String {
switch current {
case .development:
return "http://beta.api.molistar.xyz"
return "http://beta.api.pekolive.com"
case .production:
return "https://api.epartylive.com"
}

View File

@@ -170,7 +170,14 @@ struct ContentView: View {
let store: StoreOf<LoginFeature>
let initStore: StoreOf<InitFeature>
let configStore: StoreOf<ConfigFeature>
@State private var selectedLogLevel: APILogger.LogLevel = APILogger.logLevel
@State private var selectedLogLevel: APILogger.LogLevel = {
// APILogger.Config
#if DEBUG
return .detailed
#else
return .none
#endif
}()
@State private var selectedTab = 0
var body: some View {
@@ -188,7 +195,7 @@ struct ContentView: View {
.tag(1)
}
.onChange(of: selectedLogLevel) { _, selectedLogLevel in
APILogger.logLevel = selectedLogLevel
Task { await APILogger.Config.shared.set(selectedLogLevel) }
}
}
}

View File

@@ -1,114 +0,0 @@
import Foundation
import ComposableArchitecture
@Reducer
struct SplashFeature {
@ObservableState
struct State: Equatable {
var isLoading = true
var shouldShowMainApp = false
var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound
var isCheckingAuthentication = false
//
var navigationDestination: NavigationDestination?
init() {
//
}
}
//
enum NavigationDestination: Equatable {
case login //
case main //
}
enum Action: Equatable {
case onAppear
case splashFinished
case checkAuthentication
case authenticationChecked(UserInfoManager.AuthenticationStatus)
// actions
case fetchUserInfo
case userInfoFetched(Bool)
// actions
case navigateToLogin
case navigateToMain
}
@Dependency(\.apiService) var apiService // API
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
state.shouldShowMainApp = false
state.authenticationStatus = UserInfoManager.AuthenticationStatus.notFound
state.isCheckingAuthentication = false
state.navigationDestination = nil
// 1 (iOS 15.5+ )
return .run { send in
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 = 1,000,000,000
await send(.splashFinished)
}
case .splashFinished:
state.isLoading = false
// Splash
return .send(.checkAuthentication)
case .checkAuthentication:
state.isCheckingAuthentication = true
//
return .run { send in
let authStatus = await UserInfoManager.checkAuthenticationStatus()
await send(.authenticationChecked(authStatus))
}
case let .authenticationChecked(status):
state.isCheckingAuthentication = false
state.authenticationStatus = status
//
if status.canAutoLogin {
debugInfoSync("🎉 自动登录成功,开始获取用户信息")
//
return .send(.fetchUserInfo)
} else {
debugInfoSync("🔑 需要手动登录")
return .send(.navigateToLogin)
}
case .fetchUserInfo:
//
return .run { send in
let success = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: apiService)
await send(.userInfoFetched(success))
}
case let .userInfoFetched(success):
if success {
debugInfoSync("✅ 用户信息获取成功,进入主页")
} else {
debugInfoSync("⚠️ 用户信息获取失败,但仍进入主页")
}
return .send(.navigateToMain)
case .navigateToLogin:
state.navigationDestination = .login
return .none
case .navigateToMain:
state.navigationDestination = .main
state.shouldShowMainApp = true
return .none
}
}
}
}

View File

@@ -15,5 +15,7 @@
<array>
<string>Bayon-Regular.ttf</string>
</array>
<key>API_SIGNING_KEY</key>
<string></string>
</dict>
</plist>

View File

@@ -6,13 +6,154 @@ enum AppImageSource: Equatable {
case photoLibrary
}
// MARK: - Tab
public struct TabBarItem: Identifiable, Equatable {
public let id: String
public let title: String
public let systemIconName: String
public init(id: String, title: String, systemIconName: String) {
self.id = id
self.title = title
self.systemIconName = systemIconName
}
}
struct BottomTabBar: View {
let items: [TabBarItem]
@Binding var selectedId: String
let onSelect: (String) -> Void
var contentPadding: EdgeInsets = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0)
var horizontalPadding: CGFloat = 0
// 便 tabs
init(
selectedId: Binding<String>,
onSelect: @escaping (String) -> Void,
contentPadding: EdgeInsets = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0),
horizontalPadding: CGFloat = 0
) {
self.items = BottomTabBar.defaultItems()
self._selectedId = selectedId
self.onSelect = onSelect
self.contentPadding = contentPadding
self.horizontalPadding = horizontalPadding
}
// viewModel
init(viewModel: MainViewModel) {
self.items = BottomTabBar.defaultItems()
self._selectedId = Binding(
get: { viewModel.selectedTab.rawValue },
set: { raw in
if let tab = MainViewModel.Tab(rawValue: raw) {
viewModel.onTabChanged(tab)
}
}
)
self.onSelect = { _ in } // 使
self.contentPadding = EdgeInsets(top: 12, leading: 0, bottom: 12, trailing: 0)
self.horizontalPadding = 0
}
// 使 BottomTabView.swift
private func assetIconName(for item: TabBarItem, isSelected: Bool) -> String? {
switch item.id {
case "feed":
return isSelected ? "feed selected" : "feed unselected"
case "me":
return isSelected ? "me selected" : "me unselected"
default:
return nil
}
}
// items
private static func defaultItems() -> [TabBarItem] {
return [
TabBarItem(id: "feed", title: "Feed", systemIconName: "list.bullet"),
TabBarItem(id: "me", title: "Me", systemIconName: "person.circle")
]
}
var body: some View {
HStack(spacing: 8) {
ForEach(items) { item in
Button(action: {
selectedId = item.id
onSelect(item.id)
}) {
Group {
if let name = assetIconName(for: item, isSelected: selectedId == item.id) {
Image(name)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
} else {
Image(systemName: item.systemIconName)
.font(.system(size: 24))
.foregroundColor(selectedId == item.id ? .white : .white.opacity(0.6))
}
}
}
.frame(maxWidth: .infinity)
.padding(contentPadding)
.contentShape(Rectangle())
}
}
.padding(.horizontal, 8) // 8
.padding(.horizontal, horizontalPadding)
.background(LiquidGlassBackground())
.clipShape(Capsule())
.contentShape(Capsule())
.onTapGesture { /* 穿 */ }
.overlay(
Capsule()
.stroke(Color.white.opacity(0.12), lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.2), radius: 12, x: 0, y: 6)
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 0)
}
}
}
// MARK: - Liquid Glass Background (iOS 26 )
struct LiquidGlassBackground: View {
var body: some View {
Group {
if #available(iOS 26.0, *) {
// iOS 26+使
Rectangle()
.fill(Color.clear)
.glassEffect()
} else
if #available(iOS 17.0, *) {
// iOS 17-25使 +
ZStack {
Rectangle().fill(.ultraThinMaterial)
LinearGradient(
colors: [Color.white.opacity(0.06), Color.clear, Color.white.opacity(0.04)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.blendMode(.softLight)
}
} else {
//
Rectangle()
.fill(Color.black.opacity(0.2))
}
}
}
}
// MARK: -
struct LoginBackgroundView: View {
var body: some View {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
// .ignoresSafeArea(.all)
}
}

View File

@@ -0,0 +1,230 @@
import SwiftUI
import PhotosUI
@MainActor
final class CreateFeedViewModel: ObservableObject {
@Published var content: String = ""
@Published var selectedImages: [UIImage] = []
@Published var isPublishing: Bool = false
@Published var errorMessage: String? = nil
//
var canPublish: Bool { !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
}
struct CreateFeedPage: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = CreateFeedViewModel()
let onDismiss: () -> Void
// MARK: - UI State
@FocusState private var isTextEditorFocused: Bool
@State private var isShowingPreview: Bool = false
@State private var previewIndex: Int = 0
private let maxCharacters: Int = 500
private let gridSpacing: CGFloat = 8
private let gridCornerRadius: CGFloat = 16
var body: some View {
GeometryReader { geometry in
ZStack {
Color(hex: 0x0C0527)
.ignoresSafeArea()
.onTapGesture {
//
isTextEditorFocused = false
}
VStack(spacing: 16) {
HStack {
Button(action: {
onDismiss()
dismiss()
}) {
Image(systemName: "xmark")
.foregroundColor(.white)
.font(.system(size: 18, weight: .medium))
.frame(width: 44, height: 44, alignment: .center)
.contentShape(Rectangle())
}
Spacer()
Text(LocalizedString ("createFeed.title", comment: "Image & Text Publish"))
.foregroundColor(.white)
.font(.system(size: 18, weight: .medium))
Spacer()
Button(action: publish) {
if viewModel.isPublishing {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text(LocalizedString("createFeed.publish", comment: "Publish"))
.foregroundColor(.white)
.font(.system(size: 14, weight: .medium))
}
}
.disabled(!viewModel.canPublish || viewModel.isPublishing)
.opacity((!viewModel.canPublish || viewModel.isPublishing) ? 0.6 : 1)
}
.padding(.horizontal, 16)
.padding(.top, 12)
.contentShape(Rectangle())
.zIndex(10)
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 8).fill(Color(hex: 0x1C143A))
if viewModel.content.isEmpty {
Text(LocalizedString("createFeed.enterContent", comment: "Enter Content"))
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
TextEditor(text: $viewModel.content)
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.scrollContentBackground(.hidden)
.focused($isTextEditorFocused)
.frame(height: 200)
.zIndex(1) //
//
VStack { Spacer() }
.overlay(alignment: .bottomTrailing) {
Text("\(viewModel.content.count)/\(maxCharacters)")
.foregroundColor(.white.opacity(0.6))
.font(.system(size: 14))
.padding(.trailing, 8)
.padding(.bottom, 8)
}
}
.frame(height: 200)
.padding(.horizontal, 20)
.onChange(of: viewModel.content) { _, newValue in
//
if newValue.count > maxCharacters {
viewModel.content = String(newValue.prefix(maxCharacters))
}
}
NineGridImagePicker(
images: $viewModel.selectedImages,
maxCount: 9,
cornerRadius: gridCornerRadius,
spacing: gridSpacing,
horizontalPadding: 20,
onTapImage: { index in
previewIndex = index
isShowingPreview = true
}
)
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
.font(.system(size: 14))
}
Spacer()
}
}
}
.navigationBarBackButtonHidden(true)
.fullScreenCover(isPresented: $isShowingPreview) {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 0) {
HStack {
Spacer()
Button {
isShowingPreview = false
} label: {
Image(systemName: "xmark")
.foregroundColor(.white)
.font(.system(size: 18, weight: .medium))
.padding(12)
}
}
.padding(.top, 8)
TabView(selection: $previewIndex) {
ForEach(viewModel.selectedImages.indices, id: \.self) { idx in
ZStack {
Color.black
Image(uiImage: viewModel.selectedImages[idx])
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.tag(idx)
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
}
}
}
}
private func publish() {
viewModel.isPublishing = true
viewModel.errorMessage = nil
Task { @MainActor in
let apiService: any APIServiceProtocol & Sendable = LiveAPIService()
do {
// 1)
var resList: [ResListItem] = []
if !viewModel.selectedImages.isEmpty {
for image in viewModel.selectedImages {
if let url = await COSManager.shared.uploadUIImage(image, apiService: apiService) {
if let cg = image.cgImage {
let item = ResListItem(resUrl: url, width: cg.width, height: cg.height, format: "jpeg")
resList.append(item)
} else {
// 0
let item = ResListItem(resUrl: url, width: 0, height: 0, format: "jpeg")
resList.append(item)
}
} else {
viewModel.isPublishing = false
viewModel.errorMessage = "图片上传失败"
return
}
}
}
// 2)
let trimmed = viewModel.content.trimmingCharacters(in: .whitespacesAndNewlines)
let userId = await UserInfoManager.getCurrentUserId() ?? ""
let type = resList.isEmpty ? "0" : "2" // 0: , 2: /
let request = await PublishFeedRequest.make(
content: trimmed,
uid: userId,
type: type,
resList: resList.isEmpty ? nil : resList
)
let response = try await apiService.request(request)
// 3)
if response.code == 200 {
viewModel.isPublishing = false
NotificationCenter.default.post(name: .init("CreateFeedPublished"), object: nil)
onDismiss()
dismiss()
} else {
viewModel.isPublishing = false
viewModel.errorMessage = response.message.isEmpty ? "发布失败" : response.message
}
} catch {
viewModel.isPublishing = false
viewModel.errorMessage = error.localizedDescription
}
}
}
private func removeImage(at index: Int) {
guard viewModel.selectedImages.indices.contains(index) else { return }
viewModel.selectedImages.remove(at: index)
if isShowingPreview {
if previewIndex >= viewModel.selectedImages.count { previewIndex = max(0, viewModel.selectedImages.count - 1) }
if viewModel.selectedImages.isEmpty { isShowingPreview = false }
}
}
}

View File

@@ -16,6 +16,7 @@ class LoginViewModel: ObservableObject {
// MARK: - Callbacks
var onLoginSuccess: (() -> Void)?
private var hasSentSuccess: Bool = false
// MARK: - Public Methods
func onIDLoginTapped() {
@@ -47,22 +48,20 @@ class LoginViewModel: ObservableObject {
}
func onLoginCompleted() {
guard !hasSentSuccess else { return }
isAnyLoginCompleted = true
showIDLogin = false
showEmailLogin = false
hasSentSuccess = true
onLoginSuccess?()
}
func onBackFromIDLogin() {
showIDLogin = false
if isAnyLoginCompleted {
onLoginSuccess?()
}
}
func onBackFromEmailLogin() {
showEmailLogin = false
if isAnyLoginCompleted {
onLoginSuccess?()
}
}
}
@@ -73,78 +72,76 @@ struct LoginPage: View {
let onLoginSuccess: () -> Void
var body: some View {
NavigationStack {
GeometryReader { geometry in
ZStack {
backgroundView
GeometryReader { geometry in
ZStack {
backgroundView
VStack(spacing: 0) {
Image("top")
.resizable()
.aspectRatio(375/400, contentMode: .fit)
.frame(maxWidth: .infinity)
HStack {
Text(LocalizedString("login.app_title", comment: ""))
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
.foregroundColor(.white)
.padding(.leading, 20)
Spacer()
}
.padding(.bottom, 20) // top
VStack(spacing: 0) {
Image("top")
.resizable()
.aspectRatio(375/400, contentMode: .fit)
.frame(maxWidth: .infinity)
HStack {
Text(LocalizedString("login.app_title", comment: ""))
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
.foregroundColor(.white)
.padding(.leading, 20)
Spacer()
bottomSection
}
.padding(.bottom, 20)
// -
languageSettingsButton
.position(x: geometry.size.width - 40, y: 60)
Spacer()
APILoadingEffectView()
bottomSection
}
// -
languageSettingsButton
.position(x: geometry.size.width - 40, y: 60)
APILoadingEffectView()
}
.ignoresSafeArea()
}
.ignoresSafeArea()
.navigationBarHidden(true)
.navigationDestination(isPresented: $viewModel.showIDLogin) {
IDLoginPage(
onBack: {
viewModel.onBackFromIDLogin()
},
onLoginSuccess: {
viewModel.onLoginCompleted()
}
)
.navigationBarHidden(true)
.navigationDestination(isPresented: $viewModel.showIDLogin) {
IDLoginPage(
onBack: {
viewModel.onBackFromIDLogin()
},
onLoginSuccess: {
viewModel.onLoginCompleted()
}
)
.navigationBarHidden(true)
}
.navigationDestination(isPresented: $viewModel.showEmailLogin) {
EMailLoginPage(
onBack: {
viewModel.onBackFromEmailLogin()
},
onLoginSuccess: {
viewModel.onLoginCompleted()
}
)
.navigationBarHidden(true)
}
.sheet(isPresented: $viewModel.showLanguageSettings) {
LanguageSettingsView(isPresented: $viewModel.showLanguageSettings)
}
.webView(
isPresented: $viewModel.showUserAgreement,
url: APIConfiguration.webURL(for: .userAgreement)
}
.navigationDestination(isPresented: $viewModel.showEmailLogin) {
EMailLoginPage(
onBack: {
viewModel.onBackFromEmailLogin()
},
onLoginSuccess: {
viewModel.onLoginCompleted()
}
)
.webView(
isPresented: $viewModel.showPrivacyPolicy,
url: APIConfiguration.webURL(for: .privacyPolicy)
)
.alert(LocalizedString("login.agreement_alert_title", comment: ""), isPresented: $viewModel.showAgreementAlert) {
Button(LocalizedString("login.agreement_alert_confirm", comment: "")) { }
} message: {
Text(LocalizedString("login.agreement_alert_message", comment: ""))
}
.navigationBarHidden(true)
}
.sheet(isPresented: $viewModel.showLanguageSettings) {
LanguageSettingsView(isPresented: $viewModel.showLanguageSettings)
}
.webView(
isPresented: $viewModel.showUserAgreement,
url: APIConfiguration.webURL(for: .userAgreement)
)
.webView(
isPresented: $viewModel.showPrivacyPolicy,
url: APIConfiguration.webURL(for: .privacyPolicy)
)
.alert(LocalizedString("login.agreement_alert_title", comment: ""), isPresented: $viewModel.showAgreementAlert) {
Button(LocalizedString("login.agreement_alert_confirm", comment: "")) { }
} message: {
Text(LocalizedString("login.agreement_alert_message", comment: ""))
}
.onAppear {
viewModel.onLoginSuccess = onLoginSuccess

View File

@@ -5,6 +5,7 @@ import SwiftUI
struct MainPage: View {
@StateObject private var viewModel = MainViewModel()
let onLogout: () -> Void
@State private var isPresentingCreatePage: Bool = false
var body: some View {
NavigationStack(path: $viewModel.navigationPath) {
@@ -12,41 +13,28 @@ struct MainPage: View {
ZStack {
//
LoginBackgroundView()
//
mainContentView(geometry: geometry)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 使 TabView
TabView(selection: $viewModel.selectedTab) {
MomentListHomePage(onCreateTapped: { isPresentingCreatePage = true })
.tag(MainViewModel.Tab.feed)
MePage(onLogout: onLogout)
.tag(MainViewModel.Tab.me)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack {
HStack {
Spacer()
//
topRightButton
}
Spacer()
//
bottomTabView
.frame(height: 80)
.padding(.horizontal, 24)
.padding(.bottom, 100)
//
BottomTabBar(viewModel: viewModel)
.frame(height: 80)
.padding(.horizontal, 24)
.padding(.bottom)
}
}
}
.navigationDestination(for: String.self) { destination in
switch destination {
case "setting":
SettingPage(
onBack: {
viewModel.navigationPath.removeLast()
},
onLogout: {
viewModel.onLogoutTapped()
}
)
.navigationBarHidden(true)
default:
EmptyView()
}
}.ignoresSafeArea(.all)
}
.toolbar(.hidden)
}
.onAppear {
viewModel.onLogout = onLogout
@@ -56,101 +44,15 @@ struct MainPage: View {
}
viewModel.onAppear()
}
.fullScreenCover(isPresented: $isPresentingCreatePage) {
CreateFeedPage {
isPresentingCreatePage = false
}
}
.onChange(of: viewModel.isLoggedOut) { _, isLoggedOut in
if isLoggedOut {
onLogout()
}
}
}
// MARK: - UI Components
private func mainContentView(geometry: GeometryProxy) -> some View {
Group {
switch viewModel.selectedTab {
case .feed:
MomentListHomePage()
case .me:
TempMePage()
}
}
}
private var bottomTabView: some View {
HStack(spacing: 0) {
ForEach(MainViewModel.Tab.allCases, id: \.self) { tab in
Button(action: {
viewModel.onTabChanged(tab)
}) {
VStack(spacing: 4) {
Image(systemName: tab.iconName)
.font(.system(size: 24))
.foregroundColor(viewModel.selectedTab == tab ? .white : .white.opacity(0.6))
Text(tab.title)
.font(.system(size: 12))
.foregroundColor(viewModel.selectedTab == tab ? .white : .white.opacity(0.6))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
}
.background(
Rectangle()
.fill(Color.black.opacity(0.3))
.background(.ultraThinMaterial)
)
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 0)
}
}
// MARK: -
private var topRightButton: some View {
Button(action: {
viewModel.onTopRightButtonTapped()
}) {
Group {
switch viewModel.selectedTab {
case .feed:
Image("add icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
case .me:
Image(systemName: "gearshape")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.3))
.clipShape(Circle())
}
}
}
.padding(.trailing, 16)
.padding(.top, 8)
}
}
// MARK: - MeView ()
struct TempMePage: View {
var body: some View {
VStack {
Text("Me View")
.font(.title)
.foregroundColor(.white)
Text("This is a simplified MeView")
.font(.body)
.foregroundColor(.white.opacity(0.8))
}
}
}
//#Preview {
// MainPage(onLogout: {})
//}

186
yana/MVVM/MePage.swift Normal file
View File

@@ -0,0 +1,186 @@
import SwiftUI
struct MePage: View {
let onLogout: () -> Void
@State private var isShowingSettings: Bool = false
@StateObject private var viewModel = MePageViewModel()
//
@State private var previewItem: PreviewItem? = nil
@State private var previewCurrentIndex: Int = 0
//
@State private var selectedMoment: MomentsInfo? = nil
var body: some View {
ZStack {
//
// MomentListBackgroundView()
VStack(spacing: 0) {
// + + ID +
ZStack(alignment: .topTrailing) {
VStack(spacing: 12) {
AsyncImage(url: URL(string: viewModel.avatarURL)) { image in
image.resizable().scaledToFill()
} placeholder: {
Image(systemName: "person.circle.fill")
.resizable()
.scaledToFill()
.foregroundColor(.gray)
}
.frame(width: 132, height: 132)
.clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 3))
.shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
Text(viewModel.nickname.isEmpty ? "未知用户" : viewModel.nickname)
.font(.system(size: 34, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
.minimumScaleFactor(0.6)
if viewModel.userId > 0 {
HStack(spacing: 6) {
Text("ID:\(viewModel.userId)")
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.8))
Image(systemName: "doc.on.doc")
.foregroundColor(.white.opacity(0.8))
}
}
}
.frame(maxWidth: .infinity)
.padding(.top, 24)
Button(action: { isShowingSettings = true }) {
Image(systemName: "gearshape")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 40, height: 40)
.background(Color.black.opacity(0.3))
.clipShape(Circle())
}
.padding(.trailing, 16)
.padding(.top, 8)
}
.padding(.bottom, 8)
//
if !viewModel.moments.isEmpty {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(Array(viewModel.moments.enumerated()), id: \.offset) { index, moment in
MomentListItem(
moment: moment,
onImageTap: { images, tappedIndex in
previewCurrentIndex = tappedIndex
previewItem = PreviewItem(images: images, index: tappedIndex)
},
onMomentTap: { tapped in
selectedMoment = tapped
debugInfoSync("➡️ MePage: 动态被点击 - ID: \(tapped.dynamicId)")
}
)
.padding(.horizontal, 16)
.onAppear {
if index == viewModel.moments.count - 3 {
viewModel.loadMoreData()
}
}
}
if viewModel.isLoadingMore {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
Text("加载更多...")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
}
.padding(.vertical, 20)
}
if !viewModel.hasMore && !viewModel.moments.isEmpty {
Text("没有更多数据了")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.6))
.padding(.vertical, 20)
}
}
.padding(.bottom, 160)
}
.refreshable { await viewModel.refreshData() }
} else if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
} else if let error = viewModel.errorMessage {
VStack(spacing: 16) {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
Button(action: { Task { await viewModel.refreshData() } }) {
Text("重试")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(Color.white.opacity(0.2))
.cornerRadius(8)
}
}
.padding(.top, 20)
} else {
VStack(spacing: 12) {
Image(systemName: "doc.text")
.font(.system(size: 32))
.foregroundColor(.white.opacity(0.5))
Text("暂无动态")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Spacer()
}
.safeAreaPadding(.top, 8)
}
.onAppear { viewModel.onAppear() }
.onReceive(NotificationCenter.default.publisher(for: .init("CreateFeedPublished"))) { _ in
Task { await viewModel.refreshData() }
}
.sheet(isPresented: $isShowingSettings) {
SettingPage(
onBack: { isShowingSettings = false },
onLogout: {
isShowingSettings = false
onLogout()
}
)
.navigationBarHidden(true)
}
//
.sheet(item: $previewItem) { item in
ImagePreviewPager(
images: item.images as [String],
currentIndex: $previewCurrentIndex
) {
previewItem = nil
}
}
//
.sheet(item: $selectedMoment) { moment in
MomentDetailPage(moment: moment) {
selectedMoment = nil
debugInfoSync("📱 MePage: 详情页已关闭")
}
.navigationBarHidden(true)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
}

View File

@@ -0,0 +1,245 @@
import SwiftUI
// MARK: - MomentDetailPage
struct MomentDetailPage: View {
@StateObject private var viewModel: MomentDetailViewModel
let onClose: () -> Void
init(moment: MomentsInfo, onClose: @escaping () -> Void) {
_viewModel = StateObject(wrappedValue: MomentDetailViewModel(moment: moment))
self.onClose = onClose
}
var body: some View {
ZStack {
//
LoginBackgroundView()
.ignoresSafeArea()
VStack(spacing: 0) {
//
HStack {
Button {
onClose()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Color.black.opacity(0.3))
.clipShape(Circle())
}
Spacer()
Text(LocalizedString("detail.title", comment: "Detail page title"))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
Spacer()
//
Color.clear
.frame(width: 44, height: 44)
}
.padding(.horizontal, 16)
.safeAreaPadding(.top, 60)
.padding(.bottom, 12)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color.black.opacity(0.4),
Color.black.opacity(0.2),
Color.clear
]),
startPoint: .top,
endPoint: .bottom
)
)
//
ScrollView {
VStack(alignment: .leading, spacing: 12) {
//
HStack(alignment: .top) {
//
CachedAsyncImage(url: viewModel.moment.avatar) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(String(viewModel.moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
}
.frame(width: 44, height: 44)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
UserIDDisplay(uid: viewModel.moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
}
Spacer()
//
Text(formatDisplayTime(viewModel.moment.publishTime))
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
//
if !viewModel.moment.content.isEmpty {
Text(viewModel.moment.content)
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.95))
.multilineTextAlignment(.leading)
}
//
if let images = viewModel.moment.dynamicResList, !images.isEmpty {
MomentImageGrid(
images: images,
onImageTap: { images, index in
viewModel.onImageTap(index)
}
)
}
//
HStack(spacing: 20) {
Button {
viewModel.like()
} label: {
HStack(spacing: 6) {
if viewModel.isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: viewModel.localIsLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
Image(systemName: viewModel.localIsLike ? "heart.fill" : "heart")
.font(.system(size: 18))
}
Text("\(viewModel.localLikeCount)")
.font(.system(size: 16))
}
}
.foregroundColor(viewModel.localIsLike ? .red : .white.opacity(0.9))
.disabled(viewModel.isLikeLoading || viewModel.moment.status == 0)
.opacity(viewModel.moment.status == 0 ? 0.5 : 1.0)
Spacer()
// -
if viewModel.moment.status == 0 {
Text("reviewing")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.orange.opacity(0.85))
.clipShape(Capsule())
}
}
.padding(.top, 8)
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
.safeAreaPadding(.top, 8)
}
}
}
.navigationBarHidden(true)
.fullScreenCover(isPresented: $viewModel.showImagePreview) {
ImagePreviewPager(
images: viewModel.images,
currentIndex: $viewModel.currentIndex
) {
viewModel.showImagePreview = false
}
}
.onAppear {
debugInfoSync("📱 MomentDetailPage: 显示详情页")
debugInfoSync(" 动态ID: \(viewModel.moment.dynamicId)")
debugInfoSync(" 用户: \(viewModel.moment.nick)")
debugInfoSync(" 审核状态: \(viewModel.moment.status)")
}
}
// MARK: -
private func formatDisplayTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
let now = Date()
let interval = now.timeIntervalSince(date)
let calendar = Calendar.current
if calendar.isDateInToday(date) {
if interval < 60 {
return "刚刚"
} else if interval < 3600 {
return "\(Int(interval / 60))分钟前"
} else {
return "\(Int(interval / 3600))小时前"
}
} else {
formatter.dateFormat = "MM/dd"
return formatter.string(from: date)
}
}
}
//#Preview {
// let testMoment = MomentsInfo(
// dynamicId: 1,
// uid: 123456,
// nick: "",
// avatar: "",
// type: 0,
// content: " MomentDetailPage ",
// likeCount: 42,
// isLike: false,
// commentCount: 5,
// publishTime: Int(Date().timeIntervalSince1970 * 1000),
// worldId: 1,
// status: 0, //
// playCount: nil,
// dynamicResList: [
// MomentsPicture(id: 1, resUrl: "https://picsum.photos/300/300", format: "jpg", width: 300, height: 300, resDuration: nil),
// MomentsPicture(id: 2, resUrl: "https://picsum.photos/301/301", format: "jpg", width: 301, height: 301, resDuration: nil)
// ],
// gender: nil,
// squareTop: nil,
// topicTop: nil,
// newUser: nil,
// defUser: nil,
// scene: nil,
// userVipInfoVO: nil,
// headwearPic: nil,
// headwearEffect: nil,
// headwearType: nil,
// headwearName: nil,
// headwearId: nil,
// experLevelPic: nil,
// charmLevelPic: nil,
// isCustomWord: nil,
// labelList: nil
// )
//
// MomentDetailPage(moment: testMoment) {
// print("")
// }
//}

View File

@@ -1,182 +0,0 @@
import SwiftUI
import Combine
// MARK: - Splash ViewModel
@MainActor
class SplashViewModel: ObservableObject {
// MARK: - Published Properties
@Published var isLoading = true
@Published var shouldShowMainApp = false
@Published var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound
@Published var isCheckingAuthentication = false
@Published var navigationDestination: NavigationDestination?
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
// MARK: - Navigation Destination
enum NavigationDestination: Equatable {
case login
case main
}
// MARK: - Initialization
init() {
setupBindings()
}
// MARK: - Public Methods
func onAppear() {
isLoading = true
shouldShowMainApp = false
authenticationStatus = .notFound
isCheckingAuthentication = false
navigationDestination = nil
// 1
Task {
try await Task.sleep(nanoseconds: 1_000_000_000)
await MainActor.run {
self.splashFinished()
}
}
}
func splashFinished() {
isLoading = false
checkAuthentication()
}
func checkAuthentication() {
isCheckingAuthentication = true
Task {
let authStatus = await UserInfoManager.checkAuthenticationStatus()
await MainActor.run {
self.authenticationChecked(authStatus)
}
}
}
func authenticationChecked(_ status: UserInfoManager.AuthenticationStatus) {
isCheckingAuthentication = false
authenticationStatus = status
if status.canAutoLogin {
debugInfoSync("🎉 自动登录成功,开始获取用户信息")
fetchUserInfo()
} else {
debugInfoSync("🔑 需要手动登录")
navigateToLogin()
}
}
func fetchUserInfo() {
Task {
let success = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: LiveAPIService())
await MainActor.run {
self.userInfoFetched(success)
}
}
}
func userInfoFetched(_ success: Bool) {
if success {
debugInfoSync("✅ 用户信息获取成功,进入主页")
} else {
debugInfoSync("⚠️ 用户信息获取失败,但仍进入主页")
}
navigateToMain()
}
func navigateToLogin() {
navigationDestination = .login
}
func navigateToMain() {
navigationDestination = .main
shouldShowMainApp = true
}
// MARK: - Private Methods
private func setupBindings() {
// Combine
}
}
// MARK: - Splash View
struct Splash: View {
@StateObject private var viewModel = SplashViewModel()
var body: some View {
ZStack {
Group {
//
if let navigationDestination = viewModel.navigationDestination {
switch navigationDestination {
case .login:
//
LoginPage(
onLoginSuccess: {
//
viewModel.navigateToMain()
}
)
case .main:
//
MainPage(
onLogout: {
viewModel.navigateToLogin()
}
)
}
} else {
//
splashContent
}
}
.onAppear {
viewModel.onAppear()
}
// API Loading -
APILoadingEffectView()
}
}
//
private var splashContent: some View {
ZStack {
// -
LoginBackgroundView()
VStack(spacing: 32) {
Spacer()
.frame(height: 200) // storyboard
// Logo - 100x100
Image("logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
// - 40pt
Text(LocalizedString("splash.title", comment: "E-Parti"))
.font(.system(size: 40, weight: .regular))
.foregroundColor(.white)
Spacer()
}
}
}
}
//#Preview {
// Splash()
//}
#Preview {
Splash()
}

View File

@@ -0,0 +1,62 @@
import SwiftUI
struct SplashPage: View {
@State private var showLogin = false
@State private var showMain = false
@State private var hasCheckedAuth = false
private let splashTransitionAnimation: Animation = .easeInOut(duration: 0.5)
var body: some View {
Group {
if showMain {
MainPage(onLogout: {
showMain = false
showLogin = true
})
} else if showLogin {
NavigationStack {
LoginPage(onLoginSuccess: {
showMain = true
})
}
} else {
ZStack {
LoginBackgroundView()
VStack(spacing: 32) {
Spacer().frame(height: 200)
Image("logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
Text(LocalizedString("splash.title", comment: "E-Parti"))
.font(.system(size: 40, weight: .regular))
.foregroundColor(.white)
Spacer()
}
}
.onAppear {
guard !hasCheckedAuth else { return }
hasCheckedAuth = true
Task { @MainActor in
debugInfoSync("🚀 SplashV2 启动,开始检查登录缓存")
let status = await UserInfoManager.checkAuthenticationStatus()
if status.canAutoLogin {
debugInfoSync("✅ 检测到可自动登录,尝试预取用户信息")
_ = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: LiveAPIService())
withAnimation(splashTransitionAnimation) {
showMain = true
}
} else {
debugInfoSync("🔑 未登录或缓存无效,进入登录页")
withAnimation(splashTransitionAnimation) {
showLogin = true
}
}
}
}
}
}
}
}

View File

@@ -15,65 +15,95 @@ struct MomentListBackgroundView: View {
// MARK: - MomentListHomePage
struct MomentListHomePage: View {
@StateObject private var viewModel = MomentListHomeViewModel()
let onCreateTapped: () -> Void
// MARK: -
@State private var previewItem: PreviewItem? = nil
@State private var previewCurrentIndex: Int = 0
var body: some View {
GeometryReader { geometry in
ZStack {
//
MomentListBackgroundView()
// MARK: -
@State private var selectedMoment: MomentsInfo? = nil
VStack(alignment: .center, spacing: 0) {
//
// MARK: -
// MainPage TabView
var body: some View {
ZStack {
//
// MomentListBackgroundView()
VStack(alignment: .center, spacing: 0) {
// +
ZStack {
//
Text(LocalizedString("feedList.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 60)
// Volume
Image("Volume")
.frame(width: 56, height: 41)
.padding(.top, 16)
// +
HStack {
Spacer()
Button {
debugInfoSync(" MomentListHomePage: 点击添加按钮")
onCreateTapped()
} label: {
Image("add icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
}
.padding(.trailing, 16)
}
}
.frame(height: 56)
//
Text(LocalizedString("feedList.slogan",
comment: ""))
.font(.system(size: 16))
.multilineTextAlignment(.leading)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.bottom, 30)
// Volume
if !viewModel.moments.isEmpty {
ScrollView {
VStack(spacing: 0) {
// Volume +
Image("Volume")
.frame(width: 56, height: 41)
.padding(.top, 16)
Text(LocalizedString("feedList.slogan",
comment: ""))
.font(.system(size: 16))
.multilineTextAlignment(.leading)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.bottom, 30)
//
if !viewModel.moments.isEmpty {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(Array(viewModel.moments.enumerated()), id: \.element.dynamicId) { index, moment in
MomentListItem(
moment: moment,
onImageTap: { images, tappedIndex in
//
previewCurrentIndex = tappedIndex
previewItem = PreviewItem(images: images, index: tappedIndex)
debugInfoSync("📸 MomentListHomePage: 图片被点击")
debugInfoSync(" 动态索引: \(index)")
debugInfoSync(" 图片索引: \(tappedIndex)")
debugInfoSync(" 图片数量: \(images.count)")
}
)
.padding(.leading, 16)
.padding(.trailing, 32)
.onAppear {
//
if index == viewModel.moments.count - 3 {
viewModel.loadMoreData()
}
ForEach(Array(viewModel.moments.enumerated()), id: \.element.dynamicId) { index, moment in
MomentListItem(
moment: moment,
onImageTap: { images, tappedIndex in
//
previewCurrentIndex = tappedIndex
previewItem = PreviewItem(images: images, index: tappedIndex)
debugInfoSync("📸 MomentListHomePage: 图片被点击")
debugInfoSync(" 动态索引: \(index)")
debugInfoSync(" 图片索引: \(tappedIndex)")
debugInfoSync(" 图片数量: \(images.count)")
},
onMomentTap: { tappedMoment in
// -
selectedMoment = tappedMoment
debugInfoSync("➡️ MomentListHomePage: 动态被点击")
debugInfoSync(" 动态ID: \(tappedMoment.dynamicId)")
debugInfoSync(" 用户: \(tappedMoment.nick)")
}
)
.padding(.leading, 16)
.padding(.trailing, 32)
.onAppear {
//
if index == viewModel.moments.count - 3 {
viewModel.loadMoreData()
}
}
}
//
if viewModel.isLoadingMore {
@@ -98,55 +128,58 @@ struct MomentListHomePage: View {
}
.padding(.bottom, 160) //
}
.refreshable {
//
viewModel.refreshData()
}
.onAppear {
//
debugInfoSync("📱 MomentListHomePage: 显示动态列表")
debugInfoSync(" 动态数量: \(viewModel.moments.count)")
debugInfoSync(" 是否有更多: \(viewModel.hasMore)")
debugInfoSync(" 是否正在加载更多: \(viewModel.isLoadingMore)")
}
} else if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
} else if let error = viewModel.error {
VStack(spacing: 16) {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
//
Button(action: {
viewModel.refreshData()
}) {
Text("重试")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(Color.white.opacity(0.2))
.cornerRadius(8)
}
}
.padding(.top, 20)
}
.refreshable {
//
viewModel.refreshData()
}
.onAppear {
//
debugInfoSync("📱 MomentListHomePage: 显示动态列表")
debugInfoSync(" 动态数量: \(viewModel.moments.count)")
debugInfoSync(" 是否有更多: \(viewModel.hasMore)")
debugInfoSync(" 是否正在加载更多: \(viewModel.isLoadingMore)")
}
} else if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
} else if let error = viewModel.error {
VStack(spacing: 16) {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
Spacer()
//
Button(action: {
viewModel.refreshData()
}) {
Text("重试")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(Color.white.opacity(0.2))
.cornerRadius(8)
}
}
.padding(.top, 20)
}
Spacer()
}
.safeAreaPadding(.top, 8)
}
.ignoresSafeArea()
.onAppear {
viewModel.onAppear()
}
// MARK: -
.fullScreenCover(item: $previewItem) { item in
.onReceive(NotificationCenter.default.publisher(for: .init("CreateFeedPublished"))) { _ in
viewModel.refreshData()
}
// MARK: - 使 sheet
.sheet(item: $previewItem) { item in
ImagePreviewPager(
images: item.images as [String],
currentIndex: $previewCurrentIndex
@@ -155,5 +188,16 @@ struct MomentListHomePage: View {
debugInfoSync("📸 MomentListHomePage: 图片预览已关闭")
}
}
// MARK: -
.sheet(item: $selectedMoment) { moment in
MomentDetailPage(moment: moment) {
selectedMoment = nil
debugInfoSync("📱 MomentListHomePage: 详情页已关闭")
}
.navigationBarHidden(true)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
//
}
}

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct MomentListItem: View {
let moment: MomentsInfo
let onImageTap: (([String], Int)) -> Void //
let onMomentTap: (MomentsInfo) -> Void //
//
@State private var isLikeLoading = false
@@ -12,112 +13,134 @@ struct MomentListItem: View {
init(
moment: MomentsInfo,
onImageTap: @escaping (([String], Int)) -> Void = { (arg) in let (_, _) = arg; }
onImageTap: @escaping (([String], Int)) -> Void = { (arg) in let (_, _) = arg; },
onMomentTap: @escaping (MomentsInfo) -> Void = { _ in }
) {
self.moment = moment
self.onImageTap = onImageTap
self.onMomentTap = onMomentTap
//
self._localIsLike = State(initialValue: moment.isLike)
self._localLikeCount = State(initialValue: moment.likeCount)
}
var body: some View {
ZStack {
//
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
let isReviewing = moment.status == 0
//
VStack(alignment: .leading, spacing: 10) {
//
HStack(alignment: .top) {
//
CachedAsyncImage(url: moment.avatar) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(String(moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
}
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
UserIDDisplay(uid: moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
}
Spacer()
//
Text(formatDisplayTime(moment.publishTime))
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
//
if !moment.content.isEmpty {
Text(moment.content)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
.padding(.leading, 40 + 8) //
}
//
if let images = moment.dynamicResList, !images.isEmpty {
MomentImageGrid(
images: images,
onImageTap: onImageTap
ZStack(alignment: .bottomTrailing) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.padding(.leading, 40 + 8)
.padding(.bottom, images.count == 2 ? 30 : 0) //
}
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
//
HStack(spacing: 20) {
// Like
Button(action: {
if !isLikeLoading {
handleLikeTap()
//
VStack(alignment: .leading, spacing: 10) {
//
HStack(alignment: .top) {
//
CachedAsyncImage(url: moment.avatar) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(String(moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
}
}) {
HStack(spacing: 4) {
if isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: localIsLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
Image(systemName: localIsLike ? "heart.fill" : "heart")
.font(.system(size: 16))
}
Text("\(localLikeCount)")
.font(.system(size: 14))
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
UserIDDisplay(uid: moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
}
.foregroundColor(localIsLike ? .red : .white.opacity(0.8))
Spacer()
//
Text(formatDisplayTime(moment.publishTime))
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
.disabled(isLikeLoading)
.padding(.leading, 40 + 8) // +
Spacer()
//
if !moment.content.isEmpty {
Text(moment.content)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
.padding(.leading, 40 + 8) //
}
//
if let images = moment.dynamicResList, !images.isEmpty {
MomentImageGrid(
images: images,
onImageTap: onImageTap
)
.padding(.leading, 40 + 8)
.padding(.bottom, images.count == 2 ? 30 : 0) //
}
//
HStack(spacing: 20) {
// Like
Button(action: {
if !isLikeLoading && !isReviewing {
handleLikeTap()
}
}) {
HStack(spacing: 4) {
if isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: localIsLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
Image(systemName: localIsLike ? "heart.fill" : "heart")
.font(.system(size: 16))
}
Text("\(localLikeCount)")
.font(.system(size: 14))
}
.foregroundColor(localIsLike ? .red : .white.opacity(0.8))
}
.disabled(isLikeLoading || isReviewing)
.opacity(isReviewing ? 0.5 : 1.0)
.padding(.leading, 40 + 8) // +
Spacer()
// -
if isReviewing {
Text("reviewing")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.orange.opacity(0.85))
.clipShape(Capsule())
}
}
.padding(.top, 8)
}
.padding(.top, 8)
.padding(16)
}
.contentShape(Rectangle())
.onTapGesture {
onMomentTap(moment)
}
.padding(16)
}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
import PhotosUI
struct NineGridImagePicker: View {
@Binding var images: [UIImage]
var maxCount: Int = 9
var cornerRadius: CGFloat = 16
var spacing: CGFloat = 8
var horizontalPadding: CGFloat = 20
var onTapImage: (Int) -> Void = { _ in }
@State private var pickerItems: [PhotosPickerItem] = []
var body: some View {
GeometryReader { geometry in
let columns = Array(repeating: GridItem(.flexible(), spacing: spacing), count: 3)
let columnsCount: CGFloat = 3
let totalSpacing = spacing * (columnsCount - 1)
let availableWidth = geometry.size.width - horizontalPadding * 2
let cellSide = (availableWidth - totalSpacing) / columnsCount
LazyVGrid(columns: columns, spacing: spacing) {
ForEach(0..<maxCount, id: \.self) { index in
ZStack {
// DEBUG
#if DEBUG
if index >= images.count && !(index == images.count && images.count < maxCount) {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.white.opacity(0.08))
}
#endif
if index < images.count {
//
ZStack(alignment: .topTrailing) {
Image(uiImage: images[index])
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(RoundedRectangle(cornerRadius: cornerRadius))
.onTapGesture { onTapImage(index) }
Button {
removeImage(at: index)
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.white)
.background(Circle().fill(Color.black.opacity(0.4)))
.font(.system(size: 16, weight: .bold))
}
.padding(6)
.buttonStyle(.plain)
}
} else if index == images.count && images.count < maxCount {
//
PhotosPicker(
selection: $pickerItems,
maxSelectionCount: maxCount - images.count,
selectionBehavior: .ordered,
matching: .images
) {
ZStack {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color(hex: 0x1C143A))
Image(systemName: "plus")
.foregroundColor(.white.opacity(0.6))
.font(.system(size: 32, weight: .semibold))
}
}
.onChange(of: pickerItems) { _, newItems in
handlePickerItems(newItems)
}
}
}
.frame(height: cellSide)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.contentShape(RoundedRectangle(cornerRadius: cornerRadius))
}
}
.padding(.horizontal, horizontalPadding)
}
.frame(height: gridHeight(forCount: max(images.count, 1)))
}
private func gridHeight(forCount count: Int) -> CGFloat {
//
// 3 = ceil(count / 3.0) GeometryReader
let screenWidth = UIScreen.main.bounds.width
let columnsCount: CGFloat = 3
let totalSpacing = spacing * (columnsCount - 1)
let availableWidth = screenWidth - horizontalPadding * 2
let side = (availableWidth - totalSpacing) / columnsCount
let rows = ceil(CGFloat(count) / 3.0)
let totalRowSpacing = spacing * max(rows - 1, 0)
return side * rows + totalRowSpacing
}
private func handlePickerItems(_ items: [PhotosPickerItem]) {
guard !items.isEmpty else { return }
Task { @MainActor in
var appended: [UIImage] = []
for item in items {
if images.count + appended.count >= maxCount { break }
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
appended.append(image)
}
}
if !appended.isEmpty {
images.append(contentsOf: appended)
}
pickerItems = []
}
}
private func removeImage(at index: Int) {
guard images.indices.contains(index) else { return }
images.remove(at: index)
}
}

View File

@@ -56,9 +56,9 @@ class MainViewModel: ObservableObject {
func onTopRightButtonTapped() {
switch selectedTab {
case .feed:
onAddButtonTapped?()
navigationPath.append(AppRoute.publish)
case .me:
navigationPath.append("setting")
navigationPath.append(AppRoute.setting)
}
}
}

View File

@@ -0,0 +1,88 @@
import Foundation
import SwiftUI
@MainActor
final class MePageViewModel: ObservableObject {
@Published var userId: Int = 0
@Published var nickname: String = ""
@Published var avatarURL: String = ""
@Published var moments: [MomentsInfo] = []
@Published var isLoading: Bool = false
@Published var isLoadingMore: Bool = false
@Published var errorMessage: String? = nil
@Published var hasMore: Bool = true
private var page: Int = 1
private let pageSize: Int = 20
func onAppear() {
Task { @MainActor in
await loadCurrentUser()
// Tab
if moments.isEmpty {
await refreshData()
}
}
}
func refreshData() async {
page = 1
hasMore = true
errorMessage = nil
isLoading = true
moments.removeAll()
defer { isLoading = false }
await fetchMyMoments(page: page)
}
func loadMoreData() {
guard !isLoadingMore, hasMore else { return }
isLoadingMore = true
Task { @MainActor in
defer { isLoadingMore = false }
page += 1
await fetchMyMoments(page: page)
}
}
private func loadCurrentUser() async {
// /Keychain
if let account = await UserInfoManager.getAccountModel() {
if let uidString = account.uid, let uid = Int(uidString) {
userId = uid
}
// UserInfo
if let info = await UserInfoManager.getUserInfo() {
nickname = info.nick ?? nickname
avatarURL = info.avatar ?? avatarURL
}
}
//
if nickname.isEmpty { nickname = "未知用户" }
}
private func fetchMyMoments(page: Int) async {
guard userId > 0 else {
errorMessage = "未登录或用户ID无效"
return
}
let api: any APIServiceProtocol & Sendable = LiveAPIService()
let request = GetMyDynamicRequest(fromUid: userId, uid: userId, page: page, pageSize: pageSize)
do {
let response = try await api.request(request)
if let list = response.data {
let items = list.map { $0.toMomentsInfo() }
if items.isEmpty { hasMore = false }
moments.append(contentsOf: items)
} else {
hasMore = false
}
} catch {
errorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,114 @@
import SwiftUI
import Combine
// MARK: - MomentDetailViewModel
@MainActor
final class MomentDetailViewModel: ObservableObject {
// MARK: - Published Properties
@Published var moment: MomentsInfo
@Published var isLikeLoading = false
@Published var localIsLike: Bool
@Published var localLikeCount: Int
@Published var showImagePreview = false
@Published var images: [String] = []
@Published var currentIndex: Int = 0
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(moment: MomentsInfo) {
self.moment = moment
self.localIsLike = moment.isLike
self.localLikeCount = moment.likeCount
self.images = moment.dynamicResList?.compactMap { $0.resUrl } ?? []
debugInfoSync("📱 MomentDetailViewModel: 初始化")
debugInfoSync(" 动态ID: \(moment.dynamicId)")
debugInfoSync(" 用户: \(moment.nick)")
debugInfoSync(" 图片数量: \(images.count)")
}
// MARK: - Public Methods
func onImageTap(_ index: Int) {
currentIndex = index
showImagePreview = true
debugInfoSync("📸 MomentDetailViewModel: 图片被点击,索引: \(index)")
}
func like() {
guard !isLikeLoading, moment.status != 0 else {
debugInfoSync("⏸️ MomentDetailViewModel: 跳过点赞 - 正在加载: \(isLikeLoading), 审核中: \(moment.status == 0)")
return
}
isLikeLoading = true
debugInfoSync("📡 MomentDetailViewModel: 开始点赞操作")
Task {
do {
// ID
guard let uidStr = await UserInfoManager.getCurrentUserId(),
let uid = Int(uidStr) else {
await MainActor.run {
isLikeLoading = false
}
setAPILoadingErrorSync(UUID(), errorMessage: "无法获取用户信息,请重新登录")
return
}
//
let status = localIsLike ? 0 : 1 // 0: , 1:
// API
let api = LiveAPIService()
//
let request = LikeDynamicRequest(
dynamicId: moment.dynamicId,
uid: uid,
status: status,
likedUid: moment.uid,
worldId: moment.worldId
)
debugInfoSync("📡 MomentDetailViewModel: 发送点赞请求")
debugInfoSync(" 动态ID: \(moment.dynamicId)")
debugInfoSync(" 当前状态: \(localIsLike)")
debugInfoSync(" 请求状态: \(status)")
//
let response: LikeDynamicResponse = try await api.request(request)
await MainActor.run {
isLikeLoading = false
//
if response.code == 200 {
localIsLike.toggle()
localLikeCount += localIsLike ? 1 : -1
debugInfoSync("✅ MomentDetailViewModel: 点赞操作成功")
debugInfoSync(" 动态ID: \(moment.dynamicId)")
debugInfoSync(" 新状态: \(localIsLike)")
debugInfoSync(" 新数量: \(localLikeCount)")
} else {
let errorMessage = response.message.isEmpty ? "点赞失败,请重试" : response.message
setAPILoadingErrorSync(UUID(), errorMessage: errorMessage)
debugErrorSync("❌ MomentDetailViewModel: 点赞操作失败")
debugErrorSync(" 动态ID: \(moment.dynamicId)")
debugErrorSync(" 错误: \(errorMessage)")
}
}
} catch {
await MainActor.run {
isLikeLoading = false
}
setAPILoadingErrorSync(UUID(), errorMessage: error.localizedDescription)
debugErrorSync("❌ MomentDetailViewModel: 点赞请求异常")
debugErrorSync(" 动态ID: \(moment.dynamicId)")
debugErrorSync(" 错误: \(error.localizedDescription)")
}
}
}
}

View File

@@ -90,10 +90,10 @@
"createFeed.processingImages" = "Processing images...";
"createFeed.publishing" = "Publishing...";
"createFeed.publish" = "Publish";
"createFeed.title" = "Image & Text Publish";
"createFeed.title" = "Image & Text";
// MARK: - Edit Feed
"editFeed.title" = "Image & Text Edit";
"editFeed.title" = "Image & Text";
"editFeed.publish" = "Publish";
"editFeed.enterContent" = "Enter Content";

View File

@@ -0,0 +1,11 @@
import Foundation
///
enum AppRoute: Hashable {
case login
case main
case setting
case publish
}

View File

@@ -0,0 +1,38 @@
import Foundation
@preconcurrency import Combine
// @unchecked Sendable Future promise
private final class PromiseBox<Output, Failure: Error>: @unchecked Sendable {
private let fulfill: (Result<Output, Failure>) -> Void
init(_ fulfill: @escaping (Result<Output, Failure>) -> Void) { self.fulfill = fulfill }
func complete(_ result: Result<Output, Failure>) { fulfill(result) }
}
extension APIServiceProtocol {
/// async/await Combine Publisher
/// - Parameter request: APIRequestProtocol
/// - Returns: AnyPublisher<T.Response, APIError>
func requestPublisher<T: APIRequestProtocol>(_ request: T) -> AnyPublisher<T.Response, APIError> {
Deferred {
Future { promise in
let box = PromiseBox<T.Response, APIError>(promise)
Task(priority: .userInitiated) {
let result: Result<T.Response, APIError>
do {
let value = try await self.request(request)
result = .success(value)
} catch let apiError as APIError {
result = .failure(apiError)
} catch {
result = .failure(.unknown(error.localizedDescription))
}
box.complete(result)
}
}
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.eraseToAnyPublisher()
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
import UIKit
@MainActor
struct DeviceContext: Sendable {
let languageCode: String
let osName: String
let osVersion: String
let deviceModel: String
let deviceId: String
let appName: String
let appVersion: String
let channel: String
let screenScale: String
static let shared: DeviceContext = {
// 线 UIKit/Bundle
let language = Locale.current.language.languageCode?.identifier ?? "en"
let osName = "iOS"
let osVersion = UIDevice.current.systemVersion
let deviceModel = UIDevice.current.model
let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "eparty"
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
#if DEBUG
let channel = "molistar_enterprise"
#else
let channel = "appstore"
#endif
let scale = String(format: "%.2f", Double(UIScreen.main.scale))
return DeviceContext(
languageCode: language,
osName: osName,
osVersion: osVersion,
deviceModel: deviceModel,
deviceId: deviceId,
appName: appName,
appVersion: appVersion,
channel: channel,
screenScale: scale
)
}()
}
enum UserAgentProvider {
@MainActor
static func userAgent() -> String {
let ctx = DeviceContext.shared
return "\(ctx.appName)/\(ctx.appVersion) (\(ctx.deviceModel); \(ctx.osName) \(ctx.osVersion); Scale/\(ctx.screenScale))"
}
}

View File

@@ -0,0 +1,33 @@
import Foundation
import Network
///
/// WiFi=2, =1, /=0
final class NetworkMonitor: @unchecked Sendable {
static let shared = NetworkMonitor()
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.yana.network.monitor")
private var _currentType: Int = 2 //
var currentType: Int { _currentType }
private init() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
let type: Int
if path.status == .satisfied {
if path.usesInterfaceType(.wifi) { type = 2 }
else if path.usesInterfaceType(.cellular) { type = 1 }
else { type = 0 }
} else {
type = 0
}
// 线线 UI
DispatchQueue.main.async { [weak self] in
self?._currentType = type
}
}
monitor.start(queue: queue)
}
}

View File

@@ -12,11 +12,10 @@ import Security
/// -
/// - 线
/// - 访
@MainActor
final class KeychainManager {
final class KeychainManager: @unchecked Sendable {
// MARK: -
@MainActor static let shared = KeychainManager()
static let shared = KeychainManager()
private init() {}
// MARK: -

View File

@@ -0,0 +1,32 @@
import Foundation
/// API
/// - Info.plist `API_SIGNING_KEY`
/// - Debug 退
/// - Release
enum SigningKeyProvider {
/// Info.plist
private static let plistKey = "API_SIGNING_KEY"
///
static func signingKey() -> String {
if let key = Bundle.main.object(forInfoDictionaryKey: plistKey) as? String,
!key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return key
}
#if DEBUG
// Debug 退 Info.plist API_SIGNING_KEY
let legacy = "rpbs6us1m8r2j9g6u06ff2bo18orwaya"
debugWarnSync("⚠️ API_SIGNING_KEY 未配置Debug 使用历史回退密钥(请尽快配置 Info.plist")
return legacy
#else
debugErrorSync("❌ 缺少 API_SIGNING_KEY请在 Info.plist 中配置")
assertionFailure("Missing API_SIGNING_KEY in Info.plist")
return ""
#endif
}
}

View File

@@ -1,63 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct AppRootView: View {
@State private var isLoggedIn = false
@State private var mainStore: StoreOf<MainFeature>?
var body: some View {
Group {
if isLoggedIn {
if let mainStore = mainStore {
MainView(store: mainStore)
.onAppear {
debugInfoSync("🔄 AppRootView: 使用已存在的MainStore")
}
} else {
// store
let store = createMainStore()
MainView(store: store)
.onAppear {
debugInfoSync("💾 AppRootView: MainStore已创建并保存")
// onAppearstore
DispatchQueue.main.async {
self.mainStore = store
}
}
}
} else {
LoginView(
store: Store(
initialState: LoginFeature.State()
) {
LoginFeature()
},
onLoginSuccess: {
debugInfoSync("🔐 AppRootView: 登录成功准备创建MainStore")
isLoggedIn = true
// store
mainStore = createMainStore()
}
)
}
}
.onAppear {
debugInfoSync("🚀 AppRootView onAppear")
debugInfoSync(" isLoggedIn: \(isLoggedIn)")
debugInfoSync(" mainStore存在: \(mainStore != nil)")
}
}
private func createMainStore() -> StoreOf<MainFeature> {
debugInfoSync("🏗️ AppRootView: 创建新的MainStore实例")
return Store(
initialState: MainFeature.State()
) {
MainFeature()
}
}
}
//
//#Preview {
// AppRootView()
//}

View File

@@ -1,78 +0,0 @@
import SwiftUI
// MARK: - Tab
enum Tab: Int, CaseIterable {
case feed = 0
case me = 1
var title: String {
switch self {
case .feed:
return "动态"
case .me:
return "我的"
}
}
var iconName: String {
switch self {
case .feed:
return "feed unselected"
case .me:
return "me unselected"
}
}
var selectedIconName: String {
switch self {
case .feed:
return "feed selected"
case .me:
return "me selected"
}
}
}
// MARK: - BottomTabView
struct BottomTabView: View {
@Binding var selectedTab: Tab
var body: some View {
HStack(spacing: 0) {
ForEach(Tab.allCases, id: \.rawValue) { tab in
Button(action: {
selectedTab = tab
}) {
Image(selectedTab == tab ? tab.selectedIconName : tab.iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.frame(maxWidth: .infinity)
}
.buttonStyle(PlainButtonStyle())
}
}
.frame(height: 60)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 30)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 30)
.stroke(Color.white.opacity(0.1), lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(0.34),
radius: 10.7,
x: 0,
y: 1.9
)
)
.padding(.horizontal, 15)
}
}
#Preview {
BottomTabView(selectedTab: .constant(.feed))
.background(Color.purple) // 便
}

View File

@@ -1,152 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct MainView: View {
let store: StoreOf<MainFeature>
var onLogout: (() -> Void)? = nil
var body: some View {
WithPerceptionTracking {
InternalMainView(store: store)
.onChange(of: store.isLoggedOut) { _, isLoggedOut in
if isLoggedOut {
onLogout?()
}
}
}
}
}
struct InternalMainView: View {
let store: StoreOf<MainFeature>
@State private var path: [MainFeature.Destination] = []
init(store: StoreOf<MainFeature>) {
self.store = store
_path = State(initialValue: store.withState { $0.navigationPath })
}
var body: some View {
WithPerceptionTracking {
NavigationStack(path: $path) {
GeometryReader { geometry in
mainContentView(geometry: geometry)
.navigationDestination(for: MainFeature.Destination.self) { destination in
DestinationView(destination: destination, store: self.store)
}
.onChange(of: path) { _, path in
store.send(.navigationPathChanged(path))
}
.onChange(of: store.navigationPath) { _, navigationPath in
if path != navigationPath {
path = navigationPath
}
}
.onAppear {
debugInfoSync("🚀 MainView onAppear")
debugInfoSync(" 当前selectedTab: \(store.selectedTab)")
store.send(.onAppear)
}
}
}
}
}
struct DestinationView: View {
let destination: MainFeature.Destination
let store: StoreOf<MainFeature>
var body: some View {
switch destination {
case .appSetting:
IfLetStore(
store.scope(state: \.appSettingState, action: \.appSettingAction),
then: { store in
WithPerceptionTracking {
AppSettingView(store: store)
}
},
else: { Text("appSettingState is nil") }
)
case .testView:
TestView()
}
}
}
private func mainContentView(geometry: GeometryProxy) -> some View {
WithPerceptionTracking {
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
//
MainContentView(
store: store,
selectedTab: store.selectedTab
)
.onChange(of: store.selectedTab) { _, newTab in
debugInfoSync("🔄 MainView selectedTab changed: \(newTab)")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.bottom, 80) //
// -
VStack {
Spacer()
BottomTabView(selectedTab: Binding(
get: {
// MainFeature.TabBottomTabView.Tab
let currentTab = store.selectedTab == .feed ? Tab.feed : Tab.me
debugInfoSync("🔍 BottomTabView get: MainFeature.Tab.\(store.selectedTab) → BottomTabView.Tab.\(currentTab)")
return currentTab
},
set: { newTab in
// BottomTabView.TabMainFeature.Tab
let mainTab: MainFeature.Tab = newTab == .feed ? .feed : .other
debugInfoSync("🔍 BottomTabView set: BottomTabView.Tab.\(newTab) → MainFeature.Tab.\(mainTab)")
store.send(.selectTab(mainTab))
}
))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, 100)
.ignoresSafeArea(.keyboard, edges: .bottom)
// API Loading
APILoadingEffectView()
}
}
}
}
struct MainContentView: View {
let store: StoreOf<MainFeature>
let selectedTab: MainFeature.Tab
var body: some View {
WithPerceptionTracking {
let _ = debugInfoSync("📱 MainContentView selectedTab: \(selectedTab)")
let _ = debugInfoSync(" 与store.selectedTab一致: \(selectedTab == store.selectedTab)")
Group {
if selectedTab == .feed {
FeedListView(store: store.scope(
state: \.feedList,
action: \.feedList
))
} else if selectedTab == .other {
MeView(
store: store.scope(
state: \.me,
action: \.me
),
showCloseButton: false // MainView
)
} else {
CustomEmptyView(onRetry: {})
}
}
}
}
}

View File

@@ -1,91 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct SplashView: View {
let store: StoreOf<SplashFeature>
var body: some View {
ZStack {
Group {
//
if let navigationDestination = store.navigationDestination {
switch navigationDestination {
case .login:
//
LoginView(
store: Store(
initialState: LoginFeature.State()
) {
LoginFeature()
},
onLoginSuccess: {
//
store.send(.navigateToMain)
}
)
case .main:
//
MainView(
store: Store(
initialState: MainFeature.State()
) {
MainFeature()
},
onLogout: {
store.send(.navigateToLogin)
}
)
}
} else {
//
splashContent
}
}
.onAppear {
store.send(.onAppear)
}
// API Loading -
APILoadingEffectView()
}
}
//
private var splashContent: some View {
ZStack {
// -
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 32) {
Spacer()
.frame(height: 200) // storyboard
// Logo - 100x100
Image("logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
// - 40pt
Text(LocalizedString("splash.title", comment: "E-Parti"))
.font(.system(size: 40, weight: .regular))
.foregroundColor(.white)
Spacer()
}
}
}
}
//#Preview {
// SplashView(
// store: Store(
// initialState: SplashFeature.State()
// ) {
// SplashFeature()
// }
// )
//}

View File

@@ -6,7 +6,6 @@
//
import SwiftUI
import ComposableArchitecture
@main
struct yanaApp: App {
@@ -24,7 +23,8 @@ struct yanaApp: App {
var body: some Scene {
WindowGroup {
Splash()
SplashPage()
.ignoresSafeArea(.all)
}
}
}