RecordingLiveActivityWidget.swift 7.5 KB

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