Files
WerkoutIOS/iphone/Werkout_ios/Views/AllWorkouts/AllWorkoutsView.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

376 lines
13 KiB
Swift

//
// AllWorkoutsView.swift
// Werkout_ios
//
// Created by Trey Tartt on 6/15/23.
//
import Foundation
import SwiftUI
import HealthKit
import SharedCore
enum MainViewTypes: Int, CaseIterable {
case AllWorkout = 0
case MyWorkouts
var title: String {
switch self {
case .AllWorkout:
return "All Workouts"
case .MyWorkouts:
return "Planned"
}
}
}
struct AllWorkoutsView: View {
@State private var isUpdating = false
private var workouts: [Workout]? {
dataStore.allWorkouts?.sorted(by: { $0.createdAt ?? Date() < $1.createdAt ?? Date() })
}
private var uniqueWorkoutUsers: [RegisteredUser]? {
dataStore.workoutsUniqueUsers
}
let healthStore = HKHealthStore()
var bridgeModule = BridgeModule.shared
@State private var needsUpdating: Bool = true
@ObservedObject var dataStore = DataStore.shared
@ObservedObject var userStore = UserStore.shared
@State private var showWorkoutDetail = false
@State private var selectedWorkout: Workout? {
didSet {
bridgeModule.currentWorkoutInfo.workout = selectedWorkout
}
}
@State private var selectedPlannedWorkout: Workout? {
didSet {
bridgeModule.currentWorkoutInfo.workout = selectedPlannedWorkout
}
}
@State private var showLoginView = false
@State private var selectedSegment: MainViewTypes = .AllWorkout
@State private var selectedDate: Date = Date()
private let runtimeReporter = RuntimeReporter.shared
// MARK: - Hoisted filter state
@State private var searchText = ""
@State private var selectedMuscles: Set<String> = []
@State private var selectedEquipment: Set<String> = []
@State private var filteredRegisterdUser: RegisteredUser?
@State private var currentSort: SortType?
@State private var sortAscending: Bool = true
let pub = NotificationCenter.default.publisher(for: AppNotifications.createdNewWorkout)
private var hasActiveFilters: Bool {
!selectedMuscles.isEmpty || !selectedEquipment.isEmpty || filteredRegisterdUser != nil
}
var body: some View {
NavigationStack {
ZStack {
WerkoutTheme.background.ignoresSafeArea()
if let workouts = workouts {
VStack(spacing: 0) {
// Active filter chips
if hasActiveFilters {
filterChipsRow
}
switch selectedSegment {
case .AllWorkout:
if isUpdating {
ProgressView()
.progressViewStyle(.circular)
.tint(WerkoutTheme.accent)
}
AllWorkoutsListView(workouts: workouts,
searchText: $searchText,
selectedMuscles: $selectedMuscles,
selectedEquipment: $selectedEquipment,
filteredRegisterdUser: $filteredRegisterdUser,
currentSort: $currentSort,
sortAscending: $sortAscending,
selectedWorkout: { workout in
selectedWorkout = workout
}, refresh: {
self.needsUpdating = true
maybeRefreshData()
})
case .MyWorkouts:
PlannedWorkoutView(workouts: userStore.plannedWorkouts,
selectedPlannedWorkout: $selectedPlannedWorkout)
}
}
} else {
ProgressView()
.progressViewStyle(.circular)
.tint(WerkoutTheme.accent)
}
}
.navigationTitle("Workouts")
.searchable(text: $searchText, prompt: "Search workouts")
.toolbar {
ToolbarItem(placement: .principal) {
Picker("View", selection: $selectedSegment) {
ForEach(MainViewTypes.allCases, id: \.self) { viewType in
Text(viewType.title).tag(viewType)
}
}
.pickerStyle(.segmented)
.frame(width: 200)
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: WerkoutTheme.sm) {
if bridgeModule.isInWorkout {
Button(action: {
selectedWorkout = bridgeModule.currentWorkoutInfo.workout
}) {
Image(systemName: "figure.strengthtraining.traditional")
}
.tint(WerkoutTheme.accent)
}
filterMenu
}
}
}
}
.onAppear {
authorizeHealthKit()
maybeRefreshData()
}
.sheet(item: $selectedWorkout) { item in
let isPreview = item.id == bridgeModule.currentWorkoutInfo.workout?.id
let viewModel = WorkoutDetailViewModel(workout: item, isPreview: isPreview)
WorkoutDetailView(viewModel: viewModel)
}
.sheet(item: $selectedPlannedWorkout) { item in
let viewModel = WorkoutDetailViewModel(workout: item, isPreview: true)
WorkoutDetailView(viewModel: viewModel)
}
.sheet(isPresented: $showLoginView) {
LoginView(completion: {
self.needsUpdating = true
maybeRefreshData()
})
.interactiveDismissDisabled()
}
.onReceive(pub) { _ in
self.needsUpdating = true
maybeRefreshData()
}
}
// MARK: - Filter Menu
private var filterMenu: some View {
Menu {
// Sort section
Section("Sort") {
ForEach(SortType.allCases, id: \.self) { sortType in
Button(action: {
if currentSort == sortType {
sortAscending.toggle()
} else {
currentSort = sortType
sortAscending = true
}
}) {
Label {
Text(sortType.rawValue)
} icon: {
if currentSort == sortType {
Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
}
}
}
}
}
// Creator filter
if let users = uniqueWorkoutUsers, !users.isEmpty {
Section("Creator") {
ForEach(users, id: \.self) { user in
Button(action: {
filteredRegisterdUser = user
}) {
Label {
Text("\(user.firstName ?? "") \(user.lastName ?? "")")
} icon: {
if filteredRegisterdUser == user {
Image(systemName: "checkmark")
}
}
}
}
Button("All Creators") {
filteredRegisterdUser = nil
}
}
}
// Muscles filter
if let muscles = DataStore.shared.allMuscles {
Section("Muscles") {
ForEach(muscles, id: \.id) { muscle in
Button(action: {
if selectedMuscles.contains(muscle.name) {
selectedMuscles.remove(muscle.name)
} else {
selectedMuscles.insert(muscle.name)
}
}) {
Label {
Text(muscle.name)
} icon: {
if selectedMuscles.contains(muscle.name) {
Image(systemName: "checkmark")
}
}
}
}
}
}
// Equipment filter
if let equipments = DataStore.shared.allEquipment {
Section("Equipment") {
ForEach(equipments, id: \.id) { equipment in
Button(action: {
if selectedEquipment.contains(equipment.name) {
selectedEquipment.remove(equipment.name)
} else {
selectedEquipment.insert(equipment.name)
}
}) {
Label {
Text(equipment.name)
} icon: {
if selectedEquipment.contains(equipment.name) {
Image(systemName: "checkmark")
}
}
}
}
}
}
// Clear all
if hasActiveFilters || currentSort != nil {
Divider()
Button(role: .destructive) {
clearAllFilters()
} label: {
Label("Clear All Filters", systemImage: "xmark.circle")
}
}
} label: {
Image(systemName: hasActiveFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
.tint(hasActiveFilters ? WerkoutTheme.accent : nil)
}
// MARK: - Filter Chips Row
private var filterChipsRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: WerkoutTheme.sm) {
ForEach(Array(selectedMuscles), id: \.self) { muscle in
FilterChip(label: muscle, color: WerkoutTheme.accent) {
selectedMuscles.remove(muscle)
}
}
ForEach(Array(selectedEquipment), id: \.self) { equipment in
FilterChip(label: equipment, color: WerkoutTheme.textSecondary) {
selectedEquipment.remove(equipment)
}
}
if let user = filteredRegisterdUser {
FilterChip(label: user.firstName ?? "User", color: WerkoutTheme.accentNeon) {
filteredRegisterdUser = nil
}
}
}
.padding(.horizontal, WerkoutTheme.md)
.padding(.vertical, WerkoutTheme.xs)
}
}
// MARK: - Actions
private func clearAllFilters() {
selectedMuscles.removeAll()
selectedEquipment.removeAll()
filteredRegisterdUser = nil
currentSort = nil
sortAscending = true
}
func maybeRefreshData() {
if userStore.token != nil {
if userStore.plannedWorkouts.isEmpty {
userStore.fetchPlannedWorkouts()
}
if needsUpdating {
self.isUpdating = true
dataStore.fetchAllData(completion: {
DispatchQueue.main.async {
self.isUpdating = false
self.needsUpdating = false
}
})
}
} else {
isUpdating = false
showLoginView = true
}
}
func authorizeHealthKit() {
let quantityTypes = [
HKObjectType.quantityType(forIdentifier: .heartRate),
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
].compactMap { $0 }
let healthKitTypes: Set<HKObjectType> = Set(
quantityTypes + [
HKObjectType.activitySummaryType(),
HKQuantityType.workoutType()
]
)
healthStore.requestAuthorization(toShare: nil, read: healthKitTypes) { (success, error) in
if success == false {
runtimeReporter.recordWarning(
"HealthKit authorization request did not succeed",
metadata: ["error": error?.localizedDescription ?? "unknown"]
)
}
}
}
}
struct AllWorkoutsView_Previews: PreviewProvider {
static var previews: some View {
AllWorkoutsView()
}
}