Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions berkeley-mobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
E8117D362F5FBAEE0047CC89 /* TodayWeatherTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8117D352F5FBAEE0047CC89 /* TodayWeatherTileView.swift */; };
E8137C3B2F84A1F90098396F /* NewsDataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8137C3A2F84A1F90098396F /* NewsDataViewModel.swift */; };
E8137C3D2F84A4950098396F /* NewsTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8137C3C2F84A4950098396F /* NewsTileView.swift */; };
A1B2C3D4E5F601230098396F /* SportGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F601240098396F /* SportGame.swift */; };
A1B2C3D4E5F601250098396F /* SportsDataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F601260098396F /* SportsDataViewModel.swift */; };
A1B2C3D4E5F601270098396F /* SportsTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F601280098396F /* SportsTileView.swift */; };
E81767582B9A516200599254 /* MapMarkersDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81767572B9A516200599254 /* MapMarkersDropdownView.swift */; };
E82E2A532ECD833F00785593 /* FeedbackFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82E2A512ECD833F00785593 /* FeedbackFormView.swift */; };
E82E2A552ECD835200785593 /* FeedbackFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E82E2A542ECD835200785593 /* FeedbackFormViewModel.swift */; };
Expand Down Expand Up @@ -364,6 +367,9 @@
E8117D352F5FBAEE0047CC89 /* TodayWeatherTileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayWeatherTileView.swift; sourceTree = "<group>"; };
E8137C3A2F84A1F90098396F /* NewsDataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsDataViewModel.swift; sourceTree = "<group>"; };
E8137C3C2F84A4950098396F /* NewsTileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsTileView.swift; sourceTree = "<group>"; };
A1B2C3D4E5F601240098396F /* SportGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SportGame.swift; sourceTree = "<group>"; };
A1B2C3D4E5F601260098396F /* SportsDataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SportsDataViewModel.swift; sourceTree = "<group>"; };
A1B2C3D4E5F601280098396F /* SportsTileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SportsTileView.swift; sourceTree = "<group>"; };
E81767572B9A516200599254 /* MapMarkersDropdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapMarkersDropdownView.swift; sourceTree = "<group>"; };
E82E2A512ECD833F00785593 /* FeedbackFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormView.swift; sourceTree = "<group>"; };
E82E2A542ECD835200785593 /* FeedbackFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackFormViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -984,11 +990,22 @@
path = "Weather Tile";
sourceTree = "<group>";
};
A1B2C3D4E5F601290098396F /* Sports Tile */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F601240098396F /* SportGame.swift */,
A1B2C3D4E5F601280098396F /* SportsTileView.swift */,
A1B2C3D4E5F601260098396F /* SportsDataViewModel.swift */,
);
path = "Sports Tile";
sourceTree = "<group>";
};
E8B8879A2F67722E002B9632 /* Tiles */ = {
isa = PBXGroup;
children = (
E8137C392F84A1DA0098396F /* News Tile */,
E8B6D49A2F7A160D002719AF /* Weather Tile */,
A1B2C3D4E5F601290098396F /* Sports Tile */,
);
path = Tiles;
sourceTree = "<group>";
Expand Down Expand Up @@ -1194,6 +1211,7 @@
306A550023614C0000D59A7F /* MainContainerViewController.swift in Sources */,
29F11EC72516C150007DCFD7 /* BarView.swift in Sources */,
E8137C3B2F84A1F90098396F /* NewsDataViewModel.swift in Sources */,
A1B2C3D4E5F601250098396F /* SportsDataViewModel.swift in Sources */,
55DCF78723722CBD001B01B8 /* Colors.swift in Sources */,
29B3D7862453984A006762E5 /* DrawerViewDelegate.swift in Sources */,
559AF6E0251EAA2800463E2A /* DetailTapGestureRecognizer.swift in Sources */,
Expand Down Expand Up @@ -1293,6 +1311,7 @@
29B1D307254E1C8700BAD88F /* BMEventManager.swift in Sources */,
E8C2BDA72D912B3A00165554 /* CalendarView.swift in Sources */,
E8137C3D2F84A4950098396F /* NewsTileView.swift in Sources */,
A1B2C3D4E5F601270098396F /* SportsTileView.swift in Sources */,
E8472B062DE453D6005C24EA /* BMAddedCalendarStatusOverlayView.swift in Sources */,
FAFF001C2E84C62100551C44 /* ReviewPrompter.swift in Sources */,
13EA64D02399D50C00FD8E13 /* DataManager.swift in Sources */,
Expand Down Expand Up @@ -1351,6 +1370,7 @@
13EA64CD2399CEDA00FD8E13 /* BMGym.swift in Sources */,
E89F20492DF3F7B200C488EF /* FitnessView.swift in Sources */,
E8875DF52F8B069F004D50EC /* NewsArticle.swift in Sources */,
A1B2C3D4E5F601230098396F /* SportGame.swift in Sources */,
E885639E2EB02EFA0062315A /* GuidesViewModel.swift in Sources */,
E89F20452DF2CD2E00C488EF /* BMHomeSectionListView.swift in Sources */,
1396013523865E2E005E4788 /* CardView.swift in Sources */,
Expand Down
6 changes: 6 additions & 0 deletions berkeley-mobile/BerkeleyMobile+Injection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ extension Container {
}.shared
}

var sportsDataViewModel: Factory<SportsDataViewModel> {
self { @MainActor in
SportsDataViewModel()
}.shared
}

var resourcesViewModel: Factory<ResourcesViewModel> {
self { ResourcesViewModel() }.shared
}
Expand Down
41 changes: 41 additions & 0 deletions berkeley-mobile/Today/Tiles/Sports Tile/SportGame.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// SportGame.swift
// berkeley-mobile
//
// Created by ASUC OCTO on 7/28/26.
// Copyright © 2026 ASUC OCTO. All rights reserved.
//

import Foundation

struct SportGame: Codable, Identifiable {
var id: String
var sport: String
var homeTeam: String
var awayTeam: String
var homeScore: Int?
var awayScore: Int?
var gameDate: Date?
var location: String?
var status: SportGameStatus
var logoURL: String?

enum SportGameStatus: String, Codable {
case upcoming
case inProgress = "in_progress"
case final_ = "final"
}

enum CodingKeys: String, CodingKey {
case id
case sport
case homeTeam
case awayTeam
case homeScore
case awayScore
case gameDate
case location
case status
case logoURL = "logoUrl"
}
}
49 changes: 49 additions & 0 deletions berkeley-mobile/Today/Tiles/Sports Tile/SportsDataViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// SportsDataViewModel.swift
// berkeley-mobile
//
// Created by ASUC OCTO on 7/28/26.
// Copyright © 2026 ASUC OCTO. All rights reserved.
//

import FirebaseFirestore
import Foundation
import Observation
import os

@MainActor
@Observable
class SportsDataViewModel {
var games: [SportGame] = []
var showNotAvailable = false

@ObservationIgnored
private let db = Firestore.firestore()
@ObservationIgnored
private let kSportsDataEndpoint = "Berkeley Sports"

init() {
Task {
games = await fetchGames()
showNotAvailable = games.isEmpty
}
}

@concurrent
func fetchGames() async -> [SportGame] {
guard let snap = try? await db.collection(kSportsDataEndpoint).getDocuments() else {
return []
}

var games: [SportGame] = []
for doc in snap.documents {
do {
games.append(try doc.data(as: SportGame.self))
} catch {
Logger.sportsDataViewModel.error("Unable to decode sport game: \(error)")
}
}
Logger.sportsDataViewModel.info("Successfully decoded \(games.count) sport games")
return games
}
}
112 changes: 112 additions & 0 deletions berkeley-mobile/Today/Tiles/Sports Tile/SportsTileView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// SportsTileView.swift
// berkeley-mobile
//
// Created by ASUC OCTO on 7/28/26.
// Copyright © 2026 ASUC OCTO. All rights reserved.
//

import FactoryKit
import SwiftUI

struct SportsTileView: View {
@InjectedObservable(\.sportsDataViewModel) private var viewModel

private var shouldRedact: Bool {
!viewModel.showNotAvailable && viewModel.games.isEmpty
}

var body: some View {
Group {
if viewModel.showNotAvailable {
notAvailableView
} else if let game = viewModel.games.first {
gameView(for: game)
.redacted(reason: shouldRedact ? .placeholder : [])
} else {
progressView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private func gameView(for game: SportGame) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(game.sport)
.font(Font(BMFont.bold(13)))
.foregroundStyle(.white.opacity(0.8))
Spacer()
statusBadge(for: game)
}

Spacer()

VStack(alignment: .leading, spacing: 4) {
teamRow(name: game.homeTeam, score: game.homeScore)
teamRow(name: game.awayTeam, score: game.awayScore)
}

if let location = game.location {
Text(location)
.font(Font(BMFont.regular(11)))
.foregroundStyle(.white.opacity(0.6))
.lineLimit(1)
}
}
.foregroundStyle(.white)
.padding()
}

private func teamRow(name: String, score: Int?) -> some View {
HStack {
Text(name)
.font(Font(BMFont.medium(14)))
.lineLimit(1)
Spacer()
if let score {
Text("\(score)")
.font(Font(BMFont.bold(14)))
}
}
}

@ViewBuilder
private func statusBadge(for game: SportGame) -> some View {
let (label, color) = statusInfo(for: game.status)
Text(label)
.font(Font(BMFont.bold(11)))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(color.opacity(0.3))
.foregroundStyle(color)
.clipShape(Capsule())
}

private func statusInfo(for status: SportGame.SportGameStatus) -> (String, Color) {
switch status {
case .upcoming:
return ("Upcoming", .white)
case .inProgress:
return ("Live", .green)
case .final_:
return ("Final", .white.opacity(0.7))
}
}

private var notAvailableView: some View {
Text("Sports Tile is not currently available.")
.font(.caption)
.foregroundStyle(.black)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private var progressView: some View {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

#Preview {
SportsTileView()
}
14 changes: 14 additions & 0 deletions berkeley-mobile/Today/TodayTileAttributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,16 @@ struct TodayTileStyle {
enum TodayTiles: CaseIterable {
case news
case weather
case sports

func view() -> AnyView {
switch self {
case .news:
return AnyView(NewsTileView())
case .weather:
return AnyView(TodayWeatherTileView())
case .sports:
return AnyView(SportsTileView())
}
}

Expand All @@ -98,6 +101,17 @@ enum TodayTiles: CaseIterable {
Color(red: 0.20, green: 0.52, blue: 0.87),
Color(red: 0.50, green: 0.80, blue: 0.99)])]
)
case .sports:
TodayTileAttributes(
span: .halfWidth,
contentPadding: 0,
displayedStyleName: displayedStyleName,
styles: [TodayTileStyle.Name.defaultName: TodayTileStyle(colors: [
Color(red: 0.00, green: 0.11, blue: 0.27),
Color(red: 0.19, green: 0.27, blue: 0.49)],
startPoint: .topLeading,
endPoint: .bottomTrailing)]
)
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions berkeley-mobile/Utils/Logger+Ext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ extension Logger {
category: String(describing: NewsDataViewModel.self)
)

static let sportsDataViewModel = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: SportsDataViewModel.self)
)

static let weatherDataViewModel = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: WeatherDataViewModel.self)
Expand Down