Files
suixinkan_ios_new/suixinkan/Features/Projects/Views/ProjectViews.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

777 lines
33 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.

//
// ProjectViews.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
///
struct ProjectManagementView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(\.projectAPI) private var projectAPI
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = ProjectManagementViewModel()
var body: some View {
VStack(spacing: 0) {
filterBar
if accountContext.currentScenic?.id == nil {
AppContentUnavailableView("缺少景区", 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 {
AppContentUnavailableView("暂无项目", 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) private var snapshotStore
@Environment(\.projectAPI) private var projectAPI
@Environment(\.globalLoading) private var globalLoading
@StateObject 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 {
AppContentUnavailableView("暂无店铺项目", 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) 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 {
AppContentUnavailableView("加载失败", 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
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.accountSnapshotStore) private var snapshotStore
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(\.projectAPI) private var projectAPI
@Environment(\.ossUploadService) private var uploadService
@Environment(\.globalLoading) private var globalLoading
let projectId: Int?
@StateObject 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.apply(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) private var snapshotStore
@Environment(\.projectAPI) private var projectAPI
@Environment(\.ossUploadService) private var uploadService
@Environment(\.globalLoading) private var globalLoading
let projectId: Int?
@StateObject 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.apply(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
)
}
}