@@ -1,7 +1,9 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
3+import androidx.compose.foundation.background
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.PaddingValues
8import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.Row
@@ -28,12 +30,16 @@ import androidx.compose.material3.IconButton
28import androidx.compose.material3.LinearProgressIndicator
30import androidx.compose.material3.LinearProgressIndicator
29import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.MaterialTheme
30import androidx.compose.material3.Scaffold
32import androidx.compose.material3.Scaffold
33+import androidx.compose.material3.SwipeToDismissBox
34+import androidx.compose.material3.SwipeToDismissBoxValue
31import androidx.compose.material3.Text
35import androidx.compose.material3.Text
32import androidx.compose.material3.TextButton
36import androidx.compose.material3.TextButton
33import androidx.compose.material3.TopAppBar
37import androidx.compose.material3.TopAppBar
38+import androidx.compose.material3.rememberSwipeToDismissBoxState
34import androidx.compose.material3.pulltorefresh.PullToRefreshBox
39import androidx.compose.material3.pulltorefresh.PullToRefreshBox
35import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
40import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
36import androidx.compose.runtime.Composable
41import androidx.compose.runtime.Composable
42+import androidx.compose.runtime.LaunchedEffect
37import androidx.compose.runtime.getValue
43import androidx.compose.runtime.getValue
38import androidx.compose.runtime.mutableStateOf
44import androidx.compose.runtime.mutableStateOf
39import androidx.compose.runtime.remember
45import androidx.compose.runtime.remember
@@ -53,6 +59,13 @@ import soy.iko.opencode.core.UiState
53fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
59fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
54 val haptic = LocalHapticFeedback.current
60 val haptic = LocalHapticFeedback.current
55 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
61 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
62+ var isPullRefreshing by remember { mutableStateOf(false) }
64+ // The pull-to-refresh spinner should only reflect a user-initiated pull, not
65+ // every loading operation. Clear it once the underlying load completes.
66+ LaunchedEffect(state.loading) {
67+ if (!state.loading) isPullRefreshing = false
57 pendingDeleteId?.let { deleteId ->
70 pendingDeleteId?.let { deleteId ->
58 val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
71 val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
@@ -102,14 +115,17 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
103 ) { padding ->
116 ) { padding ->
104 Column(Modifier.fillMaxSize().padding(padding)) {
117 Column(Modifier.fillMaxSize().padding(padding)) {
105− if (state.loading) {
118+ if (state.loading && !isPullRefreshing) {
106 LinearProgressIndicator(Modifier.fillMaxWidth())
119 LinearProgressIndicator(Modifier.fillMaxWidth())
109 state.sessions.isEmpty() && !state.loading -> EmptySessions()
122 state.sessions.isEmpty() && !state.loading -> EmptySessions()
110 else -> PullToRefreshBox(
123 else -> PullToRefreshBox(
111− isRefreshing = state.loading,
124+ isRefreshing = isPullRefreshing,
126+ isPullRefreshing = true
112− onRefresh = { dispatch(Event.LoadSessions) },
127+ dispatch(Event.LoadSessions)
113 modifier = Modifier.fillMaxSize(),
129 modifier = Modifier.fillMaxSize(),
114 state = rememberPullToRefreshState(),
130 state = rememberPullToRefreshState(),
@@ -118,11 +134,27 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
118 contentPadding = PaddingValues(bottom = 88.dp),
134 contentPadding = PaddingValues(bottom = 88.dp),
120 items(state.sessions, key = { it.id }) { session ->
136 items(state.sessions, key = { it.id }) { session ->
121− SessionRow(
137+ val dismissState = rememberSwipeToDismissBoxState(
122− session = session,
138+ // Only a swipe from the end (right-to-left) triggers deletion;
123− onOpen = { dispatch(Event.SelectSession(session.id)) },
139+ // return false so the row snaps back and the confirm dialog decides.
140+ confirmValueChange = { value ->
141+ if (value == SwipeToDismissBoxValue.EndToStart) {
124− onDelete = { pendingDeleteId = session.id },
142+ pendingDeleteId = session.id
148+ state = dismissState,
149+ enableDismissFromStartToEnd = false,
150+ backgroundContent = { SwipeDeleteBackground() },
154+ onOpen = { dispatch(Event.SelectSession(session.id)) },
155+ onDelete = { pendingDeleteId = session.id },
126 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
158 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
@@ -137,6 +169,7 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
138 modifier = Modifier
170 modifier = Modifier
139 .fillMaxWidth()
171 .fillMaxWidth()
172+ .background(MaterialTheme.colorScheme.surface)
140 .clickable(onClick = onOpen)
173 .clickable(onClick = onOpen)
141 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
174 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
142 verticalAlignment = Alignment.CenterVertically,
175 verticalAlignment = Alignment.CenterVertically,
@@ -172,6 +205,24 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
208+/** Red background with a trailing delete icon, revealed while swiping a session row away. */
210+private fun SwipeDeleteBackground() {
214+ .background(MaterialTheme.colorScheme.error)
215+ .padding(horizontal = 24.dp),
216+ contentAlignment = Alignment.CenterEnd,
219+ Icons.Filled.DeleteOutline,
220+ contentDescription = null,
221+ tint = MaterialTheme.colorScheme.onError,
175/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
226/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
176private fun shortSessionId(id: String): String =
227private fun shortSessionId(id: String): String =
177 if (id.length > 12) id.take(8) + "…" else id
228 if (id.length > 12) id.take(8) + "…" else id