Files
WerkoutIOS/iphone/Werkout_ios/UserStore.swift
Trey t 5d39dcb66f
Some checks failed
Apple Platform CI / smoke-and-tests (push) Has been cancelled
Fix 28 issues from deep audit and UI audit + redesign changes
Deep audit (issues 2-14):
- Add missing WCSession handlers for applicationContext and userInfo
- Fix BoundedFIFOQueue race condition with serial dispatch queue
- Fix timer race condition with main thread guarantee
- Fix watch pause state divergence — phone is now source of truth
- Fix wrong notification posted on logout (createdNewWorkout → userLoggedOut)
- Fix POST status check to accept any 2xx (was exact match)
- Fix @StateObject → @ObservedObject for injected viewModel
- Add pull-to-refresh to CompletedWorkoutsView
- Fix typos: RefreshUserInfoFetcable, defualtPackageModle
- Replace string concatenation with interpolation
- Replace 6 @StateObject with @ObservedObject for BridgeModule.shared
- Replace 7 hardcoded AVPlayer URLs with BaseURLs.currentBaseURL

UI audit (issues 1-15):
- Fix GeometryReader eating VStack space — replaced with .overlay
- Fix refreshable continuation resuming before fetch completes
- Remove duplicate @State workouts — derive from DataStore
- Decouple leaf views from BridgeModule (pass discrete values)
- Convert selectedIds from Array to Set for O(1) lookups
- Extract .sorted() from var body into computed properties
- Move search filter out of ForEach render loop
- Replace import SwiftUI with import Combine in non-UI classes
- Mark all @State properties private
- Extract L/R exercise auto-add logic to WorkoutViewModel
- Use enumerated() instead of .indices in ForEach
- Make AddSupersetView frame flexible instead of fixed 300pt
- Hoist Set construction out of per-exercise filter loop
- Move ViewModel network fetch from init to load()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:24:52 -06:00

298 lines
11 KiB
Swift

//
// UserStore.swift
// Werkout_ios
//
// Created by Trey Tartt on 6/25/23.
//
import Foundation
import SharedCore
class UserStore: ObservableObject {
static let tokenKeychainValue = "auth_token"
static let userDefaultsRegisteredUserKey = "registeredUserKey"
private static let userDefaultsTokenExpirationKey = "registeredUserTokenExpiration"
static let shared = UserStore()
private let runtimeReporter = RuntimeReporter.shared
private let authRefreshQueue = DispatchQueue(label: "com.werkout.auth.refresh")
private var lastTokenRefreshAttempt = Date.distantPast
private let tokenRotationWindow: TimeInterval = 30 * 60
private let tokenRefreshThrottle: TimeInterval = 5 * 60
@Published public private(set) var registeredUser: RegisteredUser?
@Published public private(set) var plannedWorkouts = [PlannedWorkout]()
init(registeredUser: RegisteredUser? = nil) {
self.registeredUser = registeredUser ?? loadPersistedUser()
}
public var token: String? {
guard let rawToken = normalizedToken(from: registeredUser?.token) else {
return nil
}
if isTokenExpired(rawToken) {
runtimeReporter.recordWarning("Auth token expired before request", metadata: ["action": "force_logout"])
DispatchQueue.main.async {
self.logout(reason: "token_expired")
}
return nil
}
maybeRefreshTokenIfNearExpiry(rawToken)
return "Token \(rawToken)"
}
func handleUnauthorizedResponse(statusCode: Int, responseBody: String?) {
guard statusCode == 401 || statusCode == 403 else {
return
}
runtimeReporter.recordError(
"Unauthorized response from server",
metadata: [
"status_code": "\(statusCode)",
"has_body": responseBody == nil ? "false" : "true"
]
)
DispatchQueue.main.async {
self.logout(reason: "unauthorized_\(statusCode)")
}
}
private func loadPersistedUser() -> RegisteredUser? {
guard let data = UserDefaults.standard.data(forKey: UserStore.userDefaultsRegisteredUserKey),
let model = try? JSONDecoder().decode(RegisteredUser.self, from: data) else {
return nil
}
if let keychainToken = loadTokenFromKeychain() {
if isTokenExpired(keychainToken) {
runtimeReporter.recordWarning("Persisted token is expired", metadata: ["source": "keychain"])
clearPersistedTokenOnly()
return userByReplacingToken(model, token: nil)
}
persistTokenExpirationMetadata(token: keychainToken)
return userByReplacingToken(model, token: keychainToken)
}
if let legacyToken = normalizedToken(from: model.token) {
migrateLegacyTokenToKeychain(legacyToken)
if isTokenExpired(legacyToken) {
runtimeReporter.recordWarning("Persisted token is expired", metadata: ["source": "legacy_defaults"])
clearPersistedTokenOnly()
return userByReplacingToken(model, token: nil)
}
persistSanitizedModel(userByReplacingToken(model, token: nil))
persistTokenExpirationMetadata(token: legacyToken)
return userByReplacingToken(model, token: legacyToken)
}
persistTokenExpirationMetadata(token: nil)
return userByReplacingToken(model, token: nil)
}
private func persistRegisteredUser(_ model: RegisteredUser) {
let sanitizedToken = normalizedToken(from: model.token)
persistSanitizedModel(userByReplacingToken(model, token: nil))
if let sanitizedToken,
let tokenData = sanitizedToken.data(using: .utf8) {
do {
try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue)
} catch KeychainInterface.KeychainError.duplicateItem {
try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue)
} catch {
runtimeReporter.recordError(
"Failed saving token in keychain",
metadata: ["error": error.localizedDescription]
)
}
} else {
try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue)
}
persistTokenExpirationMetadata(token: sanitizedToken)
}
private func persistSanitizedModel(_ model: RegisteredUser) {
if let data = try? JSONEncoder().encode(model) {
UserDefaults.standard.set(data, forKey: UserStore.userDefaultsRegisteredUserKey)
}
}
func login(postData: [String: Any], completion: @escaping (Bool)-> Void) {
LoginFetchable(postData: postData).fetch(completion: { result in
switch result {
case .success(let model):
let sanitizedModel = self.userByReplacingToken(model, token: self.normalizedToken(from: model.token))
DispatchQueue.main.async {
self.registeredUser = sanitizedModel
self.persistRegisteredUser(sanitizedModel)
completion(true)
}
case .failure(let error):
self.runtimeReporter.recordError("Login failed", metadata: ["error": error.localizedDescription])
DispatchQueue.main.async {
completion(false)
}
}
})
}
public func refreshUserData() {
RefreshUserInfoFetchable().fetch(completion: { result in
switch result {
case .success(let registeredUser):
let sanitizedModel = self.userByReplacingToken(registeredUser, token: self.normalizedToken(from: registeredUser.token))
DispatchQueue.main.async {
self.persistRegisteredUser(sanitizedModel)
self.registeredUser = sanitizedModel
}
case .failure(let failure):
self.runtimeReporter.recordError("Failed refreshing user", metadata: ["error": failure.localizedDescription])
}
})
}
func logout() {
logout(reason: "manual_logout")
}
private func logout(reason: String) {
let email = registeredUser?.email
self.registeredUser = nil
UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsRegisteredUserKey)
persistTokenExpirationMetadata(token: nil)
try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue)
if let email, email.isEmpty == false {
try? KeychainInterface.deletePassword(account: email)
}
plannedWorkouts.removeAll()
runtimeReporter.recordInfo("User logged out", metadata: ["reason": reason])
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppNotifications.userLoggedOut, object: nil, userInfo: nil)
}
}
func setFakeUser() {
self.registeredUser = PreviewData.parseRegisterdUser()
}
func fetchPlannedWorkouts() {
PlannedWorkoutFetchable().fetch(completion: { result in
switch result {
case .success(let models):
self.plannedWorkouts = models
case .failure(let failure):
self.runtimeReporter.recordError("Failed fetching planned workouts", metadata: ["error": failure.localizedDescription])
}
})
}
func plannedWorkoutFor(date: Date) -> PlannedWorkout? {
for plannedWorkout in plannedWorkouts {
if let plannedworkoutDate = plannedWorkout.date {
if Calendar.current.isDate(date, equalTo: plannedworkoutDate, toGranularity: .day) {
return plannedWorkout
}
}
}
return nil
}
func setTreyDevRegisterdUser() {
self.registeredUser = RegisteredUser(id: 1,
firstName: "t",
lastName: "t",
image: nil,
nickName: "t",
token: nil,
email: nil,
hasNSFWToggle: nil)
}
private func migrateLegacyTokenToKeychain(_ token: String) {
guard let tokenData = token.data(using: .utf8) else {
return
}
do {
try KeychainInterface.save(password: tokenData, account: UserStore.tokenKeychainValue)
} catch KeychainInterface.KeychainError.duplicateItem {
try? KeychainInterface.update(password: tokenData, account: UserStore.tokenKeychainValue)
} catch {
runtimeReporter.recordError("Failed migrating legacy token", metadata: ["error": error.localizedDescription])
}
}
private func loadTokenFromKeychain() -> String? {
guard let tokenData = try? KeychainInterface.readPassword(account: UserStore.tokenKeychainValue),
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return normalizedToken(from: token)
}
private func normalizedToken(from rawToken: String?) -> String? {
TokenSecurity.sanitizeToken(rawToken)
}
private func persistTokenExpirationMetadata(token: String?) {
guard let token,
let expirationDate = TokenSecurity.jwtExpiration(token) else {
UserDefaults.standard.removeObject(forKey: UserStore.userDefaultsTokenExpirationKey)
return
}
UserDefaults.standard.set(expirationDate.timeIntervalSince1970, forKey: UserStore.userDefaultsTokenExpirationKey)
}
private func clearPersistedTokenOnly() {
try? KeychainInterface.deletePassword(account: UserStore.tokenKeychainValue)
persistTokenExpirationMetadata(token: nil)
}
private func maybeRefreshTokenIfNearExpiry(_ token: String) {
guard TokenSecurity.shouldRotate(token, rotationWindow: tokenRotationWindow) else {
return
}
authRefreshQueue.async {
let throttleCutoff = Date().addingTimeInterval(-self.tokenRefreshThrottle)
guard self.lastTokenRefreshAttempt <= throttleCutoff else {
return
}
self.lastTokenRefreshAttempt = Date()
self.runtimeReporter.recordInfo("Token nearing expiry; refreshing user")
DispatchQueue.main.async {
self.refreshUserData()
}
}
}
private func isTokenExpired(_ token: String) -> Bool {
TokenSecurity.isExpired(token)
}
private func userByReplacingToken(_ model: RegisteredUser, token: String?) -> RegisteredUser {
RegisteredUser(id: model.id,
firstName: model.firstName,
lastName: model.lastName,
image: model.image,
nickName: model.nickName,
token: token,
email: model.email,
hasNSFWToggle: model.hasNSFWToggle)
}
}