RecordingLiveActivityWidget.swift 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import ActivityKit
  2. import AppIntents
  3. import SwiftUI
  4. import WidgetKit
  5. struct RecordingLiveActivityWidget: Widget {
  6. var body: some WidgetConfiguration {
  7. ActivityConfiguration(for: RecordingActivityAttributes.self) { context in
  8. lockScreenView(context)
  9. .activityBackgroundTint(Color.black.opacity(0.92))
  10. .activitySystemActionForegroundColor(.white)
  11. .widgetURL(recordingURL(context.attributes))
  12. } dynamicIsland: { context in
  13. DynamicIsland {
  14. DynamicIslandExpandedRegion(.leading) {
  15. Label("正在记录", systemImage: "record.circle.fill")
  16. .font(.caption.weight(.semibold))
  17. .foregroundStyle(.red)
  18. }
  19. DynamicIslandExpandedRegion(.trailing) {
  20. elapsedTime(context.state)
  21. .font(.caption.monospacedDigit())
  22. .foregroundStyle(.white)
  23. }
  24. DynamicIslandExpandedRegion(.bottom) {
  25. VStack(spacing: 8) {
  26. Text(context.attributes.title)
  27. .font(.caption)
  28. .lineLimit(1)
  29. .frame(maxWidth: .infinity, alignment: .leading)
  30. HStack(spacing: 8) {
  31. islandIntentAction(
  32. label: context.state.isPaused ? "继续" : "暂停",
  33. systemImage: context.state.isPaused ? "play.fill" : "pause.fill",
  34. intent: TogglePauseRecordingIntent(
  35. sessionID: context.attributes.sessionID
  36. )
  37. )
  38. islandIntentAction(
  39. label: "结束",
  40. systemImage: "stop.fill",
  41. intent: StopRecordingIntent(
  42. sessionID: context.attributes.sessionID
  43. ),
  44. tint: .red
  45. )
  46. islandAction(
  47. label: "拍照",
  48. systemImage: "camera.fill",
  49. url: actionURL("photo", context.attributes)
  50. )
  51. islandAction(
  52. label: "文字",
  53. systemImage: "note.text",
  54. url: actionURL("note", context.attributes)
  55. )
  56. }
  57. }
  58. .foregroundStyle(.white)
  59. }
  60. } compactLeading: {
  61. Image(systemName: "record.circle.fill")
  62. .foregroundStyle(.red)
  63. } compactTrailing: {
  64. elapsedTime(context.state)
  65. .font(.caption2.monospacedDigit())
  66. .frame(maxWidth: 48)
  67. } minimal: {
  68. Image(systemName: "record.circle.fill")
  69. .foregroundStyle(.red)
  70. }
  71. .widgetURL(recordingURL(context.attributes))
  72. .keylineTint(.red)
  73. }
  74. }
  75. private func lockScreenView(
  76. _ context: ActivityViewContext<RecordingActivityAttributes>
  77. ) -> some View {
  78. VStack(alignment: .leading, spacing: 12) {
  79. HStack(spacing: 10) {
  80. Image(systemName: context.state.isPaused ? "pause.circle.fill" : "record.circle.fill")
  81. .font(.title2)
  82. .foregroundStyle(context.state.isPaused ? .orange : .red)
  83. VStack(alignment: .leading, spacing: 2) {
  84. Text(context.attributes.title)
  85. .font(.headline)
  86. .foregroundStyle(.white)
  87. .lineLimit(1)
  88. Text(context.state.isPaused ? "记录已暂停" : "正在记录 · \(context.attributes.sourceName)")
  89. .font(.caption)
  90. .foregroundStyle(.white.opacity(0.65))
  91. .lineLimit(1)
  92. }
  93. Spacer()
  94. elapsedTime(context.state)
  95. .font(.title3.monospacedDigit().weight(.medium))
  96. .foregroundStyle(.white)
  97. }
  98. VStack(spacing: 8) {
  99. HStack(spacing: 10) {
  100. quickIntentAction(
  101. title: context.state.isPaused ? "继续" : "暂停",
  102. systemImage: context.state.isPaused ? "play.fill" : "pause.fill",
  103. intent: TogglePauseRecordingIntent(
  104. sessionID: context.attributes.sessionID
  105. )
  106. )
  107. quickIntentAction(
  108. title: "结束",
  109. systemImage: "stop.fill",
  110. intent: StopRecordingIntent(
  111. sessionID: context.attributes.sessionID
  112. ),
  113. tint: .red
  114. )
  115. }
  116. HStack(spacing: 10) {
  117. quickAction(
  118. title: "拍照",
  119. systemImage: "camera.fill",
  120. url: actionURL("photo", context.attributes)
  121. )
  122. quickAction(
  123. title: "文字",
  124. systemImage: "note.text",
  125. url: actionURL("note", context.attributes)
  126. )
  127. }
  128. }
  129. }
  130. .padding(16)
  131. }
  132. @ViewBuilder
  133. private func elapsedTime(
  134. _ state: RecordingActivityAttributes.ContentState
  135. ) -> some View {
  136. if state.isPaused {
  137. Text(formattedDuration(state.elapsedSeconds))
  138. } else {
  139. Text(
  140. timerInterval: state.timerStartDate...Date.distantFuture,
  141. pauseTime: nil,
  142. countsDown: false,
  143. showsHours: true
  144. )
  145. }
  146. }
  147. private func quickAction(
  148. title: String,
  149. systemImage: String,
  150. url: URL,
  151. tint: Color = .white
  152. ) -> some View {
  153. Link(destination: url) {
  154. Label(title, systemImage: systemImage)
  155. .font(.caption.weight(.semibold))
  156. .foregroundStyle(tint)
  157. .frame(maxWidth: .infinity)
  158. .padding(.vertical, 8)
  159. .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
  160. }
  161. }
  162. private func quickIntentAction<Intent: AppIntent>(
  163. title: String,
  164. systemImage: String,
  165. intent: Intent,
  166. tint: Color = .white
  167. ) -> some View {
  168. Button(intent: intent) {
  169. Label(title, systemImage: systemImage)
  170. .font(.caption.weight(.semibold))
  171. .foregroundStyle(tint)
  172. .frame(maxWidth: .infinity)
  173. .padding(.vertical, 8)
  174. .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
  175. }
  176. .buttonStyle(.plain)
  177. }
  178. private func islandAction(
  179. label: String,
  180. systemImage: String,
  181. url: URL,
  182. tint: Color = .white
  183. ) -> some View {
  184. Link(destination: url) {
  185. Image(systemName: systemImage)
  186. .font(.caption.weight(.semibold))
  187. .foregroundStyle(tint)
  188. .frame(maxWidth: .infinity)
  189. .padding(.vertical, 7)
  190. .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
  191. .accessibilityLabel(label)
  192. }
  193. }
  194. private func islandIntentAction<Intent: AppIntent>(
  195. label: String,
  196. systemImage: String,
  197. intent: Intent,
  198. tint: Color = .white
  199. ) -> some View {
  200. Button(intent: intent) {
  201. Image(systemName: systemImage)
  202. .font(.caption.weight(.semibold))
  203. .foregroundStyle(tint)
  204. .frame(maxWidth: .infinity)
  205. .padding(.vertical, 7)
  206. .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
  207. .accessibilityLabel(label)
  208. }
  209. .buttonStyle(.plain)
  210. }
  211. private func recordingURL(_ attributes: RecordingActivityAttributes) -> URL {
  212. URL(string: "celestiatrace://recording/\(attributes.sessionID.uuidString)")!
  213. }
  214. private func actionURL(
  215. _ action: String,
  216. _ attributes: RecordingActivityAttributes
  217. ) -> URL {
  218. URL(string: "celestiatrace://recording/\(attributes.sessionID.uuidString)/\(action)")!
  219. }
  220. private func formattedDuration(_ seconds: TimeInterval) -> String {
  221. let totalSeconds = max(0, Int(seconds))
  222. return String(
  223. format: "%02d:%02d:%02d",
  224. totalSeconds / 3600,
  225. (totalSeconds % 3600) / 60,
  226. totalSeconds % 60
  227. )
  228. }
  229. }