- README.md with build/architecture overview - Game Center screen with at-bat timeline, pitch sequence, spray chart, and strike zone component views - VideoShuffle service: per-model bucketed random selection with no-back-to-back guarantee; replaces flat shuffle-bag approach - Refresh JWT token for authenticated NSFW feed; add josie-hamming-2 and dani-speegle-2 to the user list - MultiStreamView audio focus: remove redundant isMuted writes during startStream and playNextWerkoutClip so audio stops ducking during clip transitions; gate AVAudioSession.setCategory(.playback) behind a one-shot flag - GamesViewModel.attachPlayer: skip mute recalculation when the same player is re-attached (prevents toggle flicker on item replace) - mlbTVOSTests target wired through project.yml with GENERATE_INFOPLIST_FILE; VideoShuffleTests covers groupByModel, pickRandomFromBuckets, real-distribution no-back-to-back invariant, and uniform model distribution over 6000 picks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
661 lines
22 KiB
Swift
661 lines
22 KiB
Swift
import SwiftUI
|
|
|
|
struct StreamOptionsSheet: View {
|
|
let game: Game
|
|
var onWatch: ((BroadcastSelection) -> Void)?
|
|
@Environment(GamesViewModel.self) private var viewModel
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
|
|
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
|
|
|
|
private var hasLinescore: Bool {
|
|
!game.status.isScheduled && (game.linescore?.hasData ?? false)
|
|
}
|
|
|
|
private var canAddMoreStreams: Bool {
|
|
viewModel.activeStreams.count < 4
|
|
}
|
|
|
|
private var usesStackedLayout: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
|
|
private var horizontalPadding: CGFloat {
|
|
usesStackedLayout ? 20 : 56
|
|
}
|
|
|
|
private var verticalPadding: CGFloat {
|
|
usesStackedLayout ? 24 : 42
|
|
}
|
|
|
|
private var awayPitcherName: String? {
|
|
guard let pitchers = game.pitchers else { return nil }
|
|
let parts = pitchers.components(separatedBy: " vs ")
|
|
return parts.first
|
|
}
|
|
|
|
private var homePitcherName: String? {
|
|
guard let pitchers = game.pitchers else { return nil }
|
|
let parts = pitchers.components(separatedBy: " vs ")
|
|
return parts.count > 1 ? parts.last : nil
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 28) {
|
|
ViewThatFits {
|
|
HStack(alignment: .top, spacing: 28) {
|
|
matchupColumn
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
actionRail
|
|
.frame(width: 520, alignment: .leading)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
matchupColumn
|
|
actionRail
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
if !canAddMoreStreams {
|
|
Label(
|
|
"Multi-view is full. Remove a stream in Multi-View or Settings before adding another.",
|
|
systemImage: "rectangle.split.2x2.fill"
|
|
)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(.orange)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 22)
|
|
.padding(.vertical, 18)
|
|
.background(panelBackground)
|
|
}
|
|
|
|
GameCenterView(game: game)
|
|
}
|
|
.padding(.horizontal, horizontalPadding)
|
|
.padding(.vertical, verticalPadding)
|
|
}
|
|
.background(sheetBackground.ignoresSafeArea())
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var matchupColumn: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
headerBar
|
|
|
|
featuredTeamRow(
|
|
team: game.awayTeam,
|
|
color: awayColor,
|
|
pitcherURL: game.awayPitcherHeadshotURL,
|
|
pitcherName: awayPitcherName
|
|
)
|
|
|
|
scorePanel
|
|
|
|
featuredTeamRow(
|
|
team: game.homeTeam,
|
|
color: homeColor,
|
|
pitcherURL: game.homePitcherHeadshotURL,
|
|
pitcherName: homePitcherName
|
|
)
|
|
|
|
if hasLinescore,
|
|
let linescore = game.linescore {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack {
|
|
Text("Linescore")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
|
|
Spacer()
|
|
|
|
Text(linescoreStatusText)
|
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white.opacity(0.58))
|
|
}
|
|
|
|
LinescoreView(
|
|
linescore: linescore,
|
|
awayCode: game.awayTeam.code,
|
|
homeCode: game.homeTeam.code
|
|
)
|
|
}
|
|
.padding(22)
|
|
.background(panelBackground)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var headerBar: some View {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text((game.gameType ?? "Game Center").uppercased())
|
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white.opacity(0.58))
|
|
.kerning(1.8)
|
|
|
|
if let venue = game.venue {
|
|
Label(venue, systemImage: "mappin.and.ellipse")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.82))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 10) {
|
|
if game.isBlackedOut {
|
|
chip(title: "BLACKOUT", color: .red)
|
|
} else if !game.broadcasts.isEmpty {
|
|
chip(title: "\(game.broadcasts.count) FEED\(game.broadcasts.count == 1 ? "" : "S")", color: .blue)
|
|
}
|
|
|
|
statusChip
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
|
|
HStack(spacing: 18) {
|
|
TeamLogoView(team: team, size: 72)
|
|
.frame(width: 78, height: 78)
|
|
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
HStack(spacing: 10) {
|
|
Text(team.code)
|
|
.font(.system(size: 30, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
if let record = team.record {
|
|
Text(record)
|
|
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
.padding(.horizontal, 11)
|
|
.padding(.vertical, 6)
|
|
.background(.white.opacity(0.08))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
Text(team.displayName)
|
|
.font(.system(size: 28, weight: .bold))
|
|
.foregroundStyle(.white.opacity(0.96))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.82)
|
|
|
|
if let standing = team.standingSummary {
|
|
Text(standing)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.56))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 14)
|
|
|
|
if pitcherURL != nil || pitcherName != nil {
|
|
HStack(spacing: 12) {
|
|
if pitcherURL != nil {
|
|
PitcherHeadshotView(
|
|
url: pitcherURL,
|
|
teamCode: team.code,
|
|
name: nil,
|
|
size: 46
|
|
)
|
|
}
|
|
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
Text("Probable")
|
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white.opacity(0.42))
|
|
|
|
Text(pitcherName ?? "TBD")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.84))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 22)
|
|
.padding(.vertical, 18)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
.fill(teamPanelBackground(color: color))
|
|
}
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var scorePanel: some View {
|
|
VStack(spacing: 12) {
|
|
if let summary = scoreSummaryText {
|
|
Text(summary)
|
|
.font(.system(size: 72, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText())
|
|
} else {
|
|
Text(game.status.label)
|
|
.font(.system(size: 52, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
Text(statusHeadline)
|
|
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(statusHeadlineColor)
|
|
|
|
if let detail = centerDetailText {
|
|
Text(detail)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.62))
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 26)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
.fill(.white.opacity(0.06))
|
|
}
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var actionRail: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
actionRailHeader
|
|
|
|
if !game.broadcasts.isEmpty {
|
|
ForEach(game.broadcasts) { broadcast in
|
|
broadcastCard(broadcast)
|
|
}
|
|
} else if game.isBlackedOut {
|
|
blackoutCard
|
|
} else if game.status.isScheduled {
|
|
scheduledStateCard
|
|
} else {
|
|
fallbackActionsCard
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var actionRailHeader: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Watch Options")
|
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
Text(actionRailSubtitle)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.68))
|
|
.lineLimit(2)
|
|
}
|
|
.padding(22)
|
|
.background(panelBackground)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func broadcastCard(_ broadcast: Broadcast) -> some View {
|
|
let alreadyAdded = viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
|
|
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack(spacing: 12) {
|
|
Circle()
|
|
.fill(TeamAssets.color(for: broadcast.teamCode))
|
|
.frame(width: 10, height: 10)
|
|
|
|
Text(broadcast.teamCode)
|
|
.font(.system(size: 13, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white.opacity(0.78))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(.white.opacity(0.08))
|
|
.clipShape(Capsule())
|
|
|
|
Text(broadcast.name)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
|
|
if alreadyAdded {
|
|
chip(title: "ADDED", color: .green)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 14) {
|
|
railActionButton(
|
|
title: "Watch Full Screen",
|
|
subtitle: "Open \(broadcast.name) in the main player",
|
|
systemImage: "play.fill",
|
|
fill: .blue.opacity(0.18)
|
|
) {
|
|
let selection = BroadcastSelection(broadcast: broadcast, game: game)
|
|
if let onWatch { onWatch(selection) } else { dismiss() }
|
|
}
|
|
|
|
railActionButton(
|
|
title: alreadyAdded ? "Already Added to Multi-View" : "Add to Multi-View",
|
|
subtitle: alreadyAdded ? "This feed is already active in the grid" : "Send \(broadcast.name) into an open tile",
|
|
systemImage: alreadyAdded ? "checkmark.circle.fill" : "plus.circle.fill",
|
|
fill: alreadyAdded ? .green.opacity(0.14) : .white.opacity(0.08),
|
|
foreground: alreadyAdded ? .green : .white,
|
|
disabled: !canAddMoreStreams || alreadyAdded
|
|
) {
|
|
viewModel.addStream(broadcast: broadcast, game: game)
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.padding(22)
|
|
.background(panelBackground)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var blackoutCard: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Image(systemName: "eye.slash.fill")
|
|
.font(.system(size: 36, weight: .bold))
|
|
.foregroundStyle(.red)
|
|
|
|
Text("Blacked Out")
|
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
Text("This game is unavailable in your area. The matchup data is still live, but watch controls are disabled.")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
.lineLimit(3)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(24)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
.fill(.red.opacity(0.12))
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
.strokeBorder(.red.opacity(0.24), lineWidth: 1)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var scheduledStateCard: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Label("Feeds Not Posted Yet", systemImage: "calendar")
|
|
.font(.system(size: 16, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
|
|
Text("Streams usually appear closer to first pitch. Check back around game time.")
|
|
.font(.system(size: 17, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
.lineLimit(3)
|
|
|
|
if let venue = game.venue {
|
|
Text(venue)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(24)
|
|
.background(panelBackground)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var fallbackActionsCard: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("No Feeds Listed")
|
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
Text("You can still try building a stream from one of the team feeds.")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.68))
|
|
|
|
VStack(spacing: 14) {
|
|
fallbackButton(teamCode: game.awayTeam.code)
|
|
fallbackButton(teamCode: game.homeTeam.code)
|
|
}
|
|
}
|
|
.padding(22)
|
|
.background(panelBackground)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func fallbackButton(teamCode: String) -> some View {
|
|
railActionButton(
|
|
title: "Try \(teamCode) Feed",
|
|
subtitle: "Build a fallback stream for \(teamCode)",
|
|
systemImage: "play.circle.fill",
|
|
fill: .white.opacity(0.08),
|
|
disabled: !canAddMoreStreams
|
|
) {
|
|
viewModel.addStreamByTeam(teamCode: teamCode, game: game)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func railActionButton(
|
|
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: 14) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 20, weight: .bold))
|
|
.frame(width: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title)
|
|
.font(.system(size: 18, weight: .bold, design: .rounded))
|
|
.foregroundStyle(foreground)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text(subtitle)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundStyle(foreground.opacity(0.64))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
|
|
.padding(.horizontal, 20)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(fill)
|
|
)
|
|
}
|
|
.platformCardStyle()
|
|
.disabled(disabled)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusChip: some View {
|
|
switch game.status {
|
|
case .live(let inning):
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(.red)
|
|
.frame(width: 8, height: 8)
|
|
Text(inning ?? "LIVE")
|
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
}
|
|
.padding(.horizontal, 15)
|
|
.padding(.vertical, 10)
|
|
.background(.red.opacity(0.16))
|
|
.clipShape(Capsule())
|
|
|
|
case .scheduled(let time):
|
|
Text(time)
|
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 15)
|
|
.padding(.vertical, 10)
|
|
.background(.white.opacity(0.1))
|
|
.clipShape(Capsule())
|
|
|
|
case .final_:
|
|
Text("FINAL")
|
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 15)
|
|
.padding(.vertical, 10)
|
|
.background(.white.opacity(0.12))
|
|
.clipShape(Capsule())
|
|
|
|
case .unknown:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func chip(title: String, color: Color) -> some View {
|
|
Text(title)
|
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
|
.foregroundStyle(color)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 9)
|
|
.background(color.opacity(0.12))
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
private var scoreSummaryText: String? {
|
|
guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil }
|
|
return "\(away) - \(home)"
|
|
}
|
|
|
|
private var statusHeadline: String {
|
|
switch game.status {
|
|
case .scheduled:
|
|
return "First Pitch"
|
|
case .live(let inning):
|
|
return inning ?? "Live"
|
|
case .final_:
|
|
return "Final"
|
|
case .unknown:
|
|
return "Game Day"
|
|
}
|
|
}
|
|
|
|
private var statusHeadlineColor: Color {
|
|
switch game.status {
|
|
case .live:
|
|
return .red.opacity(0.92)
|
|
case .scheduled:
|
|
return .blue.opacity(0.92)
|
|
case .final_:
|
|
return .white.opacity(0.82)
|
|
case .unknown:
|
|
return .white.opacity(0.72)
|
|
}
|
|
}
|
|
|
|
private var centerDetailText: String? {
|
|
if game.status.isScheduled {
|
|
return game.pitchers ?? game.venue
|
|
}
|
|
return game.pitchers ?? game.venue
|
|
}
|
|
|
|
private var actionRailSubtitle: String {
|
|
if game.isBlackedOut {
|
|
return "Watch controls are unavailable for this matchup."
|
|
}
|
|
if !game.broadcasts.isEmpty {
|
|
return "Pick a feed to watch full screen or send it into Multi-View."
|
|
}
|
|
if game.status.isScheduled {
|
|
return "Streams normally appear closer to game time."
|
|
}
|
|
return "Try a team feed fallback for this matchup."
|
|
}
|
|
|
|
private var linescoreStatusText: String {
|
|
if let inning = game.currentInningDisplay, !inning.isEmpty {
|
|
return inning.uppercased()
|
|
}
|
|
if game.isFinal {
|
|
return "FINAL"
|
|
}
|
|
return "GAME"
|
|
}
|
|
|
|
private func teamPanelBackground(color: Color) -> some ShapeStyle {
|
|
LinearGradient(
|
|
colors: [
|
|
color.opacity(0.22),
|
|
.white.opacity(0.05)
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
}
|
|
|
|
@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.05, green: 0.06, blue: 0.1),
|
|
Color(red: 0.08, green: 0.06, blue: 0.09)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
Circle()
|
|
.fill(awayColor.opacity(0.16))
|
|
.frame(width: 520, height: 520)
|
|
.blur(radius: 90)
|
|
.offset(x: -360, y: -220)
|
|
|
|
Circle()
|
|
.fill(homeColor.opacity(0.18))
|
|
.frame(width: 520, height: 520)
|
|
.blur(radius: 92)
|
|
.offset(x: 420, y: 120)
|
|
}
|
|
}
|
|
}
|