Files
ios-client/IosClientKit/Sources/FeaturePlan/PlanScreen.swift
2026-09-15 14:35:52 +08:00

299 lines
11 KiB
Swift
Raw 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 PlanScreen: View {
private let viewModel: PlanViewModel
private let onNavigateToAuth: () -> Void
private let makePlanSubmitViewModel: (PlanType, String) -> PlanSubmitViewModel
@State private var submitRoute: PlanSubmitRoute?
@State private var selectedGroup: PlanGroup?
public init(
viewModel: PlanViewModel,
onNavigateToAuth: @escaping () -> Void,
makePlanSubmitViewModel: @escaping (PlanType, String) -> PlanSubmitViewModel
) {
self.viewModel = viewModel
self.onNavigateToAuth = onNavigateToAuth
self.makePlanSubmitViewModel = makePlanSubmitViewModel
}
public var body: some View {
content
.navigationTitle("计划")
.task { viewModel.load() }
.navigationDestination(item: $submitRoute) { route in
PlanSubmitScreen(
viewModel: makePlanSubmitViewModel(route.type, route.periodKey),
type: route.type,
periodKey: route.periodKey
)
}
.sheet(item: $selectedGroup) { group in
GroupDetailSheet(group: group)
}
}
@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: PlanRecord) -> some View {
ScrollView {
VStack(spacing: DS.Spacing.md) {
Picker("类型", selection: Binding(get: { viewModel.selectedType }, set: { viewModel.selectType($0) })) {
ForEach(PlanType.allCases, id: \.self) { type in
Text(type.label).tag(type)
}
}
.pickerStyle(.segmented)
.labelsHidden()
PlanHead(record: record)
MyPlanStatusCard(record: record) {
submitRoute = PlanSubmitRoute(type: record.type, periodKey: record.periodKey)
}
if record.myChecked, let myRecord = record.myRecord {
UploadedPlanCard(record: myRecord)
}
if let message = record.message {
WindowMessage(message: message)
}
if record.groups.isEmpty {
EmptyStateView(title: "暂无小组", systemImage: "person.2")
} else {
Text("小组打卡")
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(record.groups) { group in
GroupCard(group: group) { selectedGroup = group }
}
}
if record.unassignedCount > 0 {
Text("另有未分组 \(record.unassignedCount) 人")
.font(.caption)
.foregroundStyle(DS.Color.faintText)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(DS.Spacing.md)
}
.background(DS.Color.brandPaper)
}
}
private struct PlanSubmitRoute: Hashable {
let type: PlanType
let periodKey: String
}
// MARK: - 头部 / 状态卡
private struct PlanHead: View {
let record: PlanRecord
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(record.type.label)
.font(.title3.bold())
.foregroundStyle(DS.Color.brandDark)
Text(record.periodKey)
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
Spacer()
Text(record.myChecked ? "已提交" : "待提交")
.font(.caption)
.foregroundStyle(record.myChecked ? DS.Color.statusNormal : DS.Color.brandGold)
.padding(.horizontal, DS.Spacing.sm)
.padding(.vertical, 4)
.background(record.myChecked ? DS.Color.fillDone : DS.Color.fillUndone)
.clipShape(Capsule())
}
}
}
private struct MyPlanStatusCard: View {
let record: PlanRecord
let onSubmit: () -> Void
var body: some View {
HStack(spacing: DS.Spacing.md) {
Text(record.myChecked ? "✓" : "●")
.font(.headline)
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(
record.myChecked
? AnyShapeStyle(LinearGradient(colors: [DS.Color.statusNormal, DS.Color.statusNormalBright], startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(LinearGradient(colors: [DS.Color.danger, DS.Color.dangerBright], startPoint: .top, endPoint: .bottom))
)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(record.myChecked ? "已完成" : "未完成")
.font(.subheadline.bold())
.foregroundStyle(record.myChecked ? DS.Color.statusNormal : DS.Color.danger)
Text("我的\(record.type.label)")
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
}
Spacer()
Button(record.myChecked ? "修改" : "新增打卡", action: onSubmit)
.buttonStyle(.bordered)
.disabled(!record.canSubmit)
}
.padding(DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct UploadedPlanCard: View {
let record: MyPlanRecord
var body: some View {
VStack(alignment: .leading, spacing: DS.Spacing.sm) {
HStack {
Text("我上传的计划")
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
Spacer()
Text(record.status)
.font(.caption)
.foregroundStyle(DS.Color.brandGold)
.padding(.horizontal, DS.Spacing.sm)
.padding(.vertical, 2)
.background(DS.Color.fillUndone)
.clipShape(Capsule())
}
Text("\(record.submittedTime) 提交")
.font(.caption)
.foregroundStyle(DS.Color.brandMuted)
Text(record.content)
.font(.subheadline)
.foregroundStyle(DS.Color.brandBrown)
if !record.attachments.isEmpty {
HStack(spacing: DS.Spacing.sm) {
ForEach(record.attachments, id: \.self) { _ in
Text("🖼️")
.frame(width: 64, height: 64)
.background(DS.Color.brandChip)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}
}
}
.padding(DS.Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
private struct WindowMessage: View {
let message: String
var body: some View {
HStack(spacing: DS.Spacing.sm) {
Text("⏰")
Text(message)
.font(.caption)
.foregroundStyle(DS.Color.danger)
}
.padding(DS.Spacing.sm)
.frame(maxWidth: .infinity, alignment: .leading)
.background(DS.Color.fillUndone)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
// MARK: - 小组卡片 / 明细
private struct GroupCard: View {
let group: PlanGroup
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: DS.Spacing.sm) {
Text(String(group.name.prefix(1)))
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandGold)
.frame(width: 40, height: 40)
.background(DS.Gradient.groupTile)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: DS.Spacing.xs) {
Text(group.name)
.font(.subheadline.bold())
.foregroundStyle(DS.Color.brandDark)
ProgressView(value: Double(group.checkedCount), total: Double(max(group.memberCount, 1)))
.tint(DS.Color.brandGold)
}
Spacer()
Text("已打卡 \(group.checkedCount)")
.font(.caption2)
.foregroundStyle(DS.Color.statusNormal)
Text("\(group.memberCount)人")
.font(.caption2)
.foregroundStyle(DS.Color.brandGold)
Text("›")
.font(.headline)
.foregroundStyle(DS.Color.faintText)
}
.padding(DS.Spacing.md)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
}
}
private struct GroupDetailSheet: View {
let group: PlanGroup
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(group.members) { member in
HStack(spacing: DS.Spacing.sm) {
Text(String(member.name.prefix(1)))
.font(.subheadline.bold())
.foregroundStyle(member.filled ? DS.Color.statusNormal : DS.Color.brandGold)
.frame(width: 30, height: 30)
.background(member.filled ? DS.Color.fillDone : DS.Color.brandChip)
.clipShape(Circle())
Text(member.name)
.font(.subheadline)
.foregroundStyle(DS.Color.brandDark)
Spacer()
Text(member.filled ? "已完成" : "未完成")
.font(.caption)
.foregroundStyle(member.filled ? DS.Color.statusNormal : DS.Color.danger)
}
}
.navigationTitle(group.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("关闭") { dismiss() }
}
}
}
.presentationDetents([.medium])
}
}