feat(apple/widgets): Dynamic Island rebuild + brand purple everywhere (re-landed post-rebase)
Re-lands the island rebuild: a concurrent session's pull-rebase reordered the pick against the canvas-previews commit and the pre-rebuild file content won; the working tree kept the intended final state (BrandColor.swift itself survived tracked). Content identical to the original commit message: The widgets rendered system BLUE because Color.brand lived in PunktfunkKit, which the extension never links — moved to PunktfunkShared (Kit re-exports Shared). Island rebuilt: expanded = identity leading + elapsed clock trailing + one purposeful bottom row (status dot/stage + live latency/bitrate stats line, End at the trailing edge); compact trailing = the one glanceable number (live latency green / disconnect countdown orange / state glyph); keylineTint brand. Lock Screen banner shares the same StatusLine/StatsLine pieces. swift build + PunktfunkWidgetsExtension build green on the rebased base. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,7 +94,7 @@ private struct SmallHostView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(Color.brand)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(host.displayName)
|
Text(host.displayName)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -122,12 +122,12 @@ private struct MediumHostsView: View {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Punktfunk")
|
Text("Punktfunk")
|
||||||
.font(.caption).bold()
|
.font(.caption).bold()
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(Color.brand)
|
||||||
ForEach(hosts.prefix(4)) { host in
|
ForEach(hosts.prefix(4)) { host in
|
||||||
Link(destination: connectURL(host)) {
|
Link(destination: connectURL(host)) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(Color.brand)
|
||||||
Text(host.displayName)
|
Text(host.displayName)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|||||||
@@ -19,43 +19,64 @@ struct PunktfunkSessionLiveActivity: Widget {
|
|||||||
LockScreenView(context: context)
|
LockScreenView(context: context)
|
||||||
.activitySystemActionForegroundColor(.white)
|
.activitySystemActionForegroundColor(.white)
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
|
// Island layout (2026-07 rebuild): the EXPANDED island leads with identity (brand
|
||||||
|
// glyph + host), keeps the elapsed clock at the trailing edge, and gives the bottom
|
||||||
|
// region one purposeful row — session status + the live numbers on the left, the
|
||||||
|
// End action on the right. COMPACT shows the one number worth a glance: live
|
||||||
|
// latency while streaming (the sparse ~30 s pushes), the disconnect countdown while
|
||||||
|
// backgrounded, a state glyph otherwise. Brand purple is the identity accent
|
||||||
|
// everywhere `.tint` used to leak system blue.
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
Label {
|
HStack(spacing: 6) {
|
||||||
Text(context.attributes.hostName).font(.caption).lineLimit(1)
|
|
||||||
} icon: {
|
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
|
Text(context.attributes.hostName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.foregroundStyle(.tint)
|
.padding(.leading, 4)
|
||||||
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
ElapsedClock(startedAt: context.state.startedAt)
|
||||||
.font(.caption).monospacedDigit()
|
.font(.subheadline)
|
||||||
.frame(maxWidth: 56)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: 64, alignment: .trailing)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.center) {
|
DynamicIslandExpandedRegion(.center) {
|
||||||
if let title = context.attributes.launchTitle {
|
if let title = context.attributes.launchTitle {
|
||||||
Text(title).font(.caption2).lineLimit(1).foregroundStyle(.secondary)
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
VStack(spacing: 6) {
|
HStack(alignment: .center, spacing: 12) {
|
||||||
Text(context.state.modeLine)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
StatusLine(state: context.state)
|
||||||
StageLine(state: context.state)
|
StatsLine(state: context.state)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
EndButton()
|
EndButton()
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
Image(systemName: "play.tv.fill")
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
CompactReadout(state: context.state)
|
||||||
.monospacedDigit()
|
|
||||||
.frame(maxWidth: 44)
|
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Image(systemName: "play.tv.fill").foregroundStyle(.tint)
|
Image(systemName: "play.tv.fill")
|
||||||
|
.foregroundStyle(Color.brand)
|
||||||
}
|
}
|
||||||
|
.keylineTint(Color.brand)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,21 +90,20 @@ private struct LockScreenView: View {
|
|||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: "play.tv.fill")
|
Image(systemName: "play.tv.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(Color.brand)
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
Text(context.attributes.hostName).font(.headline).lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(timerInterval: context.state.startedAt...Date.distantFuture, countsDown: false)
|
ElapsedClock(startedAt: context.state.startedAt)
|
||||||
.font(.subheadline).monospacedDigit()
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let title = context.attributes.launchTitle {
|
if let title = context.attributes.launchTitle {
|
||||||
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
}
|
}
|
||||||
Text(context.state.modeLine)
|
StatusLine(state: context.state)
|
||||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
StatsLine(state: context.state)
|
||||||
StageLine(state: context.state)
|
|
||||||
}
|
}
|
||||||
if context.state.stage == .background {
|
if context.state.stage == .background {
|
||||||
EndButton()
|
EndButton()
|
||||||
@@ -95,36 +115,132 @@ private struct LockScreenView: View {
|
|||||||
|
|
||||||
// MARK: - Shared pieces
|
// MARK: - Shared pieces
|
||||||
|
|
||||||
/// The stage badge + (while backgrounded) the auto-disconnect countdown.
|
/// The ticking elapsed-session clock — client-side via `timerInterval`, no per-second push.
|
||||||
private struct StageLine: View {
|
private struct ElapsedClock: View {
|
||||||
|
let startedAt: Date
|
||||||
|
var body: some View {
|
||||||
|
Text(timerInterval: startedAt...Date.distantFuture, countsDown: false)
|
||||||
|
.monospacedDigit()
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session's state as a colored dot + label; while backgrounded with a deadline, the label
|
||||||
|
/// IS the countdown. One shared truth for the island bottom and the Lock Screen banner.
|
||||||
|
private struct StatusLine: View {
|
||||||
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
label
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch state.stage {
|
||||||
|
case .streaming: return .green
|
||||||
|
case .background: return .orange
|
||||||
|
case .reconnecting: return .yellow
|
||||||
|
case .ending: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var label: some View {
|
||||||
|
switch state.stage {
|
||||||
|
case .streaming:
|
||||||
|
Text("Streaming")
|
||||||
|
case .background:
|
||||||
|
if let deadline = state.backgroundDeadline {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text("Background · ends in")
|
||||||
|
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Running in background")
|
||||||
|
}
|
||||||
|
case .reconnecting:
|
||||||
|
Text("Reconnecting…")
|
||||||
|
case .ending:
|
||||||
|
Text("Session ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live numbers, one quiet line: latency and bitrate (the sparse ~30 s pushes) ahead of the
|
||||||
|
/// mode. Anything unreported simply doesn't appear.
|
||||||
|
private struct StatsLine: View {
|
||||||
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let ms = state.latencyMs {
|
||||||
|
stat("bolt.fill", "\(ms) ms")
|
||||||
|
}
|
||||||
|
if let mbps = state.mbps {
|
||||||
|
stat("arrow.down", String(format: "%.0f Mb/s", mbps))
|
||||||
|
}
|
||||||
|
Text(state.modeLine)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stat(_ icon: String, _ text: String) -> some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 8, weight: .semibold))
|
||||||
|
Text(text)
|
||||||
|
.font(.caption2)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compact trailing readout — the island's one glanceable number. Streaming: the live
|
||||||
|
/// latency once the app has reported one, the elapsed clock until then. Backgrounded: the
|
||||||
|
/// auto-disconnect countdown. Off-nominal stages show a state glyph instead of a number.
|
||||||
|
private struct CompactReadout: View {
|
||||||
let state: PunktfunkSessionAttributes.ContentState
|
let state: PunktfunkSessionAttributes.ContentState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch state.stage {
|
switch state.stage {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
EmptyView()
|
if let ms = state.latencyMs {
|
||||||
|
Text("\(ms) ms")
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
ElapsedClock(startedAt: state.startedAt)
|
||||||
|
.font(.caption2)
|
||||||
|
.frame(maxWidth: 48)
|
||||||
|
}
|
||||||
case .background:
|
case .background:
|
||||||
if let deadline = state.backgroundDeadline {
|
if let deadline = state.backgroundDeadline {
|
||||||
HStack(spacing: 3) {
|
Text(timerInterval: Date()...deadline, countsDown: true)
|
||||||
Text("Keeps running for")
|
.font(.caption2)
|
||||||
Text(timerInterval: Date()...deadline, countsDown: true)
|
.monospacedDigit()
|
||||||
.monospacedDigit()
|
.multilineTextAlignment(.trailing)
|
||||||
}
|
.frame(maxWidth: 48)
|
||||||
.font(.caption2)
|
.foregroundStyle(.orange)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
} else {
|
||||||
badge("Running in background", .orange)
|
Image(systemName: "moon.fill").foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
case .reconnecting:
|
case .reconnecting:
|
||||||
badge("Reconnecting…", .yellow)
|
Image(systemName: "wifi.exclamationmark").foregroundStyle(.yellow)
|
||||||
case .ending:
|
case .ending:
|
||||||
badge("Session ended", .secondary)
|
Image(systemName: "stop.fill").foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func badge(_ text: String, _ color: Color) -> some View {
|
|
||||||
Text(text).font(.caption2).foregroundStyle(color)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
/// End-stream button — runs EndStreamIntent in the app process (LiveActivityIntent).
|
||||||
|
|||||||
@@ -57,30 +57,8 @@ public enum BrandFont {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Color {
|
// Color.brand lives in PunktfunkShared/BrandColor.swift (re-exported here): the widget
|
||||||
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
// extension links Shared alone and must render the same purple.
|
||||||
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
|
||||||
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
|
||||||
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
|
||||||
static let brand: Color = {
|
|
||||||
#if canImport(UIKit)
|
|
||||||
return Color(UIColor { traits in
|
|
||||||
traits.userInterfaceStyle == .dark
|
|
||||||
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
||||||
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
||||||
})
|
|
||||||
#elseif canImport(AppKit)
|
|
||||||
return Color(NSColor(name: nil) { appearance in
|
|
||||||
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
|
||||||
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
|
||||||
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
|
||||||
})
|
|
||||||
#else
|
|
||||||
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
|
||||||
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
|
||||||
#endif
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension Font {
|
public extension Font {
|
||||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||||
|
|||||||
Reference in New Issue
Block a user