diff --git a/IosClientKit/Sources/FeaturePlan/PlanScreen.swift b/IosClientKit/Sources/FeaturePlan/PlanScreen.swift new file mode 100644 index 0000000..a97ff6e --- /dev/null +++ b/IosClientKit/Sources/FeaturePlan/PlanScreen.swift @@ -0,0 +1,298 @@ +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]) + } +} diff --git a/IosClientKit/Sources/FeaturePlan/PlanSubmitScreen.swift b/IosClientKit/Sources/FeaturePlan/PlanSubmitScreen.swift new file mode 100644 index 0000000..ec61ed7 --- /dev/null +++ b/IosClientKit/Sources/FeaturePlan/PlanSubmitScreen.swift @@ -0,0 +1,135 @@ +import SwiftUI +import CoreModel +import CoreUI +import DesignSystem + +public struct PlanSubmitScreen: View { + private let viewModel: PlanSubmitViewModel + private let type: PlanType + private let periodKey: String + + @State private var content = "" + @Environment(\.dismiss) private var dismiss + + public init(viewModel: PlanSubmitViewModel, type: PlanType, periodKey: String) { + self.viewModel = viewModel + self.type = type + self.periodKey = periodKey + } + + public var body: some View { + contentView + .navigationTitle("\(type.label) · \(periodKey)") + .task { viewModel.load() } + .onChange(of: viewModel.uiState) { _, newState in + if case .success(let data) = newState, content.isEmpty, !data.content.isEmpty { + content = data.content + } + } + .onChange(of: viewModel.saved) { _, saved in + if saved { dismiss() } + } + } + + @ViewBuilder + private var contentView: some View { + switch viewModel.uiState { + case .loading: + LoadingView() + case .error(let message): + ErrorView(message: message) { viewModel.load() } + case .success(let data): + submitContent(data) + } + } + + private func submitContent(_ data: PlanSubmitData) -> some View { + ScrollView { + VStack(spacing: DS.Spacing.md) { + if !data.canSubmit, let message = data.message { + Text(message) + .font(.caption) + .foregroundStyle(DS.Color.brandMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + + VStack(alignment: .trailing, spacing: DS.Spacing.xs) { + TextEditor(text: $content) + .frame(minHeight: 120) + .scrollContentBackground(.hidden) + .padding(DS.Spacing.sm) + .background(data.canSubmit ? DS.Color.brandPaper : DS.Color.brandChip) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(DS.Color.disabled, lineWidth: 1) + ) + Text("\(content.count)/1000") + .font(.caption) + .foregroundStyle(DS.Color.faintText) + } + + HStack { + Text("+ 添加图片(\(data.attachments.count)/4)") + .font(.subheadline) + .foregroundStyle(data.canSubmit ? DS.Color.brandGold : DS.Color.disabled) + Spacer() + } + .padding(DS.Spacing.sm) + .frame(maxWidth: .infinity) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(data.canSubmit ? DS.Color.brandGold : DS.Color.disabled, lineWidth: 1) + ) + + Button(saveTitle(data)) { + viewModel.save(content: content) + } + .buttonStyle(.borderedProminent) + .disabled(!(data.canSubmit && !content.isEmpty && !viewModel.saving)) + + HistorySection(history: data.history) + } + .padding(DS.Spacing.md) + } + .background(DS.Color.brandPaper) + } + + private func saveTitle(_ data: PlanSubmitData) -> String { + if !data.canSubmit { return "不可提交" } + if data.checked { return "保存修改" } + return "保存计划" + } +} + +private struct HistorySection: View { + let history: [MyPlanRecord] + + var body: some View { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + Text("历史计划") + .font(.subheadline.bold()) + .foregroundStyle(DS.Color.brandDark) + if history.isEmpty { + EmptyStateView(title: "暂无历史计划", systemImage: "doc") + } else { + ForEach(history.indices, id: \.self) { index in + let item = history[index] + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + Text("\(item.submittedTime) · \(item.status)") + .font(.caption) + .foregroundStyle(DS.Color.brandGold) + Text(item.content) + .font(.subheadline) + .foregroundStyle(DS.Color.brandBrown) + } + .padding(DS.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + } + } +} diff --git a/IosClientKit/Sources/FeaturePlan/PlanSubmitViewModel.swift b/IosClientKit/Sources/FeaturePlan/PlanSubmitViewModel.swift new file mode 100644 index 0000000..c07607e --- /dev/null +++ b/IosClientKit/Sources/FeaturePlan/PlanSubmitViewModel.swift @@ -0,0 +1,56 @@ +import CoreDomain +import CoreModel +import Observation + +/// 提交计划页状态。 +public enum PlanSubmitUiState: Equatable { + case loading + case success(PlanSubmitData) + case error(String) +} + +@MainActor +@Observable +public final class PlanSubmitViewModel { + public private(set) var uiState: PlanSubmitUiState = .loading + public var saving = false + public var saved = false + + private let planUseCase: PlanUseCase + private let type: PlanType + private let periodKey: String + private var loadTask: Task? + + public init(planUseCase: PlanUseCase, type: PlanType, periodKey: String) { + self.planUseCase = planUseCase + self.type = type + self.periodKey = periodKey + } + + public func load() { + loadTask?.cancel() + loadTask = Task { [weak self] in + guard let self else { return } + self.uiState = .loading + do { + for try await data in self.planUseCase.observePlanSubmit(type: self.type, periodKey: self.periodKey) { + self.uiState = .success(data) + } + } catch is CancellationError { + } catch { + self.uiState = .error(error.localizedDescription) + } + } + } + + public func save(content: String) { + guard !saving else { return } + saving = true + Task { [weak self] in + guard let self else { return } + try? await self.planUseCase.savePlan(type: self.type, periodKey: self.periodKey, content: content, attachments: []) + self.saving = false + self.saved = true + } + } +} diff --git a/IosClientKit/Sources/FeaturePlan/PlanUiState.swift b/IosClientKit/Sources/FeaturePlan/PlanUiState.swift new file mode 100644 index 0000000..e2a8d0f --- /dev/null +++ b/IosClientKit/Sources/FeaturePlan/PlanUiState.swift @@ -0,0 +1,9 @@ +import CoreModel + +/// 计划页状态。 +public enum PlanUiState { + case loading + case success(PlanRecord) + case loggedOut + case error(String) +} diff --git a/IosClientKit/Sources/FeaturePlan/PlanViewModel.swift b/IosClientKit/Sources/FeaturePlan/PlanViewModel.swift new file mode 100644 index 0000000..3645c48 --- /dev/null +++ b/IosClientKit/Sources/FeaturePlan/PlanViewModel.swift @@ -0,0 +1,66 @@ +import CoreDomain +import CoreModel +import Observation + +@MainActor +@Observable +public final class PlanViewModel { + public private(set) var uiState: PlanUiState = .loading + public var selectedType: PlanType = .weekly + + private let planUseCase: PlanUseCase + private let sessionUseCase: SessionUseCase + private var loadTask: Task? + private var dataTask: Task? + + public init(planUseCase: PlanUseCase, sessionUseCase: SessionUseCase) { + self.planUseCase = planUseCase + 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) + } + } + } + + public func selectType(_ type: PlanType) { + guard type != selectedType else { return } + selectedType = type + reloadData() + } + + private func handleLoginState(_ isLoggedIn: Bool) { + dataTask?.cancel() + guard isLoggedIn else { + uiState = .loggedOut + return + } + reloadData() + } + + private func reloadData() { + dataTask?.cancel() + uiState = .loading + dataTask = Task { [weak self] in + guard let self else { return } + do { + for try await record in self.planUseCase.observePlan(type: self.selectedType) { + self.uiState = .success(record) + } + } catch is CancellationError { + } catch { + self.uiState = .error(error.localizedDescription) + } + } + } +}