新增项目、排期与邀请模块,并接入首页路由

将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 15:57:15 +08:00
parent abcac9bfdf
commit 311a70d610
40 changed files with 4496 additions and 108 deletions

View File

@ -0,0 +1,776 @@
//
// ProjectViews.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
///
struct ProjectManagementView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(AccountSnapshotStore.self) private var snapshotStore
@Environment(ProjectAPI.self) private var projectAPI
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = ProjectManagementViewModel()
var body: some View {
VStack(spacing: 0) {
filterBar
if accountContext.currentScenic?.id == nil {
ContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。"))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
projectList
}
}
.navigationTitle("项目管理")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: HomeRoute.projectEditor(id: nil, storeMode: false)) {
Image(systemName: "plus")
}
.accessibilityLabel("新建项目")
}
}
.task(id: accountContext.currentScenic?.id) {
await reload(showLoading: true)
}
}
private var filterBar: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppDesign.textSecondary)
TextField("搜索项目名称", text: $viewModel.searchText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.search)
.onSubmit { Task { await reload(showLoading: true) } }
Button("搜索") {
Task { await reload(showLoading: true) }
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 52)
.background(.white)
}
private var projectList: some View {
ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.items.isEmpty && !viewModel.loading {
ContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。"))
.frame(maxWidth: .infinity, minHeight: 260)
}
ForEach(viewModel.items) { item in
NavigationLink(value: HomeRoute.projectDetail(id: item.id, storeMode: false)) {
ProjectCardView(item: item)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task { await viewModel.delete(id: item.id, api: projectAPI) }
} label: {
Label("删除", systemImage: "trash")
}
}
}
if viewModel.hasMore {
ProgressView()
.padding(.vertical, AppMetrics.Spacing.medium)
.onAppear {
Task { await viewModel.loadMore(api: projectAPI, scenicId: accountContext.currentScenic?.id) }
}
} else if !viewModel.items.isEmpty {
Text("没有更多")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.padding(.vertical, AppMetrics.Spacing.medium)
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.refreshable { await reload(showLoading: false) }
}
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
await viewModel.reload(api: projectAPI, scenicId: accountContext.currentScenic?.id)
}
}
}
///
struct StoreProjectManagementView: View {
@Environment(AccountSnapshotStore.self) private var snapshotStore
@Environment(ProjectAPI.self) private var projectAPI
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = StoreProjectManagementViewModel()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
storeFilterCard
storeSummaryCard
ForEach(viewModel.filteredItems) { item in
NavigationLink(value: HomeRoute.projectDetail(id: item.id, storeMode: true)) {
ProjectCompactCardView(item: item)
}
.buttonStyle(.plain)
}
if viewModel.filteredItems.isEmpty && !viewModel.loading {
ContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。"))
.frame(minHeight: 260)
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("店铺项目管理")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: HomeRoute.projectEditor(id: nil, storeMode: true)) {
Image(systemName: "plus")
}
.accessibilityLabel("新建店铺项目")
}
}
.task { await reload(showLoading: true) }
.refreshable { await reload(showLoading: false) }
}
private var storeFilterCard: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Picker("项目类型", selection: $viewModel.selectedType) {
Text("全部").tag(0)
Text("多点位").tag(19)
Text("押金").tag(4)
}
.pickerStyle(.segmented)
TextField("搜索项目名称", text: $viewModel.searchText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private var storeSummaryCard: some View {
HStack(spacing: AppMetrics.Spacing.small) {
stat("总数", "\(viewModel.filteredItems.count)")
stat("启用", "\(viewModel.filteredItems.filter { $0.status == 1 }.count)")
stat("押金", "\(viewModel.filteredItems.filter { $0.type == 4 }.count)")
}
}
private func stat(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.reload(api: projectAPI, userId: snapshotStore.load()?.profile?.userId ?? snapshotStore.load()?.businessUserId.map(String.init))
}
}
}
///
struct ProjectDetailView: View {
@Environment(ProjectAPI.self) private var projectAPI
@Environment(\.globalLoading) private var globalLoading
let projectId: Int
let storeMode: Bool
@State private var detail: PhotographerProjectDetailResponse?
@State private var errorMessage: String?
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
if let detail {
ProjectDetailContentView(detail: detail)
} else if let errorMessage {
ContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
.frame(minHeight: 360)
} else {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 360)
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("项目详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if detail != nil {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: HomeRoute.projectEditor(id: projectId, storeMode: storeMode)) {
Text("编辑")
}
}
}
}
.task { await load() }
}
private func load() async {
await globalLoading.withLoading {
do {
detail = storeMode ? try await projectAPI.storeManagerProjectDetail(id: projectId) : try await projectAPI.projectDetail(id: projectId)
} catch {
errorMessage = error.localizedDescription
}
}
}
}
///
struct ProjectEditorView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AccountContext.self) private var accountContext
@Environment(AccountSnapshotStore.self) private var snapshotStore
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(ProjectAPI.self) private var projectAPI
@Environment(OSSUploadService.self) private var uploadService
@Environment(\.globalLoading) private var globalLoading
let projectId: Int?
@State private var viewModel = ProjectEditorViewModel(mode: .create)
@State private var coverItem: PhotosPickerItem?
@State private var carouselItems: [PhotosPickerItem] = []
@State private var loadingDetail = false
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
basicForm
projectImageForm
countForm
spotForm
if let error = viewModel.errorMessage {
Text(error)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0xE5484D))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(Color(hex: 0xE5484D).opacity(0.1), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle(projectId == nil ? "新建项目" : "编辑项目")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(viewModel.submitting ? "保存中..." : "保存") {
Task { await submit() }
}
.disabled(viewModel.submitting || loadingDetail)
}
}
.task {
await prepare()
}
.onChange(of: coverItem) { _, newValue in
Task { await loadCover(newValue) }
}
.onChange(of: carouselItems) { _, newValue in
Task { await loadCarousel(newValue) }
}
}
private var basicForm: some View {
ProjectFormCard(title: "基础信息") {
TextField("项目名称", text: $viewModel.name)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("项目简介", text: $viewModel.descriptionText, axis: .vertical)
.lineLimit(3...5)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 96)
HStack(spacing: AppMetrics.Spacing.small) {
TextField("优惠价", text: $viewModel.price)
.keyboardType(.decimalPad)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("原价", text: $viewModel.otPrice)
.keyboardType(.decimalPad)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
}
TextField("押金", text: $viewModel.deposit)
.keyboardType(.decimalPad)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("标签,多个用逗号分隔", text: $viewModel.attrLabelText)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
}
}
private var projectImageForm: some View {
ProjectFormCard(title: "项目图片") {
PhotosPicker(selection: $coverItem, matching: .images) {
ProjectImagePickerLabel(title: "项目封面", url: viewModel.coverImage?.previewURL ?? viewModel.existingCoverURL)
}
PhotosPicker(selection: $carouselItems, maxSelectionCount: 8, matching: .images) {
Label("选择轮播图(\(viewModel.carouselImages.count + viewModel.existingCarouselURLs.count)", systemImage: "photo.stack")
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
.buttonStyle(.plain)
}
}
private var countForm: some View {
ProjectFormCard(title: "交付数量") {
HStack(spacing: AppMetrics.Spacing.small) {
TextField("底片", text: $viewModel.materialNum)
TextField("精修", text: $viewModel.photoNum)
TextField("视频", text: $viewModel.videoNum)
}
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
}
}
private var spotForm: some View {
ProjectFormCard(title: "打卡点") {
if scenicSpotContext.spots.isEmpty {
Text("暂无打卡点数据")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(scenicSpotContext.spots) { spot in
Button {
if viewModel.selectedSpotIds.contains(spot.id) {
viewModel.selectedSpotIds.remove(spot.id)
} else {
viewModel.selectedSpotIds.insert(spot.id)
}
} label: {
HStack {
Text(spot.name)
Spacer()
Image(systemName: viewModel.selectedSpotIds.contains(spot.id) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(viewModel.selectedSpotIds.contains(spot.id) ? AppDesign.primary : AppDesign.textSecondary)
}
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
.buttonStyle(.plain)
}
}
}
}
private func prepare() async {
guard let projectId else { return }
loadingDetail = true
defer { loadingDetail = false }
do {
let detail = try await projectAPI.projectDetail(id: projectId)
viewModel = ProjectEditorViewModel(mode: .edit(detail))
} catch {
viewModel.errorMessage = error.localizedDescription
}
}
private func submit() async {
await globalLoading.withLoading {
let success = await viewModel.submit(
scenicId: accountContext.currentScenic?.id,
userId: snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? ""),
api: projectAPI,
uploadService: uploadService
)
if success { dismiss() }
}
}
private func loadCover(_ item: PhotosPickerItem?) async {
guard let file = await ProjectPickerLoader.loadImage(from: item) else { return }
viewModel.coverImage = file
}
private func loadCarousel(_ items: [PhotosPickerItem]) async {
viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: items)
}
}
///
struct StoreProjectEditorView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AccountSnapshotStore.self) private var snapshotStore
@Environment(ProjectAPI.self) private var projectAPI
@Environment(OSSUploadService.self) private var uploadService
@Environment(\.globalLoading) private var globalLoading
let projectId: Int?
@State private var viewModel = StoreProjectEditorViewModel(mode: .create)
@State private var coverItem: PhotosPickerItem?
@State private var carouselItems: [PhotosPickerItem] = []
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
storeBasicForm
storeImageForm
storeScenicForm
storePriceForm
if let error = viewModel.errorMessage {
Text(error)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0xE5484D))
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle(projectId == nil ? "新建店铺项目" : "编辑店铺项目")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(viewModel.submitting ? "保存中..." : "保存") {
Task { await submit() }
}
.disabled(viewModel.submitting)
}
}
.task { await prepare() }
.onChange(of: coverItem) { _, newValue in
Task { viewModel.coverImage = await ProjectPickerLoader.loadImage(from: newValue) }
}
.onChange(of: carouselItems) { _, newValue in
Task { viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: newValue) }
}
}
private var storeBasicForm: some View {
ProjectFormCard(title: "基础信息") {
Picker("类型", selection: $viewModel.projectType) {
Text("多点位").tag(19)
Text("押金").tag(4)
}
.pickerStyle(.segmented)
TextField("项目名称", text: $viewModel.name)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
TextField("项目简介", text: $viewModel.descriptionText, axis: .vertical)
.lineLimit(3...5)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 96)
}
}
private var storeImageForm: some View {
ProjectFormCard(title: "项目图片") {
PhotosPicker(selection: $coverItem, matching: .images) {
ProjectImagePickerLabel(title: "项目封面", url: viewModel.coverImage?.previewURL ?? viewModel.existingCoverURL)
}
PhotosPicker(selection: $carouselItems, maxSelectionCount: 8, matching: .images) {
Label("选择轮播图(\(viewModel.carouselImages.count + viewModel.existingCarouselURLs.count)", systemImage: "photo.stack")
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
.buttonStyle(.plain)
}
}
private var storeScenicForm: some View {
ProjectFormCard(title: viewModel.projectType == 4 ? "景区与门店" : "景区") {
ForEach(viewModel.scenicList) { scenic in
Button {
viewModel.toggleScenic(scenic.id)
Task { await viewModel.loadStoreList(api: projectAPI) }
} label: {
HStack {
Text(scenic.name)
Spacer()
Image(systemName: viewModel.selectedScenicIds.contains(scenic.id) ? "checkmark.circle.fill" : "circle")
}
.foregroundStyle(viewModel.selectedScenicIds.contains(scenic.id) ? AppDesign.primary : AppDesign.textPrimary)
}
.buttonStyle(.plain)
}
if viewModel.projectType == 4 {
Picker("门店", selection: Binding(get: { viewModel.selectedStoreId ?? 0 }, set: { viewModel.selectedStoreId = $0 == 0 ? nil : $0 })) {
Text("请选择").tag(0)
ForEach(viewModel.storeList) { store in
Text(store.name).tag(store.id)
}
}
}
}
}
private var storePriceForm: some View {
ProjectFormCard(title: "价格与数量") {
if viewModel.projectType == 4 {
TextField("押金金额", text: $viewModel.priceDeposit)
.keyboardType(.decimalPad)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
} else {
TextField("项目价格", text: $viewModel.projectPrice)
.keyboardType(.decimalPad)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
HStack {
TextField("底片价", text: $viewModel.priceMaterial)
TextField("精修价", text: $viewModel.pricePhoto)
TextField("视频价", text: $viewModel.priceVideo)
}
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
}
}
}
private func prepare() async {
let userId = snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? "") ?? 0
if let projectId {
do {
let detail = try await projectAPI.storeManagerProjectDetail(id: projectId)
viewModel = StoreProjectEditorViewModel(mode: .edit(detail))
} catch {
viewModel.errorMessage = error.localizedDescription
}
}
await viewModel.loadScenicList(api: projectAPI, userId: userId)
await viewModel.loadStoreList(api: projectAPI)
}
private func submit() async {
let userId = snapshotStore.load()?.businessUserId ?? Int(snapshotStore.load()?.profile?.userId ?? "") ?? 0
await globalLoading.withLoading {
let success = await viewModel.submit(userId: userId, api: projectAPI, uploadService: uploadService)
if success { dismiss() }
}
}
}
///
private struct ProjectCardView: View {
let item: PhotographerProjectItem
var body: some View {
VStack(alignment: .leading, spacing: 0) {
RemoteImage(urlString: item.coverProject.isEmpty ? item.coverVideo : item.coverProject, contentMode: .fill) {
Image(systemName: "photo")
.font(.system(size: 30))
.foregroundStyle(AppDesign.primary)
}
.frame(height: 180)
.clipped()
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text(item.name.isEmpty ? "未命名项目" : item.name)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Text(item.statusName.isEmpty ? "状态\(item.status)" : item.statusName)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.primary)
}
Text("¥\(item.price.isEmpty ? "0.00" : item.price)")
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
}
.padding(AppMetrics.Spacing.medium)
}
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
///
private struct ProjectCompactCardView: View {
let item: PhotographerProjectItem
var body: some View {
HStack(spacing: AppMetrics.Spacing.medium) {
RemoteImage(urlString: item.coverProject.isEmpty ? item.coverVideo : item.coverProject, contentMode: .fill) {
Image(systemName: "photo")
.foregroundStyle(AppDesign.primary)
}
.frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
VStack(alignment: .leading, spacing: 6) {
Text(item.name.isEmpty ? "未命名项目" : item.name)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(item.typeName.isEmpty ? "类型\(item.type)" : item.typeName)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text("¥\(item.price.isEmpty ? item.priceDeposit : item.price)")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
Spacer()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
///
private struct ProjectDetailContentView: View {
let detail: PhotographerProjectDetailResponse
var body: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
ProjectCardView(item: detail.asListItem)
ProjectFormCard(title: "服务内容") {
HStack {
service("底片", "\(detail.materialNum)")
service("精修", "\(detail.photoNum)")
service("视频", "\(detail.videoNum)")
}
}
ProjectFormCard(title: "基础信息") {
row("项目类型", detail.typeName)
row("状态", detail.statusName)
row("创建人", detail.creatorName)
row("简介", detail.description)
if !detail.auditRemark.isEmpty {
row("审核备注", detail.auditRemark)
}
}
if !detail.scenicList.isEmpty {
ProjectFormCard(title: "打卡点") {
Text(detail.scenicList.map(\.name).joined(separator: ""))
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
}
}
}
}
private func service(_ title: String, _ value: String) -> some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(AppDesign.primary)
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
.frame(maxWidth: .infinity)
}
private func row(_ title: String, _ value: String) -> some View {
HStack(alignment: .top) {
Text(title)
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Text(value.isEmpty ? "--" : value)
.multilineTextAlignment(.trailing)
.foregroundStyle(AppDesign.textPrimary)
}
.font(.system(size: AppMetrics.FontSize.subheadline))
}
}
///
private struct ProjectFormCard<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text(title)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
content()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
///
private struct ProjectImagePickerLabel: View {
let title: String
let url: String
var body: some View {
ZStack {
RemoteImage(urlString: url, contentMode: .fill) {
VStack(spacing: 8) {
Image(systemName: "photo.badge.plus")
Text(title)
}
.foregroundStyle(AppDesign.primary)
}
}
.frame(maxWidth: .infinity)
.frame(height: 160)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
}
/// PhotosPicker
private enum ProjectPickerLoader {
/// PhotosPickerItem
static func loadImage(from item: PhotosPickerItem?) async -> ProjectLocalImage? {
guard let item, let data = try? await item.loadTransferable(type: Data.self) else { return nil }
let fileName = fileName(for: item)
return ProjectLocalImage(data: data, fileName: fileName, previewURL: dataURL(data: data, fileName: fileName))
}
/// PhotosPickerItem
static func loadImages(from items: [PhotosPickerItem]) async -> [ProjectLocalImage] {
var result: [ProjectLocalImage] = []
for item in items {
if let image = await loadImage(from: item) {
result.append(image)
}
}
return result
}
private static func fileName(for item: PhotosPickerItem) -> String {
let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg"
return "project_\(UUID().uuidString).\(ext)"
}
private static func dataURL(data: Data, fileName: String) -> String? {
let mime = UTType(filenameExtension: URL(fileURLWithPath: fileName).pathExtension)?.preferredMIMEType ?? "image/jpeg"
return "data:\(mime);base64,\(data.base64EncodedString())"
}
}
private extension PhotographerProjectDetailResponse {
///
var asListItem: PhotographerProjectItem {
PhotographerProjectItem(
id: id,
type: type,
typeName: typeName,
status: status,
statusName: statusName,
name: name,
coverProject: coverProject,
coverVideo: cover,
price: price,
otPrice: otPrice,
priceDeposit: priceDeposit,
attrLabel: attrLabel
)
}
}