Android: string (UUID) ids + bidirectional swipe

7a29e6f · gmorell · 2026-06-25 18:00

16 files +50 -23
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/feedpug/app/MainActivity.kt
+2 −2
@@ -99,9 +99,9 @@ fun FeedPugApp(
99 99 }
100 100 composable(
101 101 "detail/{id}",
102 − arguments = listOf(navArgument("id") { type = NavType.LongType }),
102 + arguments = listOf(navArgument("id") { type = NavType.StringType }),
103 103 ) { backStack ->
104 − val id = backStack.arguments?.getLong("id") ?: 0L
104 + val id = backStack.arguments?.getString("id") ?: ""
105 105 DetailScreen(tokens = tokens, itemId = id, onBack = { nav.popBackStack() })
106 106 }
107 107 }
modified android/app/src/main/java/org/neiam/feedpug/app/data/FeedPugClient.kt
+4 −4
@@ -31,16 +31,16 @@ interface FeedPugApi {
31 31 suspend fun readAll(@Query("sources") sources: String? = null): OkResponse
32 32
33 33 @GET("api/items/{id}")
34 − suspend fun item(@Path("id") id: Long): ItemResponse
34 + suspend fun item(@Path("id") id: String): ItemResponse
35 35
36 36 @POST("api/items/{id}/read")
37 − suspend fun markRead(@Path("id") id: Long): OkResponse
37 + suspend fun markRead(@Path("id") id: String): OkResponse
38 38
39 39 @POST("api/items/{id}/unread")
40 − suspend fun markUnread(@Path("id") id: Long): OkResponse
40 + suspend fun markUnread(@Path("id") id: String): OkResponse
41 41
42 42 @POST("api/items/{id}/reactions")
43 − suspend fun react(@Path("id") id: Long, @Body body: ReactBody): ReactResponse
43 + suspend fun react(@Path("id") id: String, @Body body: ReactBody): ReactResponse
44 44
45 45 @GET("api/sources")
46 46 suspend fun sources(): SourcesResponse
modified android/app/src/main/java/org/neiam/feedpug/app/data/Models.kt
+5 −5
@@ -7,11 +7,11 @@ import kotlinx.serialization.Serializable
7 7 data class ProfileResponse(val user: User)
8 8
9 9 @Serializable
10 −data class User(val id: Long, val email: String)
10 +data class User(val id: String, val email: String)
11 11
12 12 @Serializable
13 13 data class Feed(
14 − val id: Long,
14 + val id: String,
15 15 val title: String? = null,
16 16 val url: String? = null,
17 17 @SerialName("site_url") val siteUrl: String? = null,
@@ -20,7 +20,7 @@ data class Feed(
20 20
21 21 @Serializable
22 22 data class Item(
23 − val id: Long,
23 + val id: String,
24 24 val title: String? = null,
25 25 val url: String? = null,
26 26 val summary: String? = null,
@@ -55,7 +55,7 @@ data class SourcesResponse(val sources: List<Source>)
55 55
56 56 @Serializable
57 57 data class Reaction(
58 − val id: Long,
58 + val id: String,
59 59 val emoji: String,
60 60 val label: String? = null,
61 61 val position: Int = 0,
@@ -66,7 +66,7 @@ data class ReactionsResponse(val reactions: List<Reaction>)
66 66
67 67 @Serializable
68 68 data class Slice(
69 − val id: Long,
69 + val id: String,
70 70 val name: String,
71 71 @SerialName("source_keys") val sourceKeys: List<String> = emptyList(),
72 72 @SerialName("unread_only") val unreadOnly: Boolean = false,
modified android/app/src/main/java/org/neiam/feedpug/app/ui/DetailScreen.kt
+1 −1
@@ -28,7 +28,7 @@ import org.neiam.feedpug.app.data.TokenStore
28 28 @Composable
29 29 fun DetailScreen(
30 30 tokens: TokenStore,
31 − itemId: Long,
31 + itemId: String,
32 32 onBack: () -> Unit,
33 33 ) {
34 34 val context = LocalContext.current
modified android/app/src/main/java/org/neiam/feedpug/app/ui/TimelineScreen.kt
+38 −11
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.items
9 9 import androidx.compose.material.icons.Icons
10 10 import androidx.compose.material.icons.automirrored.filled.Logout
11 11 import androidx.compose.material.icons.filled.DoneAll
12 +import androidx.compose.material.icons.filled.MarkEmailRead
12 13 import androidx.compose.material.icons.filled.MarkEmailUnread
13 14 import androidx.compose.material.icons.filled.Palette
14 15 import androidx.compose.material.icons.filled.Refresh
@@ -33,7 +34,7 @@ fun TimelineScreen(
33 34 tokens: TokenStore,
34 35 currentTheme: AppTheme,
35 36 onPickTheme: (AppTheme) -> Unit,
36 − onOpenItem: (Long) -> Unit,
37 + onOpenItem: (String) -> Unit,
37 38 onUnpair: () -> Unit,
38 39 ) {
39 40 val creds = remember { tokens.load() }
@@ -46,7 +47,7 @@ fun TimelineScreen(
46 47 var query by remember { mutableStateOf("") }
47 48 var sourcesCsv by remember { mutableStateOf<String?>(null) }
48 49 var reactionEmoji by remember { mutableStateOf<String?>(null) }
49 − var activeSliceId by remember { mutableStateOf<Long?>(null) }
50 + var activeSliceId by remember { mutableStateOf<String?>(null) }
50 51 var slices by remember { mutableStateOf<List<Slice>>(emptyList()) }
51 52 var loading by remember { mutableStateOf(false) }
52 53 var error by remember { mutableStateOf<String?>(null) }
@@ -73,6 +74,13 @@ fun TimelineScreen(
73 74 }
74 75 }
75 76
77 + fun setRead(item: Item) {
78 + scope.launch {
79 + api?.runCatching { markRead(item.id) }
80 + items = items.map { if (it.id == item.id) it.copy(read = true) else it }
81 + }
82 + }
83 +
76 84 fun setUnread(item: Item) {
77 85 scope.launch {
78 86 api?.runCatching { markUnread(item.id) }
@@ -185,6 +193,7 @@ fun TimelineScreen(
185 193 SwipeableTimelineRow(
186 194 item = item,
187 195 onOpen = { onOpenItem(item.id) },
196 + onMarkRead = { setRead(item) },
188 197 onMarkUnread = { setUnread(item) },
189 198 )
190 199 HorizontalDivider()
@@ -210,31 +219,49 @@ fun TimelineScreen(
210 219
211 220 @OptIn(ExperimentalMaterial3Api::class)
212 221 @Composable
213 −private fun SwipeableTimelineRow(item: Item, onOpen: () -> Unit, onMarkUnread: () -> Unit) {
222 +private fun SwipeableTimelineRow(
223 + item: Item,
224 + onOpen: () -> Unit,
225 + onMarkRead: () -> Unit,
226 + onMarkUnread: () -> Unit,
227 +) {
214 228 val dismissState = rememberSwipeToDismissBoxState(
215 229 confirmValueChange = { value ->
216 − // Swipe-left triggers the action but the row never leaves the list.
217 − if (value == SwipeToDismissBoxValue.EndToStart) onMarkUnread()
230 + // Either direction triggers an action; the row never leaves the list.
231 + when (value) {
232 + SwipeToDismissBoxValue.StartToEnd -> onMarkRead()
233 + SwipeToDismissBoxValue.EndToStart -> onMarkUnread()
234 + SwipeToDismissBoxValue.Settled -> Unit
235 + }
218 236 false
219 237 },
220 238 )
221 239
222 240 SwipeToDismissBox(
223 241 state = dismissState,
224 − enableDismissFromStartToEnd = false,
225 − enableDismissFromEndToStart = true,
226 242 backgroundContent = {
243 + // Swipe right → mark read; swipe left → mark unread.
244 + val toRead = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
227 245 Row(
228 246 Modifier
229 247 .fillMaxSize()
230 − .background(MaterialTheme.colorScheme.primaryContainer)
248 + .background(
249 + if (toRead) MaterialTheme.colorScheme.secondaryContainer
250 + else MaterialTheme.colorScheme.primaryContainer
251 + )
231 252 .padding(horizontal = 20.dp),
232 253 verticalAlignment = Alignment.CenterVertically,
233 − horizontalArrangement = Arrangement.End,
254 + horizontalArrangement = if (toRead) Arrangement.Start else Arrangement.End,
234 255 ) {
235 − Icon(Icons.Default.MarkEmailUnread, contentDescription = null)
256 + Icon(
257 + if (toRead) Icons.Default.MarkEmailRead else Icons.Default.MarkEmailUnread,
258 + contentDescription = null,
259 + )
236 260 Spacer(Modifier.width(8.dp))
237 − Text("Mark unread", style = MaterialTheme.typography.labelLarge)
261 + Text(
262 + if (toRead) "Mark read" else "Mark unread",
263 + style = MaterialTheme.typography.labelLarge,
264 + )
238 265 }
239 266 },
240 267 content = {

Parents: 0bf367a