Stabilize beta release with warning cleanup and edge-case fixes

This commit is contained in:
Trey t
2026-02-22 13:18:14 -06:00
parent fddea81e36
commit ec2bbb4764
55 changed files with 712 additions and 315 deletions

View File

@@ -7,12 +7,13 @@
// - Expected Behavior:
// - itineraryDays() returns one ItineraryDay per calendar day from first arrival to last activity
// - Last activity day includes departure day if there are games on that day
// - tripDuration is max(1, days between first arrival and last departure + 1)
// - tripDuration is 0 when there are no stops, otherwise max(1, days between first arrival and last departure + 1)
// - cities returns deduplicated city list preserving visit order
// - displayName uses " " separator between cities
//
// - Invariants:
// - tripDuration >= 1 (minimum 1 day)
// - tripDuration >= 0
// - tripDuration == 0 iff stops is empty
// - cities has no duplicates
// - itineraryDays() dayNumber starts at 1 and increments
//

View File

@@ -56,7 +56,8 @@ struct TripPoll: Identifiable, Codable, Hashable {
private static let shareCodeCharacters = Array("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
static func generateShareCode() -> String {
String((0..<6).map { _ in shareCodeCharacters.randomElement()! })
let fallback: Character = "A"
return String((0..<6).map { _ in shareCodeCharacters.randomElement() ?? fallback })
}
// MARK: - Trip Hash
@@ -73,7 +74,11 @@ struct TripPoll: Identifiable, Codable, Hashable {
// MARK: - Deep Link URL
var shareURL: URL {
URL(string: "sportstime://poll/\(shareCode)")!
var components = URLComponents()
components.scheme = "sportstime"
components.host = "poll"
components.path = "/\(shareCode)"
return components.url ?? URL(string: "sportstime://poll")!
}
}
@@ -107,9 +112,11 @@ struct PollVote: Identifiable, Codable, Hashable {
/// Returns array where index = trip index, value = score
static func calculateScores(rankings: [Int], tripCount: Int) -> [Int] {
var scores = Array(repeating: 0, count: tripCount)
var seenTripIndices = Set<Int>()
for (rank, tripIndex) in rankings.enumerated() {
guard tripIndex < tripCount else { continue }
let points = tripCount - rank
guard tripIndex >= 0, tripIndex < tripCount else { continue }
guard seenTripIndices.insert(tripIndex).inserted else { continue }
let points = max(tripCount - rank, 0)
scores[tripIndex] = points
}
return scores

View File

@@ -556,15 +556,6 @@ final class CanonicalSport {
}
}
// MARK: - Sendable Conformance
// These SwiftData models are passed across actor boundaries during sync operations.
// Access is still coordinated by SwiftData contexts and higher-level sync orchestration.
extension StadiumAlias: @unchecked Sendable {}
extension TeamAlias: @unchecked Sendable {}
extension LeagueStructureModel: @unchecked Sendable {}
extension CanonicalSport: @unchecked Sendable {}
// MARK: - Bundled Data Timestamps
/// Timestamps for bundled data files.

View File

@@ -227,7 +227,7 @@ final class AchievementEngine {
}
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let division = LeagueStructure.division(byId: divisionId) else { return false }
guard LeagueStructure.division(byId: divisionId) != nil else { return false }
// Get stadium IDs for teams in this division
let stadiumIds = getStadiumIdsForDivision(divisionId)
@@ -237,7 +237,7 @@ final class AchievementEngine {
}
private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let conference = LeagueStructure.conference(byId: conferenceId) else { return false }
guard LeagueStructure.conference(byId: conferenceId) != nil else { return false }
// Get stadium IDs for all teams in this conference
let stadiumIds = getStadiumIdsForConference(conferenceId)

View File

@@ -51,11 +51,8 @@ struct AppleMapsLauncher {
return .noWaypoints
}
let mapItems = waypoints.map { waypoint -> MKMapItem in
let placemark = MKPlacemark(coordinate: waypoint.coordinate)
let item = MKMapItem(placemark: placemark)
item.name = waypoint.name
return item
let mapItems = waypoints.map { waypoint in
makeMapItem(name: waypoint.name, coordinate: waypoint.coordinate)
}
if mapItems.count <= maxWaypoints {
@@ -90,9 +87,7 @@ struct AppleMapsLauncher {
/// - coordinate: Location to display
/// - name: Display name for the pin
static func openLocation(coordinate: CLLocationCoordinate2D, name: String) {
let placemark = MKPlacemark(coordinate: coordinate)
let mapItem = MKMapItem(placemark: placemark)
mapItem.name = name
let mapItem = makeMapItem(name: name, coordinate: coordinate)
mapItem.openInMaps(launchOptions: nil)
}
@@ -108,13 +103,8 @@ struct AppleMapsLauncher {
to: CLLocationCoordinate2D,
toName: String
) {
let fromPlacemark = MKPlacemark(coordinate: from)
let fromItem = MKMapItem(placemark: fromPlacemark)
fromItem.name = fromName
let toPlacemark = MKPlacemark(coordinate: to)
let toItem = MKMapItem(placemark: toPlacemark)
toItem.name = toName
let fromItem = makeMapItem(name: fromName, coordinate: from)
let toItem = makeMapItem(name: toName, coordinate: to)
MKMapItem.openMaps(with: [fromItem, toItem], launchOptions: [
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
@@ -169,4 +159,17 @@ struct AppleMapsLauncher {
Array(items[start..<min(start + size, items.count)])
}
}
private static func makeMapItem(name: String, coordinate: CLLocationCoordinate2D) -> MKMapItem {
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let item: MKMapItem
if #available(iOS 26.0, *) {
item = MKMapItem(location: location, address: nil)
} else {
let placemark = MKPlacemark(coordinate: coordinate)
item = MKMapItem(placemark: placemark)
}
item.name = name
return item
}
}

View File

@@ -569,7 +569,19 @@ final class CanonicalSyncService {
var skippedOlder = 0
for remoteStructure in remoteStructures {
let result = try mergeLeagueStructure(remoteStructure, context: context)
let structureType = LeagueStructureType(rawValue: remoteStructure.structureTypeRaw.lowercased()) ?? .division
let model = LeagueStructureModel(
id: remoteStructure.id,
sport: remoteStructure.sport,
structureType: structureType,
name: remoteStructure.name,
abbreviation: remoteStructure.abbreviation,
parentId: remoteStructure.parentId,
displayOrder: remoteStructure.displayOrder,
schemaVersion: remoteStructure.schemaVersion,
lastModified: remoteStructure.lastModified
)
let result = try mergeLeagueStructure(model, context: context)
switch result {
case .applied: updated += 1
@@ -593,7 +605,18 @@ final class CanonicalSyncService {
var skippedOlder = 0
for remoteAlias in remoteAliases {
let result = try mergeTeamAlias(remoteAlias, context: context)
let aliasType = TeamAliasType(rawValue: remoteAlias.aliasTypeRaw.lowercased()) ?? .name
let model = TeamAlias(
id: remoteAlias.id,
teamCanonicalId: remoteAlias.teamCanonicalId,
aliasType: aliasType,
aliasValue: remoteAlias.aliasValue,
validFrom: remoteAlias.validFrom,
validUntil: remoteAlias.validUntil,
schemaVersion: remoteAlias.schemaVersion,
lastModified: remoteAlias.lastModified
)
let result = try mergeTeamAlias(model, context: context)
switch result {
case .applied: updated += 1
@@ -617,7 +640,15 @@ final class CanonicalSyncService {
var skippedOlder = 0
for remoteAlias in remoteAliases {
let result = try mergeStadiumAlias(remoteAlias, context: context)
let model = StadiumAlias(
aliasName: remoteAlias.aliasName,
stadiumCanonicalId: remoteAlias.stadiumCanonicalId,
validFrom: remoteAlias.validFrom,
validUntil: remoteAlias.validUntil,
schemaVersion: remoteAlias.schemaVersion,
lastModified: remoteAlias.lastModified
)
let result = try mergeStadiumAlias(model, context: context)
switch result {
case .applied: updated += 1
@@ -641,7 +672,20 @@ final class CanonicalSyncService {
var skippedOlder = 0
for remoteSport in remoteSports {
let result = try mergeSport(remoteSport, context: context)
let model = CanonicalSport(
id: remoteSport.id,
abbreviation: remoteSport.abbreviation,
displayName: remoteSport.displayName,
iconName: remoteSport.iconName,
colorHex: remoteSport.colorHex,
seasonStartMonth: remoteSport.seasonStartMonth,
seasonEndMonth: remoteSport.seasonEndMonth,
isActive: remoteSport.isActive,
lastModified: remoteSport.lastModified,
schemaVersion: remoteSport.schemaVersion,
source: .cloudKit
)
let result = try mergeSport(model, context: context)
switch result {
case .applied: updated += 1

View File

@@ -224,6 +224,51 @@ actor CloudKitService {
let stadiumCanonicalId: String?
}
struct SyncLeagueStructure: Sendable {
let id: String
let sport: String
let structureTypeRaw: String
let name: String
let abbreviation: String?
let parentId: String?
let displayOrder: Int
let schemaVersion: Int
let lastModified: Date
}
struct SyncTeamAlias: Sendable {
let id: String
let teamCanonicalId: String
let aliasTypeRaw: String
let aliasValue: String
let validFrom: Date?
let validUntil: Date?
let schemaVersion: Int
let lastModified: Date
}
struct SyncStadiumAlias: Sendable {
let aliasName: String
let stadiumCanonicalId: String
let validFrom: Date?
let validUntil: Date?
let schemaVersion: Int
let lastModified: Date
}
struct SyncSport: Sendable {
let id: String
let abbreviation: String
let displayName: String
let iconName: String
let colorHex: String
let seasonStartMonth: Int
let seasonEndMonth: Int
let isActive: Bool
let schemaVersion: Int
let lastModified: Date
}
// MARK: - Availability Check
func isAvailable() async -> Bool {
@@ -579,7 +624,7 @@ actor CloudKitService {
/// - Parameters:
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncLeagueStructure] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let deltaStart = effectiveDeltaStartDate(lastSync) {
@@ -594,11 +639,23 @@ actor CloudKitService {
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) league structure records from CloudKit")
var parsed: [LeagueStructureModel] = []
var parsed: [SyncLeagueStructure] = []
var skipped = 0
for record in records {
if let model = CKLeagueStructure(record: record).toModel() {
parsed.append(model)
parsed.append(
SyncLeagueStructure(
id: model.id,
sport: model.sport,
structureTypeRaw: model.structureTypeRaw,
name: model.name,
abbreviation: model.abbreviation,
parentId: model.parentId,
displayOrder: model.displayOrder,
schemaVersion: model.schemaVersion,
lastModified: model.lastModified
)
)
} else {
skipped += 1
}
@@ -618,7 +675,7 @@ actor CloudKitService {
/// - Parameters:
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeamAlias] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let deltaStart = effectiveDeltaStartDate(lastSync) {
@@ -633,11 +690,22 @@ actor CloudKitService {
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) team alias records from CloudKit")
var parsed: [TeamAlias] = []
var parsed: [SyncTeamAlias] = []
var skipped = 0
for record in records {
if let model = CKTeamAlias(record: record).toModel() {
parsed.append(model)
parsed.append(
SyncTeamAlias(
id: model.id,
teamCanonicalId: model.teamCanonicalId,
aliasTypeRaw: model.aliasTypeRaw,
aliasValue: model.aliasValue,
validFrom: model.validFrom,
validUntil: model.validUntil,
schemaVersion: model.schemaVersion,
lastModified: model.lastModified
)
)
} else {
skipped += 1
}
@@ -657,7 +725,7 @@ actor CloudKitService {
/// - Parameters:
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadiumAlias] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let deltaStart = effectiveDeltaStartDate(lastSync) {
@@ -672,11 +740,20 @@ actor CloudKitService {
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) stadium alias records from CloudKit")
var parsed: [StadiumAlias] = []
var parsed: [SyncStadiumAlias] = []
var skipped = 0
for record in records {
if let model = CKStadiumAlias(record: record).toModel() {
parsed.append(model)
parsed.append(
SyncStadiumAlias(
aliasName: model.aliasName,
stadiumCanonicalId: model.stadiumCanonicalId,
validFrom: model.validFrom,
validUntil: model.validUntil,
schemaVersion: model.schemaVersion,
lastModified: model.lastModified
)
)
} else {
skipped += 1
}
@@ -698,7 +775,7 @@ actor CloudKitService {
/// - Parameters:
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncSport] {
let log = SyncLogger.shared
let predicate: NSPredicate
if let deltaStart = effectiveDeltaStartDate(lastSync) {
@@ -713,11 +790,24 @@ actor CloudKitService {
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
log.log("☁️ [CK] Received \(records.count) sport records from CloudKit")
var parsed: [CanonicalSport] = []
var parsed: [SyncSport] = []
var skipped = 0
for record in records {
if let sport = CKSport(record: record).toCanonical() {
parsed.append(sport)
parsed.append(
SyncSport(
id: sport.id,
abbreviation: sport.abbreviation,
displayName: sport.displayName,
iconName: sport.iconName,
colorHex: sport.colorHex,
seasonStartMonth: sport.seasonStartMonth,
seasonEndMonth: sport.seasonEndMonth,
isActive: sport.isActive,
schemaVersion: sport.schemaVersion,
lastModified: sport.lastModified
)
)
} else {
skipped += 1
}

View File

@@ -10,7 +10,7 @@ import SwiftUI
// MARK: - Identifiable Share Code
struct IdentifiableShareCode: Identifiable {
struct IdentifiableShareCode: Identifiable, Hashable {
let id: String
var value: String { id }
}

View File

@@ -128,19 +128,14 @@ actor EVChargingService {
let chargerType = detectChargerType(from: name)
let estimatedChargeTime = estimateChargeTime(for: chargerType)
var address: String? = nil
if let placemark = item.placemark as MKPlacemark? {
var components: [String] = []
if let city = placemark.locality { components.append(city) }
if let state = placemark.administrativeArea { components.append(state) }
address = components.isEmpty ? nil : components.joined(separator: ", ")
}
let address = formattedAddress(for: item)
let coordinate = coordinate(for: item)
return EVChargingStop(
name: name,
location: LocationInput(
name: name,
coordinate: item.placemark.coordinate,
coordinate: coordinate,
address: address
),
chargerType: chargerType,
@@ -148,6 +143,20 @@ actor EVChargingService {
)
}
private func formattedAddress(for item: MKMapItem) -> String? {
if let cityContext = item.addressRepresentations?.cityWithContext, !cityContext.isEmpty {
return cityContext
}
if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty {
return shortAddress
}
return item.address?.fullAddress
}
private func coordinate(for item: MKMapItem) -> CLLocationCoordinate2D {
item.location.coordinate
}
/// Detect charger type from name using heuristics
private func detectChargerType(from name: String) -> ChargerType {
let lowercased = name.lowercased()

View File

@@ -13,7 +13,7 @@ actor ItineraryItemService {
// Debounce tracking
private var pendingUpdates: [UUID: ItineraryItem] = [:]
private var retryCount: [UUID: Int] = [:]
private var debounceTask: Task<Void, Never>?
private var flushTask: Task<Void, Never>?
private let maxRetries = 3
private let debounceInterval: Duration = .seconds(1.5)
@@ -22,8 +22,8 @@ actor ItineraryItemService {
/// Cancel any pending debounce task (for cleanup)
func cancelPendingSync() {
debounceTask?.cancel()
debounceTask = nil
flushTask?.cancel()
flushTask = nil
}
// MARK: - CRUD Operations
@@ -52,26 +52,7 @@ actor ItineraryItemService {
func updateItem(_ item: ItineraryItem) async {
pendingUpdates[item.id] = item
// Cancel existing debounce task
debounceTask?.cancel()
// Start new debounce task with proper cancellation handling
debounceTask = Task {
// Check cancellation before sleeping
guard !Task.isCancelled else { return }
do {
try await Task.sleep(for: debounceInterval)
} catch {
// Task was cancelled during sleep
return
}
// Check cancellation after sleeping (belt and suspenders)
guard !Task.isCancelled else { return }
await flushPendingUpdates()
}
scheduleFlush(after: debounceInterval)
}
/// Force immediate sync of pending updates
@@ -92,15 +73,47 @@ actor ItineraryItemService {
retryCount[item.id] = nil
}
} catch {
// Add back to pending for retry, respecting max retry limit
// Keep failed updates queued and retry with backoff; never drop user edits.
var highestRetry = 0
for item in updates.values {
let currentRetries = retryCount[item.id] ?? 0
if currentRetries < maxRetries {
pendingUpdates[item.id] = item
retryCount[item.id] = currentRetries + 1
}
// If max retries exceeded, silently drop the update to prevent infinite loop
let nextRetry = min(currentRetries + 1, maxRetries)
retryCount[item.id] = nextRetry
highestRetry = max(highestRetry, nextRetry)
pendingUpdates[item.id] = item
}
let retryDelay = retryDelay(for: highestRetry)
scheduleFlush(after: retryDelay)
}
}
// MARK: - Flush Scheduling
private func scheduleFlush(after delay: Duration) {
flushTask?.cancel()
flushTask = Task {
guard !Task.isCancelled else { return }
do {
try await Task.sleep(for: delay)
} catch {
return
}
guard !Task.isCancelled else { return }
await flushPendingUpdates()
}
}
private func retryDelay(for retryLevel: Int) -> Duration {
switch retryLevel {
case 0, 1:
return .seconds(5)
case 2:
return .seconds(15)
default:
return .seconds(30)
}
}
@@ -116,7 +129,7 @@ actor ItineraryItemService {
for item in items {
let recordId = CKRecord.ID(recordName: item.id.uuidString)
try? await database.deleteRecord(withID: recordId)
_ = try? await database.deleteRecord(withID: recordId)
}
}
}

View File

@@ -7,7 +7,7 @@ import Foundation
import CoreLocation
import MapKit
extension MKPolyline: @unchecked Sendable {}
extension MKPolyline: @retroactive @unchecked Sendable {}
actor LocationService {
static let shared = LocationService()
@@ -80,22 +80,29 @@ actor LocationService {
}
}
@available(iOS, deprecated: 26.0, message: "Uses placemark for address formatting")
private func formatMapItem(_ item: MKMapItem) -> String {
var components: [String] = []
if let locality = item.placemark.locality {
components.append(locality)
if let cityContext = item.addressRepresentations?.cityWithContext,
!cityContext.isEmpty {
components.append(cityContext)
}
if let state = item.placemark.administrativeArea {
components.append(state)
if let regionName = item.addressRepresentations?.regionName,
regionName != "United States" {
components.append(regionName)
}
if let country = item.placemark.country, country != "United States" {
components.append(country)
if !components.isEmpty {
return components.joined(separator: ", ")
}
if components.isEmpty {
return item.name ?? ""
if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty {
return shortAddress
}
return components.joined(separator: ", ")
if let fullAddress = item.address?.fullAddress, !fullAddress.isEmpty {
return fullAddress
}
return item.name ?? ""
}
// MARK: - Distance Calculations
@@ -163,7 +170,7 @@ actor LocationService {
struct RouteInfo: Sendable {
let distance: CLLocationDistance // meters
let expectedTravelTime: TimeInterval // seconds
nonisolated(unsafe) let polyline: MKPolyline?
let polyline: MKPolyline?
var distanceMiles: Double { distance * 0.000621371 }
var travelTimeHours: Double { expectedTravelTime / 3600.0 }

View File

@@ -16,6 +16,7 @@ enum PollError: Error, LocalizedError {
case pollNotFound
case alreadyVoted
case notPollOwner
case notVoteOwner
case networkUnavailable
case encodingError
case unknown(Error)
@@ -30,6 +31,8 @@ enum PollError: Error, LocalizedError {
return "You have already voted on this poll."
case .notPollOwner:
return "Only the poll owner can perform this action."
case .notVoteOwner:
return "Only the vote owner can update this vote."
case .networkUnavailable:
return "Unable to connect. Please check your internet connection."
case .encodingError:
@@ -49,7 +52,7 @@ actor PollService {
private let publicDatabase: CKDatabase
private var currentUserRecordID: String?
private var pollSubscriptionID: CKSubscription.ID?
private var pollSubscriptionIDs: Set<CKSubscription.ID> = []
private init() {
self.container = CloudKitContainerConfig.makeContainer()
@@ -231,7 +234,7 @@ actor PollService {
func updateVote(_ vote: PollVote) async throws -> PollVote {
let userId = try await getCurrentUserRecordID()
guard vote.odg == userId else {
throw PollError.notPollOwner // Using this error for now - voter can only update own vote
throw PollError.notVoteOwner
}
// Fetch the existing record to get the server's changeTag
@@ -245,6 +248,13 @@ actor PollService {
throw PollError.unknown(error)
}
guard let existingVoterId = existingRecord[CKPollVote.voterIdKey] as? String else {
throw PollError.encodingError
}
guard existingVoterId == userId else {
throw PollError.notVoteOwner
}
// Update the fields on the fetched record
let now = Date()
existingRecord[CKPollVote.rankingsKey] = vote.rankings
@@ -320,11 +330,12 @@ actor PollService {
// MARK: - Subscriptions
func subscribeToVoteUpdates(forPollId pollId: UUID) async throws {
let subscriptionId = "poll-votes-\(pollId.uuidString)"
let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString)
let subscription = CKQuerySubscription(
recordType: CKRecordType.pollVote,
predicate: predicate,
subscriptionID: "poll-votes-\(pollId.uuidString)",
subscriptionID: subscriptionId,
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
@@ -334,10 +345,12 @@ actor PollService {
do {
try await publicDatabase.save(subscription)
pollSubscriptionID = subscription.subscriptionID
pollSubscriptionIDs.insert(subscription.subscriptionID)
} catch let error as CKError {
// Subscription already exists is OK
if error.code != .serverRejectedRequest {
if error.code == .serverRejectedRequest {
pollSubscriptionIDs.insert(subscriptionId)
} else {
throw mapCloudKitError(error)
}
} catch {
@@ -345,14 +358,23 @@ actor PollService {
}
}
func unsubscribeFromVoteUpdates() async throws {
guard let subscriptionID = pollSubscriptionID else { return }
func unsubscribeFromVoteUpdates(forPollId pollId: UUID? = nil) async throws {
let subscriptionIds: [CKSubscription.ID]
if let pollId {
subscriptionIds = ["poll-votes-\(pollId.uuidString)"]
} else {
subscriptionIds = Array(pollSubscriptionIDs)
}
do {
try await publicDatabase.deleteSubscription(withID: subscriptionID)
pollSubscriptionID = nil
} catch {
// Ignore errors - subscription may not exist
guard !subscriptionIds.isEmpty else { return }
for subscriptionID in subscriptionIds {
do {
try await publicDatabase.deleteSubscription(withID: subscriptionID)
} catch {
// Ignore errors - subscription may not exist
}
pollSubscriptionIDs.remove(subscriptionID)
}
}

View File

@@ -25,24 +25,24 @@ final class RouteDescriptionGenerator {
}
private func checkAvailability() {
// TEMPORARILY DISABLED: Foundation Models crashes in iOS 26.2 Simulator
// due to NLLanguageRecognizer.processString: assertion failure in Collection.suffix(from:)
// TODO: Re-enable when Apple fixes the Foundation Models framework
#if targetEnvironment(simulator)
// Keep simulator behavior deterministic and avoid known FoundationModels simulator crashes.
isAvailable = false
return
// Original code (disabled):
// switch SystemLanguageModel.default.availability {
// case .available:
// isAvailable = true
// session = LanguageModelSession(instructions: """
// You are a travel copywriter creating exciting, brief descriptions for sports road trips.
// Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
// Keep descriptions to 1-2 short sentences maximum.
// """)
// case .unavailable:
// isAvailable = false
// }
session = nil
#else
switch SystemLanguageModel.default.availability {
case .available:
isAvailable = true
session = LanguageModelSession(instructions: """
You are a travel copywriter creating exciting, brief descriptions for sports road trips.
Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
Keep descriptions to 1-2 short sentences maximum.
""")
case .unavailable:
isAvailable = false
session = nil
}
#endif
}
/// Generate a brief, exciting description for a route option

View File

@@ -131,7 +131,6 @@ struct NBAStatsProvider: ScoreAPIProvider {
// Get column indices
let homeTeamIdIdx = headers.firstIndex(of: "HOME_TEAM_ID")
let visitorTeamIdIdx = headers.firstIndex(of: "VISITOR_TEAM_ID")
let gameStatusIdx = headers.firstIndex(of: "GAME_STATUS_TEXT")
// Find the LineScore result set for scores
let lineScoreSet = resultSets.first(where: { ($0["name"] as? String) == "LineScore" })

View File

@@ -16,7 +16,8 @@ final class SyncLogger: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.88oakapps.SportsTime.synclogger")
private init() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
fileURL = docs.appendingPathComponent("sync_log.txt")
}

View File

@@ -75,6 +75,7 @@ final class StoreManager {
// MARK: - Transaction Listener
@ObservationIgnored
nonisolated(unsafe) private var transactionListenerTask: Task<Void, Never>?
// MARK: - Initialization

View File

@@ -75,8 +75,10 @@ final class ThemeManager: @unchecked Sendable {
private func updateAppIcon(for theme: AppTheme) {
let iconName = theme.alternateIconName
guard UIApplication.shared.alternateIconName != iconName else { return }
UIApplication.shared.setAlternateIconName(iconName)
Task { @MainActor in
guard UIApplication.shared.alternateIconName != iconName else { return }
UIApplication.shared.setAlternateIconName(iconName)
}
}
private init() {
@@ -460,11 +462,13 @@ enum Theme {
}
/// Whether the system Reduce Motion preference is enabled.
@MainActor
static var prefersReducedMotion: Bool {
UIAccessibility.isReduceMotionEnabled
}
/// Performs a state change with animation, or instantly if Reduce Motion is enabled.
@MainActor
static func withMotion(_ animation: SwiftUI.Animation = spring, _ body: () -> Void) {
if prefersReducedMotion {
body()