| @@ -1,8 +1,14 @@ |
| 1 | import SwiftUI |
1 | import SwiftUI |
| |
2 | import os |
| |
3 | |
| |
4 | private let inboxNavigationLogger = Logger(subsystem: "net.cleberg.Hutch", category: "InboxNavigation") |
| 2 | |
5 | |
| 3 | struct InboxView: View { |
6 | struct InboxView: View { |
| 4 | @Environment(AppState.self) private var appState |
7 | @Environment(AppState.self) private var appState |
| 5 | @State private var viewModel: InboxViewModel? |
8 | @State private var viewModel: InboxViewModel? |
| |
9 | @State private var selectedThreadID: InboxThreadSummary.ID? |
| |
10 | @State private var selectedThreadSnapshot: InboxThreadSummary? |
| |
11 | @State private var isShowingThreadDetail = false |
| 6 | |
12 | |
| 7 | var body: some View { |
13 | var body: some View { |
| 8 | Group { |
14 | Group { |
| @@ -28,9 +34,12 @@ struct InboxView: View { |
| 28 | |
34 | |
| 29 | List { |
35 | List { |
| 30 | ForEach(viewModel.threads) { thread in |
36 | ForEach(viewModel.threads) { thread in |
| 31 | NavigationLink(value: thread) { |
37 | Button { |
| |
38 | selectThread(thread) |
| |
39 | } label: { |
| 32 | InboxThreadRow(thread: thread) |
40 | InboxThreadRow(thread: thread) |
| 33 | } |
41 | } |
| |
42 | .buttonStyle(.plain) |
| 34 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
43 | .swipeActions(edge: .leading, allowsFullSwipe: true) { |
| 35 | readStateAction(for: thread, in: viewModel) |
44 | readStateAction(for: thread, in: viewModel) |
| 36 | } |
45 | } |
| @@ -64,9 +73,37 @@ struct InboxView: View { |
| 64 | .refreshable { |
73 | .refreshable { |
| 65 | await viewModel.loadThreads() |
74 | await viewModel.loadThreads() |
| 66 | } |
75 | } |
| 67 | .navigationDestination(for: InboxThreadSummary.self) { thread in |
76 | .onChange(of: viewModel.threads) { _, threads in |
| 68 | ThreadDetailView(thread: thread) { |
77 | syncSelectedThreadSnapshot(with: threads) |
| 69 | viewModel.markThreadRead(thread) |
78 | } |
| |
79 | .navigationDestination(isPresented: Binding( |
| |
80 | get: { isShowingThreadDetail && selectedThread(for: viewModel) != nil }, |
| |
81 | set: { isPresented in |
| |
82 | if !isPresented { |
| |
83 | clearSelection() |
| |
84 | } |
| |
85 | isShowingThreadDetail = isPresented |
| |
86 | } |
| |
87 | )) { |
| |
88 | if let thread = selectedThread(for: viewModel) { |
| |
89 | ThreadDetailView(thread: thread) { |
| |
90 | viewModel.markThreadRead(thread) |
| |
91 | } |
| |
92 | .onAppear { |
| |
93 | cacheSelectedThread(thread) |
| |
94 | } |
| |
95 | .onDisappear { |
| |
96 | handleThreadDetailDisappear(for: thread.id) |
| |
97 | } |
| |
98 | } else { |
| |
99 | ContentUnavailableView( |
| |
100 | "Thread Unavailable", |
| |
101 | systemImage: "tray", |
| |
102 | description: Text("This thread could not be restored.") |
| |
103 | ) |
| |
104 | .onAppear { |
| |
105 | inboxNavigationLogger.error("Inbox navigation destination missing thread snapshot") |
| |
106 | } |
| 70 | } |
107 | } |
| 71 | } |
108 | } |
| 72 | } |
109 | } |
| @@ -82,6 +119,48 @@ struct InboxView: View { |
| 82 | } |
119 | } |
| 83 | .tint(.blue) |
120 | .tint(.blue) |
| 84 | } |
121 | } |
| |
122 | |
| |
123 | private func selectThread(_ thread: InboxThreadSummary) { |
| |
124 | cacheSelectedThread(thread) |
| |
125 | isShowingThreadDetail = true |
| |
126 | inboxNavigationLogger.debug( |
| |
127 | "Inbox navigation triggered: threadID=\(thread.id, privacy: .public) subject=\(thread.subject, privacy: .public)" |
| |
128 | ) |
| |
129 | } |
| |
130 | |
| |
131 | private func cacheSelectedThread(_ thread: InboxThreadSummary) { |
| |
132 | selectedThreadID = thread.id |
| |
133 | selectedThreadSnapshot = thread |
| |
134 | } |
| |
135 | |
| |
136 | private func selectedThread(for viewModel: InboxViewModel) -> InboxThreadSummary? { |
| |
137 | guard let selectedThreadID else { return selectedThreadSnapshot } |
| |
138 | return viewModel.thread(withID: selectedThreadID) ?? (selectedThreadSnapshot?.id == selectedThreadID ? selectedThreadSnapshot : nil) |
| |
139 | } |
| |
140 | |
| |
141 | private func handleThreadDetailDisappear(for threadID: String) { |
| |
142 | let isActiveSelection = selectedThreadID == threadID |
| |
143 | inboxNavigationLogger.debug( |
| |
144 | "Inbox thread detail disappeared: threadID=\(threadID, privacy: .public) activeSelection=\(isActiveSelection, privacy: .public)" |
| |
145 | ) |
| |
146 | guard isActiveSelection else { return } |
| |
147 | clearSelection() |
| |
148 | } |
| |
149 | |
| |
150 | private func syncSelectedThreadSnapshot(with threads: [InboxThreadSummary]) { |
| |
151 | guard let selectedThreadID else { return } |
| |
152 | guard let updatedThread = threads.first(where: { $0.id == selectedThreadID }) else { return } |
| |
153 | selectedThreadSnapshot = updatedThread |
| |
154 | } |
| |
155 | |
| |
156 | private func clearSelection() { |
| |
157 | if let selectedThreadID { |
| |
158 | inboxNavigationLogger.debug("Inbox selection cleared: threadID=\(selectedThreadID, privacy: .public)") |
| |
159 | } |
| |
160 | selectedThreadID = nil |
| |
161 | selectedThreadSnapshot = nil |
| |
162 | isShowingThreadDetail = false |
| |
163 | } |
| 85 | } |
164 | } |
| 86 | |
165 | |
| 87 | struct InboxThreadRow: View { |
166 | struct InboxThreadRow: View { |