Add TravelAlbum, ProfileSpace, and wired camera transfer modules.

Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 09:41:05 +08:00
parent 5692134efc
commit d2fe5d71e4
48 changed files with 6477 additions and 11 deletions

View File

@ -0,0 +1,302 @@
//
// ProfileSpaceViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
@MainActor
/// ViewModel稿
final class ProfileSpaceViewModel: ObservableObject {
@Published private(set) var loading = false
@Published private(set) var saving = false
@Published var errorMessage: String?
@Published var nickname = ""
@Published private(set) var realName = ""
@Published private(set) var avatarURL = ""
@Published private(set) var scenicCertification: [String] = []
@Published var introduction = ""
@Published var attrLabels: [String] = []
@Published var shootLabels: [String] = []
@Published var cameraDevices: [String] = []
@Published var businessStartTime: Date?
@Published var businessEndTime: Date?
@Published var holidayStartTime: Date?
@Published var holidayEndTime: Date?
@Published var acceptOrderStatus = 0
@Published private(set) var scheduleMap: [String: [ScheduleItem]] = [:]
@Published private(set) var pendingAvatarData: Data?
@Published private(set) var pendingAvatarFileName: String?
@Published private(set) var avatarUploadProgress: Int?
private var originalSnapshot: ProfileSpaceEditableSnapshot?
///
var hasChanges: Bool {
guard let originalSnapshot else { return pendingAvatarData != nil }
return editableSnapshot() != originalSnapshot || pendingAvatarData != nil
}
/// 使
func load(api: any ProfileSpaceServing, scenicId: Int?) async {
guard let scenicId else {
resetForMissingScenic()
return
}
guard !loading else { return }
loading = true
errorMessage = nil
defer { loading = false }
do {
let response = try await api.profileSpaceInfo(scenicId: scenicId)
apply(response)
} catch {
errorMessage = error.localizedDescription
}
}
///
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
pendingAvatarData = processed.data
pendingAvatarFileName = processed.fileName
}
///
func addAttrLabel(_ label: String) throws {
attrLabels = try adding(label, to: attrLabels)
}
///
func addShootLabel(_ label: String) throws {
shootLabels = try adding(label, to: shootLabels)
}
///
func removeAttrLabel(_ label: String) {
attrLabels.removeAll { $0 == label }
}
///
func removeShootLabel(_ label: String) {
shootLabels.removeAll { $0 == label }
}
///
func addCameraDevice(_ device: String) {
let value = device.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
cameraDevices.append(value)
}
///
func removeCameraDevice(_ device: String) {
cameraDevices.removeAll { $0 == device }
}
///
func save(api: any ProfileSpaceServing, uploader: any OSSUploadServing, scenicId: Int?) async throws {
guard let scenicId else {
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
throw ProfileSpaceValidationError.missingScenic
}
do {
let request = try makeUpdateRequest(scenicId: scenicId)
guard !saving else { return }
saving = true
errorMessage = nil
avatarUploadProgress = pendingAvatarData == nil ? nil : 1
defer {
saving = false
avatarUploadProgress = nil
}
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
if let uploadedAvatarURL {
try await api.updateUserAvatarURL(uploadedAvatarURL)
}
let nextNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
if nextNickname != originalSnapshot?.nickname {
try await api.updateUserInfo(nickname: nextNickname, password: nil, avatar: nil)
}
try await api.updateProfileSpaceInfo(request)
if let uploadedAvatarURL {
avatarURL = uploadedAvatarURL
}
nickname = nextNickname
pendingAvatarData = nil
pendingAvatarFileName = nil
originalSnapshot = editableSnapshot()
} catch {
errorMessage = error.localizedDescription
throw error
}
}
///
func deleteSchedule(
item: ScheduleItem,
profileAPI: any ProfileSpaceServing,
scheduleAPI: any ScheduleServing,
scenicId: Int?
) async {
guard let scenicId else {
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
return
}
do {
try await scheduleAPI.deleteSchedule(id: item.id)
await load(api: profileAPI, scenicId: scenicId)
} catch {
errorMessage = error.localizedDescription
}
}
/// AccountContext
func accountProfileFallback(_ fallback: AccountProfile?) -> AccountProfile? {
AccountProfile(
userId: fallback?.userId ?? "",
displayName: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? (fallback?.displayName ?? "") : nickname,
phone: fallback?.phone,
avatarURL: avatarURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fallback?.avatarURL : avatarURL
)
}
///
func makeUpdateRequest(scenicId: Int) throws -> UpdateProfileSpaceRequest {
let nextNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
guard !nextNickname.isEmpty else {
throw ProfileSpaceValidationError.emptyNickname
}
guard let businessStartTime else {
throw ProfileSpaceValidationError.missingWorkdayStart
}
guard let businessEndTime else {
throw ProfileSpaceValidationError.missingWorkdayEnd
}
guard let holidayStartTime else {
throw ProfileSpaceValidationError.missingHolidayStart
}
guard let holidayEndTime else {
throw ProfileSpaceValidationError.missingHolidayEnd
}
guard seconds(in: businessStartTime) < seconds(in: businessEndTime) else {
throw ProfileSpaceValidationError.invalidWorkdayTime
}
guard seconds(in: holidayStartTime) < seconds(in: holidayEndTime) else {
throw ProfileSpaceValidationError.invalidHolidayTime
}
return UpdateProfileSpaceRequest(
scenicId: "\(scenicId)",
description: introduction.trimmingCharacters(in: .whitespacesAndNewlines),
cameraDevice: normalized(cameraDevices),
attrLabel: normalized(attrLabels),
shootLabel: normalized(shootLabels),
businessStartTime: businessStartTime.profileSpaceTimeText,
businessEndTime: businessEndTime.profileSpaceTimeText,
holidayStartTime: holidayStartTime.profileSpaceTimeText,
holidayEndTime: holidayEndTime.profileSpaceTimeText,
businessRange: [],
acceptOrderStatus: acceptOrderStatus
)
}
///
private func apply(_ response: ProfileSpaceInfoResponse) {
realName = response.realName
nickname = response.nickname
avatarURL = response.avatar
scenicCertification = response.scenicCertification
introduction = response.description
attrLabels = response.attrLabel
shootLabels = response.shootLabel
cameraDevices = response.cameraDevice
businessStartTime = Date.profileSpaceTime(from: response.businessStartTime)
businessEndTime = Date.profileSpaceTime(from: response.businessEndTime)
holidayStartTime = Date.profileSpaceTime(from: response.holidayStartTime)
holidayEndTime = Date.profileSpaceTime(from: response.holidayEndTime)
acceptOrderStatus = response.acceptOrderStatus
scheduleMap = response.schedule
pendingAvatarData = nil
pendingAvatarFileName = nil
originalSnapshot = editableSnapshot()
}
///
private func resetForMissingScenic() {
loading = false
errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription
realName = ""
nickname = ""
avatarURL = ""
scenicCertification = []
introduction = ""
attrLabels = []
shootLabels = []
cameraDevices = []
businessStartTime = nil
businessEndTime = nil
holidayStartTime = nil
holidayEndTime = nil
acceptOrderStatus = 0
scheduleMap = [:]
originalSnapshot = nil
}
///
private func editableSnapshot() -> ProfileSpaceEditableSnapshot {
ProfileSpaceEditableSnapshot(
nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines),
description: introduction.trimmingCharacters(in: .whitespacesAndNewlines),
attrLabel: normalized(attrLabels),
shootLabel: normalized(shootLabels),
cameraDevice: normalized(cameraDevices),
businessStartTime: businessStartTime,
businessEndTime: businessEndTime,
holidayStartTime: holidayStartTime,
holidayEndTime: holidayEndTime,
acceptOrderStatus: acceptOrderStatus,
avatarURL: avatarURL
)
}
///
private func adding(_ label: String, to labels: [String]) throws -> [String] {
let value = label.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { throw ProfileSpaceValidationError.emptyLabel }
guard value.count <= 20 else { throw ProfileSpaceValidationError.labelTooLong }
guard labels.count < 8 else { throw ProfileSpaceValidationError.labelLimit }
guard !labels.contains(value) else { throw ProfileSpaceValidationError.duplicateLabel }
return labels + [value]
}
///
private func normalized(_ values: [String]) -> [String] {
values
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
///
private func seconds(in date: Date) -> Int {
let components = Calendar.current.dateComponents([.hour, .minute, .second], from: date)
return (components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60 + (components.second ?? 0)
}
/// nil
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
guard let pendingAvatarData else { return nil }
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
Task { @MainActor in
self?.avatarUploadProgress = progress
}
}
}
}