feat: 打卡页对齐 android

This commit is contained in:
AndyChen1024
2026-09-15 14:35:52 +08:00
parent 2986340ad8
commit 3e90f8edee
3 changed files with 369 additions and 0 deletions

View File

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

View File

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

View File

@@ -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<Void, Never>?
private var dataTask: Task<Void, Never>?
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)
}
}
}
}