Files
suixinkan_uikit/suixinkan/Features/Profile/ViewModels/ProfileSpaceSettingsViewModel.swift
汉秋 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

576 lines
20 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.

//
// ProfileSpaceSettingsViewModel.swift
// suixinkan
//
import Foundation
/// 便 API
@MainActor
protocol ProfileSpaceSettingsAPI {
///
func profileInfo(scenicId: Int) async throws -> PhotographerProfileInfoResponse
///
func updateProfileInfo(_ request: UpdatePhotographerProfileInfoRequest) async throws
///
func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws
///
func addSchedule(_ request: AddPhotographerScheduleRequest) async throws
///
func deleteSchedule(id: Int) async throws
///
func uploadAvatar(data: Data, fileName: String) async throws
}
extension ProfileAPI: ProfileSpaceSettingsAPI {}
///
enum ProfileSpaceSettingsValidationError: LocalizedError, Equatable {
case emptyTime(String)
case invalidTimeRange(String)
case emptyLabel
case duplicateLabel
case labelTooLong
case labelLimitReached
case emptyDevice
case emptyScheduleName
case emptyScheduleTime
case invalidScheduleTime
var errorDescription: String? {
switch self {
case .emptyTime(let title):
"请输入\(title)"
case .invalidTimeRange(let title):
"\(title)开始时间不能大于结束时间"
case .emptyLabel:
"标签不能为空"
case .duplicateLabel:
"标签已存在"
case .labelTooLong:
"标签最多20个字符"
case .labelLimitReached:
"最多8个标签"
case .emptyDevice:
"设备名称不能为空"
case .emptyScheduleName:
"请输入日程名称"
case .emptyScheduleTime:
"请选择日程时间"
case .invalidScheduleTime:
"结束时间必须晚于开始时间"
}
}
}
/// ViewModel Android `ProfileInfoViewModel`
final class ProfileSpaceSettingsViewModel {
private(set) var avatarURL = ""
private(set) var realName = ""
private(set) var nickname = ""
private(set) var editingNickname = ""
private(set) var introduction = ""
private(set) var scenicCertification: [String] = []
private(set) var attrLabels: [String] = []
private(set) var shootLabels: [String] = []
private(set) var businessStartTime: String?
private(set) var businessEndTime: String?
private(set) var holidayStartTime: String?
private(set) var holidayEndTime: String?
private(set) var cameraDevices: [String] = []
private(set) var acceptOrderStatus = 0
private(set) var scheduleMap: [String: [PhotographerSchedule]] = [:]
private(set) var selectedDate: Date
private(set) var isEditingProfile = false
private(set) var isCertificationExpanded = false
private(set) var isLoading = false
private(set) var hasChanges = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private var originalSnapshot = Snapshot()
private let appStore: AppStore
private let calendar: Calendar
private let nowProvider: () -> Date
init(
appStore: AppStore = .shared,
calendar: Calendar = Calendar(identifier: .gregorian),
nowProvider: @escaping () -> Date = Date.init
) {
self.appStore = appStore
self.calendar = calendar
self.nowProvider = nowProvider
selectedDate = calendar.startOfDay(for: nowProvider())
}
/// ID
var scenicId: Int {
appStore.session.currentScenicId
}
///
var selectedDateSchedules: [PhotographerSchedule] {
scheduleMap[Self.dateKey(from: selectedDate, calendar: calendar)] ?? []
}
/// 7
var weekDates: [Date] {
let start = calendar.startOfDay(for: nowProvider())
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: start) }
}
///
func load(api: any ProfileSpaceSettingsAPI) async {
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
setLoading(true)
defer { setLoading(false) }
do {
let response = try await api.profileInfo(scenicId: scenicId)
apply(response)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
/// 退 Android
func toggleProfileEditing(api: any ProfileSpaceSettingsAPI) async {
if isEditingProfile {
let newNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
let currentNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
guard !newNickname.isEmpty, newNickname != currentNickname else {
isEditingProfile = false
notifyStateChange()
return
}
setLoading(true)
defer { setLoading(false) }
do {
try await api.updateUserInfo(nickname: newNickname, password: nil, avatar: nil)
nickname = newNickname
appStore.session.userName = newNickname
isEditingProfile = false
onShowMessage?("修改成功")
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
return
}
editingNickname = nickname
isEditingProfile = true
notifyStateChange()
}
/// URL
func updateAvatarURL(_ url: String) {
avatarURL = url
appStore.session.avatar = url
notifyStateChange()
}
///
func toggleCertificationExpanded() {
isCertificationExpanded.toggle()
notifyStateChange()
}
///
func uploadAvatar(
data: Data,
fileName: String,
api: any ProfileSpaceSettingsAPI
) async {
setLoading(true)
defer { setLoading(false) }
do {
try await api.uploadAvatar(data: data, fileName: fileName)
onShowMessage?("上传成功")
await load(api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func updateEditingNickname(_ value: String) {
editingNickname = value
notifyStateChange()
}
///
func updateIntroduction(_ value: String) {
introduction = value
updateHasChanges()
}
///
func addAttrLabel(_ value: String) {
do {
attrLabels = try adding(label: value, to: attrLabels)
updateHasChanges()
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func removeAttrLabel(_ label: String) {
attrLabels.removeAll { $0 == label }
updateHasChanges()
}
///
func addShootLabel(_ value: String) {
do {
shootLabels = try adding(label: value, to: shootLabels)
updateHasChanges()
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func removeShootLabel(_ label: String) {
shootLabels.removeAll { $0 == label }
updateHasChanges()
}
///
func updateBusinessStartTime(_ value: String) {
if let end = businessEndTime, !Self.isStart(value, before: end) {
onShowMessage?("开始时间不能大于结束时间")
return
}
businessStartTime = value
updateHasChanges()
}
///
func updateBusinessEndTime(_ value: String) {
if let start = businessStartTime, !Self.isStart(start, before: value) {
onShowMessage?("结束时间不能小于开始时间")
return
}
businessEndTime = value
updateHasChanges()
}
///
func updateHolidayStartTime(_ value: String) {
if let end = holidayEndTime, !Self.isStart(value, before: end) {
onShowMessage?("开始时间不能大于结束时间")
return
}
holidayStartTime = value
updateHasChanges()
}
///
func updateHolidayEndTime(_ value: String) {
if let start = holidayStartTime, !Self.isStart(start, before: value) {
onShowMessage?("结束时间不能小于开始时间")
return
}
holidayEndTime = value
updateHasChanges()
}
///
func addDevice(_ value: String) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
onShowMessage?(ProfileSpaceSettingsValidationError.emptyDevice.localizedDescription)
return
}
cameraDevices.append(text)
updateHasChanges()
}
///
func removeDevice(_ value: String) {
cameraDevices.removeAll { $0 == value }
updateHasChanges()
}
///
func updateAcceptOrderStatus(_ status: Int) {
acceptOrderStatus = status
updateHasChanges()
}
///
func selectDate(_ date: Date) {
selectedDate = calendar.startOfDay(for: date)
notifyStateChange()
}
///
func save(api: any ProfileSpaceSettingsAPI) async {
do {
let request = try makeSaveRequest()
setLoading(true)
defer { setLoading(false) }
try await api.updateProfileInfo(request)
originalSnapshot = currentSnapshot()
hasChanges = false
onShowMessage?("保存成功")
notifyStateChange()
await load(api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func makeSaveRequest() throws -> UpdatePhotographerProfileInfoRequest {
guard let businessStartTime else { throw ProfileSpaceSettingsValidationError.emptyTime("工作日开始时间") }
guard let businessEndTime else { throw ProfileSpaceSettingsValidationError.emptyTime("工作日结束时间") }
guard let holidayStartTime else { throw ProfileSpaceSettingsValidationError.emptyTime("节假日开始时间") }
guard let holidayEndTime else { throw ProfileSpaceSettingsValidationError.emptyTime("节假日结束时间") }
guard Self.isStart(businessStartTime, before: businessEndTime) else {
throw ProfileSpaceSettingsValidationError.invalidTimeRange("工作日")
}
guard Self.isStart(holidayStartTime, before: holidayEndTime) else {
throw ProfileSpaceSettingsValidationError.invalidTimeRange("节假日")
}
return UpdatePhotographerProfileInfoRequest(
scenicId: String(scenicId),
description: introduction,
cameraDevice: cameraDevices,
attrLabel: attrLabels,
shootLabel: shootLabels,
businessStartTime: Self.fullTimeString(businessStartTime),
businessEndTime: Self.fullTimeString(businessEndTime),
holidayStartTime: Self.fullTimeString(holidayStartTime),
holidayEndTime: Self.fullTimeString(holidayEndTime),
businessRange: [],
acceptOrderStatus: acceptOrderStatus
)
}
///
func makeAddScheduleRequest(
name: String,
remark: String,
startTime: String?,
endTime: String?,
scheduleDate: Date,
orderNumber: String?
) throws -> AddPhotographerScheduleRequest {
let scheduleName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !scheduleName.isEmpty else { throw ProfileSpaceSettingsValidationError.emptyScheduleName }
guard let startTime, let endTime else { throw ProfileSpaceSettingsValidationError.emptyScheduleTime }
guard Self.isStart(startTime, before: endTime) else { throw ProfileSpaceSettingsValidationError.invalidScheduleTime }
let order = orderNumber?.trimmingCharacters(in: .whitespacesAndNewlines)
return AddPhotographerScheduleRequest(
scenicId: String(scenicId),
name: scheduleName,
remark: remark,
startTime: startTime,
endTime: endTime,
scheduleDate: Self.dateKey(from: scheduleDate, calendar: calendar),
orderNumber: order?.isEmpty == true ? nil : order
)
}
///
func addSchedule(
name: String,
remark: String,
startTime: String?,
endTime: String?,
scheduleDate: Date,
orderNumber: String?,
api: any ProfileSpaceSettingsAPI
) async -> Bool {
do {
let request = try makeAddScheduleRequest(
name: name,
remark: remark,
startTime: startTime,
endTime: endTime,
scheduleDate: scheduleDate,
orderNumber: orderNumber
)
setLoading(true)
defer { setLoading(false) }
try await api.addSchedule(request)
onShowMessage?("添加成功")
await load(api: api)
return true
} catch is CancellationError {
return false
} catch {
onShowMessage?(error.localizedDescription)
return false
}
}
///
func deleteSchedule(id: Int, api: any ProfileSpaceSettingsAPI) async {
setLoading(true)
defer { setLoading(false) }
do {
try await api.deleteSchedule(id: id)
onShowMessage?("删除成功")
await load(api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func apply(_ response: PhotographerProfileInfoResponse) {
realName = response.realName
nickname = response.nickname
editingNickname = response.nickname
if !response.avatar.isEmpty {
avatarURL = response.avatar
appStore.session.avatar = response.avatar
}
scenicCertification = response.scenicCertification
introduction = response.description
attrLabels = response.attrLabel
shootLabels = response.shootLabel
cameraDevices = response.cameraDevice
businessStartTime = Self.shortTimeString(response.businessStartTime)
businessEndTime = Self.shortTimeString(response.businessEndTime)
holidayStartTime = Self.shortTimeString(response.holidayStartTime)
holidayEndTime = Self.shortTimeString(response.holidayEndTime)
acceptOrderStatus = response.acceptOrderStatus
scheduleMap = response.schedule
appStore.session.userName = response.nickname
appStore.session.realName = response.realName
originalSnapshot = currentSnapshot()
hasChanges = false
notifyStateChange()
}
private func adding(label rawValue: String, to labels: [String]) throws -> [String] {
let label = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty else { throw ProfileSpaceSettingsValidationError.emptyLabel }
guard label.count <= 20 else { throw ProfileSpaceSettingsValidationError.labelTooLong }
guard labels.count < 8 else { throw ProfileSpaceSettingsValidationError.labelLimitReached }
guard !labels.contains(label) else { throw ProfileSpaceSettingsValidationError.duplicateLabel }
return labels + [label]
}
private func setLoading(_ loading: Bool) {
isLoading = loading
notifyStateChange()
}
private func updateHasChanges() {
hasChanges = currentSnapshot() != originalSnapshot
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
private func currentSnapshot() -> Snapshot {
Snapshot(
introduction: introduction,
attrLabels: attrLabels,
shootLabels: shootLabels,
businessStartTime: businessStartTime,
businessEndTime: businessEndTime,
holidayStartTime: holidayStartTime,
holidayEndTime: holidayEndTime,
cameraDevices: cameraDevices,
acceptOrderStatus: acceptOrderStatus
)
}
private static func shortTimeString(_ value: String) -> String? {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, text != "00:00:00" else { return nil }
if text.count >= 5 {
return String(text.prefix(5))
}
return nil
}
static func fullTimeString(_ value: String) -> String {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return text.count == 5 ? "\(text):00" : text
}
static func isStart(_ start: String, before end: String) -> Bool {
minutes(from: start) < minutes(from: end)
}
static func minutes(from value: String) -> Int {
let pieces = value.split(separator: ":").compactMap { Int($0) }
guard pieces.count >= 2 else { return 0 }
return pieces[0] * 60 + pieces[1]
}
static func dateKey(from date: Date, calendar: Calendar) -> String {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
/// Android yyyyMd
static func displayDate(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
let components = calendar.dateComponents([.year, .month, .day], from: date)
return "\(components.year ?? 0)\(components.month ?? 0)\(components.day ?? 0)"
}
///
static func displayDay(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
String(calendar.component(.day, from: date))
}
///
static func displayWeekday(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
switch calendar.component(.weekday, from: date) {
case 1: return ""
case 2: return ""
case 3: return ""
case 4: return ""
case 5: return ""
case 6: return ""
case 7: return ""
default: return ""
}
}
}
private struct Snapshot: Equatable {
var introduction = ""
var attrLabels: [String] = []
var shootLabels: [String] = []
var businessStartTime: String?
var businessEndTime: String?
var holidayStartTime: String?
var holidayEndTime: String?
var cameraDevices: [String] = []
var acceptOrderStatus = 0
}