Skip to content

Commit bd47935

Browse files
committed
feat(android): add pull-to-refresh
1 parent 1c54250 commit bd47935

5 files changed

Lines changed: 196 additions & 82 deletions

File tree

apps/android/app/src/androidTest/java/dev/pi/postbox/question/QuestionWorkflowScreenTest.kt

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import androidx.compose.ui.test.hasSetTextAction
1212
import androidx.compose.ui.test.hasTestTag
1313
import androidx.compose.ui.test.hasText
1414
import androidx.compose.ui.test.junit4.createComposeRule
15+
import androidx.compose.ui.test.onNodeWithTag
1516
import androidx.compose.ui.test.onNodeWithText
1617
import androidx.compose.ui.test.performClick
1718
import androidx.compose.ui.test.performTextInput
19+
import androidx.compose.ui.test.performTouchInput
20+
import androidx.compose.ui.test.swipeDown
1821
import dev.pi.postbox.ui.theme.PostboxTheme
1922
import org.junit.Assert.assertEquals
2023
import org.junit.Rule
@@ -165,6 +168,29 @@ class QuestionWorkflowScreenTest {
165168
composeRule.onNode(hasText("Stage first", substring = true) and hasClickAction()).assertIsNotSelected()
166169
}
167170

171+
@Test
172+
fun pullingDownFromTheQueueRequestsOneRefresh() {
173+
var refreshRequests = 0
174+
setQuestionScreen(
175+
stateProvider = {
176+
QuestionWorkflowState(
177+
baseUrl = "https://postbox.example/",
178+
isLoading = false,
179+
isSyncing = false,
180+
connectionState = QuestionConnectionState.CONNECTED,
181+
pendingQuestions = listOf(question("normal", QuestionUrgency.NORMAL)),
182+
navigationSelection = QuestionNavigationSelection.Queue
183+
)
184+
},
185+
onRefresh = { refreshRequests += 1 }
186+
)
187+
188+
composeRule.onNodeWithTag(QUESTION_PULL_REFRESH_TEST_TAG)
189+
.performTouchInput { swipeDown() }
190+
191+
composeRule.runOnIdle { assertEquals(1, refreshRequests) }
192+
}
193+
168194
@Test
169195
fun queueShowsEveryUrgencyLevel() {
170196
setQuestionScreen(
@@ -197,7 +223,8 @@ class QuestionWorkflowScreenTest {
197223

198224
private fun setQuestionScreen(
199225
stateProvider: () -> QuestionWorkflowState,
200-
onToggleOption: (String) -> Unit = {}
226+
onToggleOption: (String) -> Unit = {},
227+
onRefresh: () -> Unit = {}
201228
) {
202229
composeRule.setContent {
203230
PostboxTheme {
@@ -211,7 +238,8 @@ class QuestionWorkflowScreenTest {
211238
onSubmitAnswer = {},
212239
onCancelQuestion = {},
213240
onDismissQuestion = {},
214-
onEditServerUrl = {}
241+
onEditServerUrl = {},
242+
onRefresh = onRefresh
215243
)
216244
}
217245
}

apps/android/app/src/main/java/dev/pi/postbox/MainActivity.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,8 @@ private fun ConnectedQuestionWorkflow(
329329
onSubmitAnswer = workflowViewModel::submitAnswer,
330330
onCancelQuestion = workflowViewModel::cancelQuestion,
331331
onDismissQuestion = workflowViewModel::dismissQuestion,
332-
onEditServerUrl = onEditServerUrl
332+
onEditServerUrl = onEditServerUrl,
333+
onRefresh = workflowViewModel::refreshQuestions
333334
)
334335
}
335336

apps/android/app/src/main/java/dev/pi/postbox/question/QuestionWorkflowScreen.kt

Lines changed: 114 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.CircleShape
3535
import androidx.compose.foundation.shape.RoundedCornerShape
3636
import androidx.compose.foundation.verticalScroll
3737
import androidx.compose.material3.DrawerValue
38+
import androidx.compose.material3.ExperimentalMaterial3Api
3839
import androidx.compose.material3.HorizontalDivider
3940
import androidx.compose.material3.Icon
4041
import androidx.compose.material3.MaterialTheme
@@ -43,6 +44,7 @@ import androidx.compose.material3.ModalNavigationDrawer
4344
import androidx.compose.material3.OutlinedTextField
4445
import androidx.compose.material3.OutlinedTextFieldDefaults
4546
import androidx.compose.material3.Text
47+
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
4648
import androidx.compose.material3.rememberDrawerState
4749
import androidx.compose.runtime.Composable
4850
import androidx.compose.runtime.LaunchedEffect
@@ -97,6 +99,7 @@ import java.time.Instant
9799
import kotlinx.coroutines.delay
98100
import kotlinx.coroutines.launch
99101

102+
@OptIn(ExperimentalMaterial3Api::class)
100103
@Composable
101104
fun 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"
933967
internal 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
}

apps/android/app/src/main/java/dev/pi/postbox/question/QuestionWorkflowViewModel.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ class QuestionWorkflowViewModel(
104104
)
105105
}
106106

107+
fun refreshQuestions() {
108+
if (state.isRefreshing) return
109+
state = state.copy(
110+
isRefreshing = true,
111+
isSyncing = true,
112+
errorMessage = null
113+
)
114+
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
115+
try {
116+
refreshState()
117+
} finally {
118+
state = state.copy(isRefreshing = false)
119+
}
120+
}
121+
}
122+
107123
fun selectProject(projectId: String) {
108124
if (state.sessions.none { it.projectId == projectId }) return
109125
state = state.copy(
@@ -518,6 +534,8 @@ class QuestionWorkflowViewModel(
518534
data class QuestionWorkflowState(
519535
val baseUrl: String,
520536
val isLoading: Boolean = true,
537+
/** True only while a user-initiated refresh is fetching an authoritative snapshot. */
538+
val isRefreshing: Boolean = false,
521539
/** True from (re)start until a state snapshot arrives; empty views show "checking" instead of "all caught up". */
522540
val isSyncing: Boolean = true,
523541
val connectionState: QuestionConnectionState = QuestionConnectionState.CONNECTING,

0 commit comments

Comments
 (0)