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 } } }