Fix 28 issues from deep audit and UI audit + redesign changes
Some checks failed
Apple Platform CI / smoke-and-tests (push) Has been cancelled
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>
This commit is contained in:
@@ -13,88 +13,88 @@ enum SortType: String, CaseIterable {
|
||||
}
|
||||
|
||||
struct AllWorkoutsListView: View {
|
||||
@State var searchNameString: String = ""
|
||||
@State var selectedMuscles: Set<String> = []
|
||||
@State var selectedEquipment: Set<String> = []
|
||||
@Binding var uniqueWorkoutUsers: [RegisteredUser]?
|
||||
@State private var filteredRegisterdUser: RegisteredUser?
|
||||
|
||||
let workouts: [Workout]
|
||||
@Binding var searchText: String
|
||||
@Binding var selectedMuscles: Set<String>
|
||||
@Binding var selectedEquipment: Set<String>
|
||||
@Binding var filteredRegisterdUser: RegisteredUser?
|
||||
@Binding var currentSort: SortType?
|
||||
@Binding var sortAscending: Bool
|
||||
|
||||
let selectedWorkout: ((Workout) -> Void)
|
||||
@State var filteredWorkouts = [Workout]()
|
||||
var refresh: (() -> Void)
|
||||
@State var currentSort: SortType?
|
||||
|
||||
|
||||
@State private var filteredWorkouts = [Workout]()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack {
|
||||
if let filteredRegisterdUser = filteredRegisterdUser {
|
||||
Text((filteredRegisterdUser.firstName ?? "NA") + "'s Workouts")
|
||||
}
|
||||
|
||||
FilterAllView(selectedMuscles: $selectedMuscles,
|
||||
selectedEquipment: $selectedEquipment,
|
||||
searchNameString: $searchNameString,
|
||||
uniqueWorkoutUsers: $uniqueWorkoutUsers,
|
||||
filteredRegisterdUser: $filteredRegisterdUser,
|
||||
filteredWorkouts: $filteredWorkouts,
|
||||
workouts: .constant(workouts),
|
||||
currentSort: $currentSort)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(filteredWorkouts, id:\.id) { workout in
|
||||
Button(action: {
|
||||
selectedWorkout(workout)
|
||||
}, label: {
|
||||
WorkoutOverviewView(workout: workout)
|
||||
.padding([.leading, .trailing], 4)
|
||||
.contentShape(Rectangle())
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open \(workout.name)")
|
||||
.accessibilityHint("Shows workout details")
|
||||
}
|
||||
ScrollView {
|
||||
LazyVStack(spacing: WerkoutTheme.sm) {
|
||||
ForEach(filteredWorkouts, id: \.id) { workout in
|
||||
Button(action: {
|
||||
selectedWorkout(workout)
|
||||
}, label: {
|
||||
WorkoutOverviewView(workout: workout)
|
||||
.padding(.horizontal, WerkoutTheme.sm)
|
||||
.contentShape(Rectangle())
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open \(workout.name)")
|
||||
.accessibilityHint("Shows workout details")
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
.refreshable {
|
||||
refresh()
|
||||
}
|
||||
.padding(.top, WerkoutTheme.sm)
|
||||
}
|
||||
.onChange(of: searchNameString) { newValue in
|
||||
filterWorkouts()
|
||||
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||
.background(WerkoutTheme.background)
|
||||
.refreshable {
|
||||
refresh()
|
||||
}
|
||||
.onChange(of: selectedMuscles) { newValue in
|
||||
filterWorkouts()
|
||||
}
|
||||
.onChange(of: selectedEquipment) { newValue in
|
||||
filterWorkouts()
|
||||
}
|
||||
.onAppear{
|
||||
filterWorkouts()
|
||||
}
|
||||
.onChange(of: workouts) { _ in
|
||||
filterWorkouts()
|
||||
.onAppear {
|
||||
applyFilters()
|
||||
}
|
||||
.onChange(of: searchText) { applyFilters() }
|
||||
.onChange(of: selectedMuscles) { applyFilters() }
|
||||
.onChange(of: selectedEquipment) { applyFilters() }
|
||||
.onChange(of: filteredRegisterdUser) { applyFilters() }
|
||||
.onChange(of: currentSort) { applyFilters() }
|
||||
.onChange(of: sortAscending) { applyFilters() }
|
||||
.onChange(of: workouts) { applyFilters() }
|
||||
}
|
||||
|
||||
func filterWorkouts() {
|
||||
filteredWorkouts = workouts.filterWorkouts(
|
||||
nameSearchString: searchNameString,
|
||||
|
||||
private func applyFilters() {
|
||||
var results = workouts.filterWorkouts(
|
||||
nameSearchString: searchText,
|
||||
musclesSearchString: selectedMuscles,
|
||||
equipmentSearchString: selectedEquipment,
|
||||
filteredRegisterdUser: filteredRegisterdUser
|
||||
)
|
||||
|
||||
if let sort = currentSort {
|
||||
switch sort {
|
||||
case .name:
|
||||
results.sort { $0.name < $1.name }
|
||||
case .createdDate:
|
||||
results.sort { ($0.createdAt ?? Date()) < ($1.createdAt ?? Date()) }
|
||||
}
|
||||
if !sortAscending {
|
||||
results.reverse()
|
||||
}
|
||||
}
|
||||
|
||||
filteredWorkouts = results
|
||||
}
|
||||
}
|
||||
|
||||
struct AllWorkoutsListView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AllWorkoutsListView(uniqueWorkoutUsers: .constant([]),
|
||||
workouts: PreviewData.allWorkouts(),
|
||||
selectedWorkout: { workout in },
|
||||
refresh: { })
|
||||
AllWorkoutsListView(workouts: PreviewData.allWorkouts(),
|
||||
searchText: .constant(""),
|
||||
selectedMuscles: .constant([]),
|
||||
selectedEquipment: .constant([]),
|
||||
filteredRegisterdUser: .constant(nil),
|
||||
currentSort: .constant(nil),
|
||||
sortAscending: .constant(true),
|
||||
selectedWorkout: { _ in },
|
||||
refresh: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,88 +13,142 @@ 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 Workouts"
|
||||
return "Planned"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AllWorkoutsView: View {
|
||||
@State var isUpdating = false
|
||||
@State var workouts: [Workout]?
|
||||
@State var uniqueWorkoutUsers: [RegisteredUser]?
|
||||
@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 public var needsUpdating: Bool = true
|
||||
|
||||
@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 var selectedDate: Date = Date()
|
||||
@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)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let workouts = workouts {
|
||||
VStack {
|
||||
AllWorkoutPickerView(mainViews: MainViewTypes.allCases,
|
||||
selectedSegment: $selectedSegment,
|
||||
showCurrentWorkout: {
|
||||
selectedWorkout = bridgeModule.currentWorkoutInfo.workout
|
||||
})
|
||||
|
||||
switch selectedSegment {
|
||||
case .AllWorkout:
|
||||
if isUpdating {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
|
||||
AllWorkoutsListView(uniqueWorkoutUsers: $uniqueWorkoutUsers,
|
||||
workouts: workouts,
|
||||
selectedWorkout: { workout in
|
||||
selectedWorkout = workout
|
||||
}, refresh: {
|
||||
self.needsUpdating = true
|
||||
maybeRefreshData()
|
||||
})
|
||||
private var hasActiveFilters: Bool {
|
||||
!selectedMuscles.isEmpty || !selectedEquipment.isEmpty || filteredRegisterdUser != nil
|
||||
}
|
||||
|
||||
Divider()
|
||||
case .MyWorkouts:
|
||||
PlannedWorkoutView(workouts: userStore.plannedWorkouts,
|
||||
selectedPlannedWorkout: $selectedPlannedWorkout)
|
||||
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
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ProgressView("Updating")
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: .systemGray5))
|
||||
.onAppear{
|
||||
// UserStore.shared.logout()
|
||||
.onAppear {
|
||||
authorizeHealthKit()
|
||||
maybeRefreshData()
|
||||
}
|
||||
@@ -114,31 +168,171 @@ struct AllWorkoutsView: View {
|
||||
})
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
.onReceive(pub) { (output) in
|
||||
.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.token != nil {
|
||||
if userStore.plannedWorkouts.isEmpty {
|
||||
userStore.fetchPlannedWorkouts()
|
||||
}
|
||||
|
||||
|
||||
if needsUpdating {
|
||||
self.isUpdating = true
|
||||
dataStore.fetchAllData(completion: {
|
||||
DispatchQueue.main.async {
|
||||
guard let allWorkouts = dataStore.allWorkouts else {
|
||||
self.isUpdating = false
|
||||
return
|
||||
}
|
||||
self.workouts = allWorkouts.sorted(by: {
|
||||
$0.createdAt ?? Date() < $1.createdAt ?? Date()
|
||||
})
|
||||
self.isUpdating = false
|
||||
self.uniqueWorkoutUsers = dataStore.workoutsUniqueUsers
|
||||
self.needsUpdating = false
|
||||
}
|
||||
})
|
||||
@@ -148,7 +342,7 @@ struct AllWorkoutsView: View {
|
||||
showLoginView = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func authorizeHealthKit() {
|
||||
let quantityTypes = [
|
||||
HKObjectType.quantityType(forIdentifier: .heartRate),
|
||||
@@ -162,7 +356,7 @@ struct AllWorkoutsView: View {
|
||||
HKQuantityType.workoutType()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
healthStore.requestAuthorization(toShare: nil, read: healthKitTypes) { (success, error) in
|
||||
if success == false {
|
||||
runtimeReporter.recordWarning(
|
||||
@@ -176,6 +370,6 @@ struct AllWorkoutsView: View {
|
||||
|
||||
struct AllWorkoutsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AllWorkoutsView(workouts: PreviewData.allWorkouts())
|
||||
AllWorkoutsView()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user