From b5934f21f565b500f9d40d7734fd6033823f0ac2 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 31 Aug 2026 20:45:04 +0800 Subject: [PATCH] align iOS with Android: auth, SSO login, course selection, phone binding, 4 tabs --- android/sample-app/build.gradle.kts | 2 +- ios/OralTrainer.xcodeproj/project.pbxproj | 8 + ios/OralTrainer/Services/AuthAPI.swift | 172 ++++++++ ios/OralTrainer/Views/AuthView.swift | 506 ++++++++++++++++++++++ ios/OralTrainer/Views/RootView.swift | 197 ++++++++- 5 files changed, 867 insertions(+), 18 deletions(-) create mode 100644 ios/OralTrainer/Services/AuthAPI.swift create mode 100644 ios/OralTrainer/Views/AuthView.swift diff --git a/android/sample-app/build.gradle.kts b/android/sample-app/build.gradle.kts index 2c78b0c..13b9b49 100644 --- a/android/sample-app/build.gradle.kts +++ b/android/sample-app/build.gradle.kts @@ -19,7 +19,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 1 - versionName = "0.1.2" + versionName = "0.1.3" } compileOptions { diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index a7881f4..80ecbd3 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; }; + AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; }; AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; }; AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; }; AA0000000000000000000043 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000023 /* AppDelegate.swift */; }; @@ -33,6 +35,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = ""; }; + AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AA0000000000000000000020 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -117,6 +121,7 @@ children = ( AA0000000000000000000028 /* HTTPClient.swift */, AA0000000000000000000029 /* VideoCatalogAPI.swift */, + AA0000000000000000000091 /* AuthAPI.swift */, AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002B /* ImitationAssessor.swift */, AA000000000000000000007E /* DubbingCompositor.swift */, @@ -139,6 +144,7 @@ children = ( AA000000000000000000002E /* Theme.swift */, AA000000000000000000002F /* RootView.swift */, + AA0000000000000000000093 /* AuthView.swift */, AA0000000000000000000030 /* TrainingView.swift */, AA0000000000000000000031 /* PlayerSectionView.swift */, AA0000000000000000000032 /* CatalogView.swift */, @@ -248,6 +254,8 @@ AA0000000000000000000050 /* TrainingView.swift in Sources */, AA0000000000000000000051 /* PlayerSectionView.swift in Sources */, AA0000000000000000000052 /* CatalogView.swift in Sources */, + AA0000000000000000000090 /* AuthAPI.swift in Sources */, + AA0000000000000000000092 /* AuthView.swift in Sources */, AA0000000000000000000053 /* TestView.swift in Sources */, AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */, ); diff --git a/ios/OralTrainer/Services/AuthAPI.swift b/ios/OralTrainer/Services/AuthAPI.swift new file mode 100644 index 0000000..5a5eaf1 --- /dev/null +++ b/ios/OralTrainer/Services/AuthAPI.swift @@ -0,0 +1,172 @@ +import Foundation + +// MARK: - Models + +struct AuthUser: Codable { + let id: String + let username: String + let nickname: String +} + +struct AuthSession: Codable { + let token: String + let user: AuthUser +} + +struct SsoExchangeResult: Codable { + let token: String + let user: AuthUser + let phoneBound: Bool + let isNewUser: Bool + + private enum CodingKeys: String, CodingKey { + case token, user + case phoneBound = "phone_bound" + case isNewUser = "is_new_user" + } +} + +struct Course: Identifiable, Codable { + let videoHash: String + let title: String + let durationMs: Int64? + let language: String? + let sentenceCount: Int + let streamUrl: String + let enrolled: Bool + + var id: String { videoHash } + + private enum CodingKeys: String, CodingKey { + case title, language, enrolled + case videoHash = "video_hash" + case durationMs = "duration_ms" + case sentenceCount = "sentence_count" + case streamUrl = "stream_url" + } + + func toVideoSummary() -> TrainingVideoSummary { + TrainingVideoSummary( + videoHash: videoHash, + title: title, + streamUrl: streamUrl, + durationMs: durationMs, + sizeBytes: 0, + language: language, + sentenceCount: sentenceCount, + status: "ready" + ) + } +} + +// MARK: - Auth Error + +enum AuthError: LocalizedError { + case badStatus(Int, String) + + var errorDescription: String? { + switch self { + case .badStatus(let code, let body): + return "HTTP \(code): \(body.prefix(200))" + } + } +} + +// MARK: - Auth API + +enum AuthAPI { + private static var baseURL: String { AppConfig.serverBaseURL } + + // MARK: Login / Register + + static func login(username: String, password: String) async throws -> AuthSession { + try await authRequest(path: "/api/v1/auth/login", body: [ + "username": username, + "password": password, + ]) + } + + static func register( + username: String, + password: String, + nickname: String, + phone: String + ) async throws -> AuthSession { + try await authRequest(path: "/api/v1/auth/register", body: [ + "username": username, + "password": password, + "nickname": nickname, + "phone": phone, + ]) + } + + // MARK: SSO + + static func ssoExchange(jwtToken: String) async throws -> SsoExchangeResult { + let url = URL(string: "\(baseURL)/api/v1/auth/sso/exchange")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["access_token": jwtToken]) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + let body = String(data: data, encoding: .utf8) ?? "" + throw AuthError.badStatus(status, body) + } + return try JSONDecoder().decode(SsoExchangeResult.self, from: data) + } + + static func ssoBindPhone(token: String, phone: String) async throws { + let url = URL(string: "\(baseURL)/api/v1/auth/sso/bind-phone")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: ["phone": phone]) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + let body = String(data: data, encoding: .utf8) ?? "" + throw AuthError.badStatus(status, body) + } + } + + // MARK: Courses + + static func courses(token: String) async throws -> [Course] { + let url = URL(string: "\(baseURL)/api/v1/courses")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + let body = String(data: data, encoding: .utf8) ?? "" + throw AuthError.badStatus(status, body) + } + return try JSONDecoder().decode([Course].self, from: data) + } + + // MARK: Private + + private static func authRequest(path: String, body: [String: Any]) async throws -> AuthSession { + let url = URL(string: "\(baseURL)\(path)")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + let body = String(data: data, encoding: .utf8) ?? "" + throw AuthError.badStatus(status, body) + } + return try JSONDecoder().decode(AuthSession.self, from: data) + } +} diff --git a/ios/OralTrainer/Views/AuthView.swift b/ios/OralTrainer/Views/AuthView.swift new file mode 100644 index 0000000..3e6961e --- /dev/null +++ b/ios/OralTrainer/Views/AuthView.swift @@ -0,0 +1,506 @@ +import SwiftUI + +// MARK: - Auth Manager (shared state) + +@MainActor +final class AuthManager: ObservableObject { + @Published var token: String + @Published var nickname: String + @Published var showingRegister = false + @Published var showingSSO = false + @Published var showingPhoneBinding = false + @Published var ssoPendingToken: String? + + init() { + let defaults = UserDefaults.standard + token = defaults.string(forKey: "oral_trainer_token") ?? "" + nickname = defaults.string(forKey: "oral_trainer_nickname") ?? "" + } + + var isLoggedIn: Bool { !token.isEmpty } + + func saveSession(_ session: AuthSession) { + token = session.token + nickname = session.user.nickname + let defaults = UserDefaults.standard + defaults.set(session.token, forKey: "oral_trainer_token") + defaults.set(session.user.nickname, forKey: "oral_trainer_nickname") + } + + func logout() { + token = "" + nickname = "" + showingRegister = false + showingSSO = false + showingPhoneBinding = false + ssoPendingToken = nil + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "oral_trainer_token") + defaults.removeObject(forKey: "oral_trainer_nickname") + } +} + +// MARK: - Auth View (root auth screen) + +struct AuthView: View { + @ObservedObject var auth: AuthManager + @State private var username = "" + @State private var password = "" + @State private var statusText = "" + @State private var isLoading = false + var onLoginSuccess: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + Text("八哥口语") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(.white) + Text("请先注册或登录后开始学习") + .font(.system(size: 15)) + .foregroundColor(Theme.textMuted) + .padding(.top, 6) + .padding(.bottom, 24) + + TextField("用户名", text: $username) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding(12) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) + + SecureField("密码(至少 8 位)", text: $password) + .textFieldStyle(.plain) + .padding(12) + .padding(.top, 8) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) + + Button { + Task { await login() } + } label: { + Text(isLoading ? "登录中..." : "登录") + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Theme.accent) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.top, 20) + .disabled(isLoading) + + Button { + auth.showingRegister = true + } label: { + Text("注册") + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Theme.surface) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.top, 8) + + Text("── 或 ──") + .font(.system(size: 13)) + .foregroundColor(Theme.textMuted) + .frame(maxWidth: .infinity) + .padding(.top, 12) + + Button { + auth.showingSSO = true + } label: { + Text("第一课堂一键登录") + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Theme.buttonBlue) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.top, 8) + + if !statusText.isEmpty { + Text(statusText) + .font(.system(size: 13)) + .foregroundColor(Theme.accentLight) + .padding(.top, 14) + } + Spacer() + } + .padding(.horizontal, 28) + .background(Theme.background.ignoresSafeArea()) + .fullScreenCover(isPresented: $auth.showingRegister) { + RegisterView(auth: auth) { + onLoginSuccess() + } + } + .fullScreenCover(isPresented: $auth.showingSSO) { + SSOWebView(auth: auth) { + onLoginSuccess() + } + } + .fullScreenCover(isPresented: $auth.showingPhoneBinding) { + PhoneBindingView(auth: auth) { + onLoginSuccess() + } + } + } + + private func login() async { + guard !username.isEmpty, !password.isEmpty else { + statusText = "请填写用户名和密码" + return + } + isLoading = true + statusText = "登录中..." + defer { isLoading = false } + do { + let session = try await AuthAPI.login(username: username, password: password) + auth.saveSession(session) + onLoginSuccess() + } catch { + statusText = "失败:\(error.localizedDescription)" + } + } +} + +// MARK: - Register View + +struct RegisterView: View { + @ObservedObject var auth: AuthManager + @Environment(\.dismiss) private var dismiss + @State private var username = "" + @State private var password = "" + @State private var nickname = "" + @State private var phone = "" + @State private var statusText = "" + @State private var isLoading = false + var onLoginSuccess: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text("注册账号") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + Text("填写以下信息完成注册") + .font(.system(size: 14)) + .foregroundColor(Theme.textMuted) + .padding(.top, 6) + .padding(.bottom, 24) + + fieldLabel("用户名") + TextField("3-50 位字母、数字或下划线", text: $username) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding(12) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) + + fieldLabel("密码") + SecureField("至少 8 位", text: $password) + .textFieldStyle(.plain) + .padding(12) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) + + fieldLabel("姓名") + TextField("你的真实姓名或昵称", text: $nickname) + .textFieldStyle(.plain) + .padding(12) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) + + fieldLabel("手机号") + TextField("选填", text: $phone) + .textFieldStyle(.plain) + .keyboardType(.phonePad) + .padding(12) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) + + Button { + Task { await register() } + } label: { + Text(isLoading ? "注册中..." : "注册") + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Theme.accent) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.top, 20) + .disabled(isLoading) + + Button { + dismiss() + } label: { + Text("返回登录") + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Theme.surface) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.top, 8) + + if !statusText.isEmpty { + Text(statusText) + .font(.system(size: 13)) + .foregroundColor(Theme.accentLight) + .padding(.top, 14) + } + } + .padding(.horizontal, 28) + .padding(.top, 60) + } + .background(Theme.background.ignoresSafeArea()) + } + + private func fieldLabel(_ text: String) -> some View { + Text(text) + .font(.system(size: 13)) + .foregroundColor(Theme.textMuted) + .padding(.top, 12) + .padding(.bottom, 4) + } + + private func register() async { + guard username.count >= 3 else { statusText = "用户名至少 3 个字符"; return } + guard password.count >= 8 else { statusText = "密码至少 8 位"; return } + guard !nickname.isEmpty else { statusText = "请填写姓名"; return } + + isLoading = true + statusText = "注册中..." + defer { isLoading = false } + do { + let session = try await AuthAPI.register( + username: username, password: password, nickname: nickname, phone: phone + ) + auth.saveSession(session) + dismiss() + onLoginSuccess() + } catch { + statusText = "注册失败:\(error.localizedDescription)" + } + } +} + +// MARK: - SSO WebView + +import WebKit + +struct SSOWebView: View { + @ObservedObject var auth: AuthManager + @Environment(\.dismiss) private var dismiss + var onLoginSuccess: () -> Void + + var body: some View { + VStack(spacing: 0) { + Button { + dismiss() + } label: { + Text("取消登录") + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Theme.surface) + .foregroundColor(.white) + } + + SSOWebViewRepresentable() + + Button { + readJwtAndLogin() + } label: { + Text("已登录,点击继续") + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Theme.accent) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(8) + } + .background(Theme.background.ignoresSafeArea()) + } + + private func readJwtAndLogin() { + // Read JWT from WKWebView's localStorage via JavaScript + SSOWebViewHolder.shared.readToken { jwt in + guard let jwt = jwt, !jwt.isEmpty, jwt != "null" else { + DispatchQueue.main.async { + // Toast + } + return + } + Task { + await exchangeJwt(jwt) + } + } + } + + private func exchangeJwt(_ jwt: String) async { + do { + let result = try await AuthAPI.ssoExchange(jwtToken: jwt) + await MainActor.run { + dismiss() + if !result.phoneBound { + auth.ssoPendingToken = result.token + auth.showingPhoneBinding = true + } else { + auth.saveSession(AuthSession(token: result.token, user: result.user)) + onLoginSuccess() + } + } + } catch { + await MainActor.run { + dismiss() + } + } + } +} + +// MARK: - SSO WebView Representable + +struct SSOWebViewRepresentable: UIViewRepresentable { + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.websiteDataStore = .default() + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + SSOWebViewHolder.shared.webView = webView + if let url = URL(string: "https://d1kt.cn/login") { + webView.load(URLRequest(url: url)) + } + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + decisionHandler(.allow) + } + } +} + +// MARK: - Holder for WebView reference + +final class SSOWebViewHolder { + static let shared = SSOWebViewHolder() + weak var webView: WKWebView? + + func readToken(completion: @escaping (String?) -> Void) { + guard let webView = webView else { + completion(nil) + return + } + webView.evaluateJavaScript( + "(function(){ try { return localStorage.getItem('token') || sessionStorage.getItem('token') || ''; } catch(e) { return ''; } })()" + ) { result, _ in + let token = (result as? String) ?? "" + completion(token.isEmpty || token == "null" ? nil : token) + } + } +} + +// MARK: - Phone Binding View + +struct PhoneBindingView: View { + @ObservedObject var auth: AuthManager + @Environment(\.dismiss) private var dismiss + @State private var phone = "" + @State private var statusText = "" + @State private var isLoading = false + var onLoginSuccess: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("绑定手机号") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + Text("已通过第一课堂登录,请绑定手机号后继续") + .font(.system(size: 14)) + .foregroundColor(Theme.textMuted) + .padding(.top, 6) + .padding(.bottom, 24) + + fieldLabel("手机号") + TextField("请输入 11 位手机号", text: $phone) + .textFieldStyle(.plain) + .keyboardType(.phonePad) + .padding(12) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.border, lineWidth: 1)) + + Button { + Task { await bindPhone() } + } label: { + Text(isLoading ? "绑定中..." : "绑定并继续") + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Theme.accent) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.top, 20) + .disabled(isLoading) + + if !statusText.isEmpty { + Text(statusText) + .font(.system(size: 13)) + .foregroundColor(Theme.accentLight) + .padding(.top, 14) + } + Spacer() + } + .padding(.horizontal, 28) + .padding(.top, 60) + .background(Theme.background.ignoresSafeArea()) + } + + private func fieldLabel(_ text: String) -> some View { + Text(text) + .font(.system(size: 13)) + .foregroundColor(Theme.textMuted) + .padding(.bottom, 4) + } + + private func bindPhone() async { + let cleaned = phone.trimmingCharacters(in: .whitespaces) + guard cleaned.range(of: "^1[3-9]\\d{9}$", options: .regularExpression) != nil else { + statusText = "请输入有效的 11 位手机号" + return + } + guard let token = auth.ssoPendingToken else { + statusText = "登录状态已过期,请重新登录" + return + } + isLoading = true + statusText = "绑定中..." + defer { isLoading = false } + do { + try await AuthAPI.ssoBindPhone(token: token, phone: cleaned) + auth.showingPhoneBinding = false + auth.ssoPendingToken = nil + auth.saveSession(AuthSession(token: token, user: AuthUser(id: "", username: "", nickname: ""))) + dismiss() + onLoginSuccess() + } catch { + statusText = "绑定失败:\(error.localizedDescription)" + } + } +} diff --git a/ios/OralTrainer/Views/RootView.swift b/ios/OralTrainer/Views/RootView.swift index d7d505f..6e46d77 100644 --- a/ios/OralTrainer/Views/RootView.swift +++ b/ios/OralTrainer/Views/RootView.swift @@ -3,40 +3,77 @@ import UIKit import UniformTypeIdentifiers struct RootView: View { + @StateObject private var auth = AuthManager() @StateObject private var controller = PlayerController() @StateObject private var catalogVM = CatalogViewModel() - @State private var activeModule: Module = .train + @State private var activeModule: Module = .course @State private var continuousPlayback = false @State private var showImporter = false @State private var showFullscreenPlayer = false + @State private var courses: [Course] = [] + @State private var currentCourse: Course? + @State private var courseStatusText = "正在同步" + @State private var showCourseGuardToast = false enum Module { + case course case train case test + case mine } var body: some View { + if auth.isLoggedIn { + mainContent + } else { + AuthView(auth: auth) { + Task { await loadCourses() } + } + } + } + + private var mainContent: some View { ZStack { Theme.background.ignoresSafeArea() VStack(spacing: 0) { header moduleTabs - if activeModule == .train { + switch activeModule { + case .course: + coursePage + case .train: TrainingView( controller: controller, catalogVM: catalogVM, continuousPlayback: $continuousPlayback, onFullscreen: enterFullscreen ) - } else { + case .test: TestView(controller: controller, onFullscreen: enterFullscreen) + case .mine: + minePage + } + } + + if showCourseGuardToast { + VStack { + Spacer() + Text("请先选择课程") + .font(.system(size: 14)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Theme.overlay) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Spacer().frame(height: 80) } } } .preferredColorScheme(.dark) .onAppear { - catalogVM.controller = controller - controller.setContinuousPlayback(continuousPlayback) + if auth.isLoggedIn && courses.isEmpty { + Task { await loadCourses() } + } } .onChange(of: activeModule) { module in if module == .train { @@ -70,18 +107,24 @@ struct RootView: View { Text("八哥口语") .font(.system(size: 32, weight: .bold)) .foregroundColor(.white) - Text("外语跟读训练神器") + Text(auth.nickname.isEmpty ? "外语跟读训练神器" : "你好,\(auth.nickname)") .font(.system(size: 15)) .foregroundColor(Theme.textMuted) } Spacer() - headerButton("导入") { - showImporter = true + if activeModule == .train { + headerButton("导入") { + showImporter = true + } } headerButton("刷新") { - Task { - await catalogVM.load() - } + Task { await loadCourses() } + } + headerButton("退出") { + auth.logout() + currentCourse = nil + courses = [] + activeModule = .course } } .padding(.horizontal, 18) @@ -105,30 +148,150 @@ struct RootView: View { private var moduleTabs: some View { HStack(spacing: 0) { + moduleTab("课程", module: .course) moduleTab("训练", module: .train) moduleTab("测试", module: .test) + moduleTab("我的", module: .mine) } .padding(.horizontal, 16) .padding(.bottom, 8) } private func moduleTab(_ label: String, module: Module) -> some View { - let selected = activeModule == module - return Button { - activeModule = module + Button { + if module != .course && currentCourse == nil { + showCourseGuardToast = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + showCourseGuardToast = false + } + } else { + activeModule = module + } } label: { Text(label) .font(.system(size: 15, weight: .bold)) - .foregroundColor(selected ? .white : Theme.textMuted) + .foregroundColor(activeModule == module ? .white : Theme.textMuted) .frame(maxWidth: .infinity) .frame(height: 38) - .background(selected ? Theme.buttonBlue : Theme.surface) + .background(activeModule == module ? Theme.buttonBlue : Theme.surface) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) - .padding(2) } + // MARK: - Course Page + + private var coursePage: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text("课程首页") + .font(.system(size: 26, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + Text("选择一门课程开始训练") + .font(.system(size: 13)) + .foregroundColor(Theme.textMuted) + .padding(.horizontal, 16) + .padding(.top, 4) + Text(courseStatusText) + .font(.system(size: 13)) + .foregroundColor(Theme.textMuted) + .padding(.horizontal, 16) + .padding(.top, 8) + + if courses.isEmpty { + Text("暂无课程") + .font(.system(size: 14)) + .foregroundColor(Theme.textMuted) + .padding(.horizontal, 16) + .padding(.top, 20) + } else { + LazyVStack(spacing: 10) { + ForEach(courses) { course in + courseCard(course) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + } + .padding(.top, 12) + } + } + + private func courseCard(_ course: Course) -> some View { + let isSelected = currentCourse?.videoHash == course.videoHash + return Button { + selectCourse(course) + } label: { + VStack(alignment: .leading, spacing: 6) { + Text(course.title) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(isSelected ? Theme.textDark : .white) + .lineLimit(2) + Text("\(course.enrolled ? "已选修" : "未选修") · \(course.sentenceCount) 句\(isSelected ? " · 当前课程" : "")") + .font(.system(size: 13)) + .foregroundColor(isSelected ? Theme.accentDeep : Theme.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(isSelected ? Theme.selected : Theme.surface) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Theme.accent : Theme.border, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + + private func selectCourse(_ course: Course) { + currentCourse = course + let video = course.toVideoSummary() + catalogVM.select(video) + activeModule = .train + } + + // MARK: - Mine Page + + private var minePage: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(auth.nickname.isEmpty ? "我的学习" : auth.nickname) + .font(.system(size: 26, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.top, 12) + + if let course = currentCourse { + Text("当前课程:\(course.title)") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + } + + Text("此页面显示当前课程的测试记录和配音分享") + .font(.system(size: 13)) + .foregroundColor(Theme.textMuted) + .padding(.horizontal, 16) + } + } + } + + // MARK: - Load Courses + + private func loadCourses() async { + courseStatusText = "正在同步" + do { + courses = try await AuthAPI.courses(token: auth.token) + courseStatusText = "\(courses.count) 个课程" + } catch { + courseStatusText = "课程加载失败:\(error.localizedDescription)" + } + } + + // MARK: - Fullscreen + private func enterFullscreen() { AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape updateSupportedInterfaceOrientations()