Files
Ascently/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift

224 lines
8.3 KiB
Swift

//
// SessionStatusLiveLiveActivity.swift
import ActivityKit
import SwiftUI
import WidgetKit
struct SessionActivityAttributes: ActivityAttributes, Sendable {
public struct ContentState: Codable, Hashable, Sendable {
var elapsed: TimeInterval
var totalAttempts: Int
var completedProblems: Int
}
var gymName: String
var startTime: Date
}
struct SessionStatusLiveLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: SessionActivityAttributes.self) { context in
LiveActivityView(context: context)
.activityBackgroundTint(Color.blue.opacity(0.2))
.activitySystemActionForegroundColor(Color.primary)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
if let uiImage = UIImage(named: "AppIcon") {
Image(uiImage: uiImage)
.resizable()
.frame(width: 24, height: 24)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "figure.climbing")
.font(.title3)
.foregroundColor(.accentColor)
}
Text(context.attributes.gymName)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
.padding(.leading, 8)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 4) {
LiveTimerView(start: context.attributes.startTime)
.font(.title2)
.fontWeight(.bold)
.monospacedDigit()
HStack(spacing: 4) {
Image(systemName: "hand.raised.fill")
.foregroundColor(.green)
.font(.caption)
Text("\(context.state.totalAttempts)")
.font(.caption)
.fontWeight(.semibold)
Text("attempts")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.trailing, 8)
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Text("\(context.state.completedProblems) completed")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text("Tap to open")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
} compactLeading: {
Image(systemName: "figure.climbing")
.font(.footnote)
.foregroundColor(.accentColor)
} compactTrailing: {
LiveTimerView(start: context.attributes.startTime, compact: true)
} minimal: {
Image(systemName: "figure.climbing")
.font(.system(size: 8))
.foregroundColor(.accentColor)
}
}
}
}
struct LiveActivityView: View {
let context: ActivityViewContext<SessionActivityAttributes>
var body: some View {
HStack(spacing: 16) {
LiveTimerView(start: context.attributes.startTime)
.font(.largeTitle)
.fontWeight(.bold)
.monospacedDigit()
.frame(minWidth: 80)
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
if let uiImage = UIImage(named: "AppIcon") {
Image(uiImage: uiImage)
.resizable()
.frame(width: 28, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 7))
} else {
Image(systemName: "figure.climbing")
.font(.title2)
.foregroundColor(.accentColor)
}
VStack(alignment: .leading, spacing: 0) {
Text(context.attributes.gymName)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(1)
Text("Climbing Session")
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack(spacing: 20) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "hand.raised.fill")
.foregroundColor(.green)
.font(.title3)
Text("\(context.state.totalAttempts)")
.font(.title2)
.fontWeight(.bold)
}
Text("Total Attempts")
.font(.caption)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title3)
Text("\(context.state.completedProblems)")
.font(.title2)
.fontWeight(.bold)
}
Text("Completed")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
struct LiveTimerView: View {
let start: Date
let compact: Bool
let minimal: Bool
init(start: Date, compact: Bool = false, minimal: Bool = false) {
self.start = start
self.compact = compact
self.minimal = minimal
}
var body: some View {
if minimal {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.font(.system(size: 8, weight: .medium, design: .monospaced))
} else if compact {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.font(.caption.monospacedDigit())
.frame(maxWidth: 40)
.minimumScaleFactor(0.7)
} else {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.monospacedDigit()
}
}
}
// Alias for compatibility
typealias TimerView = LiveTimerView
extension SessionActivityAttributes {
fileprivate static var preview: SessionActivityAttributes {
SessionActivityAttributes(
gymName: "Summit Climbing Gym", startTime: Date().addingTimeInterval(-1234))
}
}
extension SessionActivityAttributes.ContentState {
fileprivate static var active: SessionActivityAttributes.ContentState {
SessionActivityAttributes.ContentState(
elapsed: 1234, totalAttempts: 8, completedProblems: 2)
}
fileprivate static var busy: SessionActivityAttributes.ContentState {
SessionActivityAttributes.ContentState(
elapsed: 3600, totalAttempts: 25, completedProblems: 7)
}
}
#Preview("Notification", as: .content, using: SessionActivityAttributes.preview) {
SessionStatusLiveLiveActivity()
} contentStates: {
SessionActivityAttributes.ContentState.active
SessionActivityAttributes.ContentState.busy
}