@@ -1,7 +1,9 @@
11package soy.iko.opencode.ui.screens
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
57import androidx.compose.foundation.layout.Column
68import androidx.compose.foundation.layout.PaddingValues
79import androidx.compose.foundation.layout.Row
@@ -28,12 +30,16 @@ import androidx.compose.material3.IconButton
2830import androidx.compose.material3.LinearProgressIndicator
2931import androidx.compose.material3.MaterialTheme
3032import androidx.compose.material3.Scaffold
33+import androidx.compose.material3.SwipeToDismissBox
34+import androidx.compose.material3.SwipeToDismissBoxValue
3135import androidx.compose.material3.Text
3236import androidx.compose.material3.TextButton
3337import androidx.compose.material3.TopAppBar
38+import androidx.compose.material3.rememberSwipeToDismissBoxState
3439import androidx.compose.material3.pulltorefresh.PullToRefreshBox
3540import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
3641import androidx.compose.runtime.Composable
42+import androidx.compose.runtime.LaunchedEffect
3743import androidx.compose.runtime.getValue
3844import androidx.compose.runtime.mutableStateOf
3945import androidx.compose.runtime.remember
@@ -53,6 +59,13 @@ import soy.iko.opencode.core.UiState
5359fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
5460 val haptic = LocalHapticFeedback.current
5561 var pendingDeleteId by remember { mutableStateOf<String?>(null) }
62+ var isPullRefreshing by remember { mutableStateOf(false) }
63+
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
68+ }
5669
5770 pendingDeleteId?.let { deleteId ->
5871 val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
@@ -102,14 +115,17 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
102115 },
103116 ) { padding ->
104117 Column(Modifier.fillMaxSize().padding(padding)) {
105− if (state.loading) {
118+ if (state.loading && !isPullRefreshing) {
106119 LinearProgressIndicator(Modifier.fillMaxWidth())
107120 }
108121 when {
109122 state.sessions.isEmpty() && !state.loading -> EmptySessions()
110123 else -> PullToRefreshBox(
111− isRefreshing = state.loading,
112− onRefresh = { dispatch(Event.LoadSessions) },
124+ isRefreshing = isPullRefreshing,
125+ onRefresh = {
126+ isPullRefreshing = true
127+ dispatch(Event.LoadSessions)
128+ },
113129 modifier = Modifier.fillMaxSize(),
114130 state = rememberPullToRefreshState(),
115131 ) {
@@ -118,11 +134,27 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
118134 contentPadding = PaddingValues(bottom = 88.dp),
119135 ) {
120136 items(state.sessions, key = { it.id }) { session ->
121− SessionRow(
122− session = session,
123− onOpen = { dispatch(Event.SelectSession(session.id)) },
124− onDelete = { pendingDeleteId = session.id },
137+ val dismissState = rememberSwipeToDismissBoxState(
138+ // Only a swipe from the end (right-to-left) triggers deletion;
139+ // return false so the row snaps back and the confirm dialog decides.
140+ confirmValueChange = { value ->
141+ if (value == SwipeToDismissBoxValue.EndToStart) {
142+ pendingDeleteId = session.id
143+ }
144+ false
145+ },
125146 )
147+ SwipeToDismissBox(
148+ state = dismissState,
149+ enableDismissFromStartToEnd = false,
150+ backgroundContent = { SwipeDeleteBackground() },
151+ ) {
152+ SessionRow(
153+ session = session,
154+ onOpen = { dispatch(Event.SelectSession(session.id)) },
155+ onDelete = { pendingDeleteId = session.id },
156+ )
157+ }
126158 HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
127159 }
128160 }
@@ -137,6 +169,7 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
137169 Row(
138170 modifier = Modifier
139171 .fillMaxWidth()
172+ .background(MaterialTheme.colorScheme.surface)
140173 .clickable(onClick = onOpen)
141174 .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
142175 verticalAlignment = Alignment.CenterVertically,
@@ -172,6 +205,24 @@ private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () ->
172205 }
173206}
174207
208+/** Red background with a trailing delete icon, revealed while swiping a session row away. */
209+@Composable
210+private fun SwipeDeleteBackground() {
211+ Box(
212+ modifier = Modifier
213+ .fillMaxSize()
214+ .background(MaterialTheme.colorScheme.error)
215+ .padding(horizontal = 24.dp),
216+ contentAlignment = Alignment.CenterEnd,
217+ ) {
218+ Icon(
219+ Icons.Filled.DeleteOutline,
220+ contentDescription = null,
221+ tint = MaterialTheme.colorScheme.onError,
222+ )
223+ }
224+}
225+
175226/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
176227private fun shortSessionId(id: String): String =
177228 if (id.length > 12) id.take(8) + "…" else id