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: {
showingTimePicker = false
},
formatHour: formatHour
}
)
}
}
@@ -563,18 +562,37 @@ struct NotificationTimePickerRow: View {
struct TimePickerSheet: View {
@State private var selectedHour: Int
@State private var isPresented: Bool = true
let onSave: (Int) -> Void
let onCancel: () -> Void
let formatHour: (Int) -> String
// Pre-computed hours array to avoid range issues with wheel picker
private let hours: [Int] = Array(0..<24)
// Pre-computed hour labels as a simple struct for stable identity
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)
self.onSave = onSave
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 {
@@ -585,14 +603,17 @@ struct TimePickerSheet: View {
.foregroundColor(Color.appTextPrimary)
.padding(.top)
Picker("Hour", selection: $selectedHour) {
ForEach(hours, id: \.self) { hour in
Text(formatHour(hour))
.tag(hour)
if isPresented {
Picker("Hour", selection: $selectedHour) {
ForEach(Self.hourOptions) { option in
Text(option.label)
.tag(option.hour)
}
}
.pickerStyle(.wheel)
.frame(height: 150)
.id("hourPicker") // Stable identity to prevent view recycling issues
}
.pickerStyle(.wheel)
.frame(height: 150)
Text("Notifications will be sent at \(formatHour(selectedHour)) in your local timezone")
.font(.caption)
@@ -609,13 +630,21 @@ struct TimePickerSheet: View {
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
onCancel()
// Hide picker before dismissing to prevent race condition
isPresented = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onCancel()
}
}
.foregroundColor(Color.appTextSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(selectedHour)
// Hide picker before dismissing to prevent race condition
isPresented = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onSave(selectedHour)
}
}
.foregroundColor(Color.appPrimary)
.fontWeight(.semibold)
@@ -623,6 +652,7 @@ struct TimePickerSheet: View {
}
}
.presentationDetents([.medium])
.interactiveDismissDisabled() // Prevent swipe-to-dismiss which can cause race condition
}
}