Some checks failed
Apple Platform CI / smoke-and-tests (push) Has been cancelled
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>
376 lines
13 KiB
Swift
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()
|
|
}
|
|
}
|