Files
Sportstime/SportsTime/Core/Services/LocationPermissionManager.swift
Trey t d63d311cab feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs
- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:27:23 -06:00

218 lines
6.8 KiB
Swift

//
// LocationPermissionManager.swift
// SportsTime
//
// Manages location permission requests and status
//
import Foundation
import CoreLocation
import SwiftUI
@MainActor
@Observable
final class LocationPermissionManager: NSObject {
static let shared = LocationPermissionManager()
private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
private(set) var currentLocation: CLLocation?
private(set) var isRequestingPermission = false
private let locationManager = CLLocationManager()
override private init() {
super.init()
locationManager.delegate = self
authorizationStatus = locationManager.authorizationStatus
}
// MARK: - Computed Properties
var isAuthorized: Bool {
authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways
}
var needsPermission: Bool {
authorizationStatus == .notDetermined
}
var isDenied: Bool {
authorizationStatus == .denied || authorizationStatus == .restricted
}
var statusMessage: String {
switch authorizationStatus {
case .notDetermined:
return "Location access helps find nearby stadiums and optimize your route."
case .restricted:
return "Location access is restricted on this device."
case .denied:
return "Location access was denied. Enable it in Settings to use this feature."
case .authorizedAlways, .authorizedWhenInUse:
return "Location access granted."
@unknown default:
return "Unknown location status."
}
}
// MARK: - Actions
func requestPermission() {
guard authorizationStatus == .notDetermined else { return }
isRequestingPermission = true
locationManager.requestWhenInUseAuthorization()
}
func requestCurrentLocation() {
guard isAuthorized else { return }
locationManager.requestLocation()
}
func openSettings() {
guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(settingsURL)
}
}
// MARK: - CLLocationManagerDelegate
extension LocationPermissionManager: CLLocationManagerDelegate {
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in
self.authorizationStatus = manager.authorizationStatus
self.isRequestingPermission = false
// Auto-request location if newly authorized
if self.isAuthorized {
self.requestCurrentLocation()
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
self.currentLocation = locations.last
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Location error handled silently
}
}
// MARK: - Location Permission View
struct LocationPermissionView: View {
@Bindable var manager = LocationPermissionManager.shared
var body: some View {
VStack(spacing: 16) {
Image(systemName: "location.circle.fill")
.font(.largeTitle)
.foregroundStyle(.blue)
.accessibilityHidden(true)
Text("Enable Location")
.font(.title2)
.fontWeight(.bold)
Text(manager.statusMessage)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if manager.needsPermission {
Button {
manager.requestPermission()
} label: {
Text("Allow Location Access")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal)
} else if manager.isDenied {
Button {
manager.openSettings()
} label: {
Text("Open Settings")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal)
} else if manager.isAuthorized {
Label("Location Enabled", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
.padding()
}
}
// MARK: - Location Permission Banner
struct LocationPermissionBanner: View {
@Bindable var manager = LocationPermissionManager.shared
@Binding var isPresented: Bool
var body: some View {
if manager.needsPermission || manager.isDenied {
HStack(spacing: 12) {
Image(systemName: "location.slash")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Location Not Available")
.font(.subheadline)
.fontWeight(.medium)
Text(manager.needsPermission ? "Enable for better route planning" : "Tap to enable in Settings")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
if manager.needsPermission {
manager.requestPermission()
} else {
manager.openSettings()
}
} label: {
Text(manager.needsPermission ? "Enable" : "Settings")
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(Capsule())
}
Button {
isPresented = false
} label: {
Image(systemName: "xmark")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
#Preview {
LocationPermissionView()
}