Files
edgeever/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift
T

628 lines
25 KiB
Swift

import SwiftUI
/// Android `WorkspaceSettingsView` parity: full-screen “我的”, not a system Form/List.
struct SettingsView: View {
@Environment(AppEnvironment.self) private var env
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
private enum RootTab: Hashable {
case general
case account
case system
// Extended (not on Android root Me, but reachable from system/account extras)
case tags
case tokens
case devices
case users
}
@State private var tab: RootTab?
@State private var showLocalePicker = false
@State private var copiedSystemInfo = false
private var title: String {
switch tab {
case .general: return env.preferences.t("常规设置", en: "General")
case .account: return env.preferences.t("登录设置", en: "Account")
case .system: return env.preferences.t("系统信息", en: "System info")
case .tags: return env.preferences.t("标签管理", en: "Tags")
case .tokens: return "API Token"
case .devices: return env.preferences.t("登录设备", en: "Devices")
case .users: return env.preferences.t("用户管理", en: "Users")
case nil: return env.preferences.t("我的", en: "Me")
}
}
private var headerIcon: String {
switch tab {
case .general: return "slider.horizontal.3"
case .account: return "shield.checkered"
case .system: return "info.circle"
case .tags: return "tag"
case .tokens: return "key"
case .devices: return "iphone"
case .users: return "person.2"
case nil: return "person"
}
}
var body: some View {
VStack(spacing: 0) {
settingsHeader
ScrollView {
Group {
if let tab {
detailContent(tab)
.transition(.opacity.combined(with: .move(edge: .trailing)))
} else {
rootMenu
.transition(.opacity)
}
}
.padding(16)
.padding(.bottom, 96)
.animation(Motion.listContent, value: tab)
}
.background(AppTheme.background)
}
.background(AppTheme.background.ignoresSafeArea())
.preferredColorScheme(env.preferences.colorScheme)
}
// MARK: - Header (Android settingsHeader)
private var settingsHeader: some View {
HStack(spacing: 0) {
Button {
if tab != nil {
withAnimation(Motion.chip) { tab = nil }
} else {
dismiss()
}
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(AppTheme.secondary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
HStack(spacing: 8) {
Image(systemName: headerIcon)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(AppTheme.accentStrong)
Text(title)
.font(.system(size: 16, weight: .heavy))
.foregroundStyle(AppTheme.title)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 4)
Button {
withAnimation(Motion.chip) {
env.preferences.theme = resolvedDarkMode ? "light" : "dark"
}
} label: {
HStack(spacing: 8) {
Image(systemName: resolvedDarkMode ? "sun.max" : "moon")
.font(.system(size: 16, weight: .semibold))
Text(themeToggleLabel)
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
}
.foregroundStyle(AppTheme.slate)
.padding(.horizontal, 8)
.frame(height: 36)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.frame(minHeight: 56)
.background(AppTheme.card)
.overlay(alignment: .bottom) {
Rectangle().fill(AppTheme.border).frame(height: 1)
}
}
private var themeToggleLabel: String {
// Android shows the *action* text: switch to light when dark, else switch to dark
if resolvedDarkMode {
return env.preferences.t("切换到浅色模式", en: "Light mode")
}
return env.preferences.t("切换到深色模式", en: "Dark mode")
}
private var resolvedDarkMode: Bool {
env.preferences.theme == "dark" || (env.preferences.theme == "system" && colorScheme == .dark)
}
// MARK: - Root Me menu (Android activeTab === null)
private var rootMenu: some View {
VStack(spacing: 16) {
// Card 1: general + account
settingsMenuCard {
menuRow(
icon: "slider.horizontal.3",
iconTint: AppTheme.accent,
iconBg: AppTheme.accentSoft,
title: env.preferences.t("常规设置", en: "General"),
showBorder: false
) {
withAnimation(Motion.chip) { tab = .general }
}
menuRow(
icon: "shield.checkered",
iconTint: AppTheme.accent,
iconBg: AppTheme.accentSoft,
title: env.preferences.t("登录设置", en: "Account"),
showBorder: true
) {
withAnimation(Motion.chip) { tab = .account }
}
}
// Card 2: system + feedback
settingsMenuCard {
menuRow(
icon: "info.circle",
iconTint: AppTheme.accent,
iconBg: AppTheme.accentSoft,
title: env.preferences.t("系统信息", en: "System info"),
subtitle: env.preferences.t(
"查看版本与运行环境信息。",
en: "View version and runtime info."
),
showBorder: false
) {
withAnimation(Motion.chip) { tab = .system }
}
menuRow(
icon: "bubble.left.and.bubble.right",
iconTint: AppTheme.secondary,
iconBg: AppTheme.searchFill,
title: env.preferences.t("意见反馈", en: "Feedback"),
subtitle: env.preferences.t(
"报告问题或提出功能建议",
en: "Report issues or suggest features"
),
trailing: .external,
showBorder: true
) {
if let url = feedbackURL { UIApplication.shared.open(url) }
}
}
}
}
private enum TrailingKind { case chevron, external }
private func settingsMenuCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
VStack(spacing: 0) {
content()
}
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
}
private func menuRow(
icon: String,
iconTint: Color,
iconBg: Color,
title: String,
subtitle: String? = nil,
trailing: TrailingKind = .chevron,
showBorder: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(iconBg)
.frame(width: 32, height: 32)
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(iconTint)
}
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppTheme.title)
if let subtitle {
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(AppTheme.secondary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: trailing == .external ? "arrow.up.right" : "chevron.right")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppTheme.muted)
}
.padding(.horizontal, 16)
.frame(minHeight: 64)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.overlay(alignment: .top) {
if showBorder {
Rectangle().fill(AppTheme.cardBorder).frame(height: 1)
.padding(.leading, 16)
}
}
}
// MARK: - Detail tabs
@ViewBuilder
private func detailContent(_ tab: RootTab) -> some View {
switch tab {
case .general:
generalContent
case .account:
accountContent
case .system:
systemContent
case .tags:
TagsManagementView()
case .tokens:
ApiTokensView()
case .devices:
DevicesView()
case .users:
UsersManagementView()
}
}
private var generalContent: some View {
VStack(spacing: 16) {
settingsGroup(
title: env.preferences.t("偏好设置", en: "Preferences"),
icon: "photo"
) {
preferenceBlock(
title: env.preferences.t("界面语言", en: "Language"),
description: env.preferences.t("切换产品界面的显示语言。", en: "Switch the product UI language.")
) {
Menu {
Button(env.preferences.t("跟随系统", en: "System")) { env.preferences.localeCode = "system" }
Button("简体中文") { env.preferences.localeCode = "zh-CN" }
Button("English") { env.preferences.localeCode = "en-US" }
} label: {
HStack {
Text(localeLabel)
.font(.system(size: 14))
.foregroundStyle(AppTheme.title)
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppTheme.secondary)
}
.padding(.horizontal, 12)
.frame(minHeight: 40)
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8).stroke(AppTheme.border, lineWidth: 1)
)
}
}
preferenceBlock(
title: env.preferences.t("压缩笔记内图片", en: "Compress note images"),
description: env.preferences.t(
"上传前将大图压缩为 WebP(最长边 2560),节省存储与流量。",
en: "Compress large images to WebP (max edge 2560) before upload to save storage and bandwidth."
),
showTopBorder: true
) {
Toggle("", isOn: Bindable(env.preferences).useCompression)
.labelsHidden()
.tint(AppTheme.accent)
.frame(maxWidth: .infinity, alignment: .leading)
}
// List density lives in list-options sheet (Android NotesActionsModal), not here.
}
}
}
private var accountContent: some View {
VStack(spacing: 16) {
// Account summary card
HStack(spacing: 12) {
ZStack {
Circle().fill(AppTheme.accentSoft).frame(width: 40, height: 40)
Image(systemName: "person")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(AppTheme.accentStrong)
}
VStack(alignment: .leading, spacing: 2) {
Text(env.preferences.t("当前账户", en: "Current account"))
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppTheme.secondary)
Text(env.session.session?.user?.displayName ?? env.session.session?.user?.username ?? "—")
.font(.system(size: 15, weight: .heavy))
.foregroundStyle(AppTheme.title)
let role = env.session.session?.user?.role == "owner"
? env.preferences.t("实例管理员", en: "Owner")
: env.preferences.t("成员", en: "Member")
Text("@\(env.session.session?.user?.username ?? "—") · \(role)")
.font(.system(size: 12))
.foregroundStyle(AppTheme.secondary)
.padding(.top, 1)
}
Spacer(minLength: 0)
}
.padding(16)
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
// Change password panel (Android AccountSecurityPanel)
AccountPasswordPanel()
// Extended links (tokens/devices/users/tags) — compact rows, not on Android root Me
settingsMenuCard {
menuRow(icon: "iphone", iconTint: AppTheme.accent, iconBg: AppTheme.accentSoft,
title: env.preferences.t("登录设备", en: "Devices"), showBorder: false) {
withAnimation(Motion.chip) { tab = .devices }
}
menuRow(icon: "key", iconTint: AppTheme.accent, iconBg: AppTheme.accentSoft,
title: "API Token", showBorder: true) {
withAnimation(Motion.chip) { tab = .tokens }
}
menuRow(icon: "tag", iconTint: AppTheme.accent, iconBg: AppTheme.accentSoft,
title: env.preferences.t("标签管理", en: "Tags"), showBorder: true) {
withAnimation(Motion.chip) { tab = .tags }
}
if env.session.session?.user?.role == "owner" {
menuRow(icon: "person.2", iconTint: AppTheme.accent, iconBg: AppTheme.accentSoft,
title: env.preferences.t("用户管理", en: "Users"), showBorder: true) {
withAnimation(Motion.chip) { tab = .users }
}
}
}
// Logout card
VStack(alignment: .leading, spacing: 0) {
Button {
Task {
await env.session.signOut()
dismiss()
}
} label: {
HStack(spacing: 8) {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.system(size: 15, weight: .bold))
Text(env.preferences.t("退出登录", en: "Sign out"))
.font(.system(size: 14, weight: .heavy))
}
.foregroundStyle(.white)
.padding(.horizontal, 14)
.frame(minHeight: 40)
.background(AppTheme.dangerAction)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppTheme.dangerSurface)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(AppTheme.dangerBorder, lineWidth: 1)
)
}
}
private var systemContent: some View {
VStack(spacing: 16) {
settingsGroup(title: env.preferences.t("系统信息", en: "System info"), icon: "info.circle") {
Button {
UIPasteboard.general.string = systemInfoText
copiedSystemInfo = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { copiedSystemInfo = false }
} label: {
HStack(spacing: 8) {
Image(systemName: copiedSystemInfo ? "checkmark.shield" : "doc.on.doc")
Text(copiedSystemInfo
? env.preferences.t("已复制", en: "Copied")
: env.preferences.t("复制信息", en: "Copy info"))
.font(.system(size: 14, weight: .bold))
Spacer()
}
.foregroundStyle(copiedSystemInfo ? AppTheme.accentStrong : AppTheme.title)
.padding(.horizontal, 16)
.frame(minHeight: 44)
}
.buttonStyle(.plain)
ForEach(Array(systemInfoItems.enumerated()), id: \.offset) { index, item in
infoRow(item.label, item.value, showBorder: true)
}
infoRow(env.preferences.t("实例", en: "Instance"),
env.session.session?.baseUrl ?? "—",
showBorder: true)
}
Button {
Task { await env.runSyncCycle() }
} label: {
HStack {
Image(systemName: "arrow.clockwise")
Text(env.preferences.t("立即同步", en: "Sync now"))
.font(.system(size: 14, weight: .bold))
if env.isSyncing { ProgressView() }
}
.frame(maxWidth: .infinity)
.frame(minHeight: 44)
.foregroundStyle(AppTheme.title)
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(AppTheme.border, lineWidth: 1))
}
.buttonStyle(.plain)
if let err = env.lastSyncError {
Text(err)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Building blocks
private var localeLabel: String {
switch env.preferences.localeCode {
case "zh-CN": return "简体中文"
case "en-US": return "English"
default: return env.preferences.t("跟随系统", en: "System")
}
}
private var systemInfoItems: [(label: String, value: String)] {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—"
let language = env.preferences.localeCode == "system"
? "\(env.preferences.resolvedLocale.identifier) (\(env.preferences.t("跟随系统", en: "Follow system")))"
: env.preferences.resolvedLocale.identifier
return [
(env.preferences.t("版本", en: "Version"), "v\(version)"),
(env.preferences.t("构建", en: "Build"), build),
(env.preferences.t("客户端", en: "Client"), env.preferences.t("移动应用", en: "Mobile app")),
(env.preferences.t("系统", en: "System"), "iOS"),
(env.preferences.t("系统版本", en: "System version"), UIDevice.current.systemVersion),
(env.preferences.t("语言", en: "Language"), language),
(env.preferences.t("时区", en: "Time zone"), TimeZone.current.identifier),
(env.preferences.t("安装形态", en: "Mode"), env.preferences.t("原生 SwiftUI 应用", en: "Native SwiftUI app")),
]
}
private var systemInfoText: String {
systemInfoItems.map { "\($0.label): \($0.value)" }.joined(separator: "\n")
}
private var feedbackURL: URL? {
let english = env.preferences.isEnglish
let heading = english ? "Feedback" : "反馈内容"
let prompt = english
? "Describe the problem, steps to reproduce it, or the feature you would like to see."
: "请描述遇到的问题、复现步骤,或你希望增加的功能。"
let privacy = english
? "GitHub Issues are public. Do not include passwords, tokens, instance URLs, or private note content."
: "GitHub Issue 公开可见,请勿提交密码、Token、实例地址或私人笔记内容。"
let infoHeading = english ? "System information" : "系统信息"
let notice = english
? "The following information was generated by EdgeEver to help diagnose the issue."
: "以下信息由 EdgeEver 自动生成,可帮助定位问题。"
let body = """
## \(heading)
\(prompt)
> \(privacy)
## \(infoHeading)
\(notice)
```text
\(systemInfoText)
```
"""
var components = URLComponents(string: "https://github.com/tianma-if/edgeever/issues/new")
components?.queryItems = [
URLQueryItem(name: "title", value: english ? "[Feedback] " : "[反馈] "),
URLQueryItem(name: "body", value: body),
]
return components?.url
}
private func settingsGroup<Content: View>(
title: String,
icon: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppTheme.accentStrong)
Text(title)
.font(.system(size: 14, weight: .heavy))
.foregroundStyle(AppTheme.title)
}
.padding(16)
content()
}
.background(AppTheme.card)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(AppTheme.border, lineWidth: 1)
)
}
private func preferenceBlock<Content: View>(
title: String,
description: String,
showTopBorder: Bool = false,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppTheme.title)
Text(description)
.font(.system(size: 12))
.foregroundStyle(AppTheme.secondary)
.fixedSize(horizontal: false, vertical: true)
}
content()
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .top) {
if showTopBorder {
Rectangle().fill(AppTheme.cardBorder).frame(height: 1)
}
}
}
private func infoRow(_ title: String, _ value: String, showBorder: Bool) -> some View {
HStack(alignment: .top) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppTheme.secondary)
Spacer()
Text(value)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(AppTheme.title)
.multilineTextAlignment(.trailing)
}
.padding(16)
.overlay(alignment: .top) {
if showBorder {
Rectangle().fill(AppTheme.cardBorder).frame(height: 1)
}
}
}
}
// MARK: - Password panel (Android AccountSecurityPanel)