Fix wheel picker crash caused by UIKit/SwiftUI race condition

The crash occurred when UIKit's didSelectRow callback fired during
SwiftUI view teardown, causing an array index out of bounds error.

Fixes:
- Use Identifiable struct for stable ForEach identity
- Hide picker before dismissing to prevent race condition
- Add .id() modifier for stable picker identity
- Disable interactive dismiss to prevent mid-scroll dismissal
- Add small delay before dismiss callbacks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-16 18:02:23 -06:00
parent 59a827f692
commit 67f8dcc80f

View File

@@ -552,8 +552,7 @@ struct NotificationTimePickerRow: View {
}, },
onCancel: { onCancel: {
showingTimePicker = false showingTimePicker = false
}, }
formatHour: formatHour
) )
} }
} }
@@ -563,18 +562,37 @@ struct NotificationTimePickerRow: View {
struct TimePickerSheet: View { struct TimePickerSheet: View {
@State private var selectedHour: Int @State private var selectedHour: Int
@State private var isPresented: Bool = true
let onSave: (Int) -> Void let onSave: (Int) -> Void
let onCancel: () -> Void let onCancel: () -> Void
let formatHour: (Int) -> String
// Pre-computed hours array to avoid range issues with wheel picker // Pre-computed hour labels as a simple struct for stable identity
private let hours: [Int] = Array(0..<24) private struct HourOption: Identifiable {
let id: Int
let label: String
init(selectedHour: Int, onSave: @escaping (Int) -> Void, onCancel: @escaping () -> Void, formatHour: @escaping (Int) -> String) { var hour: Int { id }
}
private static let hourOptions: [HourOption] = (0..<24).map { hour in
let label: String
switch hour {
case 0: label = "12:00 AM"
case 1...11: label = "\(hour):00 AM"
case 12: label = "12:00 PM"
default: label = "\(hour - 12):00 PM"
}
return HourOption(id: hour, label: label)
}
init(selectedHour: Int, onSave: @escaping (Int) -> Void, onCancel: @escaping () -> Void) {
_selectedHour = State(initialValue: selectedHour) _selectedHour = State(initialValue: selectedHour)
self.onSave = onSave self.onSave = onSave
self.onCancel = onCancel self.onCancel = onCancel
self.formatHour = formatHour }
private func formatHour(_ hour: Int) -> String {
Self.hourOptions.first { $0.hour == hour }?.label ?? "\(hour):00"
} }
var body: some View { var body: some View {
@@ -585,14 +603,17 @@ struct TimePickerSheet: View {
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.padding(.top) .padding(.top)
if isPresented {
Picker("Hour", selection: $selectedHour) { Picker("Hour", selection: $selectedHour) {
ForEach(hours, id: \.self) { hour in ForEach(Self.hourOptions) { option in
Text(formatHour(hour)) Text(option.label)
.tag(hour) .tag(option.hour)
} }
} }
.pickerStyle(.wheel) .pickerStyle(.wheel)
.frame(height: 150) .frame(height: 150)
.id("hourPicker") // Stable identity to prevent view recycling issues
}
Text("Notifications will be sent at \(formatHour(selectedHour)) in your local timezone") Text("Notifications will be sent at \(formatHour(selectedHour)) in your local timezone")
.font(.caption) .font(.caption)
@@ -609,20 +630,29 @@ struct TimePickerSheet: View {
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button("Cancel") {
// Hide picker before dismissing to prevent race condition
isPresented = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onCancel() onCancel()
} }
}
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Save") { Button("Save") {
// Hide picker before dismissing to prevent race condition
isPresented = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onSave(selectedHour) onSave(selectedHour)
} }
}
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
} }
} }
.presentationDetents([.medium]) .presentationDetents([.medium])
.interactiveDismissDisabled() // Prevent swipe-to-dismiss which can cause race condition
} }
} }