Files
2026-09-15 14:35:52 +08:00

287 lines
9.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}