287 lines
9.2 KiB
Swift
287 lines
9.2 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|