feat: 计划页对齐 android 并新增提交页
This commit is contained in:
298
IosClientKit/Sources/FeaturePlan/PlanScreen.swift
Normal file
298
IosClientKit/Sources/FeaturePlan/PlanScreen.swift
Normal file
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
135
IosClientKit/Sources/FeaturePlan/PlanSubmitScreen.swift
Normal file
135
IosClientKit/Sources/FeaturePlan/PlanSubmitScreen.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
IosClientKit/Sources/FeaturePlan/PlanSubmitViewModel.swift
Normal file
56
IosClientKit/Sources/FeaturePlan/PlanSubmitViewModel.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
IosClientKit/Sources/FeaturePlan/PlanUiState.swift
Normal file
9
IosClientKit/Sources/FeaturePlan/PlanUiState.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import CoreModel
|
||||||
|
|
||||||
|
/// 计划页状态。
|
||||||
|
public enum PlanUiState {
|
||||||
|
case loading
|
||||||
|
case success(PlanRecord)
|
||||||
|
case loggedOut
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
66
IosClientKit/Sources/FeaturePlan/PlanViewModel.swift
Normal file
66
IosClientKit/Sources/FeaturePlan/PlanViewModel.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
private var dataTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user