@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.session
1package soy.iko.opencode.ui.session
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
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
@@ -41,10 +42,12 @@ import androidx.compose.material3.OutlinedTextField
41import androidx.compose.material3.Scaffold
42import androidx.compose.material3.Scaffold
42import androidx.compose.material3.SnackbarHost
43import androidx.compose.material3.SnackbarHost
43import androidx.compose.material3.SnackbarHostState
44import androidx.compose.material3.SnackbarHostState
45+import androidx.compose.material3.SwipeToDismissBox
44import androidx.compose.material3.Text
46import androidx.compose.material3.Text
45import androidx.compose.material3.TextButton
47import androidx.compose.material3.TextButton
46import androidx.compose.material3.TopAppBar
48import androidx.compose.material3.TopAppBar
47import androidx.compose.material3.pulltorefresh.PullToRefreshBox
49import androidx.compose.material3.pulltorefresh.PullToRefreshBox
50+import androidx.compose.material3.rememberSwipeToDismissBoxState
48import androidx.compose.runtime.Composable
51import androidx.compose.runtime.Composable
49import androidx.compose.runtime.LaunchedEffect
52import androidx.compose.runtime.LaunchedEffect
50import androidx.compose.runtime.getValue
53import androidx.compose.runtime.getValue
@@ -53,6 +56,7 @@ import androidx.compose.runtime.remember
53import androidx.compose.runtime.setValue
56import androidx.compose.runtime.setValue
54import androidx.compose.ui.Alignment
57import androidx.compose.ui.Alignment
55import androidx.compose.ui.Modifier
58import androidx.compose.ui.Modifier
59+import androidx.compose.ui.draw.clip
56import androidx.compose.ui.platform.LocalHapticFeedback
60import androidx.compose.ui.platform.LocalHapticFeedback
57import androidx.compose.ui.semantics.Role
61import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.role
62import androidx.compose.ui.semantics.role
@@ -66,6 +70,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
66import soy.iko.opencode.data.model.Session
70import soy.iko.opencode.data.model.Session
67import soy.iko.opencode.di.AppContainer
71import soy.iko.opencode.di.AppContainer
68import soy.iko.opencode.R
72import soy.iko.opencode.R
73+import soy.iko.opencode.ui.components.ConnectionBanner
69import soy.iko.opencode.ui.components.relativeTime
74import soy.iko.opencode.ui.components.relativeTime
70import soy.iko.opencode.ui.vmFactory
75import soy.iko.opencode.ui.vmFactory
@@ -85,6 +90,8 @@ fun SessionListScreen(
85 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
90 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
86 val profiles by vm.profiles.collectAsStateWithLifecycle()
91 val profiles by vm.profiles.collectAsStateWithLifecycle()
87 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
92 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
93+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
94+ val unread by vm.unread.collectAsStateWithLifecycle()
88 val haptics = LocalHapticFeedback.current
95 val haptics = LocalHapticFeedback.current
89 val snackbar = remember { SnackbarHostState() }
96 val snackbar = remember { SnackbarHostState() }
90 var showServerMenu by remember { mutableStateOf(false) }
97 var showServerMenu by remember { mutableStateOf(false) }
@@ -176,6 +183,10 @@ fun SessionListScreen(
177 ) { padding ->
184 ) { padding ->
178 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
185 Box(modifier = Modifier.fillMaxSize().padding(padding)) {
187+ state = connectionState,
188+ modifier = Modifier.align(Alignment.TopCenter),
180 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
191 state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
181 state.sessions.isEmpty() && state.error != null -> Text(
192 state.sessions.isEmpty() && state.error != null -> Text(
@@ -219,6 +230,7 @@ fun SessionListScreen(
219 SessionCard(
230 SessionCard(
220 session = session,
231 session = session,
221 preview = state.previews[session.id],
232 preview = state.previews[session.id],
233+ unread = unread.contains(session.id),
222 onClick = { onOpenSession(session.id) },
234 onClick = { onOpenSession(session.id) },
223 onRename = { pendingRename = session },
235 onRename = { pendingRename = session },
224 onDelete = { pendingDelete = session },
236 onDelete = { pendingDelete = session },
@@ -296,61 +308,108 @@ private fun RenameSessionDialog(
311+@OptIn(ExperimentalMaterial3Api::class)
299@Composable
312@Composable
300private fun SessionCard(
313private fun SessionCard(
301 session: Session,
314 session: Session,
302 preview: String?,
315 preview: String?,
303 onClick: () -> Unit,
317 onClick: () -> Unit,
304 onRename: () -> Unit,
318 onRename: () -> Unit,
305 onDelete: () -> Unit,
319 onDelete: () -> Unit,
306 modifier: Modifier = Modifier,
320 modifier: Modifier = Modifier,
308− Card(
322+ // Swipe toward the start reveals a delete affordance; releasing snaps back and opens
309− modifier = modifier
323+ // the same confirmation dialog the trash icon uses, so the destructive action is
310− .fillMaxWidth()
324+ // always confirmed.
311− .clickable { onClick() },
325+ val dismissState = rememberSwipeToDismissBoxState(
326+ confirmValueChange = {
328+ false // snap back; the dialog owns the actual deletion
332+ state = dismissState,
333+ backgroundContent = { SwipeDeleteBackground() },
334+ enableDismissFromStartToEnd = false,
335+ modifier = modifier,
340+ .clickable { onClick() },
313− Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
342+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
315− modifier = Modifier.fillMaxWidth(),
344+ modifier = Modifier.fillMaxWidth(),
316− verticalAlignment = Alignment.CenterVertically,
345+ verticalAlignment = Alignment.CenterVertically,
318− Text(
347+ if (unread) {
319− session.displayTitle,
348+ Box(
320− style = MaterialTheme.typography.titleMedium,
349+ modifier = Modifier
321− maxLines = 1,
350+ .padding(end = 8.dp)
322− overflow = TextOverflow.Ellipsis,
351+ .size(8.dp)
323− modifier = Modifier.weight(1f),
352+ .clip(androidx.compose.foundation.shape.CircleShape)
353+ .background(MaterialTheme.colorScheme.primary),
325− val time = relativeTime(session.time?.updated ?: session.time?.created)
355+ }
326− if (time.isNotEmpty()) {
328− time,
357+ session.displayTitle,
329− style = MaterialTheme.typography.labelSmall,
358+ style = MaterialTheme.typography.titleMedium,
330− color = MaterialTheme.colorScheme.onSurfaceVariant,
359+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
361+ overflow = TextOverflow.Ellipsis,
362+ modifier = Modifier.weight(1f),
364+ val time = relativeTime(session.time?.updated ?: session.time?.created)
365+ if (time.isNotEmpty()) {
368+ style = MaterialTheme.typography.labelSmall,
369+ color = MaterialTheme.colorScheme.onSurfaceVariant,
372+ IconButton(onClick = onRename) {
373+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
375+ IconButton(onClick = onDelete) {
376+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
333− IconButton(onClick = onRename) {
379+ if (!preview.isNullOrBlank()) {
334− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
380+ Spacer(Modifier.size(4.dp))
336− IconButton(onClick = onDelete) {
382+ preview,
337− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
383+ style = MaterialTheme.typography.bodyMedium,
384+ color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
386+ overflow = TextOverflow.Ellipsis,
340− if (!preview.isNullOrBlank()) {
341− Spacer(Modifier.size(4.dp))
344− style = MaterialTheme.typography.bodyMedium,
345− color = MaterialTheme.colorScheme.onSurfaceVariant,
347− overflow = TextOverflow.Ellipsis,
394+/** Red background with a trash icon shown behind a session card as it is swiped away. */
396+private fun SwipeDeleteBackground() {
400+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
401+ .background(MaterialTheme.colorScheme.errorContainer)
402+ .padding(horizontal = 20.dp),
403+ contentAlignment = Alignment.CenterEnd,
406+ Icons.Filled.Delete,
407+ contentDescription = null,
408+ tint = MaterialTheme.colorScheme.onErrorContainer,
354@Composable
413@Composable
355private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
414private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {