127 lines
5.9 KiB
Swift
127 lines
5.9 KiB
Swift
import ActivityKit
|
|||
|
|
import SwiftUI
|
||
|
|
import WidgetKit
|
||
|
|
|
||
|
|
/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3):
|
||
|
|
/// glance = running count + needs-you count + the most urgent session; tap opens the app
|
||
|
|
/// (straight to the approval, via the notification-style route). Self-contained colors — the
|
||
|
|
/// extension doesn't link the app's Theme, so the two palette constants it needs live here
|
||
|
|
/// (the same teal/amber/blue as `Palette`).
|
||
|
|
struct SessionLiveActivity: Widget {
|
||
|
|
var body: some WidgetConfiguration {
|
||
|
|
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
|
||
|
|
// Lock screen banner.
|
||
|
|
LockScreenView(state: context.state, hostName: context.attributes.hostName)
|
||
|
|
.activityBackgroundTint(Color.black.opacity(0.55))
|
||
|
|
.activitySystemActionForegroundColor(.white)
|
||
|
|
} dynamicIsland: { context in
|
||
|
|
DynamicIsland {
|
||
|
|
DynamicIslandExpandedRegion(.leading) {
|
||
|
|
Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled")
|
||
|
|
.font(.headline)
|
||
|
|
.foregroundStyle(ActivityPalette.active)
|
||
|
|
}
|
||
|
|
DynamicIslandExpandedRegion(.trailing) {
|
||
|
|
if context.state.needsYouCount > 0 {
|
||
|
|
Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill")
|
||
|
|
.font(.headline)
|
||
|
|
.foregroundStyle(ActivityPalette.attention)
|
||
|
|
} else {
|
||
|
|
Label("0", systemImage: "checkmark.circle")
|
||
|
|
.font(.headline)
|
||
|
|
.foregroundStyle(.secondary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
DynamicIslandExpandedRegion(.bottom) {
|
||
|
|
if let title = context.state.topTitle {
|
||
|
|
HStack(spacing: 6) {
|
||
|
|
Image(systemName: context.state.topNeedsYou
|
||
|
|
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||
|
|
.foregroundStyle(context.state.topNeedsYou
|
||
|
|
? ActivityPalette.attention : ActivityPalette.active)
|
||
|
|
Text(title).font(.subheadline.weight(.medium)).lineLimit(1)
|
||
|
|
Spacer()
|
||
|
|
Text(statusLine(context.state))
|
||
|
|
.font(.caption).foregroundStyle(.secondary)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Text(statusLine(context.state))
|
||
|
|
.font(.caption).foregroundStyle(.secondary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} compactLeading: {
|
||
|
|
Image(systemName: "circle.lefthalf.filled")
|
||
|
|
.foregroundStyle(ActivityPalette.active)
|
||
|
|
} compactTrailing: {
|
||
|
|
if context.state.needsYouCount > 0 {
|
||
|
|
Text("\(context.state.needsYouCount)")
|
||
|
|
.font(.caption2.bold())
|
||
|
|
.foregroundStyle(ActivityPalette.attention)
|
||
|
|
} else {
|
||
|
|
Text("\(context.state.runningCount)")
|
||
|
|
.font(.caption2.bold())
|
||
|
|
.foregroundStyle(ActivityPalette.active)
|
||
|
|
}
|
||
|
|
} minimal: {
|
||
|
|
Image(systemName: context.state.needsYouCount > 0
|
||
|
|
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||
|
|
.foregroundStyle(context.state.needsYouCount > 0
|
||
|
|
? ActivityPalette.attention : ActivityPalette.active)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func statusLine(_ state: NucleicSessionAttributes.ContentState) -> String {
|
||
|
|
var parts: [String] = []
|
||
|
|
if state.runningCount > 0 { parts.append("\(state.runningCount) running") }
|
||
|
|
if state.needsYouCount > 0 { parts.append("\(state.needsYouCount) need you") }
|
||
|
|
return parts.isEmpty ? "Idle" : parts.joined(separator: " · ")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private struct LockScreenView: View {
|
||
|
|
let state: NucleicSessionAttributes.ContentState
|
||
|
|
let hostName: String
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
HStack(spacing: 12) {
|
||
|
|
Image(systemName: state.needsYouCount > 0
|
||
|
|
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||
|
|
.font(.title2)
|
||
|
|
.foregroundStyle(state.needsYouCount > 0
|
||
|
|
? ActivityPalette.attention : ActivityPalette.active)
|
||
|
|
VStack(alignment: .leading, spacing: 2) {
|
||
|
|
if let title = state.topTitle {
|
||
|
|
Text(title).font(.headline).lineLimit(1)
|
||
|
|
}
|
||
|
|
HStack(spacing: 4) {
|
||
|
|
if state.runningCount > 0 {
|
||
|
|
Text("\(state.runningCount) running").foregroundStyle(ActivityPalette.active)
|
||
|
|
}
|
||
|
|
if state.runningCount > 0 && state.needsYouCount > 0 { Text("·") }
|
||
|
|
if state.needsYouCount > 0 {
|
||
|
|
Text("\(state.needsYouCount) waiting for you")
|
||
|
|
.foregroundStyle(ActivityPalette.attention)
|
||
|
|
}
|
||
|
|
if state.runningCount == 0 && state.needsYouCount == 0 {
|
||
|
|
Text("Idle").foregroundStyle(.secondary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.font(.subheadline)
|
||
|
|
}
|
||
|
|
Spacer()
|
||
|
|
Text(hostName)
|
||
|
|
.font(.caption2).foregroundStyle(.secondary)
|
||
|
|
.lineLimit(1)
|
||
|
|
}
|
||
|
|
.padding(14)
|
||
|
|
.foregroundStyle(.white)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The two categorical colors the Activity needs — same constants as the app's `Palette`.
|
||
|
|
private enum ActivityPalette {
|
||
|
|
static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue
|
||
|
|
static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // needs-you amber
|
||
|
|
}
|