diff --git a/ios/Ascently.xcodeproj/project.pbxproj b/ios/Ascently.xcodeproj/project.pbxproj index 51b5227..1d60cd1 100644 --- a/ios/Ascently.xcodeproj/project.pbxproj +++ b/ios/Ascently.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -487,7 +487,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -513,7 +513,7 @@ CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = 4BC9Y2LL4B; DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; @@ -535,7 +535,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -602,7 +602,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -613,7 +613,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -632,7 +632,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 35; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -643,7 +643,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index c24534b..45a7623 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/Views/ProblemsView.swift b/ios/Ascently/Views/ProblemsView.swift index a73bb06..c8ff92d 100644 --- a/ios/Ascently/Views/ProblemsView.swift +++ b/ios/Ascently/Views/ProblemsView.swift @@ -8,10 +8,16 @@ struct ProblemsView: View { @State private var selectedGym: Gym? @State private var searchText = "" @State private var showingSearch = false + @State private var showingFilters = false @FocusState private var isSearchFocused: Bool @State private var cachedFilteredProblems: [Problem] = [] + // State moved from ProblemsList + @State private var problemToDelete: Problem? + @State private var problemToEdit: Problem? + @State private var animationKey = 0 + private func updateFilteredProblems() { Task(priority: .userInitiated) { let result = await computeFilteredProblems() @@ -71,61 +77,67 @@ struct ProblemsView: View { var body: some View { NavigationStack { Group { - VStack(spacing: 0) { - if showingSearch { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - .font(.system(size: 16, weight: .medium)) - - TextField("Search problems...", text: $searchText) - .textFieldStyle(.plain) - .font(.system(size: 16)) - .focused($isSearchFocused) - .submitLabel(.search) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background { - if #available(iOS 18.0, *) { - RoundedRectangle(cornerRadius: 12) - .fill(.regularMaterial) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(.quaternary, lineWidth: 0.5) - } - } else { - RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemGray6)) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(Color(.systemGray4), lineWidth: 0.5) - } - } - } - .padding(.horizontal) - .padding(.top, 8) - .animation(.easeInOut(duration: 0.3), value: showingSearch) - } - - if !dataManager.problems.isEmpty && !showingSearch { - FilterSection( - selectedClimbType: $selectedClimbType, - selectedGym: $selectedGym, - filteredProblems: cachedFilteredProblems - ) - .padding() - .background(.regularMaterial) - } - - if cachedFilteredProblems.isEmpty { + if cachedFilteredProblems.isEmpty { + VStack(spacing: 0) { + headerContent + EmptyProblemsView( isEmpty: dataManager.problems.isEmpty, isFiltered: !dataManager.problems.isEmpty ) - } else { - ProblemsList(problems: cachedFilteredProblems) } + } else { + List { + if showingSearch { + Section { + headerContent + } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + + ForEach(cachedFilteredProblems) { problem in + NavigationLink(destination: ProblemDetailView(problemId: problem.id)) { + ProblemRow(problem: problem) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + problemToDelete = problem + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1)) + { + let updatedProblem = problem.updated(isActive: !problem.isActive) + dataManager.updateProblem(updatedProblem) + } + } label: { + Label( + problem.isActive ? "Mark as Reset" : "Mark as Active", + systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle") + } + .tint(.orange) + + Button { + problemToEdit = problem + } label: { + HStack { + Image(systemName: "pencil") + Text("Edit") + } + } + .tint(themeManager.accentColor) + } + } + } + .listStyle(.plain) + .animation( + .spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1), + value: animationKey + ) } } .navigationTitle("Problems") @@ -166,6 +178,14 @@ struct ProblemsView: View { .foregroundColor(showingSearch ? .secondary : themeManager.accentColor) } + Button(action: { + showingFilters = true + }) { + Image(systemName: (selectedClimbType != nil || selectedGym != nil) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(themeManager.accentColor) + } + if !dataManager.gyms.isEmpty { Button("Add") { showingAddProblem = true @@ -176,6 +196,32 @@ struct ProblemsView: View { .sheet(isPresented: $showingAddProblem) { AddEditProblemView() } + .sheet(isPresented: $showingFilters) { + FilterSheet( + selectedClimbType: $selectedClimbType, + selectedGym: $selectedGym, + filteredProblems: cachedFilteredProblems + ) + .presentationDetents([.height(320)]) + } + .sheet(item: $problemToEdit) { problem in + AddEditProblemView(problemId: problem.id) + } + .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { + Button("Cancel", role: .cancel) { + problemToDelete = nil + } + Button("Delete", role: .destructive) { + if let problem = problemToDelete { + dataManager.deleteProblem(problem) + problemToDelete = nil + } + } + } message: { + Text( + "Are you sure you want to delete this problem? This will also delete all associated attempts." + ) + } } .onAppear { updateFilteredProblems() @@ -192,6 +238,51 @@ struct ProblemsView: View { .onChange(of: selectedGym) { updateFilteredProblems() } + .onChange(of: cachedFilteredProblems) { + animationKey += 1 + } + } + + @ViewBuilder + private var headerContent: some View { + VStack(spacing: 0) { + if showingSearch { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 16, weight: .medium)) + + TextField("Search problems...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 16)) + .focused($isSearchFocused) + .submitLabel(.search) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background { + if #available(iOS 18.0, *) { + RoundedRectangle(cornerRadius: 12) + .fill(.regularMaterial) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(.quaternary, lineWidth: 0.5) + } + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray6)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(Color(.systemGray4), lineWidth: 0.5) + } + } + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 8) + .animation(.easeInOut(duration: 0.3), value: showingSearch) + } + } } } @@ -300,81 +391,7 @@ struct FilterChip: View { } } -struct ProblemsList: View { - let problems: [Problem] - @EnvironmentObject var dataManager: ClimbingDataManager - @EnvironmentObject var themeManager: ThemeManager - @State private var problemToDelete: Problem? - @State private var problemToEdit: Problem? - @State private var animationKey = 0 - var body: some View { - List(problems, id: \.id) { problem in - NavigationLink(destination: ProblemDetailView(problemId: problem.id)) { - ProblemRow(problem: problem) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - problemToDelete = problem - } label: { - Label("Delete", systemImage: "trash") - } - - Button { - withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1)) - { - let updatedProblem = problem.updated(isActive: !problem.isActive) - dataManager.updateProblem(updatedProblem) - } - } label: { - Label( - problem.isActive ? "Mark as Reset" : "Mark as Active", - systemImage: problem.isActive ? "xmark.circle" : "checkmark.circle") - } - .tint(.orange) - - Button { - problemToEdit = problem - } label: { - HStack { - Image(systemName: "pencil") - Text("Edit") - } - } - .tint(themeManager.accentColor) - } - } - .animation( - .spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1), - value: animationKey - ) - .onChange(of: problems) { - animationKey += 1 - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .scrollIndicators(.hidden) - .clipped() - .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { - Button("Cancel", role: .cancel) { - problemToDelete = nil - } - Button("Delete", role: .destructive) { - if let problem = problemToDelete { - dataManager.deleteProblem(problem) - problemToDelete = nil - } - } - } message: { - Text( - "Are you sure you want to delete this problem? This will also delete all associated attempts." - ) - } - .sheet(item: $problemToEdit) { problem in - AddEditProblemView(problemId: problem.id) - } - } -} struct ProblemRow: View { let problem: Problem @@ -528,6 +545,71 @@ struct EmptyProblemsView: View { } } +struct FilterSheet: View { + @Binding var selectedClimbType: ClimbType? + @Binding var selectedGym: Gym? + let filteredProblems: [Problem] + @Environment(\.dismiss) var dismiss + @EnvironmentObject var themeManager: ThemeManager + + var body: some View { + NavigationStack { + ScrollView { + FilterSection( + selectedClimbType: $selectedClimbType, + selectedGym: $selectedGym, + filteredProblems: filteredProblems + ) + .padding() + } + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + dismiss() + }) { + Text("Done") + .font(.subheadline) + .fontWeight(.semibold) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(Color.secondary.opacity(0.2), lineWidth: 0.5) + ) + .foregroundColor(themeManager.accentColor) + } + } + + ToolbarItem(placement: .navigationBarLeading) { + if selectedClimbType != nil || selectedGym != nil { + Button(action: { + selectedClimbType = nil + selectedGym = nil + }) { + Text("Reset") + .font(.subheadline) + .fontWeight(.medium) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(Color.secondary.opacity(0.2), lineWidth: 0.5) + ) + .foregroundColor(.red) + } + } + } + } + } + } +} + #Preview { ProblemsView() .environmentObject(ClimbingDataManager.preview)