feat(ios): AppSession 状态枚举接入路由 + TabBar 精简 + authorizationCode
- AuthManager: 移除 isAuthenticated/isRestoring,仅保留 AppSession 枚举 - App 入口: switch session 分发行路由,支持 disabled/deleted 状态页 - 新增 AccountStatusView(禁用/注销提示) - ContentView: TabBar 5→4(学习/知识库/分析/我的),去掉 AI 首页 - StudyHomeView: 融合每日思考题,去掉 AI 输入栏和本周活跃 - AnalysisHomeView: 新增本周学习活跃柱状图 - AppleAuthRequest 新增 authorizationCode,LoginPage 提取传递 - APIError 支持 errorCode,APIClient 解析服务端 errorCode - AuthManager.applyErrorCode() 根据错误码切换状态 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0f8e542b2a
commit
539b9a7d2b
@ -18,9 +18,11 @@ struct AIStudyAppApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
Group {
|
Group {
|
||||||
if authManager.isRestoring {
|
switch authManager.session {
|
||||||
|
case .unknown:
|
||||||
SplashScreen()
|
SplashScreen()
|
||||||
} else if authManager.isAuthenticated {
|
|
||||||
|
case .authenticated:
|
||||||
if hasCompletedOnboarding {
|
if hasCompletedOnboarding {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(authManager)
|
.environmentObject(authManager)
|
||||||
@ -28,9 +30,29 @@ struct AIStudyAppApp: App {
|
|||||||
PostLoginOnboardingFlow(hasCompletedOnboarding: $hasCompletedOnboarding)
|
PostLoginOnboardingFlow(hasCompletedOnboarding: $hasCompletedOnboarding)
|
||||||
.environmentObject(authManager)
|
.environmentObject(authManager)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
case .disabled:
|
||||||
|
AccountStatusView(
|
||||||
|
icon: "person.crop.circle.badge.xmark",
|
||||||
|
title: "账号已被禁用",
|
||||||
|
message: "您的账号已被管理员禁用,如有疑问请联系客服。",
|
||||||
|
onBackToLogin: { authManager.session = .unauthenticated }
|
||||||
|
)
|
||||||
|
|
||||||
|
case .deleted:
|
||||||
|
AccountStatusView(
|
||||||
|
icon: "person.crop.circle.badge.minus",
|
||||||
|
title: "账号已注销",
|
||||||
|
message: "您的账号已成功注销,欢迎随时回来重新注册。",
|
||||||
|
onBackToLogin: { authManager.session = .unauthenticated }
|
||||||
|
)
|
||||||
|
|
||||||
|
case .expired, .unauthenticated:
|
||||||
PreLoginFlow()
|
PreLoginFlow()
|
||||||
.environmentObject(authManager)
|
.environmentObject(authManager)
|
||||||
|
|
||||||
|
case .authenticating, .refreshing:
|
||||||
|
SplashScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(effectiveColorScheme)
|
.preferredColorScheme(effectiveColorScheme)
|
||||||
@ -253,6 +275,7 @@ struct LoginPage: View {
|
|||||||
}
|
}
|
||||||
let givenName = credential.fullName?.givenName
|
let givenName = credential.fullName?.givenName
|
||||||
let familyName = credential.fullName?.familyName
|
let familyName = credential.fullName?.familyName
|
||||||
|
let authCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) }
|
||||||
|
|
||||||
isLoggingIn = true
|
isLoggingIn = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@ -264,6 +287,7 @@ struct LoginPage: View {
|
|||||||
LoginPage.currentRawNonce = nil // 用完即清,防重放
|
LoginPage.currentRawNonce = nil // 用完即清,防重放
|
||||||
let resp = try await AuthService.shared.appleLogin(
|
let resp = try await AuthService.shared.appleLogin(
|
||||||
identityToken: tokenStr,
|
identityToken: tokenStr,
|
||||||
|
authorizationCode: authCode,
|
||||||
givenName: givenName,
|
givenName: givenName,
|
||||||
familyName: familyName,
|
familyName: familyName,
|
||||||
nonce: rawNonce
|
nonce: rawNonce
|
||||||
@ -325,6 +349,50 @@ struct OnboardingPage: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Account Status (disabled / deleted)
|
||||||
|
|
||||||
|
struct AccountStatusView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
let onBackToLogin: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ZXGradient.page.ignoresSafeArea()
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 56))
|
||||||
|
.foregroundColor(Color.zxCoral.opacity(0.6))
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 22, weight: .heavy))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineSpacing(4)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onBackToLogin()
|
||||||
|
} label: {
|
||||||
|
Text("返回首页")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxOnPrimary)
|
||||||
|
.frame(width: 200, height: 48)
|
||||||
|
.background(ZXGradient.brand)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - GoalSetup
|
// MARK: - GoalSetup
|
||||||
|
|
||||||
struct GoalSetupPage: View {
|
struct GoalSetupPage: View {
|
||||||
|
|||||||
@ -1,72 +1,51 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var selectedTab = "ai"
|
@State private var selectedTab = "study"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
TabView(selection: $selectedTab) {
|
||||||
switch selectedTab {
|
NavigationStack {
|
||||||
case "ai": NavigationStack { AIHomeView().background(Color.zxBg0.ignoresSafeArea()) }
|
StudyHomeView()
|
||||||
case "library": NavigationStack { LibraryHomeView().background(Color.zxBg0.ignoresSafeArea()) }
|
.background(Color.zxCanvas.ignoresSafeArea())
|
||||||
case "study": NavigationStack { StudyHomeView().background(Color.zxBg0.ignoresSafeArea()) }
|
|
||||||
case "analysis": NavigationStack { AnalysisHomeView().background(Color.zxBg0.ignoresSafeArea()) }
|
|
||||||
case "profile": NavigationStack { ProfileView().background(Color.zxBg0.ignoresSafeArea()) }
|
|
||||||
default: NavigationStack { AIHomeView() }
|
|
||||||
}
|
}
|
||||||
VStack { Spacer(); ZXTabBar(active: $selectedTab) }.ignoresSafeArea(edges: .bottom)
|
.tabItem {
|
||||||
|
Label("学习", systemImage: "flame")
|
||||||
|
}
|
||||||
|
.tag("study")
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
LibraryHomeView()
|
||||||
|
.background(Color.zxCanvas.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("知识库", systemImage: "books.vertical")
|
||||||
|
}
|
||||||
|
.tag("library")
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
AnalysisHomeView()
|
||||||
|
.background(Color.zxCanvas.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("分析", systemImage: "chart.bar")
|
||||||
|
}
|
||||||
|
.tag("analysis")
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
ProfileView()
|
||||||
|
.background(Color.zxCanvas.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("我的", systemImage: "person.crop.circle")
|
||||||
|
}
|
||||||
|
.tag("profile")
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2), value: selectedTab)
|
.tint(Color.zxPrimary)
|
||||||
.ignoresSafeArea(edges: .bottom)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ZXTabBar: View {
|
// MARK: - Shared Components
|
||||||
@Binding var active: String
|
|
||||||
private let items = [
|
|
||||||
("ai", "AI", "brain.head.profile"),
|
|
||||||
("library", "知识库", "books.vertical"),
|
|
||||||
("study", "学习", "bolt"),
|
|
||||||
("analysis", "分析", "chart.bar"),
|
|
||||||
("profile", "我的", "person"),
|
|
||||||
]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(items, id: \.0) { item in
|
|
||||||
let on = item.0 == active
|
|
||||||
Button {
|
|
||||||
active = item.0
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
if on {
|
|
||||||
Capsule()
|
|
||||||
.fill(Color.zxPurple)
|
|
||||||
.frame(width: 20, height: 3)
|
|
||||||
.offset(y: -4)
|
|
||||||
}
|
|
||||||
Image(systemName: on ? "\(item.2).fill" : item.2)
|
|
||||||
.font(.system(size: 22))
|
|
||||||
.foregroundColor(on ? Color.zxPurple : Color.zxF03)
|
|
||||||
}
|
|
||||||
Text(item.1)
|
|
||||||
.font(.system(size: 10, weight: on ? .semibold : .regular))
|
|
||||||
.foregroundColor(on ? Color.zxPurple : Color.zxF03)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.accessibilityLabel("\(item.1)标签")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 6)
|
|
||||||
.padding(.bottom, 34)
|
|
||||||
.frame(height: 83)
|
|
||||||
.background(.ultraThinMaterial)
|
|
||||||
.background(Color.zxBg0.opacity(0.95))
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
Rectangle().fill(Color.zxBorder008).frame(height: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ZXIconBtn: View {
|
struct ZXIconBtn: View {
|
||||||
let icon: String; let size: CGFloat; var branded = false; let action: () -> Void
|
let icon: String; let size: CGFloat; var branded = false; let action: () -> Void
|
||||||
@ -140,4 +119,4 @@ struct ZXAIInputBar: View {
|
|||||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder008, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder008, lineWidth: 1))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5,10 +5,23 @@ extension Notification.Name {
|
|||||||
static let tokenExpired = Notification.Name("cloud.longde.AIStudyApp.tokenExpired")
|
static let tokenExpired = Notification.Name("cloud.longde.AIStudyApp.tokenExpired")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AppSession: Equatable {
|
||||||
|
case unknown
|
||||||
|
case unauthenticated
|
||||||
|
case authenticating
|
||||||
|
case authenticated
|
||||||
|
case refreshing
|
||||||
|
case expired
|
||||||
|
case disabled
|
||||||
|
case deleted
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AuthManager: ObservableObject {
|
final class AuthManager: ObservableObject {
|
||||||
@Published var isAuthenticated = false
|
@Published var session: AppSession = .unknown
|
||||||
@Published var isRestoring = true
|
|
||||||
|
var isLoading: Bool { session == .unknown }
|
||||||
|
var isLoggedIn: Bool { session == .authenticated }
|
||||||
|
|
||||||
static let shared = AuthManager()
|
static let shared = AuthManager()
|
||||||
|
|
||||||
@ -30,12 +43,13 @@ final class AuthManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Session restore
|
||||||
|
|
||||||
func restoreSession() async {
|
func restoreSession() async {
|
||||||
isRestoring = true
|
session = .unknown
|
||||||
defer { isRestoring = false }
|
|
||||||
|
|
||||||
guard let token = KeychainHelper.getAccessToken() else {
|
guard let token = KeychainHelper.getAccessToken() else {
|
||||||
isAuthenticated = false
|
session = .unauthenticated
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,8 +57,9 @@ final class AuthManager: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let _: UserProfileResponse = try await APIClient.shared.request("/users/me")
|
let _: UserProfileResponse = try await APIClient.shared.request("/users/me")
|
||||||
isAuthenticated = true
|
session = .authenticated
|
||||||
} catch {
|
} catch {
|
||||||
|
session = .refreshing
|
||||||
if let refreshed = await tryRefresh() {
|
if let refreshed = await tryRefresh() {
|
||||||
await APIClient.shared.setToken(refreshed.accessToken)
|
await APIClient.shared.setToken(refreshed.accessToken)
|
||||||
KeychainHelper.save(
|
KeychainHelper.save(
|
||||||
@ -52,25 +67,30 @@ final class AuthManager: ObservableObject {
|
|||||||
refreshToken: refreshed.refreshToken,
|
refreshToken: refreshed.refreshToken,
|
||||||
userId: refreshed.user?.id ?? ""
|
userId: refreshed.user?.id ?? ""
|
||||||
)
|
)
|
||||||
isAuthenticated = true
|
session = .authenticated
|
||||||
} else {
|
} else {
|
||||||
await APIClient.shared.setToken(nil)
|
await APIClient.shared.setToken(nil)
|
||||||
KeychainHelper.clear()
|
KeychainHelper.clear()
|
||||||
isAuthenticated = false
|
session = .expired
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sign in
|
||||||
|
|
||||||
func signIn(_ response: AuthResponse) async {
|
func signIn(_ response: AuthResponse) async {
|
||||||
|
session = .authenticating
|
||||||
await APIClient.shared.setToken(response.accessToken)
|
await APIClient.shared.setToken(response.accessToken)
|
||||||
KeychainHelper.save(
|
KeychainHelper.save(
|
||||||
accessToken: response.accessToken,
|
accessToken: response.accessToken,
|
||||||
refreshToken: response.refreshToken,
|
refreshToken: response.refreshToken,
|
||||||
userId: response.user?.id ?? ""
|
userId: response.user?.id ?? ""
|
||||||
)
|
)
|
||||||
isAuthenticated = true
|
session = .authenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sign out
|
||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
if let refreshToken = KeychainHelper.getRefreshToken() {
|
if let refreshToken = KeychainHelper.getRefreshToken() {
|
||||||
let body = RefreshRequest(refreshToken: refreshToken)
|
let body = RefreshRequest(refreshToken: refreshToken)
|
||||||
@ -80,14 +100,31 @@ final class AuthManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
await APIClient.shared.setToken(nil)
|
await APIClient.shared.setToken(nil)
|
||||||
KeychainHelper.clear()
|
KeychainHelper.clear()
|
||||||
isAuthenticated = false
|
session = .unauthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Force session update from error code
|
||||||
|
|
||||||
|
func applyErrorCode(_ code: String?) {
|
||||||
|
switch code {
|
||||||
|
case "AUTH_USER_DISABLED":
|
||||||
|
session = .disabled
|
||||||
|
case "AUTH_USER_DELETED":
|
||||||
|
session = .deleted
|
||||||
|
case "AUTH_REFRESH_TOKEN_EXPIRED", "AUTH_REFRESH_TOKEN_REVOKED":
|
||||||
|
session = .expired
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
private func handleUnauthorized() {
|
private func handleUnauthorized() {
|
||||||
Task {
|
Task {
|
||||||
await APIClient.shared.setToken(nil)
|
await APIClient.shared.setToken(nil)
|
||||||
KeychainHelper.clear()
|
KeychainHelper.clear()
|
||||||
isAuthenticated = false
|
session = .unauthenticated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -90,6 +90,7 @@ struct AuthUser: Codable, Identifiable {
|
|||||||
|
|
||||||
struct AppleAuthRequest: Codable {
|
struct AppleAuthRequest: Codable {
|
||||||
let identityToken: String
|
let identityToken: String
|
||||||
|
let authorizationCode: String?
|
||||||
let fullName: AppleFullName?
|
let fullName: AppleFullName?
|
||||||
let nonce: String?
|
let nonce: String?
|
||||||
|
|
||||||
|
|||||||
@ -107,23 +107,40 @@ actor APIClient {
|
|||||||
|
|
||||||
// MARK: - Server error decoding
|
// MARK: - Server error decoding
|
||||||
|
|
||||||
/// 从服务端错误响应中提取 message,避免丢弃真正的错误原因
|
/// 从服务端错误响应中提取 message 和 errorCode
|
||||||
|
func extractErrorInfo(_ data: Data) -> (message: String?, errorCode: String?) {
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
message: json["message"] as? String,
|
||||||
|
errorCode: json["errorCode"] as? String
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func decodeServerError(_ data: Data, fallback: APIError) -> APIError {
|
private func decodeServerError(_ data: Data, fallback: APIError) -> APIError {
|
||||||
if let serverMsg = extractServerMessage(data) {
|
let info = extractErrorInfo(data)
|
||||||
return APIError.serverError(serverMsg)
|
if let serverMsg = info.message {
|
||||||
|
return APIError.serverError(serverMsg, code: info.errorCode)
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func extractServerMessage(_ data: Data) -> String? {
|
// MARK: - Server error info (public for AuthManager)
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
|
struct ServerErrorInfo {
|
||||||
|
let message: String
|
||||||
|
let errorCode: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension APIClient {
|
||||||
|
nonisolated func parseErrorInfo(from data: Data) -> ServerErrorInfo? {
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let msg = json["message"] as? String else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// NestJS GlobalExceptionFilter 格式: { success, statusCode, message }
|
return ServerErrorInfo(message: msg, errorCode: json["errorCode"] as? String)
|
||||||
if let msg = json["message"] as? String, !msg.isEmpty {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Decoding
|
// MARK: - Decoding
|
||||||
|
|||||||
@ -10,7 +10,13 @@ enum APIError: LocalizedError {
|
|||||||
case decodingFailed(String)
|
case decodingFailed(String)
|
||||||
case networkError(Error)
|
case networkError(Error)
|
||||||
case unauthorized
|
case unauthorized
|
||||||
case serverError(String)
|
case serverError(String, code: String? = nil)
|
||||||
|
|
||||||
|
/// 语义错误码(如 AUTH_USER_DISABLED),用于 AppSession 状态判断
|
||||||
|
var errorCode: String? {
|
||||||
|
if case .serverError(_, let code) = self { return code }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@ -19,7 +25,7 @@ enum APIError: LocalizedError {
|
|||||||
case .decodingFailed(let msg): return "数据解析失败: \(msg)"
|
case .decodingFailed(let msg): return "数据解析失败: \(msg)"
|
||||||
case .networkError(let e): return e.localizedDescription
|
case .networkError(let e): return e.localizedDescription
|
||||||
case .unauthorized: return "未授权,请重新登录"
|
case .unauthorized: return "未授权,请重新登录"
|
||||||
case .serverError(let msg): return msg
|
case .serverError(let msg, _): return msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,9 +30,10 @@ class AuthService {
|
|||||||
static let shared = AuthService()
|
static let shared = AuthService()
|
||||||
private let client = APIClient.shared
|
private let client = APIClient.shared
|
||||||
|
|
||||||
func appleLogin(identityToken: String, givenName: String?, familyName: String?, nonce: String? = nil) async throws -> AuthResponse {
|
func appleLogin(identityToken: String, authorizationCode: String? = nil, givenName: String?, familyName: String?, nonce: String? = nil) async throws -> AuthResponse {
|
||||||
let body = AppleAuthRequest(
|
let body = AppleAuthRequest(
|
||||||
identityToken: identityToken,
|
identityToken: identityToken,
|
||||||
|
authorizationCode: authorizationCode,
|
||||||
fullName: givenName != nil
|
fullName: givenName != nil
|
||||||
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
|
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
|
||||||
: nil,
|
: nil,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ struct AnalysisHomeView: View {
|
|||||||
@StateObject private var viewModel = ActivityViewModel()
|
@StateObject private var viewModel = ActivityViewModel()
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.zxBg0.ignoresSafeArea()
|
Color.zxCanvas.ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("学习分析").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
|
Text("学习分析").font(.system(size: 22, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.5)
|
||||||
@ -29,6 +29,16 @@ struct AnalysisHomeView: View {
|
|||||||
HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) }
|
HStack { Text("掌握度趋势").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); Text("↑ +8% 本周").font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxGreen) }
|
||||||
ZXChartView()
|
ZXChartView()
|
||||||
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
|
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("本周学习活跃").font(.system(size: 14, weight: .bold)).foregroundColor(Color.zxF0)
|
||||||
|
ZXWeekBarChart()
|
||||||
|
HStack {
|
||||||
|
Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03)
|
||||||
|
Spacer()
|
||||||
|
Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
}.padding(16).background(Color.zxFill004).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(value: Route.weakPoints) { Text("全部 \(viewModel.focusItems.count) 个").font(.system(size: 12)).foregroundColor(Color.zxPurple) }.accessibilityLabel("查看全部薄弱知识点") }
|
HStack { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 14)).foregroundColor(Color.zxYellow); Text("薄弱知识点").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0) }; Spacer(); NavigationLink(value: Route.weakPoints) { Text("全部 \(viewModel.focusItems.count) 个").font(.system(size: 12)).foregroundColor(Color.zxPurple) }.accessibilityLabel("查看全部薄弱知识点") }
|
||||||
ForEach(viewModel.focusItems.prefix(5)) { item in
|
ForEach(viewModel.focusItems.prefix(5)) { item in
|
||||||
@ -101,3 +111,34 @@ struct ZXChartView: View {
|
|||||||
.animation(reduceMotion ? nil : .default, value: showChart)
|
.animation(reduceMotion ? nil : .default, value: showChart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ZXWeekBarChart: View {
|
||||||
|
let data: [(String, CGFloat)] = [("一", 0.3), ("二", 0.5), ("三", 0.7), ("四", 0.45), ("五", 0.8), ("六", 0.9), ("日", 0.6)]
|
||||||
|
@State private var show = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
ForEach(data, id: \.0) { d in
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.zxPurple.opacity(0.8), Color.zxAccent.opacity(0.6)],
|
||||||
|
startPoint: .top, endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(height: show ? d.1 * 80 : 4)
|
||||||
|
.animation(.spring(response: 0.6, dampingFraction: 0.7), value: show)
|
||||||
|
Text(d.0)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF03)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 100)
|
||||||
|
.onAppear { show = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,45 +6,237 @@ struct StudyHomeView: View {
|
|||||||
@StateObject private var reviewVM = ReviewViewModel()
|
@StateObject private var reviewVM = ReviewViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack { ZXGradient.page.ignoresSafeArea()
|
ZStack {
|
||||||
ScrollView { VStack(spacing: 16) {
|
Color.zxCanvas.ignoresSafeArea()
|
||||||
HStack { VStack(alignment: .leading, spacing: 2) { Text("学习工作台").font(.system(size: 20, weight: .heavy)).foregroundColor(Color.zxF0).tracking(-0.4) }; Spacer()
|
|
||||||
if studyVM.isLoading { ZXLoadingView(size: 22, lineWidth: 2) }
|
ScrollView {
|
||||||
HStack(spacing: 4) { Image(systemName: "flame.fill").font(.system(size: 14)).foregroundColor(Color.zxOrange); Text("14 天连续").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxOrange) }.padding(.horizontal, 12).padding(.vertical, 6).background(Color.zxOrangeBG(0.1)).clipShape(Capsule()).overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1)) }
|
VStack(spacing: 16) {
|
||||||
.padding(.horizontal, 20).padding(.top, ZXSpacing.statusBarH + 16).padding(.bottom, 4)
|
// MARK: - Header
|
||||||
pc
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 12) { HStack { Text("今日任务").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0); Spacer(); HStack(spacing: 4) { Image(systemName: "calendar").font(.system(size: 12)).foregroundColor(Color.zxF04); Text("AI 自动排期").font(.system(size: 12)).foregroundColor(Color.zxF04) } }
|
Spacer()
|
||||||
ForEach($studyHomeVM.tasks) { $t in
|
if studyVM.isLoading {
|
||||||
if t.tp == "回忆测试" {
|
ZXLoadingView(size: 22, lineWidth: 2)
|
||||||
NavigationLink(value: Route.activeRecall) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
|
}
|
||||||
} else if t.tp == "费曼练习" {
|
HStack(spacing: 4) {
|
||||||
NavigationLink(value: Route.aiChat) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
|
Image(systemName: "flame.fill")
|
||||||
} else if t.tp == "薄弱点" {
|
.font(.system(size: 14))
|
||||||
NavigationLink(value: Route.weakPoints) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
|
.foregroundColor(Color.zxOrange)
|
||||||
} else if t.tp == "间隔复习" {
|
Text("14 天连续")
|
||||||
NavigationLink(value: Route.reviewCard) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
|
.font(.system(size: 13, weight: .bold))
|
||||||
} else {
|
.foregroundColor(Color.zxOrange)
|
||||||
NavigationLink(value: Route.learningSession(taskTitle: t.t, taskType: t.tp, taskColorHex: t.ch)) { ZXSTaskRowView(task: t) { t.d.toggle() } }.foregroundColor(.primary)
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.zxOrangeBG(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(Capsule().stroke(Color(hex: "#F97316", opacity: 0.2), lineWidth: 1))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, ZXSpacing.statusBarH + 16)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
// MARK: - Progress
|
||||||
|
progressCard
|
||||||
|
|
||||||
|
// MARK: - Today's Tasks
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("今日任务")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
Text("AI 自动排期")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach($studyHomeVM.tasks) { $t in
|
||||||
|
if t.tp == "回忆测试" {
|
||||||
|
NavigationLink(value: Route.activeRecall) {
|
||||||
|
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
} else if t.tp == "费曼练习" {
|
||||||
|
NavigationLink(value: Route.aiChat) {
|
||||||
|
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
} else if t.tp == "薄弱点" {
|
||||||
|
NavigationLink(value: Route.weakPoints) {
|
||||||
|
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
} else if t.tp == "间隔复习" {
|
||||||
|
NavigationLink(value: Route.reviewCard) {
|
||||||
|
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
} else {
|
||||||
|
NavigationLink(value: Route.learningSession(taskTitle: t.t, taskType: t.tp, taskColorHex: t.ch)) {
|
||||||
|
ZXSTaskRowView(task: t) { t.d.toggle() }
|
||||||
|
}
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
// MARK: - Daily Thinking
|
||||||
|
dailyThinkingCard
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
// Bottom spacer
|
||||||
|
Color.clear.frame(height: 100)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 14) { Text("本周学习活跃").font(.system(size: 15, weight: .bold)).foregroundColor(Color.zxF0)
|
|
||||||
HStack(alignment: .bottom, spacing: 8) { ForEach(0..<7, id: \.self) { i in VStack(spacing: 8) { RoundedRectangle(cornerRadius: 6).fill(i == 6 ? Color.zxFill01 : Color(hex: "#7C6EFA", opacity: studyHomeVM.weekActivity[i] * 0.9 + 0.1)).frame(height: studyHomeVM.weekActivity[i] * 60).animation(.spring(response: 0.6, dampingFraction: 0.7).delay(Double(i) * 0.05), value: studyHomeVM.weekActivity[i]); Text(studyHomeVM.dayLabels[i]).font(.system(size: 10, weight: i == 2 ? .bold : .regular)).foregroundColor(i == 2 ? Color.zxPurple : Color.zxF03) }.frame(maxWidth: .infinity) } }
|
|
||||||
HStack { Text("总计 3.5 小时").font(.system(size: 11)).foregroundColor(Color.zxF03); Spacer(); Text("日均 30 分钟").font(.system(size: 11)).foregroundColor(Color.zxF03) } }
|
|
||||||
.padding(.bottom, 120) }
|
|
||||||
.padding(.horizontal, 20) }
|
|
||||||
.scrollIndicators(.hidden)
|
|
||||||
.zxPullToRefresh { await studyVM.loadSessions() }
|
|
||||||
}
|
}
|
||||||
.task { await studyVM.loadSessions() }
|
.scrollIndicators(.hidden)
|
||||||
.navigationDestination(for: Route.self) { $0.destination }
|
.zxPullToRefresh { await studyVM.loadSessions() }
|
||||||
|
}
|
||||||
|
.task { await studyVM.loadSessions() }
|
||||||
|
.navigationDestination(for: Route.self) { $0.destination }
|
||||||
}
|
}
|
||||||
private var pc: some View { let dn = studyHomeVM.doneCount; let pct = CGFloat(dn) / 5
|
|
||||||
return VStack(spacing: 12) { HStack { VStack(alignment: .leading, spacing: 2) { Text("今日进度").font(.system(size: 13, weight: .medium)).foregroundColor(Color.zxF05); HStack(alignment: .lastTextBaseline, spacing: 6) { Text("\(dn)").font(.system(size: 26, weight: .black)).foregroundColor(Color.zxF0).contentTransition(.numericText()); Text("/ 5"); Text("个任务").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04) } }; Spacer()
|
// MARK: - Progress Card
|
||||||
ZStack { Circle().trim(from: 0, to: pct).stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round)).rotationEffect(.degrees(-90)).frame(width: 64, height: 64).animation(.easeInOut(duration: 0.8), value: pct); Text("\(Int(pct * 100))%").font(.system(size: 14, weight: .heavy)).foregroundColor(Color.zxPurple).contentTransition(.numericText()) } }
|
|
||||||
ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3).fill(Color.zxFill008).frame(height: 6); RoundedRectangle(cornerRadius: 3).fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing)).frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6).animation(.easeInOut(duration: 0.6), value: pct) }
|
private var progressCard: some View {
|
||||||
HStack { VStack(alignment: .leading, spacing: 2) { Text("\(dn * 12) 分钟").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("已学").font(.system(size: 10)).foregroundColor(Color.zxF04) }; Spacer(); VStack(spacing: 2) { Text("\((5 - dn) * 11) 分钟").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("剩余").font(.system(size: 10)).foregroundColor(Color.zxF04) }; Spacer(); VStack(alignment: .trailing, spacing: 2) { Text("+5 点").font(.system(size: 13, weight: .bold)).foregroundColor(Color.zxF0); Text("掌握").font(.system(size: 10)).foregroundColor(Color.zxF04) } } }
|
let dn = studyHomeVM.doneCount
|
||||||
.padding(16).background(ZXGradient.progressCard).overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.15), lineWidth: 1)).clipShape(RoundedRectangle(cornerRadius: 20)) }
|
let pct = CGFloat(dn) / 5
|
||||||
|
return VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("今日进度")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF05)
|
||||||
|
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||||
|
Text("\(dn)")
|
||||||
|
.font(.system(size: 26, weight: .black))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
Text("/ 5")
|
||||||
|
Text("个任务")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: pct)
|
||||||
|
.stroke(ZXGradient.brand, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
.animation(.easeInOut(duration: 0.8), value: pct)
|
||||||
|
Text("\(Int(pct * 100))%")
|
||||||
|
.font(.system(size: 14, weight: .heavy))
|
||||||
|
.foregroundColor(Color.zxPurple)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(Color.zxFill008)
|
||||||
|
.frame(height: 6)
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(LinearGradient(colors: [Color.zxPurple, Color.zxAccent], startPoint: .leading, endPoint: .trailing))
|
||||||
|
.frame(width: max(6, pct * (UIScreen.main.bounds.width - 72)), height: 6)
|
||||||
|
.animation(.easeInOut(duration: 0.6), value: pct)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(dn * 12) 分钟")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text("已学")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text("\((5 - dn) * 11) 分钟")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text("剩余")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("+5 点")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(Color.zxF0)
|
||||||
|
Text("掌握")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.zxF04)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(ZXGradient.progressCard)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "#7C6EFA", opacity: 0.15), lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily Thinking Card
|
||||||
|
|
||||||
|
private var dailyThinkingCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(Color.zxPrimary)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.background(Color.zxPrimarySoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.sm))
|
||||||
|
|
||||||
|
Text("每日思考题")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxInkPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("待回答")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(Color.zxAmberDeep)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color.zxAmberSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.xs))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("解释「注意力机制」在 Transformer 中的作用,不能使用搜索,用你自己的话说。")
|
||||||
|
.font(.system(size: 15, weight: .regular))
|
||||||
|
.foregroundColor(Color.zxInkPrimary)
|
||||||
|
.lineSpacing(6)
|
||||||
|
|
||||||
|
NavigationLink(value: Route.dailyThinking) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("开始回答")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.zxOnPrimary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(ZXGradient.brand)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.md))
|
||||||
|
}
|
||||||
|
.accessibilityLabel("开始回答每日思考题")
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.zxSurfaceElevated)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: ZXRadius.lg)
|
||||||
|
.stroke(Color.zxHairline, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ZXRadius.lg))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ZXSTask: Identifiable { let id = UUID(); let t: String; let tp: String; let ch: String; let m: Int; var d: Bool; var c: Color { Color(hex: ch) } }
|
struct ZXSTask: Identifiable { let id = UUID(); let t: String; let tp: String; let ch: String; let m: Int; var d: Bool; var c: Color { Color(hex: ch) } }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user