SettingsView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import SwiftUI
  2. // MARK: - SettingsView
  3. /// The settings screen with storage info, debug toggles,
  4. /// background keep-alive guide, and app branding.
  5. /// Redesigned to use a minimalist wireframe business style.
  6. struct SettingsView: View {
  7. @State private var showClearCacheAlert = false
  8. @State private var usedStorageBytes: Int64 = 0
  9. @State private var availableStorageBytes: Int64 = 0
  10. var body: some View {
  11. NavigationStack {
  12. ZStack {
  13. Color.spaceBlack.ignoresSafeArea()
  14. ScrollView {
  15. VStack(spacing: 20) {
  16. // Section: Storage
  17. storageSection
  18. .padding(.top, 12)
  19. // Section: Debug
  20. debugSection
  21. // Section: Keep-Alive Guide
  22. keepAliveSection
  23. // Section: About
  24. aboutSection
  25. // Footer
  26. footer
  27. .padding(.top, 12)
  28. .padding(.bottom, 40)
  29. }
  30. .padding(.horizontal, 16)
  31. }
  32. }
  33. .navigationTitle("设置")
  34. .navigationBarTitleDisplayMode(.inline)
  35. .alert("清理缓存", isPresented: $showClearCacheAlert) {
  36. Button("取消", role: .cancel) { }
  37. Button("清理", role: .destructive) {
  38. clearTemporaryCache()
  39. HapticManager.trigger(.tapFeedback)
  40. }
  41. } message: {
  42. Text("只清除系统缓存目录,不会删除录音、照片或会话记录。")
  43. }
  44. .task { reloadStorageMetrics() }
  45. }
  46. }
  47. // MARK: - Storage Section
  48. private var storageSection: some View {
  49. settingsCard {
  50. VStack(alignment: .leading, spacing: 16) {
  51. sectionHeader(icon: "internaldrive", title: "存储空间")
  52. // Thin storage bar
  53. VStack(alignment: .leading, spacing: 8) {
  54. GeometryReader { geometry in
  55. ZStack(alignment: .leading) {
  56. // Background bar
  57. RoundedRectangle(cornerRadius: 3)
  58. .fill(Color.primary.opacity(0.06))
  59. .frame(height: 5)
  60. // Used portion
  61. RoundedRectangle(cornerRadius: 3)
  62. .fill(Color.primary)
  63. .frame(
  64. width: geometry.size.width * storageUsageRatio,
  65. height: 5
  66. )
  67. }
  68. }
  69. .frame(height: 5)
  70. HStack {
  71. Text("App 数据 \(formattedBytes(usedStorageBytes))")
  72. .font(.system(size: 11, weight: .regular))
  73. .foregroundStyle(Color.primary.opacity(0.8))
  74. Spacer()
  75. Text("设备可用 \(formattedBytes(availableStorageBytes))")
  76. .font(.system(size: 11, weight: .regular))
  77. .foregroundStyle(Color.secondary)
  78. }
  79. }
  80. // Clear cache button (minimal red outline)
  81. Button {
  82. showClearCacheAlert = true
  83. } label: {
  84. HStack(spacing: 6) {
  85. Image(systemName: "trash")
  86. .font(.system(size: 12, weight: .light))
  87. Text("清理缓存")
  88. .font(.system(size: 13, weight: .regular))
  89. }
  90. .foregroundStyle(Color.recordingRed)
  91. .padding(.vertical, 8)
  92. .frame(maxWidth: .infinity)
  93. .overlay(
  94. RoundedRectangle(cornerRadius: 6)
  95. .stroke(Color.recordingRed.opacity(0.3), lineWidth: 1)
  96. )
  97. }
  98. .padding(.top, 4)
  99. }
  100. }
  101. }
  102. // MARK: - Debug Section
  103. private var debugSection: some View {
  104. settingsCard {
  105. VStack(alignment: .leading, spacing: 14) {
  106. sectionHeader(icon: "externaldrive.connected.to.line.below", title: "数据与同步")
  107. // Data Storage Mode
  108. VStack(alignment: .leading, spacing: 4) {
  109. HStack {
  110. Text("数据存储模式")
  111. .font(.system(size: 13, weight: .regular))
  112. .foregroundStyle(Color.primary)
  113. Spacer()
  114. Text("本地优先")
  115. .font(.system(size: 13, weight: .medium))
  116. .foregroundStyle(Color.secondary)
  117. }
  118. Text("记录先保存在本机;登录后由用户主动同步到服务器")
  119. .font(.system(size: 10))
  120. .foregroundStyle(Color.secondary.opacity(0.6))
  121. }
  122. }
  123. }
  124. }
  125. // MARK: - Keep-Alive Section
  126. private var keepAliveSection: some View {
  127. settingsCard {
  128. VStack(alignment: .leading, spacing: 14) {
  129. sectionHeader(icon: "shield", title: "后台运行保障")
  130. Text("为保障全局不中断录音正常运行,请手动开启以下系统权限:")
  131. .font(.system(size: 12))
  132. .foregroundStyle(Color.secondary)
  133. VStack(alignment: .leading, spacing: 12) {
  134. keepAliveStep(
  135. number: 1,
  136. title: "开启后台音频",
  137. description: "Xcode 开启后台模式 -> 音频与 AirPlay",
  138. isChecked: true
  139. )
  140. keepAliveStep(
  141. number: 2,
  142. title: "禁用自动锁屏",
  143. description: "系统设置 -> 显示与亮度 -> 自动锁定 -> 永不",
  144. isChecked: true
  145. )
  146. keepAliveStep(
  147. number: 3,
  148. title: "允许始终访问定位",
  149. description: "系统设置 -> 隐私与安全性 -> 定位服务 -> 现场记录 -> 始终允许",
  150. isChecked: false
  151. )
  152. }
  153. .padding(.top, 4)
  154. }
  155. }
  156. }
  157. private func keepAliveStep(number: Int, title: String, description: String, isChecked: Bool) -> some View {
  158. HStack(alignment: .top, spacing: 10) {
  159. ZStack {
  160. Circle()
  161. .fill(Color.primary.opacity(isChecked ? 0.05 : 0.01))
  162. .frame(width: 24, height: 24)
  163. .businessBorder(cornerRadius: 12)
  164. if isChecked {
  165. Image(systemName: "checkmark")
  166. .font(.system(size: 10, weight: .semibold))
  167. .foregroundStyle(Color.primary)
  168. } else {
  169. Text("\(number)")
  170. .font(.system(size: 10, weight: .medium, design: .monospaced))
  171. .foregroundStyle(Color.secondary)
  172. }
  173. }
  174. VStack(alignment: .leading, spacing: 2) {
  175. Text(title)
  176. .font(.system(size: 13, weight: .medium))
  177. .foregroundStyle(Color.primary)
  178. Text(description)
  179. .font(.system(size: 11))
  180. .foregroundStyle(Color.secondary.opacity(0.8))
  181. }
  182. }
  183. }
  184. // MARK: - About Section
  185. private var aboutSection: some View {
  186. settingsCard {
  187. VStack(spacing: 14) {
  188. sectionHeader(icon: "info.circle", title: "关于")
  189. VStack(spacing: 10) {
  190. infoRow(label: "应用名称", value: "现场记录 (Celestia Trace)")
  191. infoRow(label: "版本信息", value: "1.0.0")
  192. infoRow(label: "底层框架", value: "SwiftUI + SwiftData")
  193. infoRow(label: "系统支持", value: "iOS 17.0+")
  194. }
  195. .padding(.top, 4)
  196. }
  197. }
  198. }
  199. private func infoRow(label: String, value: String) -> some View {
  200. HStack {
  201. Text(label)
  202. .font(.system(size: 12))
  203. .foregroundStyle(Color.secondary)
  204. Spacer()
  205. Text(value)
  206. .font(.system(size: 12, weight: .regular, design: .monospaced))
  207. .foregroundStyle(Color.primary)
  208. }
  209. }
  210. // MARK: - Footer
  211. private var footer: some View {
  212. VStack(spacing: 4) {
  213. Text("星痕现场记录")
  214. .font(.system(size: 10, weight: .bold, design: .monospaced))
  215. .foregroundStyle(Color.secondary.opacity(0.4))
  216. .tracking(2)
  217. Text("极简专业现场记录工作台")
  218. .font(.system(size: 9))
  219. .foregroundStyle(Color.secondary.opacity(0.3))
  220. }
  221. }
  222. // MARK: - Reusable Components
  223. private func settingsCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
  224. VStack(alignment: .leading, spacing: 0) {
  225. content()
  226. }
  227. .padding(16)
  228. .background(Color.cardBackground.opacity(0.15))
  229. .businessBorder(cornerRadius: 10)
  230. }
  231. private func sectionHeader(icon: String, title: String) -> some View {
  232. HStack(spacing: 8) {
  233. Image(systemName: icon)
  234. .font(.system(size: 12, weight: .light))
  235. .foregroundStyle(Color.secondary)
  236. Text(title)
  237. .font(.system(size: 14, weight: .semibold))
  238. .foregroundStyle(Color.primary)
  239. }
  240. }
  241. private var storageUsageRatio: Double {
  242. let total = usedStorageBytes + availableStorageBytes
  243. guard total > 0 else { return 0 }
  244. return min(1, Double(usedStorageBytes) / Double(total))
  245. }
  246. private func formattedBytes(_ bytes: Int64) -> String {
  247. ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
  248. }
  249. private func reloadStorageMetrics() {
  250. let fileManager = FileManager.default
  251. let roots = [
  252. fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
  253. fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
  254. ].compactMap { $0 }
  255. usedStorageBytes = roots.reduce(0) { total, root in
  256. let keys: Set<URLResourceKey> = [.isRegularFileKey, .fileSizeKey]
  257. guard let enumerator = fileManager.enumerator(at: root, includingPropertiesForKeys: Array(keys)) else { return total }
  258. var subtotal: Int64 = 0
  259. for case let url as URL in enumerator {
  260. guard let values = try? url.resourceValues(forKeys: keys), values.isRegularFile == true else { continue }
  261. subtotal += Int64(values.fileSize ?? 0)
  262. }
  263. return total + subtotal
  264. }
  265. let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
  266. availableStorageBytes = Int64((try? documents?.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]).volumeAvailableCapacityForImportantUsage) ?? 0)
  267. }
  268. private func clearTemporaryCache() {
  269. let fileManager = FileManager.default
  270. guard let cacheURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first,
  271. let children = try? fileManager.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil) else { return }
  272. for child in children {
  273. try? fileManager.removeItem(at: child)
  274. }
  275. reloadStorageMetrics()
  276. }
  277. }
  278. // MARK: - Preview
  279. #Preview {
  280. SettingsView()
  281. }