From 7f6cbb31900b150adbd9d45653c514821ac2f84b Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Tue, 15 Sep 2026 14:35:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=88=91=E7=9A=84=E9=A1=B5=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeatureProfile/ProfileScreen.swift | 168 ++++++++++++++++++ .../FeatureProfile/ProfileUiState.swift | 9 + .../FeatureProfile/ProfileViewModel.swift | 60 +++++++ 3 files changed, 237 insertions(+) create mode 100644 IosClientKit/Sources/FeatureProfile/ProfileScreen.swift create mode 100644 IosClientKit/Sources/FeatureProfile/ProfileUiState.swift create mode 100644 IosClientKit/Sources/FeatureProfile/ProfileViewModel.swift diff --git a/IosClientKit/Sources/FeatureProfile/ProfileScreen.swift b/IosClientKit/Sources/FeatureProfile/ProfileScreen.swift new file mode 100644 index 0000000..2ee115f --- /dev/null +++ b/IosClientKit/Sources/FeatureProfile/ProfileScreen.swift @@ -0,0 +1,168 @@ +import SwiftUI +import CoreModel +import CoreUI +import DesignSystem + +public struct ProfileScreen: View { + private let viewModel: ProfileViewModel + private let onNavigateToAuth: () -> Void + + @State private var showLogoutConfirm = false + + public init(viewModel: ProfileViewModel, onNavigateToAuth: @escaping () -> Void) { + self.viewModel = viewModel + self.onNavigateToAuth = onNavigateToAuth + } + + public var body: some View { + content + .navigationTitle("我的") + .task { viewModel.load() } + } + + @ViewBuilder + private var content: some View { + switch viewModel.uiState { + case .loading: + LoadingView() + case .loggedOut: + LoggedOutView(onNavigateToAuth: onNavigateToAuth) + case .error(let message): + ErrorView(message: message) { viewModel.load() } + case .success(let profile): + successContent(profile) + } + } + + private func successContent(_ profile: UserProfile) -> some View { + ScrollView { + VStack(spacing: DS.Spacing.md) { + ProfileHeaderCard(profile: profile) + InfoCard(profile: profile) + if !profile.classGroups.isEmpty { + ClassGroupCard(groups: profile.classGroups) + } + Button("退出登录", role: .destructive) { + showLogoutConfirm = true + } + .frame(maxWidth: .infinity) + .buttonStyle(.bordered) + } + .padding(DS.Spacing.md) + } + .background(DS.Color.brandPaper) + .alert("退出登录", isPresented: $showLogoutConfirm) { + Button("取消", role: .cancel) {} + Button("确定", role: .destructive) { viewModel.logout() } + } message: { + Text("确定要退出登录吗?退出后需要重新登录。") + } + } +} + +// MARK: - 头像卡 + +private struct ProfileHeaderCard: View { + let profile: UserProfile + + var body: some View { + HStack(spacing: DS.Spacing.md) { + ZStack(alignment: .bottomTrailing) { + Text(String(profile.displayName.prefix(1))) + .font(.title.bold()) + .foregroundStyle(.white) + .frame(width: 56, height: 56) + .background(DS.Color.brandGold) + .clipShape(Circle()) + Text("📷") + .font(.caption2) + .frame(width: 20, height: 20) + .background(DS.Color.brandGold) + .clipShape(Circle()) + .overlay(Circle().stroke(DS.Color.brandPaper, lineWidth: 2)) + } + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: DS.Spacing.xs) { + Text(profile.displayName) + .font(.title3.bold()) + .foregroundStyle(DS.Color.brandDark) + Text(profile.role) + .font(.caption.bold()) + .foregroundStyle(DS.Color.brandGold) + .padding(.horizontal, DS.Spacing.sm) + .padding(.vertical, 2) + .background(DS.Color.brandChip) + .clipShape(Capsule()) + } + Text(profile.studentId) + .font(.caption) + .foregroundStyle(DS.Color.brandMuted) + } + Spacer() + } + .padding(DS.Spacing.md) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +// MARK: - 信息卡 + +private struct InfoCard: View { + let profile: UserProfile + + var body: some View { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + Text("个人信息") + .font(.subheadline.bold()) + .foregroundStyle(DS.Color.brandDark) + LabeledRow(label: "姓名", value: profile.name) + LabeledRow(label: "学号", value: profile.studentId) + LabeledRow(label: "手机号", value: profile.phone ?? "未绑定") + LabeledRow(label: "角色", value: profile.role) + LabeledRow(label: "小组", value: profile.group ?? "未分组") + LabeledRow(label: "登录方式", value: profile.loginType) + } + .padding(DS.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +private struct ClassGroupCard: View { + let groups: [ClassGroup] + + var body: some View { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + Text("小组分组") + .font(.subheadline.bold()) + .foregroundStyle(DS.Color.brandDark) + ForEach(groups) { group in + LabeledRow(label: group.period, value: "\(group.group) · \(group.role)") + } + } + .padding(DS.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +private struct LabeledRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.subheadline) + .foregroundStyle(DS.Color.brandMuted) + Spacer() + Text(value) + .font(.subheadline) + .foregroundStyle(DS.Color.brandDark) + } + .padding(.vertical, DS.Spacing.xs) + } +} diff --git a/IosClientKit/Sources/FeatureProfile/ProfileUiState.swift b/IosClientKit/Sources/FeatureProfile/ProfileUiState.swift new file mode 100644 index 0000000..8f5e261 --- /dev/null +++ b/IosClientKit/Sources/FeatureProfile/ProfileUiState.swift @@ -0,0 +1,9 @@ +import CoreModel + +/// 我的页状态。 +public enum ProfileUiState { + case loading + case success(UserProfile) + case loggedOut + case error(String) +} diff --git a/IosClientKit/Sources/FeatureProfile/ProfileViewModel.swift b/IosClientKit/Sources/FeatureProfile/ProfileViewModel.swift new file mode 100644 index 0000000..35943b4 --- /dev/null +++ b/IosClientKit/Sources/FeatureProfile/ProfileViewModel.swift @@ -0,0 +1,60 @@ +import CoreDomain +import CoreModel +import Observation + +@MainActor +@Observable +public final class ProfileViewModel { + public private(set) var uiState: ProfileUiState = .loading + + private let profileUseCase: ProfileUseCase + private let sessionUseCase: SessionUseCase + private var loadTask: Task? + private var dataTask: Task? + + public init(profileUseCase: ProfileUseCase, sessionUseCase: SessionUseCase) { + self.profileUseCase = profileUseCase + self.sessionUseCase = sessionUseCase + } + + public func load() { + loadTask?.cancel() + loadTask = Task { [weak self] in + guard let self else { return } + do { + for try await isLoggedIn in self.sessionUseCase.observeIsLoggedIn() { + self.handleLoginState(isLoggedIn) + } + } catch is CancellationError { + } catch { + self.uiState = .error(error.localizedDescription) + } + } + } + + public func logout() { + Task { [weak self] in + try? await self?.sessionUseCase.logout() + } + } + + private func handleLoginState(_ isLoggedIn: Bool) { + dataTask?.cancel() + guard isLoggedIn else { + uiState = .loggedOut + return + } + uiState = .loading + dataTask = Task { [weak self] in + guard let self else { return } + do { + for try await profile in self.profileUseCase.observeProfile() { + self.uiState = .success(profile) + } + } catch is CancellationError { + } catch { + self.uiState = .error(error.localizedDescription) + } + } + } +}