1 Commits

Author SHA1 Message Date
Trey t
987ebf9690 fix: Save to Collection button not responding without user interaction
The Save to Collection button on the identification screen would remain
disabled after plant identification completed, requiring the user to tap
a prediction row before it would enable. This was caused by an @Observable
+ @State tracking issue where computed properties in SwiftUI view modifiers
don't always trigger re-renders when the underlying observable changes.

Replaced the empty .onChange workaround with a local @State property
(saveEnabled) that is explicitly updated when selectedPrediction or
saveState changes, ensuring the button state always reflects the current
ViewModel state.

Fixes #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:08:30 -05:00
3 changed files with 12 additions and 19 deletions

View File

@@ -19,6 +19,11 @@ struct IdentificationView: View {
/// Tracks whether we've announced results to avoid duplicate announcements /// Tracks whether we've announced results to avoid duplicate announcements
@State private var hasAnnouncedResults = false @State private var hasAnnouncedResults = false
/// Local state to reliably drive the save button's enabled/disabled state.
/// Works around an @Observable + @State tracking issue where computed
/// properties in view modifiers don't always trigger re-renders.
@State private var saveEnabled = false
// MARK: - Scaled Metrics for Dynamic Type // MARK: - Scaled Metrics for Dynamic Type
@ScaledMetric(relativeTo: .body) private var closeIconSize: CGFloat = 16 @ScaledMetric(relativeTo: .body) private var closeIconSize: CGFloat = 16
@@ -76,8 +81,10 @@ struct IdentificationView: View {
announceStateChange(from: oldValue, to: newValue) announceStateChange(from: oldValue, to: newValue)
} }
.onChange(of: viewModel.selectedPrediction?.id) { _, _ in .onChange(of: viewModel.selectedPrediction?.id) { _, _ in
// Force view update when selection changes saveEnabled = viewModel.canSaveToCollection
// This ensures SwiftUI tracks @Observable property changes correctly }
.onChange(of: viewModel.saveState) { _, _ in
saveEnabled = viewModel.canSaveToCollection
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Identification.identificationView) .accessibilityIdentifier(AccessibilityIdentifiers.Identification.identificationView)
.alert("Plant Saved!", isPresented: .init( .alert("Plant Saved!", isPresented: .init(
@@ -406,12 +413,12 @@ struct IdentificationView: View {
.padding(.vertical, 14) .padding(.vertical, 14)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(viewModel.canSaveToCollection ? Color.accentColor : Color.gray) .fill(saveEnabled ? Color.accentColor : Color.gray)
) )
} }
.disabled(!viewModel.canSaveToCollection) .disabled(!saveEnabled)
.accessibilityLabel(viewModel.saveState == .saving ? "Saving plant" : "Save to Collection") .accessibilityLabel(viewModel.saveState == .saving ? "Saving plant" : "Save to Collection")
.accessibilityHint(viewModel.canSaveToCollection ? "Saves the selected plant to your collection" : "Select a plant first") .accessibilityHint(saveEnabled ? "Saves the selected plant to your collection" : "Select a plant first")
.accessibilityIdentifier(AccessibilityIdentifiers.Identification.saveToCollectionButton) .accessibilityIdentifier(AccessibilityIdentifiers.Identification.saveToCollectionButton)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)

View File

@@ -116,14 +116,6 @@ struct PlantDetailView: View {
.task { .task {
await viewModel.loadCareInfo() await viewModel.loadCareInfo()
} }
.onAppear {
// Reload schedule from Core Data every time the view appears.
// .task only runs on first appearance; this ensures task completion
// states are always current when navigating back to this view.
Task {
await viewModel.refreshSchedule()
}
}
.refreshable { .refreshable {
await viewModel.refresh() await viewModel.refresh()
} }

View File

@@ -144,12 +144,6 @@ final class PlantDetailViewModel {
isLoading = false isLoading = false
} }
/// Reloads the care schedule from the repository.
/// Call on view reappearance to pick up persisted task completions.
func refreshSchedule() async {
await loadExistingSchedule()
}
/// Loads an existing care schedule from the repository /// Loads an existing care schedule from the repository
private func loadExistingSchedule() async { private func loadExistingSchedule() async {
do { do {