@@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.CircleShape
3535import androidx.compose.foundation.shape.RoundedCornerShape
3636import androidx.compose.foundation.verticalScroll
3737import androidx.compose.material3.DrawerValue
38+ import androidx.compose.material3.ExperimentalMaterial3Api
3839import androidx.compose.material3.HorizontalDivider
3940import androidx.compose.material3.Icon
4041import androidx.compose.material3.MaterialTheme
@@ -43,6 +44,7 @@ import androidx.compose.material3.ModalNavigationDrawer
4344import androidx.compose.material3.OutlinedTextField
4445import androidx.compose.material3.OutlinedTextFieldDefaults
4546import androidx.compose.material3.Text
47+ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
4648import androidx.compose.material3.rememberDrawerState
4749import androidx.compose.runtime.Composable
4850import androidx.compose.runtime.LaunchedEffect
@@ -97,6 +99,7 @@ import java.time.Instant
9799import kotlinx.coroutines.delay
98100import kotlinx.coroutines.launch
99101
102+ @OptIn(ExperimentalMaterial3Api ::class )
100103@Composable
101104fun QuestionWorkflowScreen (
102105 state : QuestionWorkflowState ,
@@ -109,6 +112,7 @@ fun QuestionWorkflowScreen(
109112 onCancelQuestion : (note: String? ) -> Unit ,
110113 onDismissQuestion : (String ) -> Unit ,
111114 onEditServerUrl : () -> Unit ,
115+ onRefresh : () -> Unit ,
112116 modifier : Modifier = Modifier
113117) {
114118 // Set when the answer is stamped (submitted); cleared again if the submit errors.
@@ -184,102 +188,131 @@ fun QuestionWorkflowScreen(
184188 onOpenNavigation = { coroutineScope.launch { drawerState.open() } }
185189 )
186190
187- Column (
191+ PullToRefreshBox (
192+ isRefreshing = state.isRefreshing,
193+ onRefresh = onRefresh,
188194 modifier = Modifier
189195 .weight(1f )
190- .padding(16 .dp),
191- verticalArrangement = Arrangement .spacedBy(12 .dp)
196+ .testTag(QUESTION_PULL_REFRESH_TEST_TAG )
192197 ) {
193- ConnectionNotice (state)
194-
195- state.terminalMessage?.let { message ->
196- PostalMessageCard (
197- title = " Question resolved" ,
198- body = message.message
199- )
200- }
201- state.errorMessage?.let { error ->
202- PostalMessageCard (
203- title = " Unable to load questions" ,
204- body = error,
205- tone = PostalMessageTone .DANGER
206- )
207- }
208-
209- if (state.isLoading) {
210- Box (
211- modifier = Modifier .fillMaxSize(),
212- contentAlignment = Alignment .Center
213- ) {
214- Text (
215- text = " Loading Postbox questions…" ,
216- color = PostalColors .subtle
198+ Column (
199+ modifier = Modifier
200+ .fillMaxSize()
201+ .padding(16 .dp),
202+ verticalArrangement = Arrangement .spacedBy(12 .dp)
203+ ) {
204+ ConnectionNotice (state)
205+
206+ state.terminalMessage?.let { message ->
207+ PostalMessageCard (
208+ title = " Question resolved" ,
209+ body = message.message
217210 )
218211 }
219- } else {
220- when (val selection = state.navigationSelection) {
221- QuestionNavigationSelection .Queue -> QuestionQueueView (
222- title = " Questions waiting for you" ,
223- subtitle = " All pending Postbox decisions, highest urgency and oldest first." ,
224- questions = state.pendingQuestions,
225- dismissEnabled = state.dismissingRequestId == null ,
226- isSyncing = state.isSyncing,
227- onSelectQuestion = onSelectQuestion,
228- onDismissQuestion = onDismissQuestion,
229- modifier = Modifier .weight(1f )
212+ state.errorMessage?.let { error ->
213+ PostalMessageCard (
214+ title = " Unable to load questions" ,
215+ body = error,
216+ tone = PostalMessageTone .DANGER
230217 )
231- is QuestionNavigationSelection .Project -> {
232- val sessions = visibleSidebarSessions(state.sessions, state.snapshotTimestamp)
233- .filter { it.projectId == selection.projectId }
234- val sessionIds = sessions.mapTo(mutableSetOf ()) { it.sessionId }
235- QuestionQueueView (
236- title = sessions.firstOrNull()?.projectName ? : " Project" ,
237- subtitle = " Pending Postbox decisions for this project, highest urgency and oldest first." ,
238- questions = state.pendingQuestions.filter { it.sessionId in sessionIds },
218+ }
219+
220+ if (state.isLoading) {
221+ Box (
222+ modifier = Modifier
223+ .weight(1f )
224+ .fillMaxWidth()
225+ .verticalScroll(rememberScrollState()),
226+ contentAlignment = Alignment .Center
227+ ) {
228+ Text (
229+ text = " Loading Postbox questions…" ,
230+ color = PostalColors .subtle
231+ )
232+ }
233+ } else {
234+ when (val selection = state.navigationSelection) {
235+ QuestionNavigationSelection .Queue -> QuestionQueueView (
236+ title = " Questions waiting for you" ,
237+ subtitle = " All pending Postbox decisions, highest urgency and oldest first." ,
238+ questions = state.pendingQuestions,
239239 dismissEnabled = state.dismissingRequestId == null ,
240240 isSyncing = state.isSyncing,
241241 onSelectQuestion = onSelectQuestion,
242242 onDismissQuestion = onDismissQuestion,
243243 modifier = Modifier .weight(1f )
244244 )
245- }
246- is QuestionNavigationSelection .Session -> {
247- val session = state.sessions.firstOrNull { it.sessionId == selection.sessionId }
248- if (session == null ) {
249- PostalMessageCard (
250- title = " Session ended" ,
251- body = " This Pi session is no longer registered."
252- )
253- } else {
254- SessionDetailView (
255- session = session,
256- questions = state.pendingQuestions.filter { it.sessionId == session.sessionId },
245+ is QuestionNavigationSelection .Project -> {
246+ val sessions = visibleSidebarSessions(state.sessions, state.snapshotTimestamp)
247+ .filter { it.projectId == selection.projectId }
248+ val sessionIds = sessions.mapTo(mutableSetOf ()) { it.sessionId }
249+ QuestionQueueView (
250+ title = sessions.firstOrNull()?.projectName ? : " Project" ,
251+ subtitle = " Pending Postbox decisions for this project, highest urgency and oldest first." ,
252+ questions = state.pendingQuestions.filter { it.sessionId in sessionIds },
257253 dismissEnabled = state.dismissingRequestId == null ,
254+ isSyncing = state.isSyncing,
258255 onSelectQuestion = onSelectQuestion,
259256 onDismissQuestion = onDismissQuestion,
260257 modifier = Modifier .weight(1f )
261258 )
262259 }
263- }
264- is QuestionNavigationSelection .Question , null -> {
265- if (visibleQuestion == null ) {
266- EmptyQuestionsCard (isSyncing = state.isSyncing)
267- } else {
268- val session = state.sessions.firstOrNull { it.sessionId == visibleQuestion.sessionId }
269- val listItem = state.pendingQuestions.firstOrNull { it.requestId == visibleQuestion.requestId }
270- QuestionDetailCard (
271- question = visibleQuestion,
272- projectLabel = session?.projectName ? : " Unknown project" ,
273- branchLabel = session?.branch ? : " Unknown branch" ,
274- askedAgo = listItem?.createdAt?.let (::formatTimeAgo),
275- onToggleOption = onToggleOption,
276- onSubmitAnswer = { note ->
277- stampedRequestId = visibleQuestion.requestId
278- onSubmitAnswer(note)
279- },
280- onCancelQuestion = onCancelQuestion,
281- modifier = Modifier .weight(1f )
282- )
260+ is QuestionNavigationSelection .Session -> {
261+ val session = state.sessions.firstOrNull { it.sessionId == selection.sessionId }
262+ if (session == null ) {
263+ Column (
264+ modifier = Modifier
265+ .weight(1f )
266+ .fillMaxWidth()
267+ .verticalScroll(rememberScrollState())
268+ ) {
269+ PostalMessageCard (
270+ title = " Session ended" ,
271+ body = " This Pi session is no longer registered."
272+ )
273+ }
274+ } else {
275+ SessionDetailView (
276+ session = session,
277+ questions = state.pendingQuestions.filter { it.sessionId == session.sessionId },
278+ dismissEnabled = state.dismissingRequestId == null ,
279+ onSelectQuestion = onSelectQuestion,
280+ onDismissQuestion = onDismissQuestion,
281+ modifier = Modifier .weight(1f )
282+ )
283+ }
284+ }
285+ is QuestionNavigationSelection .Question , null -> {
286+ if (visibleQuestion == null ) {
287+ Column (
288+ modifier = Modifier
289+ .weight(1f )
290+ .fillMaxWidth()
291+ .verticalScroll(rememberScrollState())
292+ ) {
293+ EmptyQuestionsCard (isSyncing = state.isSyncing)
294+ }
295+ } else {
296+ val session = state.sessions.firstOrNull {
297+ it.sessionId == visibleQuestion.sessionId
298+ }
299+ val listItem = state.pendingQuestions.firstOrNull {
300+ it.requestId == visibleQuestion.requestId
301+ }
302+ QuestionDetailCard (
303+ question = visibleQuestion,
304+ projectLabel = session?.projectName ? : " Unknown project" ,
305+ branchLabel = session?.branch ? : " Unknown branch" ,
306+ askedAgo = listItem?.createdAt?.let (::formatTimeAgo),
307+ onToggleOption = onToggleOption,
308+ onSubmitAnswer = { note ->
309+ stampedRequestId = visibleQuestion.requestId
310+ onSubmitAnswer(note)
311+ },
312+ onCancelQuestion = onCancelQuestion,
313+ modifier = Modifier .weight(1f )
314+ )
315+ }
283316 }
284317 }
285318 }
@@ -930,6 +963,7 @@ private fun QuestionListItem(
930963 }
931964}
932965
966+ internal const val QUESTION_PULL_REFRESH_TEST_TAG = " question-pull-refresh"
933967internal const val QUESTION_QUEUE_TEST_TAG = " question-queue"
934968
935969@Composable
@@ -1825,7 +1859,8 @@ private fun QuestionWorkflowScreenPreview() {
18251859 onSubmitAnswer = {},
18261860 onCancelQuestion = {},
18271861 onDismissQuestion = {},
1828- onEditServerUrl = {}
1862+ onEditServerUrl = {},
1863+ onRefresh = {}
18291864 )
18301865 }
18311866}
0 commit comments