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