feat: Add dedicated pinned bookmarks screen with paginated data loading

- Add getPinnedLinksPaged() query to LinkDao for paginated pinned link retrieval
- Implement getPinnedLinksStream() in LinkRepository with PagingConfig for efficient data loading
- Create PinnedScreen navigation route and composable in NavGraph
- Add "Épinglés" (Pinned) drawer navigation item with PushPin icon in FeedScreen
- Wire navigation callbacks from FeedScreen through NavGraph to PinnedScreen
This commit is contained in:
Bruno Charest 2026-02-09 14:43:27 -05:00
parent 98f2ef2e7e
commit c8a9e6653b
7 changed files with 212 additions and 0 deletions

View File

@ -149,6 +149,9 @@ interface LinkDao {
@Query("SELECT * FROM links WHERE is_pinned = 1 ORDER BY created_at DESC") @Query("SELECT * FROM links WHERE is_pinned = 1 ORDER BY created_at DESC")
fun getPinnedLinks(): Flow<List<LinkEntity>> fun getPinnedLinks(): Flow<List<LinkEntity>>
@Query("SELECT * FROM links WHERE is_pinned = 1 ORDER BY created_at DESC")
fun getPinnedLinksPaged(): PagingSource<Int, LinkEntity>
// ====== Sync ====== // ====== Sync ======
@Query("SELECT * FROM links WHERE sync_status = :status") @Query("SELECT * FROM links WHERE sync_status = :status")

View File

@ -514,6 +514,15 @@ constructor(
} }
} }
override fun getPinnedLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { linkDao.getPinnedLinksPaged() }
).flow.map { pagingData ->
pagingData.map { it.toDomainModel() }
}
}
private fun parseExistingLink(errorBody: String?): LinkDto? { private fun parseExistingLink(errorBody: String?): LinkDto? {
if (errorBody.isNullOrBlank()) return null if (errorBody.isNullOrBlank()) return null
return try { return try {

View File

@ -61,4 +61,6 @@ interface LinkRepository {
suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit> suspend fun updateLinkClassification(id: Int, contentType: String?, siteName: String?, tagsToAdd: List<String>? = null): Result<Unit>
fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>> fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>>
fun getPinnedLinksStream(): Flow<PagingData<ShaarliLink>>
} }

View File

@ -277,6 +277,7 @@ fun FeedScreen(
onNavigateToRandom: () -> Unit = {}, onNavigateToRandom: () -> Unit = {},
onNavigateToHelp: () -> Unit = {}, onNavigateToHelp: () -> Unit = {},
onNavigateToDeadLinks: () -> Unit = {}, onNavigateToDeadLinks: () -> Unit = {},
onNavigateToPinned: () -> Unit = {},
initialTagFilter: String? = null, initialTagFilter: String? = null,
initialCollectionId: Long? = null, initialCollectionId: Long? = null,
viewModel: FeedViewModel = hiltViewModel() viewModel: FeedViewModel = hiltViewModel()
@ -405,6 +406,15 @@ fun FeedScreen(
} }
) )
DrawerNavigationItem(
icon = Icons.Default.PushPin,
label = "Épinglés",
onClick = {
scope.launch { drawerState.close() }
onNavigateToPinned()
}
)
DrawerNavigationItem( DrawerNavigationItem(
icon = Icons.Default.StickyNote2, icon = Icons.Default.StickyNote2,
label = "Notes", label = "Notes",

View File

@ -43,6 +43,7 @@ sealed class Screen(val route: String) {
object Settings : Screen("settings") object Settings : Screen("settings")
object Help : Screen("help") object Help : Screen("help")
object DeadLinks : Screen("dead_links") object DeadLinks : Screen("dead_links")
object Pinned : Screen("pinned")
} }
@Composable @Composable
@ -128,6 +129,7 @@ fun AppNavGraph(
onNavigateToRandom = { }, onNavigateToRandom = { },
onNavigateToHelp = { navController.navigate(Screen.Help.route) }, onNavigateToHelp = { navController.navigate(Screen.Help.route) },
onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) }, onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) },
onNavigateToPinned = { navController.navigate(Screen.Pinned.route) },
initialTagFilter = tag, initialTagFilter = tag,
initialCollectionId = collectionId initialCollectionId = collectionId
) )
@ -270,5 +272,16 @@ fun AppNavGraph(
} }
) )
} }
composable(
route = Screen.Pinned.route
) {
com.shaarit.presentation.pinned.PinnedScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { linkId ->
navController.navigate(Screen.Edit.createRoute(linkId))
}
)
}
} }
} }

View File

@ -0,0 +1,137 @@
package com.shaarit.presentation.pinned
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import com.shaarit.presentation.feed.ListViewItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PinnedScreen(
onNavigateBack: () -> Unit,
onNavigateToEdit: (Int) -> Unit,
viewModel: PinnedViewModel = hiltViewModel()
) {
val pagingItems = viewModel.pagedPinnedLinks.collectAsLazyPagingItems()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(MaterialTheme.colorScheme.background, MaterialTheme.colorScheme.surface)
)
)
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
"Épinglés",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Retour",
tint = MaterialTheme.colorScheme.onBackground
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.9f),
titleContentColor = MaterialTheme.colorScheme.onBackground
)
)
},
containerColor = Color.Transparent
) { paddingValues ->
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
if (pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
} else if (pagingItems.itemCount == 0) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = null,
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Aucun bookmark épinglé",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Épinglez vos bookmarks favoris avec le bouton 📌",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(count = pagingItems.itemCount) { index ->
val link = pagingItems[index]
if (link != null) {
ListViewItem(
link = link,
onItemClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
context.startActivity(intent)
},
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
},
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
onTagClick = { },
onViewClick = { },
onTogglePin = { id -> viewModel.togglePin(id) }
)
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,38 @@
package com.shaarit.presentation.pinned
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PinnedViewModel @Inject constructor(
private val linkRepository: LinkRepository,
private val linkDao: LinkDao
) : ViewModel() {
val pagedPinnedLinks: Flow<PagingData<ShaarliLink>> =
linkRepository.getPinnedLinksStream()
.cachedIn(viewModelScope)
fun togglePin(id: Int) {
viewModelScope.launch {
linkDao.getLinkById(id)?.let { link ->
linkDao.updatePinStatus(id, !link.isPinned)
}
}
}
fun deleteLink(id: Int) {
viewModelScope.launch {
linkRepository.deleteLink(id)
}
}
}