Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests. Co-authored-by: Cursor <cursoragent@cursor.com>
676 lines
24 KiB
Swift
676 lines
24 KiB
Swift
//
|
||
// ProfileView.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/20.
|
||
//
|
||
|
||
import PhotosUI
|
||
import SwiftUI
|
||
import UIKit
|
||
|
||
/// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。
|
||
struct ProfileView: View {
|
||
@EnvironmentObject private var appSession: AppSession
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@EnvironmentObject private var permissionContext: PermissionContext
|
||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||
@EnvironmentObject private var appRouter: AppRouter
|
||
@EnvironmentObject private var router: RouterPath
|
||
@Environment(\.profileAPI) private var profileAPI
|
||
@Environment(\.ossUploadService) private var ossUploadService
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||
|
||
@StateObject private var viewModel = ProfileViewModel()
|
||
@State private var showsPasswordSheet = false
|
||
@State private var showsLogoutConfirm = false
|
||
@State private var pickedAvatarItem: PhotosPickerItem?
|
||
@FocusState private var nicknameFocused: Bool
|
||
|
||
private var contentMaxWidth: CGFloat {
|
||
horizontalSizeClass == .regular ? 720 : .infinity
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 22) {
|
||
header
|
||
infoCard
|
||
logoutButton
|
||
}
|
||
.padding(.horizontal, 19)
|
||
.padding(.top, 24)
|
||
.padding(.bottom, 24)
|
||
.frame(maxWidth: contentMaxWidth)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.background(profileBackground.ignoresSafeArea())
|
||
.navigationTitle("个人信息")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task {
|
||
await reloadProfile(showToast: false)
|
||
}
|
||
.refreshable {
|
||
await reloadProfile(showToast: true)
|
||
}
|
||
.sheet(isPresented: $showsPasswordSheet) {
|
||
PasswordUpdateSheet { password in
|
||
await updatePassword(password)
|
||
}
|
||
}
|
||
.confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) {
|
||
Button("退出登录", role: .destructive) {
|
||
authSessionCoordinator.logout(
|
||
appSession: appSession,
|
||
accountContext: accountContext,
|
||
permissionContext: permissionContext,
|
||
scenicSpotContext: scenicSpotContext,
|
||
appRouter: appRouter,
|
||
toastCenter: toastCenter
|
||
)
|
||
}
|
||
Button("取消", role: .cancel) {}
|
||
}
|
||
.onChange(of: viewModel.isEditingProfile) { isEditing in
|
||
guard isEditing else { return }
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||
nicknameFocused = true
|
||
}
|
||
}
|
||
.onChange(of: pickedAvatarItem) { item in
|
||
Task { await prepareAvatarImage(from: item) }
|
||
}
|
||
}
|
||
|
||
private var profileBackground: some View {
|
||
LinearGradient(
|
||
colors: [
|
||
Color(hex: 0xF7FAFF),
|
||
Color(hex: 0xF3F7FD),
|
||
Color.white
|
||
],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack(alignment: .center, spacing: 14) {
|
||
avatarImage
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
if viewModel.isEditingProfile {
|
||
TextField("请输入昵称", text: nicknameBinding)
|
||
.font(.system(size: 24, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x252525))
|
||
.tint(AppDesign.primary)
|
||
.textFieldStyle(.plain)
|
||
.focused($nicknameFocused)
|
||
.submitLabel(.done)
|
||
.padding(.horizontal, 10)
|
||
.frame(height: 42)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(AppDesign.primary, lineWidth: 1)
|
||
}
|
||
.onSubmit {
|
||
Task { await saveProfileEdits() }
|
||
}
|
||
} else {
|
||
Text(viewModel.displayNickname)
|
||
.font(.system(size: 24, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x252525))
|
||
.lineLimit(nil)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
Text("UID: \(accountContext.profile?.userId.isEmpty == false ? accountContext.profile?.userId ?? "--" : "--")")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x606A7A))
|
||
.lineLimit(1)
|
||
}
|
||
.layoutPriority(1)
|
||
|
||
Spacer(minLength: 0)
|
||
|
||
Button {
|
||
if viewModel.isEditingProfile {
|
||
Task { await saveProfileEdits() }
|
||
} else {
|
||
viewModel.beginEditing()
|
||
}
|
||
} label: {
|
||
ZStack {
|
||
if viewModel.isSaving && viewModel.isEditingProfile {
|
||
ProgressView()
|
||
.tint(.white)
|
||
.scaleEffect(0.82)
|
||
} else {
|
||
Image(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
}
|
||
}
|
||
.foregroundStyle(.white)
|
||
.frame(width: 44, height: 44)
|
||
.background(
|
||
LinearGradient(
|
||
colors: [Color(hex: 0x238BFF), Color(hex: 0x006BFF)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
),
|
||
in: Circle()
|
||
)
|
||
.shadow(color: Color(hex: 0x0073FF, alpha: 0.24), radius: 10, x: 0, y: 5)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(viewModel.isSaving)
|
||
.opacity(viewModel.isSaving ? 0.6 : 1)
|
||
.accessibilityLabel(viewModel.isEditingProfile ? "完成编辑" : "编辑个人信息")
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 18)
|
||
.padding(.vertical, 21)
|
||
.frame(minHeight: 132)
|
||
.background {
|
||
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
||
.fill(.white)
|
||
ProfileHeaderWaveShape()
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [
|
||
Color(hex: 0xBFDFFF, alpha: 0.18),
|
||
Color(hex: 0x8EC4FF, alpha: 0.28)
|
||
],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
ProfileHeaderWaveShape(phase: 0.35)
|
||
.fill(Color(hex: 0xE4F1FF, alpha: 0.62))
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
||
.stroke(Color(hex: 0xE4ECF7), lineWidth: 1)
|
||
}
|
||
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.32), radius: 16, x: 0, y: 8)
|
||
}
|
||
|
||
private var avatarImage: some View {
|
||
PhotosPicker(selection: $pickedAvatarItem, matching: .images) {
|
||
avatarPreview
|
||
.frame(width: 86, height: 86)
|
||
.clipShape(Circle())
|
||
.overlay {
|
||
Circle()
|
||
.stroke(.white, lineWidth: 3)
|
||
}
|
||
.overlay(alignment: .bottomTrailing) {
|
||
Image(systemName: "camera.fill")
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 26, height: 26)
|
||
.background(AppDesign.primary, in: Circle())
|
||
.overlay {
|
||
Circle().stroke(.white, lineWidth: 2)
|
||
}
|
||
}
|
||
.overlay {
|
||
if let progress = viewModel.avatarUploadProgress {
|
||
ZStack {
|
||
Circle()
|
||
.fill(.black.opacity(0.34))
|
||
Text("\(progress)%")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(viewModel.isSaving)
|
||
.accessibilityLabel("头像")
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var avatarPreview: some View {
|
||
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFill()
|
||
} else {
|
||
RemoteAvatarImage(urlString: viewModel.displayAvatarURL)
|
||
}
|
||
}
|
||
|
||
private var infoCard: some View {
|
||
VStack(spacing: 0) {
|
||
infoRow(title: "姓名") {
|
||
plainInfoValue(viewModel.displayRealName)
|
||
}
|
||
|
||
divider
|
||
|
||
Button {
|
||
router.navigate(to: .profile(.accountSwitch))
|
||
} label: {
|
||
infoRow(title: "当前账号") {
|
||
VStack(alignment: .trailing, spacing: 3) {
|
||
rowValueText(currentAccountDisplayName)
|
||
Text(currentAccountTypeText)
|
||
.font(.system(size: 12, weight: .medium))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
.lineLimit(1)
|
||
}
|
||
rowChevron
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
divider
|
||
|
||
infoRow(title: "手机号") {
|
||
plainInfoValue(viewModel.displayPhone)
|
||
}
|
||
|
||
divider
|
||
|
||
Button {
|
||
showsPasswordSheet = true
|
||
} label: {
|
||
infoRow(title: "修改密码") {
|
||
rowChevron
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
divider
|
||
|
||
Button {
|
||
router.navigate(to: .profile(.realNameAuth))
|
||
} label: {
|
||
infoRow(title: "认证状态") {
|
||
realNameStatusView
|
||
rowChevron
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
divider
|
||
|
||
infoRow(title: "账号状态") {
|
||
accountStatusView
|
||
}
|
||
|
||
divider
|
||
|
||
infoRow(title: "当前景区") {
|
||
scenicStatusView
|
||
}
|
||
|
||
divider
|
||
|
||
Button {
|
||
router.navigate(to: .profile(.settings))
|
||
} label: {
|
||
infoRow(title: "系统设置") {
|
||
rowChevron
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
#if DEBUG
|
||
divider
|
||
|
||
Button {
|
||
router.navigate(to: .profile(.debugHomeMenus))
|
||
} label: {
|
||
infoRow(title: "首页调试") {
|
||
statusBadge(text: "DEBUG", icon: "hammer.fill", foreground: AppDesign.primary, background: AppDesign.primarySoft)
|
||
rowChevron
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
#endif
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 4)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
||
.stroke(Color(hex: 0xEEF3FA), lineWidth: 1)
|
||
}
|
||
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.30), radius: 16, x: 0, y: 8)
|
||
}
|
||
|
||
private var divider: some View {
|
||
Rectangle()
|
||
.fill(Color(hex: 0xE8EDF4))
|
||
.frame(height: 1)
|
||
}
|
||
|
||
private var logoutButton: some View {
|
||
Button {
|
||
showsLogoutConfirm = true
|
||
} label: {
|
||
Text("退出登录")
|
||
.font(.system(size: 20, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0xFF3B3B))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 58)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
||
.stroke(Color(hex: 0xEEF3FA), lineWidth: 1)
|
||
}
|
||
.shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
/// 构造资料信息行,统一左右布局和点击区域。
|
||
private func infoRow<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||
HStack(spacing: 8) {
|
||
Text(title)
|
||
.font(.system(size: 20, weight: .semibold))
|
||
.foregroundStyle(Color(hex: 0x101827))
|
||
.frame(width: 104, alignment: .leading)
|
||
Spacer(minLength: 4)
|
||
content()
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.frame(height: 69)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
@ViewBuilder
|
||
/// 根据文本是否为空占位,选择普通文本或状态徽标展示。
|
||
private func plainInfoValue(_ text: String) -> some View {
|
||
if text == "--" {
|
||
statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6))
|
||
} else {
|
||
rowValueText(text)
|
||
}
|
||
}
|
||
|
||
/// 构造资料行右侧的普通值文本。
|
||
private func rowValueText(_ text: String) -> some View {
|
||
Text(text)
|
||
.font(.system(size: 18, weight: .medium))
|
||
.foregroundStyle(Color(hex: 0x333333))
|
||
.lineLimit(1)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
private var rowChevron: some View {
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 22, weight: .medium))
|
||
.foregroundStyle(Color(hex: 0x9AA1AA))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var realNameStatusView: some View {
|
||
if viewModel.realNameInfo == nil {
|
||
Text(viewModel.realNameStatusText)
|
||
.font(.system(size: 17, weight: .medium))
|
||
.foregroundStyle(Color(hex: 0xFF7B00))
|
||
.lineLimit(1)
|
||
} else {
|
||
statusBadge(
|
||
text: viewModel.realNameStatusText,
|
||
icon: viewModel.realNameInfo?.auditStatus == 2 ? "checkmark.shield.fill" : nil,
|
||
foreground: realNameStatusColor,
|
||
background: realNameStatusBackground
|
||
)
|
||
}
|
||
}
|
||
|
||
private var accountStatusView: some View {
|
||
let text = viewModel.accountStatusText
|
||
return Group {
|
||
if text == "--" {
|
||
statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6))
|
||
} else {
|
||
statusBadge(text: text, icon: "checkmark.circle.fill", foreground: Color(hex: 0x14964A), background: Color(hex: 0xDDF8E9))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var scenicStatusView: some View {
|
||
let text = nonEmpty(accountContext.currentScenic?.name) ?? "--"
|
||
return Group {
|
||
if text == "--" {
|
||
statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6))
|
||
} else {
|
||
statusBadge(text: text, icon: "mappin.circle.fill", foreground: AppDesign.primary, background: Color(hex: 0xE7F1FF))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 构造状态徽标,支持可选图标、前景色和背景色。
|
||
private func statusBadge(text: String, icon: String? = nil, foreground: Color, background: Color) -> some View {
|
||
HStack(spacing: 7) {
|
||
if let icon {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 17, weight: .semibold))
|
||
}
|
||
Text(text)
|
||
.lineLimit(1)
|
||
}
|
||
.font(.system(size: 17, weight: .semibold))
|
||
.foregroundStyle(foreground)
|
||
.padding(.horizontal, icon == nil ? 14 : 12)
|
||
.frame(height: 34)
|
||
.background(background, in: Capsule())
|
||
}
|
||
|
||
private var realNameStatusColor: Color {
|
||
guard let realNameInfo = viewModel.realNameInfo else { return Color(hex: 0xFF7B00) }
|
||
switch realNameInfo.auditStatus {
|
||
case 2:
|
||
return Color(hex: 0x14964A)
|
||
case 3:
|
||
return Color(hex: 0xEF4444)
|
||
default:
|
||
return Color(hex: 0xFF7B00)
|
||
}
|
||
}
|
||
|
||
private var realNameStatusBackground: Color {
|
||
guard let realNameInfo = viewModel.realNameInfo else { return Color(hex: 0xF4F4F4) }
|
||
switch realNameInfo.auditStatus {
|
||
case 2:
|
||
return Color(hex: 0xDDF8E9)
|
||
case 3:
|
||
return Color(hex: 0xFFE7E7)
|
||
default:
|
||
return Color(hex: 0xFFF0E2)
|
||
}
|
||
}
|
||
|
||
private var currentAccountDisplayName: String {
|
||
nonEmpty(accountContext.currentStore?.name)
|
||
?? nonEmpty(accountContext.currentScenic?.name)
|
||
?? accountContext.profile?.displayName
|
||
?? viewModel.displayNickname
|
||
}
|
||
|
||
private var currentAccountTypeText: String {
|
||
if accountContext.currentStore != nil {
|
||
return "门店账号"
|
||
}
|
||
if accountContext.currentScenic != nil {
|
||
return "景区账号"
|
||
}
|
||
return nonEmpty(viewModel.userInfo?.roleName) ?? "账号"
|
||
}
|
||
|
||
private var nicknameBinding: Binding<String> {
|
||
Binding(
|
||
get: { viewModel.editingNickname },
|
||
set: { viewModel.editingNickname = $0 }
|
||
)
|
||
}
|
||
|
||
/// 重新拉取个人资料,并同步更新全局账号资料。
|
||
private func reloadProfile(showToast: Bool) async {
|
||
do {
|
||
try await globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载中...") {
|
||
try await viewModel.reload(api: profileAPI)
|
||
}
|
||
if let userInfo = viewModel.userInfo {
|
||
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
||
} else {
|
||
accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile))
|
||
}
|
||
if showToast {
|
||
toastCenter.show("个人信息已刷新")
|
||
}
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 保存昵称编辑内容,并在成功后刷新全局账号展示。
|
||
private func saveProfileEdits() async {
|
||
do {
|
||
try await globalLoading.withLoading(message: "保存中...") {
|
||
try await viewModel.saveProfile(
|
||
api: profileAPI,
|
||
uploader: ossUploadService,
|
||
scenicId: accountContext.currentScenic?.id ?? 0
|
||
)
|
||
}
|
||
if let userInfo = viewModel.userInfo {
|
||
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
||
} else {
|
||
accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile))
|
||
}
|
||
toastCenter.show("个人信息已保存")
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 处理用户选择的头像图片,并生成待上传数据。
|
||
private func prepareAvatarImage(from item: PhotosPickerItem?) async {
|
||
guard let item else { return }
|
||
defer { pickedAvatarItem = nil }
|
||
do {
|
||
guard let data = try await item.loadTransferable(type: Data.self) else {
|
||
toastCenter.show("请选择有效的头像图片")
|
||
return
|
||
}
|
||
try viewModel.prepareAvatarImage(data: data)
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 提交新密码,并在成功后关闭密码弹窗。
|
||
private func updatePassword(_ password: String) async {
|
||
do {
|
||
try await viewModel.updatePassword(password, api: profileAPI)
|
||
showsPasswordSheet = false
|
||
toastCenter.show("密码修改成功")
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 去除空白字符后返回非空字符串。
|
||
private func nonEmpty(_ value: String?) -> String? {
|
||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return text.isEmpty ? nil : text
|
||
}
|
||
}
|
||
|
||
/// 个人信息头部背景波浪形状,用于还原旧版蓝白卡片视觉。
|
||
private struct ProfileHeaderWaveShape: Shape {
|
||
var phase: CGFloat = 0
|
||
|
||
/// 根据当前矩形尺寸生成波浪路径。
|
||
func path(in rect: CGRect) -> Path {
|
||
var path = Path()
|
||
let startY = rect.height * (0.94 - phase * 0.16)
|
||
path.move(to: CGPoint(x: rect.width * (0.26 + phase * 0.16), y: rect.height))
|
||
path.addCurve(
|
||
to: CGPoint(x: rect.width, y: rect.height * (0.26 + phase * 0.08)),
|
||
control1: CGPoint(x: rect.width * (0.55 + phase * 0.06), y: startY),
|
||
control2: CGPoint(x: rect.width * (0.70 + phase * 0.08), y: rect.height * (0.40 + phase * 0.14))
|
||
)
|
||
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
|
||
path.closeSubpath()
|
||
return path
|
||
}
|
||
}
|
||
|
||
/// 修改密码弹窗,负责采集新密码并提交给外部动作。
|
||
private struct PasswordUpdateSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var password = ""
|
||
let onSubmit: (String) async -> Void
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
SecureField("请输入新密码", text: $password)
|
||
.textContentType(.newPassword)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.padding(.horizontal, 12)
|
||
.frame(height: 48)
|
||
.background(Color(hex: 0xF5F7FA), in: RoundedRectangle(cornerRadius: 8))
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(Color(hex: 0xE2E8F0), lineWidth: 1)
|
||
}
|
||
|
||
Text("密码至少 6 位。")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(16)
|
||
.navigationTitle("修改密码")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("取消") {
|
||
dismiss()
|
||
}
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("完成") {
|
||
Task { await onSubmit(password) }
|
||
}
|
||
.disabled(password.trimmingCharacters(in: .whitespacesAndNewlines).count < 6)
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.height(220)])
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
NavigationStack {
|
||
ProfileView()
|
||
.environmentObject(AppSession())
|
||
.environmentObject(AccountContext())
|
||
.environmentObject(PermissionContext())
|
||
.environmentObject(ScenicSpotContext())
|
||
.environmentObject(AppRouter())
|
||
.environmentObject(ToastCenter())
|
||
.environment(\.profileAPI, ProfileAPI(client: APIClient()))
|
||
.environment(\.ossUploadService, OSSUploadService(configService: UploadAPI(client: APIClient())))
|
||
.environment(\.authSessionCoordinator, AuthSessionCoordinator())
|
||
}
|
||
}
|