diff --git a/IosClientKit/Sources/FeatureCheckin/CheckinScreen.swift b/IosClientKit/Sources/FeatureCheckin/CheckinScreen.swift new file mode 100644 index 0000000..207bda4 --- /dev/null +++ b/IosClientKit/Sources/FeatureCheckin/CheckinScreen.swift @@ -0,0 +1,286 @@ +import SwiftUI +import CoreModel +import CoreUI +import DesignSystem + +public struct CheckinScreen: View { + private let viewModel: CheckinViewModel + private let onNavigateToAuth: () -> Void + + public init(viewModel: CheckinViewModel, 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 record): + successContent(record) + } + } + + private func successContent(_ record: CheckinRecord) -> some View { + ScrollView { + VStack(spacing: DS.Spacing.md) { + StatusTitleCard(record: record) + StatusSelector( + selected: viewModel.selectedStatus, + submitting: viewModel.submitting, + onSelect: { viewModel.submit($0) } + ) + RecordPanel(record: record) + } + .padding(DS.Spacing.md) + } + .background(DS.Color.brandPaper) + } +} + +// MARK: - 顶部状态卡 + +private struct StatusTitleCard: View { + let record: CheckinRecord + + var body: some View { + HStack(spacing: DS.Spacing.md) { + VStack(spacing: 2) { + Text(record.weekday) + .font(.caption) + .foregroundStyle(DS.Color.brandCream) + Text("\(record.dayNum)") + .font(.title3.bold()) + .foregroundStyle(.white) + } + .frame(width: 62) + .padding(.vertical, DS.Spacing.sm) + .background(DS.Color.brandDark) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + Text(record.checked ? "已打卡" : "未打卡") + .font(.title3.bold()) + .foregroundStyle(record.checked ? DS.Color.statusNormal : DS.Color.brandGold) + if record.checked, let status = record.status { + StatusBadge(status: status) + } else { + Text("请选择状态完成打卡") + .font(.caption) + .foregroundStyle(DS.Color.brandMuted) + } + } + Spacer() + } + .padding(DS.Spacing.md) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} + +// MARK: - 状态选择器 + +private struct StatusSelector: View { + let selected: CheckinStatus + let submitting: Bool + let onSelect: (CheckinStatus) -> Void + + var body: some View { + VStack(spacing: DS.Spacing.sm) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: DS.Spacing.sm) { + ForEach(CheckinStatus.allCases, id: \.self) { status in + StatusPill(status: status, isSelected: status == selected) { + onSelect(status) + } + } + } + if submitting { + Text("提交中…") + .font(.caption) + .foregroundStyle(DS.Color.brandMuted) + } + } + .padding(DS.Spacing.md) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } +} + +private struct StatusPill: View { + let status: CheckinStatus + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 4) { + Circle() + .fill(isSelected ? Color.white : status.color) + .frame(width: 8, height: 8) + Text(status.rawValue) + .font(.footnote) + .lineLimit(1) + } + .padding(.vertical, DS.Spacing.sm) + .frame(maxWidth: .infinity) + .background(isSelected ? status.color : Color.white) + .foregroundStyle(isSelected ? Color.white : DS.Color.brandBrown) + .clipShape(Capsule()) + .overlay(Capsule().stroke(isSelected ? Color.clear : DS.Color.disabled, lineWidth: 1)) + } + .buttonStyle(.plain) + } +} + +// MARK: - 记录面板(小组/个人) + +private struct RecordPanel: View { + let record: CheckinRecord + @State private var tab: CheckinTab = .group + + var body: some View { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + HStack(spacing: DS.Spacing.lg) { + tabButton("小组", .group) + tabButton("个人", .personal) + } + if tab == .group { + groupList + } else { + personalList + } + } + .padding(DS.Spacing.md) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + private func tabButton(_ title: String, _ value: CheckinTab) -> some View { + Button { + tab = value + } label: { + VStack(spacing: 4) { + Text(title) + .font(.subheadline.bold()) + .foregroundStyle(tab == value ? DS.Color.brandGold : DS.Color.brandBrown) + Rectangle() + .fill(tab == value ? DS.Color.brandGold : Color.clear) + .frame(height: 2) + } + } + .buttonStyle(.plain) + } + + private var checkedCount: Int { + record.groupMembers.filter(\.checkedIn).count + } + + private var groupList: some View { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + HStack { + Text("\(record.groupName) · \(record.classDate)") + .font(.subheadline.bold()) + .foregroundStyle(DS.Color.brandDark) + Spacer() + Text("\(checkedCount)/\(record.groupMembers.count) 已打卡") + .font(.caption) + .foregroundStyle(DS.Color.brandMuted) + } + ForEach(record.groupMembers) { member in + GroupMemberRow(member: member) + } + } + } + + private var personalList: some View { + Group { + if record.personalHistory.isEmpty { + EmptyStateView(title: "暂无个人打卡记录", systemImage: "book") + } else { + VStack(spacing: DS.Spacing.sm) { + ForEach(record.personalHistory, id: \.date) { item in + HStack { + Text(item.date) + .font(.subheadline.bold()) + .foregroundStyle(DS.Color.brandDark) + Spacer() + StatusBadge(status: item.status) + Text(item.time) + .font(.caption) + .foregroundStyle(DS.Color.brandMuted) + } + } + } + } + } + } +} + +// MARK: - 成员行 / 状态徽标 + +private struct GroupMemberRow: View { + let member: GroupMember + + var body: some View { + HStack(spacing: DS.Spacing.sm) { + Text(String(member.name.prefix(1))) + .font(.subheadline.bold()) + .foregroundStyle(member.checkedIn ? DS.Color.statusNormal : DS.Color.brandGold) + .frame(width: 30, height: 30) + .background(member.checkedIn ? DS.Color.fillDone : DS.Color.brandChip) + .clipShape(Circle()) + Text(member.name) + .font(.subheadline) + .foregroundStyle(DS.Color.brandDark) + Spacer() + if member.checkedIn, let status = member.status { + Text("打卡 \(member.checkinTime ?? "")") + .font(.caption) + .foregroundStyle(DS.Color.brandMuted) + StatusBadge(status: status) + } else { + Text("未打卡") + .font(.caption) + .foregroundStyle(DS.Color.faintText) + } + } + .padding(.vertical, DS.Spacing.xs) + } +} + +private struct StatusBadge: View { + let status: CheckinStatus + + var body: some View { + Text(status.rawValue) + .font(.caption) + .foregroundStyle(status.color) + .padding(.horizontal, DS.Spacing.sm) + .padding(.vertical, 2) + .background(status.color.opacity(0.12)) + .clipShape(Capsule()) + } +} + +private extension CheckinStatus { + var color: Color { + switch self { + case .normal: return DS.Color.statusNormal + case .late: return DS.Color.statusLate + case .leave: return DS.Color.statusLeave + case .earlyLeave: return DS.Color.statusEarlyLeave + case .absent: return DS.Color.statusAbsent + } + } +} diff --git a/IosClientKit/Sources/FeatureCheckin/CheckinUiState.swift b/IosClientKit/Sources/FeatureCheckin/CheckinUiState.swift new file mode 100644 index 0000000..d13f433 --- /dev/null +++ b/IosClientKit/Sources/FeatureCheckin/CheckinUiState.swift @@ -0,0 +1,15 @@ +import CoreModel + +/// 打卡页状态。 +public enum CheckinUiState { + case loading + case success(CheckinRecord) + case loggedOut + case error(String) +} + +/// 记录面板 tab。 +public enum CheckinTab: Hashable { + case group + case personal +} diff --git a/IosClientKit/Sources/FeatureCheckin/CheckinViewModel.swift b/IosClientKit/Sources/FeatureCheckin/CheckinViewModel.swift new file mode 100644 index 0000000..ca0c5f8 --- /dev/null +++ b/IosClientKit/Sources/FeatureCheckin/CheckinViewModel.swift @@ -0,0 +1,68 @@ +import CoreDomain +import CoreModel +import Observation + +@MainActor +@Observable +public final class CheckinViewModel { + public private(set) var uiState: CheckinUiState = .loading + public var selectedStatus: CheckinStatus = .normal + public var submitting = false + + private let checkinUseCase: CheckinUseCase + private let sessionUseCase: SessionUseCase + private var loadTask: Task? + private var dataTask: Task? + + public init(checkinUseCase: CheckinUseCase, sessionUseCase: SessionUseCase) { + self.checkinUseCase = checkinUseCase + 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) + } + } + } + + /// 提交打卡(对齐 android:只即时高亮选中 pill,卡片/成员靠流重新发射刷新)。 + public func submit(_ status: CheckinStatus) { + guard !submitting else { return } + selectedStatus = status + submitting = true + Task { [weak self] in + guard let self else { return } + try? await self.checkinUseCase.submitCheckin(status: status) + self.submitting = false + } + } + + 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 record in self.checkinUseCase.observeCheckin() { + self.uiState = .success(record) + } + } catch is CancellationError { + } catch { + self.uiState = .error(error.localizedDescription) + } + } + } +}