Add Werkout channel playback and autoplay
This commit is contained in:
@@ -8,12 +8,53 @@ private func logDashboard(_ message: String) {
|
||||
print("[Dashboard] \(message)")
|
||||
}
|
||||
|
||||
enum SpecialPlaybackChannelConfig {
|
||||
static let werkoutNSFWStreamID = "WKNSFW"
|
||||
static let werkoutNSFWTitle = "Werkout NSFW"
|
||||
static let werkoutNSFWSubtitle = "Authenticated private HLS feed"
|
||||
static let werkoutNSFWFeedURLString = "https://dev.werkout.fitness/videos/nsfw_videos/"
|
||||
static let werkoutNSFWAuthToken = "15d7565cde9e8c904ae934f8235f68f6a24b4a03"
|
||||
static let werkoutNSFWTeamCode = "WK"
|
||||
|
||||
static var werkoutNSFWHeaders: [String: String] {
|
||||
[
|
||||
"authorization": "Token \(werkoutNSFWAuthToken)",
|
||||
]
|
||||
}
|
||||
|
||||
static var werkoutNSFWFeedURL: URL {
|
||||
URL(string: werkoutNSFWFeedURLString)!
|
||||
}
|
||||
|
||||
static var werkoutNSFWBroadcast: Broadcast {
|
||||
Broadcast(
|
||||
id: werkoutNSFWStreamID,
|
||||
teamCode: werkoutNSFWTeamCode,
|
||||
name: werkoutNSFWTitle,
|
||||
mediaId: "",
|
||||
streamURL: werkoutNSFWFeedURLString
|
||||
)
|
||||
}
|
||||
|
||||
static var werkoutNSFWGame: Game {
|
||||
Game(
|
||||
id: werkoutNSFWStreamID,
|
||||
awayTeam: TeamInfo(code: werkoutNSFWTeamCode, name: werkoutNSFWTitle, score: nil),
|
||||
homeTeam: TeamInfo(code: werkoutNSFWTeamCode, name: werkoutNSFWTitle, score: nil),
|
||||
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
|
||||
pitchers: nil, gamePk: nil, gameDate: "",
|
||||
broadcasts: [], isBlackedOut: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct DashboardView: View {
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@State private var selectedGame: Game?
|
||||
@State private var fullScreenBroadcast: BroadcastSelection?
|
||||
@State private var pendingFullScreenBroadcast: BroadcastSelection?
|
||||
@State private var showMLBNetworkSheet = false
|
||||
@State private var showWerkoutNSFWSheet = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -64,7 +105,7 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
mlbNetworkCard
|
||||
featuredChannelsSection
|
||||
|
||||
if !viewModel.activeStreams.isEmpty {
|
||||
multiViewStatus
|
||||
@@ -88,29 +129,7 @@ struct DashboardView: View {
|
||||
selectedGame = nil
|
||||
}
|
||||
}
|
||||
.fullScreenCover(item: $fullScreenBroadcast) { selection in
|
||||
SingleStreamPlaybackScreen(
|
||||
resolveURL: {
|
||||
logDashboard("resolveURL closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||
if selection.broadcast.id == "MLBN" {
|
||||
return await viewModel.buildEventStreamURL(event: "MLBN")
|
||||
}
|
||||
let s = ActiveStream(
|
||||
id: selection.broadcast.id,
|
||||
game: selection.game,
|
||||
label: selection.broadcast.displayLabel,
|
||||
mediaId: selection.broadcast.mediaId,
|
||||
streamURLString: selection.broadcast.streamURL
|
||||
)
|
||||
return await viewModel.resolveStreamURL(for: s)
|
||||
},
|
||||
tickerGames: viewModel.games
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||
}
|
||||
}
|
||||
.fullScreenCover(item: $fullScreenBroadcast, content: fullScreenPlaybackScreen)
|
||||
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
|
||||
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
|
||||
}
|
||||
@@ -123,10 +142,19 @@ struct DashboardView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showWerkoutNSFWSheet, onDismiss: presentPendingFullScreenBroadcast) {
|
||||
WerkoutNSFWSheet(
|
||||
onWatchFullScreen: {
|
||||
logDashboard("Queued fullscreen broadcast from Werkout NSFW sheet")
|
||||
showWerkoutNSFWSheet = false
|
||||
pendingFullScreenBroadcast = nsfwVideosBroadcastSelection
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentPendingFullScreenBroadcast() {
|
||||
guard selectedGame == nil, !showMLBNetworkSheet else {
|
||||
guard selectedGame == nil, !showMLBNetworkSheet, !showWerkoutNSFWSheet else {
|
||||
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
|
||||
return
|
||||
}
|
||||
@@ -141,6 +169,97 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func presentFullScreenBroadcast(_ selection: BroadcastSelection) {
|
||||
if selectedGame == nil, !showMLBNetworkSheet, !showWerkoutNSFWSheet {
|
||||
fullScreenBroadcast = selection
|
||||
} else {
|
||||
pendingFullScreenBroadcast = selection
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func fullScreenPlaybackScreen(selection: BroadcastSelection) -> some View {
|
||||
SingleStreamPlaybackScreen(
|
||||
resolveSource: {
|
||||
await resolveFullScreenSource(for: selection)
|
||||
},
|
||||
resolveNextSource: nextFullScreenSourceResolver(for: selection),
|
||||
tickerGames: tickerGames(for: selection)
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveFullScreenSource(for selection: BroadcastSelection) async -> SingleStreamPlaybackSource? {
|
||||
logDashboard("resolveSource closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||
if let directSource = selection.directSource {
|
||||
return directSource
|
||||
}
|
||||
if selection.broadcast.id == "MLBN" {
|
||||
let url = await viewModel.buildEventStreamURL(event: "MLBN")
|
||||
return SingleStreamPlaybackSource(url: url)
|
||||
}
|
||||
if selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID {
|
||||
guard let url = await viewModel.resolveAuthenticatedVideoFeedURL(
|
||||
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return SingleStreamPlaybackSource(
|
||||
url: url,
|
||||
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||
)
|
||||
}
|
||||
let stream = ActiveStream(
|
||||
id: selection.broadcast.id,
|
||||
game: selection.game,
|
||||
label: selection.broadcast.displayLabel,
|
||||
mediaId: selection.broadcast.mediaId,
|
||||
streamURLString: selection.broadcast.streamURL
|
||||
)
|
||||
guard let url = await viewModel.resolveStreamURL(for: stream) else { return nil }
|
||||
return SingleStreamPlaybackSource(url: url)
|
||||
}
|
||||
|
||||
private func resolveNextFullScreenSource(
|
||||
for selection: BroadcastSelection,
|
||||
currentURL: URL?
|
||||
) async -> SingleStreamPlaybackSource? {
|
||||
guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else {
|
||||
return nil
|
||||
}
|
||||
guard let nextURL = await viewModel.resolveAuthenticatedVideoFeedURL(
|
||||
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||
excluding: currentURL
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return SingleStreamPlaybackSource(
|
||||
url: nextURL,
|
||||
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||
)
|
||||
}
|
||||
|
||||
private func nextFullScreenSourceResolver(
|
||||
for selection: BroadcastSelection
|
||||
) -> (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? {
|
||||
guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return { currentURL in
|
||||
await resolveNextFullScreenSource(for: selection, currentURL: currentURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func tickerGames(for selection: BroadcastSelection) -> [Game] {
|
||||
selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID ? [] : viewModel.games
|
||||
}
|
||||
|
||||
private var mlbNetworkBroadcastSelection: BroadcastSelection {
|
||||
let bc = Broadcast(
|
||||
id: "MLBN",
|
||||
@@ -160,6 +279,13 @@ struct DashboardView: View {
|
||||
return BroadcastSelection(broadcast: bc, game: game)
|
||||
}
|
||||
|
||||
private var nsfwVideosBroadcastSelection: BroadcastSelection {
|
||||
return BroadcastSelection(
|
||||
broadcast: SpecialPlaybackChannelConfig.werkoutNSFWBroadcast,
|
||||
game: SpecialPlaybackChannelConfig.werkoutNSFWGame
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Game Shelf (Horizontal)
|
||||
|
||||
@ViewBuilder
|
||||
@@ -262,7 +388,18 @@ struct DashboardView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
// MARK: - MLB Network
|
||||
// MARK: - Featured Channels
|
||||
|
||||
@ViewBuilder
|
||||
private var featuredChannelsSection: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
mlbNetworkCard
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
nsfwVideosCard
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mlbNetworkCard: some View {
|
||||
@@ -294,6 +431,49 @@ struct DashboardView: View {
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
|
||||
.padding(24)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var nsfwVideosCard: some View {
|
||||
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
|
||||
Button {
|
||||
showWerkoutNSFWSheet = true
|
||||
} label: {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.pink)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(.pink.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle)
|
||||
.font(.title3.weight(.bold))
|
||||
Text(SpecialPlaybackChannelConfig.werkoutNSFWSubtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if added {
|
||||
Label("In Multi-View", systemImage: "checkmark.circle.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Open", systemImage: "play.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.pink)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
|
||||
.padding(24)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
@@ -331,10 +511,345 @@ struct BroadcastSelection: Identifiable {
|
||||
let id: String
|
||||
let broadcast: Broadcast
|
||||
let game: Game
|
||||
let directSource: SingleStreamPlaybackSource?
|
||||
|
||||
init(broadcast: Broadcast, game: Game) {
|
||||
init(
|
||||
broadcast: Broadcast,
|
||||
game: Game,
|
||||
directSource: SingleStreamPlaybackSource? = nil
|
||||
) {
|
||||
self.id = broadcast.id
|
||||
self.broadcast = broadcast
|
||||
self.game = game
|
||||
self.directSource = directSource
|
||||
}
|
||||
}
|
||||
|
||||
struct WerkoutNSFWSheet: View {
|
||||
var onWatchFullScreen: () -> Void
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isResolvingMultiViewSource = false
|
||||
@State private var multiViewErrorMessage: String?
|
||||
|
||||
private var added: Bool {
|
||||
viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
|
||||
}
|
||||
|
||||
private var canToggleIntoMultiview: Bool {
|
||||
added || viewModel.activeStreams.count < 4
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
sheetBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
HStack(alignment: .top, spacing: 32) {
|
||||
overviewColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
actionColumn
|
||||
.frame(width: 360, alignment: .leading)
|
||||
}
|
||||
.padding(38)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||
.fill(.black.opacity(0.46))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 94)
|
||||
.padding(.vertical, 70)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var overviewColumn: some View {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(spacing: 12) {
|
||||
statusPill(title: "PRIVATE FEED", color: .pink)
|
||||
|
||||
if added {
|
||||
statusPill(title: "IN MULTI-VIEW", color: .green)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 22) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(.pink.opacity(0.16))
|
||||
.frame(width: 110, height: 110)
|
||||
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.font(.system(size: 46, weight: .bold))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.white, .pink.opacity(0.88)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle)
|
||||
.font(.system(size: 46, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Launch the authenticated Werkout video feed full screen or drop it into an open Multi-View tile.")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 14) {
|
||||
featurePill(title: "Authenticated", systemImage: "lock.fill")
|
||||
featurePill(title: "Private Media", systemImage: "video.fill")
|
||||
featurePill(title: "Direct Playback", systemImage: "play.fill")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Notes")
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.48))
|
||||
.kerning(1.2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
overviewLine(
|
||||
icon: "lock.shield.fill",
|
||||
title: "Token-gated playback",
|
||||
detail: "This feed is played with an Authorization header instead of a public URL."
|
||||
)
|
||||
overviewLine(
|
||||
icon: "square.grid.2x2.fill",
|
||||
title: "Multi-View compatible",
|
||||
detail: "You can pin this feed into the active grid and route audio to it like other channel streams."
|
||||
)
|
||||
overviewLine(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Backend auth still matters",
|
||||
detail: "If the server rejects the token, playback will fail in both full screen and Multi-View."
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(panelBackground)
|
||||
|
||||
if let multiViewErrorMessage {
|
||||
Label(multiViewErrorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.red)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 16)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
if !canToggleIntoMultiview {
|
||||
Label(
|
||||
"Multi-View is full. Remove a stream before adding Werkout NSFW.",
|
||||
systemImage: "rectangle.split.2x2.fill"
|
||||
)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 16)
|
||||
.background(panelBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionColumn: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Actions")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Play the feed full screen or send it into your active Multi-View lineup.")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.68))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(24)
|
||||
.background(panelBackground)
|
||||
|
||||
actionButton(
|
||||
title: "Watch Full Screen",
|
||||
subtitle: "Open the Werkout feed in the main player",
|
||||
systemImage: "play.fill",
|
||||
fill: .pink.opacity(0.18)
|
||||
) {
|
||||
onWatchFullScreen()
|
||||
}
|
||||
|
||||
actionButton(
|
||||
title: added ? "Remove From Multi-View" : "Add to Multi-View",
|
||||
subtitle: added ? "Take the feed out of your active grid" : isResolvingMultiViewSource ? "Resolving a playable HLS source from the feed" : "Send the feed into an open tile",
|
||||
systemImage: added ? "minus.circle.fill" : "plus.circle.fill",
|
||||
fill: added ? .red.opacity(0.16) : .white.opacity(0.08),
|
||||
foreground: added ? .red : .white,
|
||||
disabled: !canToggleIntoMultiview || isResolvingMultiViewSource
|
||||
) {
|
||||
if added {
|
||||
viewModel.removeStream(id: SpecialPlaybackChannelConfig.werkoutNSFWStreamID)
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
multiViewErrorMessage = nil
|
||||
isResolvingMultiViewSource = true
|
||||
Task { @MainActor in
|
||||
let didAddStream = await viewModel.addSpecialStreamFromAuthenticatedFeed(
|
||||
id: SpecialPlaybackChannelConfig.werkoutNSFWStreamID,
|
||||
label: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
|
||||
game: SpecialPlaybackChannelConfig.werkoutNSFWGame,
|
||||
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||
)
|
||||
isResolvingMultiViewSource = false
|
||||
if didAddStream {
|
||||
dismiss()
|
||||
} else {
|
||||
multiViewErrorMessage = "Could not resolve a playable Werkout stream from the authenticated feed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func actionButton(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
fill: Color,
|
||||
foreground: Color = .white,
|
||||
disabled: Bool = false,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.system(size: 21, weight: .bold, design: .rounded))
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(foreground.opacity(0.68))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if isResolvingMultiViewSource && title == "Add to Multi-View" {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.tint(.white.opacity(0.84))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(foreground)
|
||||
.frame(maxWidth: .infinity, minHeight: 88, alignment: .leading)
|
||||
.padding(.horizontal, 22)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(fill)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func overviewLine(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.pink.opacity(0.9))
|
||||
.frame(width: 26)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(detail)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.66))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusPill(title: String, color: Color) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .black, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.background(color.opacity(0.14))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func featurePill(title: String, systemImage: String) -> some View {
|
||||
Label(title, systemImage: systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.84))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.white.opacity(0.06))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var panelBackground: some View {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(.black.opacity(0.22))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sheetBackground: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.07, green: 0.04, blue: 0.08),
|
||||
Color(red: 0.09, green: 0.05, blue: 0.08),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(.pink.opacity(0.18))
|
||||
.frame(width: 520, height: 520)
|
||||
.blur(radius: 92)
|
||||
.offset(x: -320, y: -220)
|
||||
|
||||
Circle()
|
||||
.fill(.red.opacity(0.15))
|
||||
.frame(width: 460, height: 460)
|
||||
.blur(radius: 88)
|
||||
.offset(x: 380, y: 140)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user