feat: 打卡页对齐 android
This commit is contained in:
286
IosClientKit/Sources/FeatureCheckin/CheckinScreen.swift
Normal file
286
IosClientKit/Sources/FeatureCheckin/CheckinScreen.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
IosClientKit/Sources/FeatureCheckin/CheckinUiState.swift
Normal file
15
IosClientKit/Sources/FeatureCheckin/CheckinUiState.swift
Normal 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
|
||||||
|
}
|
||||||
68
IosClientKit/Sources/FeatureCheckin/CheckinViewModel.swift
Normal file
68
IosClientKit/Sources/FeatureCheckin/CheckinViewModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user