()) }
var showAddToCollectionDialog by remember { mutableStateOf(false) }
+ // Reminder bottom sheet state
+ var showReminderSheet by remember { mutableStateOf(false) }
+ var reminderTargetLinkId by remember { mutableIntStateOf(-1) }
+ var reminderTargetLinkTitle by remember { mutableStateOf("") }
+ val reminderViewModel: com.shaarit.presentation.reminders.ReminderViewModel = hiltViewModel()
+ val linkIdsWithReminders by reminderViewModel.linkIdsWithReminders.collectAsState()
+
// États des accordéons du drawer
var mainMenuExpanded by remember { mutableStateOf(true) }
var collectionsExpanded by remember { mutableStateOf(true) }
@@ -460,6 +469,15 @@ fun FeedScreen(
onNavigateToDeadLinks()
}
)
+
+ DrawerNavigationItem(
+ icon = Icons.Default.Alarm,
+ label = "Rappels de lecture",
+ onClick = {
+ scope.launch { drawerState.close() }
+ onNavigateToReminders()
+ }
+ )
}
}
@@ -1451,7 +1469,8 @@ fun FeedScreen(
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
- onTogglePin = { id -> viewModel.togglePin(id) }
+ onTogglePin = { id -> viewModel.togglePin(id) },
+ hasReminder = linkIdsWithReminders.contains(link.id)
)
}
}
@@ -1512,7 +1531,8 @@ fun FeedScreen(
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
- onTogglePin = { id -> viewModel.togglePin(id) }
+ onTogglePin = { id -> viewModel.togglePin(id) },
+ hasReminder = linkIdsWithReminders.contains(link.id)
)
}
}
@@ -1574,7 +1594,8 @@ fun FeedScreen(
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
- onTogglePin = { id -> viewModel.togglePin(id) }
+ onTogglePin = { id -> viewModel.togglePin(id) },
+ hasReminder = linkIdsWithReminders.contains(link.id)
)
}
}
@@ -1623,6 +1644,14 @@ fun FeedScreen(
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
+ },
+ onReadClick = { linkId ->
+ onNavigateToReader(linkId)
+ },
+ onReminderClick = { linkId ->
+ reminderTargetLinkId = linkId
+ reminderTargetLinkTitle = link.title
+ showReminderSheet = true
}
)
}
@@ -1666,6 +1695,20 @@ fun FeedScreen(
}
)
}
+
+ // Reminder Bottom Sheet
+ if (showReminderSheet && reminderTargetLinkId > 0) {
+ com.shaarit.presentation.reminders.ReminderBottomSheet(
+ linkTitle = reminderTargetLinkTitle,
+ onQuickReminderSelected = { quickReminder ->
+ reminderViewModel.scheduleReminder(reminderTargetLinkId, quickReminder)
+ },
+ onCustomTimeSelected = { timestamp ->
+ reminderViewModel.scheduleReminderAt(reminderTargetLinkId, timestamp)
+ },
+ onDismiss = { showReminderSheet = false }
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
index 19414b0..d4f74d3 100644
--- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
+++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
@@ -21,6 +21,8 @@ import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.filled.MenuBook
+import androidx.compose.material.icons.filled.Alarm
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.window.DialogProperties
@@ -62,7 +64,8 @@ fun ListViewItem(
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
- onTogglePin: (Int) -> Unit = {}
+ onTogglePin: (Int) -> Unit = {},
+ hasReminder: Boolean = false
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -215,11 +218,24 @@ fun ListViewItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- Text(
- text = link.date,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.outline
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = link.date,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ if (hasReminder) {
+ Icon(
+ Icons.Default.Alarm,
+ contentDescription = "Rappel programmé",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ }
if (link.isPrivate) {
Row(
@@ -258,7 +274,8 @@ fun GridViewItem(
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
- onTogglePin: (Int) -> Unit = {}
+ onTogglePin: (Int) -> Unit = {},
+ hasReminder: Boolean = false
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -412,6 +429,14 @@ fun GridViewItem(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
+ if (hasReminder) {
+ Icon(
+ Icons.Default.Alarm,
+ contentDescription = "Rappel programmé",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(12.dp)
+ )
+ }
}
Row {
@@ -485,7 +510,8 @@ fun CompactViewItem(
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
- onTogglePin: (Int) -> Unit = {}
+ onTogglePin: (Int) -> Unit = {},
+ hasReminder: Boolean = false
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -578,6 +604,14 @@ fun CompactViewItem(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
+ if (hasReminder) {
+ Icon(
+ Icons.Default.Alarm,
+ contentDescription = "Rappel programmé",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(12.dp)
+ )
+ }
if (link.tags.isNotEmpty()) {
Text(
@@ -691,7 +725,9 @@ fun DeleteConfirmationDialog(
fun LinkDetailsView(
link: ShaarliLink,
onDismiss: () -> Unit,
- onLinkClick: (String) -> Unit
+ onLinkClick: (String) -> Unit,
+ onReadClick: ((Int) -> Unit)? = null,
+ onReminderClick: ((Int) -> Unit)? = null
) {
Box(
modifier = Modifier
@@ -777,6 +813,35 @@ fun LinkDetailsView(
.padding(vertical = 4.dp)
)
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Reader Mode & Reminder actions
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ if (onReadClick != null && !link.url.startsWith("note://")) {
+ OutlinedButton(
+ onClick = {
+ onReadClick(link.id)
+ onDismiss()
+ }
+ ) {
+ Icon(Icons.Default.MenuBook, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(6.dp))
+ Text("Lire", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ if (onReminderClick != null) {
+ OutlinedButton(
+ onClick = { onReminderClick(link.id) }
+ ) {
+ Icon(Icons.Default.Alarm, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(6.dp))
+ Text("Rappel", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ }
+
Spacer(modifier = Modifier.height(16.dp))
// Tags
diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt
index b0316a3..7bd5917 100644
--- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt
+++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt
@@ -44,6 +44,10 @@ sealed class Screen(val route: String) {
object Help : Screen("help")
object DeadLinks : Screen("dead_links")
object Pinned : Screen("pinned")
+ object Reader : Screen("reader/{linkId}") {
+ fun createRoute(linkId: Int): String = "reader/$linkId"
+ }
+ object Reminders : Screen("reminders")
}
@Composable
@@ -153,6 +157,10 @@ fun AppNavGraph(
onNavigateToHelp = { navController.navigate(Screen.Help.route) },
onNavigateToDeadLinks = { navController.navigate(Screen.DeadLinks.route) },
onNavigateToPinned = { navController.navigate(Screen.Pinned.route) },
+ onNavigateToReader = { linkId ->
+ navController.navigate(Screen.Reader.createRoute(linkId))
+ },
+ onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
initialTagFilter = tag,
initialCollectionId = collectionId
)
@@ -306,5 +314,32 @@ fun AppNavGraph(
}
)
}
+
+ composable(
+ route = "reader/{linkId}",
+ arguments = listOf(
+ navArgument("linkId") {
+ type = NavType.IntType
+ }
+ ),
+ deepLinks = listOf(
+ navDeepLink { uriPattern = "shaarit://reader/{linkId}" }
+ )
+ ) {
+ com.shaarit.presentation.reader.ReaderModeScreen(
+ onNavigateBack = { navController.popBackStack() }
+ )
+ }
+
+ composable(
+ route = Screen.Reminders.route
+ ) {
+ com.shaarit.presentation.reminders.RemindersScreen(
+ onNavigateBack = { navController.popBackStack() },
+ onNavigateToReader = { linkId ->
+ navController.navigate(Screen.Reader.createRoute(linkId))
+ }
+ )
+ }
}
}
diff --git a/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt
new file mode 100644
index 0000000..2bfe018
--- /dev/null
+++ b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeScreen.kt
@@ -0,0 +1,496 @@
+package com.shaarit.presentation.reader
+
+import android.content.Intent
+import android.net.Uri
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.FormatSize
+import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.shaarit.data.reader.ReaderFont
+import com.shaarit.data.reader.ReaderSettings
+import com.shaarit.data.reader.ReaderTheme
+import com.shaarit.data.reader.ReadableArticle
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReaderModeScreen(
+ onNavigateBack: () -> Unit,
+ viewModel: ReaderModeViewModel = hiltViewModel()
+) {
+ val readerState by viewModel.readerState.collectAsState()
+ val settings by viewModel.settings.collectAsState()
+ val context = LocalContext.current
+ var showSettingsSheet by remember { mutableStateOf(false) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ when (val state = readerState) {
+ is ReaderState.Success -> {
+ Column {
+ Text(
+ text = state.article.title,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ val subtitle = buildString {
+ state.article.siteName?.let { append(it) }
+ append(" · ${state.article.readingTimeMinutes} min")
+ }
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ else -> Text("Mode Lecture")
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Filled.ArrowBack, contentDescription = "Retour")
+ }
+ },
+ actions = {
+ IconButton(onClick = { showSettingsSheet = true }) {
+ Icon(Icons.Default.FormatSize, contentDescription = "Paramètres")
+ }
+ if (readerState is ReaderState.Success) {
+ val link = (readerState as ReaderState.Success).link
+ IconButton(onClick = {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
+ context.startActivity(intent)
+ }) {
+ Icon(Icons.Default.OpenInBrowser, contentDescription = "Ouvrir")
+ }
+ IconButton(onClick = {
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, link.url)
+ putExtra(Intent.EXTRA_SUBJECT, link.title)
+ }
+ context.startActivity(Intent.createChooser(shareIntent, "Partager"))
+ }) {
+ Icon(Icons.Default.Share, contentDescription = "Partager")
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+ }
+ ) { padding ->
+ when (val state = readerState) {
+ is ReaderState.Loading -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ CircularProgressIndicator()
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Extraction de l'article...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ is ReaderState.Error -> {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = state.message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ TextButton(onClick = { viewModel.loadArticle() }) {
+ Icon(Icons.Default.Refresh, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Réessayer")
+ }
+ }
+ }
+ }
+
+ is ReaderState.Success -> {
+ ReaderContent(
+ article = state.article,
+ settings = settings,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ )
+ }
+ }
+ }
+
+ if (showSettingsSheet) {
+ ReaderSettingsSheet(
+ settings = settings,
+ onDismiss = { showSettingsSheet = false },
+ onFontChange = viewModel::updateFont,
+ onFontSizeChange = viewModel::updateFontSize,
+ onLineSpacingChange = viewModel::updateLineSpacing,
+ onThemeChange = viewModel::updateTheme
+ )
+ }
+}
+
+@Composable
+private fun ReaderContent(
+ article: ReadableArticle,
+ settings: ReaderSettings,
+ modifier: Modifier = Modifier
+) {
+ val isDark = isSystemInDarkTheme()
+ val bgColor = when (settings.theme) {
+ ReaderTheme.DARK -> Color(0xFF1A1A1A)
+ ReaderTheme.SEPIA -> Color(0xFFF4ECD8)
+ ReaderTheme.LIGHT -> Color(0xFFFAFAFA)
+ ReaderTheme.AUTO -> if (isDark) Color(0xFF1A1A1A) else Color(0xFFFAFAFA)
+ }
+ val textColor = when (settings.theme) {
+ ReaderTheme.DARK -> Color(0xFFE0E0E0)
+ ReaderTheme.SEPIA -> Color(0xFF5B4636)
+ ReaderTheme.LIGHT -> Color(0xFF1A1A1A)
+ ReaderTheme.AUTO -> if (isDark) Color(0xFFE0E0E0) else Color(0xFF1A1A1A)
+ }
+ val linkColor = when (settings.theme) {
+ ReaderTheme.DARK -> Color(0xFF64B5F6)
+ ReaderTheme.SEPIA -> Color(0xFF8B6914)
+ ReaderTheme.LIGHT -> Color(0xFF1565C0)
+ ReaderTheme.AUTO -> if (isDark) Color(0xFF64B5F6) else Color(0xFF1565C0)
+ }
+
+ val fontFamily = when (settings.fontFamily) {
+ ReaderFont.SANS_SERIF -> "sans-serif"
+ ReaderFont.SERIF -> "serif"
+ ReaderFont.MONOSPACE -> "monospace"
+ }
+
+ val htmlContent = buildReaderHtml(
+ article = article,
+ bgColor = bgColor,
+ textColor = textColor,
+ linkColor = linkColor,
+ fontFamily = fontFamily,
+ fontSize = settings.fontSize.value,
+ lineSpacing = settings.lineSpacing
+ )
+
+ AndroidView(
+ factory = { ctx ->
+ WebView(ctx).apply {
+ webViewClient = WebViewClient()
+ getSettings().javaScriptEnabled = false
+ getSettings().loadWithOverviewMode = true
+ getSettings().useWideViewPort = true
+ setBackgroundColor(bgColor.toArgb())
+ loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
+ }
+ },
+ update = { webView ->
+ webView.setBackgroundColor(bgColor.toArgb())
+ webView.loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
+ },
+ modifier = modifier.background(bgColor)
+ )
+}
+
+private fun buildReaderHtml(
+ article: ReadableArticle,
+ bgColor: Color,
+ textColor: Color,
+ linkColor: Color,
+ fontFamily: String,
+ fontSize: Float,
+ lineSpacing: Float
+): String {
+ val bgHex = colorToHex(bgColor)
+ val textHex = colorToHex(textColor)
+ val linkHex = colorToHex(linkColor)
+ val mutedHex = colorToHex(textColor.copy(alpha = 0.6f))
+
+ return """
+
+
+
+
+
+
+
+
+ ${escapeHtml(article.title)}
+
+ ${article.author?.let { "Par ${escapeHtml(it)} · " } ?: ""}${article.siteName ?: ""}
+ · ${article.readingTimeMinutes} min de lecture
+ · ${article.wordCount} mots
+
+ ${article.content}
+
+
+ """.trimIndent()
+}
+
+private fun colorToHex(color: Color): String {
+ val r = (color.red * 255).toInt()
+ val g = (color.green * 255).toInt()
+ val b = (color.blue * 255).toInt()
+ return String.format("#%02X%02X%02X", r, g, b)
+}
+
+private fun escapeHtml(text: String): String {
+ return text
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ReaderSettingsSheet(
+ settings: ReaderSettings,
+ onDismiss: () -> Unit,
+ onFontChange: (ReaderFont) -> Unit,
+ onFontSizeChange: (Float) -> Unit,
+ onLineSpacingChange: (Float) -> Unit,
+ onThemeChange: (ReaderTheme) -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 8.dp)
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ ) {
+ Text(
+ text = "Paramètres de lecture",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // Font family
+ Text("Police", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ ReaderFont.entries.forEach { font ->
+ FilterChip(
+ selected = settings.fontFamily == font,
+ onClick = { onFontChange(font) },
+ label = { Text(font.displayName, fontSize = 13.sp) }
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // Font size
+ Text(
+ "Taille du texte: ${settings.fontSize.value.toInt()}sp",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Slider(
+ value = settings.fontSize.value,
+ onValueChange = onFontSizeChange,
+ valueRange = 14f..28f,
+ steps = 6
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Line spacing
+ Text(
+ "Interligne: ${String.format("%.1f", settings.lineSpacing)}",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Slider(
+ value = settings.lineSpacing,
+ onValueChange = onLineSpacingChange,
+ valueRange = 1.2f..2.0f,
+ steps = 3
+ )
+
+ Spacer(modifier = Modifier.height(20.dp))
+
+ // Theme
+ Text("Thème", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ ReaderTheme.entries.forEach { theme ->
+ FilterChip(
+ selected = settings.theme == theme,
+ onClick = { onThemeChange(theme) },
+ label = { Text(theme.displayName, fontSize = 13.sp) }
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt
new file mode 100644
index 0000000..b941f42
--- /dev/null
+++ b/app/src/main/java/com/shaarit/presentation/reader/ReaderModeViewModel.kt
@@ -0,0 +1,125 @@
+package com.shaarit.presentation.reader
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.shaarit.data.local.dao.LinkDao
+import com.shaarit.data.local.entity.LinkEntity
+import com.shaarit.data.reader.ArticleExtractor
+import com.shaarit.data.reader.ReadableArticle
+import com.shaarit.data.reader.ReaderPreferences
+import com.shaarit.data.reader.ReaderSettings
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed class ReaderState {
+ object Loading : ReaderState()
+ data class Success(val article: ReadableArticle, val link: LinkEntity) : ReaderState()
+ data class Error(val message: String) : ReaderState()
+}
+
+@HiltViewModel
+class ReaderModeViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val linkDao: LinkDao,
+ private val articleExtractor: ArticleExtractor,
+ private val readerPreferences: ReaderPreferences
+) : ViewModel() {
+
+ private val linkId: Int = savedStateHandle["linkId"] ?: -1
+
+ private val _readerState = MutableStateFlow(ReaderState.Loading)
+ val readerState: StateFlow = _readerState.asStateFlow()
+
+ val settings: StateFlow = readerPreferences.settings
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), readerPreferences.settings.value)
+
+ companion object {
+ private const val CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days
+ }
+
+ init {
+ loadArticle()
+ }
+
+ fun loadArticle() {
+ viewModelScope.launch {
+ _readerState.value = ReaderState.Loading
+ try {
+ val link = linkDao.getLinkById(linkId)
+ if (link == null) {
+ _readerState.value = ReaderState.Error("Lien introuvable")
+ return@launch
+ }
+
+ // Check cached content
+ val cachedContent = link.readerContent
+ val cacheAge = System.currentTimeMillis() - link.readerContentFetchedAt
+ if (!cachedContent.isNullOrBlank() && cacheAge < CACHE_TTL_MS) {
+ _readerState.value = ReaderState.Success(
+ article = ReadableArticle(
+ title = link.title,
+ author = null,
+ siteName = link.siteName,
+ content = cachedContent,
+ leadImage = link.thumbnailUrl,
+ readingTimeMinutes = link.readingTimeMinutes ?: 1,
+ wordCount = cachedContent.split(Regex("\\s+")).size
+ ),
+ link = link
+ )
+ return@launch
+ }
+
+ // Check if it's a note (no URL to extract from)
+ if (link.url.startsWith("note://")) {
+ // Use the description as content for notes
+ _readerState.value = ReaderState.Success(
+ article = ReadableArticle(
+ title = link.title,
+ author = null,
+ siteName = "Note",
+ content = "${link.description}
",
+ leadImage = null,
+ readingTimeMinutes = link.readingTimeMinutes ?: 1,
+ wordCount = link.description.split(Regex("\\s+")).size
+ ),
+ link = link
+ )
+ return@launch
+ }
+
+ // Extract article from URL
+ val article = articleExtractor.extract(link.url)
+ if (article != null) {
+ // Cache the content
+ linkDao.updateLink(
+ link.copy(
+ readerContent = article.content,
+ readerContentFetchedAt = System.currentTimeMillis()
+ )
+ )
+ _readerState.value = ReaderState.Success(
+ article = article,
+ link = link
+ )
+ } else {
+ _readerState.value = ReaderState.Error("Impossible d'extraire l'article")
+ }
+ } catch (e: Exception) {
+ _readerState.value = ReaderState.Error(e.message ?: "Erreur inconnue")
+ }
+ }
+ }
+
+ fun updateFont(font: com.shaarit.data.reader.ReaderFont) = readerPreferences.updateFont(font)
+ fun updateFontSize(size: Float) = readerPreferences.updateFontSize(size)
+ fun updateLineSpacing(spacing: Float) = readerPreferences.updateLineSpacing(spacing)
+ fun updateTheme(theme: com.shaarit.data.reader.ReaderTheme) = readerPreferences.updateTheme(theme)
+}
diff --git a/app/src/main/java/com/shaarit/presentation/reminders/ReminderBottomSheet.kt b/app/src/main/java/com/shaarit/presentation/reminders/ReminderBottomSheet.kt
new file mode 100644
index 0000000..5734266
--- /dev/null
+++ b/app/src/main/java/com/shaarit/presentation/reminders/ReminderBottomSheet.kt
@@ -0,0 +1,206 @@
+package com.shaarit.presentation.reminders
+
+import android.app.DatePickerDialog
+import android.app.TimePickerDialog
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.DarkMode
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.WbSunny
+import androidx.compose.material.icons.filled.Weekend
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.shaarit.data.worker.QuickReminder
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReminderBottomSheet(
+ linkTitle: String,
+ onQuickReminderSelected: (QuickReminder) -> Unit,
+ onCustomTimeSelected: (Long) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState()
+ val context = LocalContext.current
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 8.dp)
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ ) {
+ Text(
+ text = "Rappeler de lire",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = linkTitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Quick reminder options
+ ReminderOption(
+ icon = Icons.Default.AccessTime,
+ label = QuickReminder.IN_1_HOUR.displayName,
+ subtitle = formatTime(QuickReminder.IN_1_HOUR.computeTimestamp()),
+ onClick = {
+ onQuickReminderSelected(QuickReminder.IN_1_HOUR)
+ onDismiss()
+ }
+ )
+ ReminderOption(
+ icon = Icons.Default.DarkMode,
+ label = QuickReminder.TONIGHT.displayName,
+ subtitle = formatTime(QuickReminder.TONIGHT.computeTimestamp()),
+ onClick = {
+ onQuickReminderSelected(QuickReminder.TONIGHT)
+ onDismiss()
+ }
+ )
+ ReminderOption(
+ icon = Icons.Default.WbSunny,
+ label = QuickReminder.TOMORROW.displayName,
+ subtitle = formatDate(QuickReminder.TOMORROW.computeTimestamp()),
+ onClick = {
+ onQuickReminderSelected(QuickReminder.TOMORROW)
+ onDismiss()
+ }
+ )
+ ReminderOption(
+ icon = Icons.Default.Weekend,
+ label = QuickReminder.THIS_WEEKEND.displayName,
+ subtitle = formatDate(QuickReminder.THIS_WEEKEND.computeTimestamp()),
+ onClick = {
+ onQuickReminderSelected(QuickReminder.THIS_WEEKEND)
+ onDismiss()
+ }
+ )
+ ReminderOption(
+ icon = Icons.Default.DateRange,
+ label = QuickReminder.NEXT_WEEK.displayName,
+ subtitle = formatDate(QuickReminder.NEXT_WEEK.computeTimestamp()),
+ onClick = {
+ onQuickReminderSelected(QuickReminder.NEXT_WEEK)
+ onDismiss()
+ }
+ )
+ ReminderOption(
+ icon = Icons.Default.CalendarMonth,
+ label = QuickReminder.CUSTOM.displayName,
+ subtitle = null,
+ onClick = {
+ val cal = Calendar.getInstance()
+ DatePickerDialog(
+ context,
+ { _, year, month, day ->
+ TimePickerDialog(
+ context,
+ { _, hour, minute ->
+ val selectedCal = Calendar.getInstance().apply {
+ set(year, month, day, hour, minute, 0)
+ }
+ onCustomTimeSelected(selectedCal.timeInMillis)
+ onDismiss()
+ },
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE),
+ true
+ ).show()
+ },
+ cal.get(Calendar.YEAR),
+ cal.get(Calendar.MONTH),
+ cal.get(Calendar.DAY_OF_MONTH)
+ ).show()
+ }
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
+
+@Composable
+private fun ReminderOption(
+ icon: ImageVector,
+ label: String,
+ subtitle: String?,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+private fun formatTime(timestamp: Long): String {
+ val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
+ return sdf.format(Date(timestamp))
+}
+
+private fun formatDate(timestamp: Long): String {
+ val sdf = SimpleDateFormat("EEE d MMM, HH:mm", Locale.getDefault())
+ return sdf.format(Date(timestamp))
+}
diff --git a/app/src/main/java/com/shaarit/presentation/reminders/ReminderViewModel.kt b/app/src/main/java/com/shaarit/presentation/reminders/ReminderViewModel.kt
new file mode 100644
index 0000000..1503cb7
--- /dev/null
+++ b/app/src/main/java/com/shaarit/presentation/reminders/ReminderViewModel.kt
@@ -0,0 +1,131 @@
+package com.shaarit.presentation.reminders
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.shaarit.data.local.dao.LinkDao
+import com.shaarit.data.local.dao.ReminderDao
+import com.shaarit.data.local.entity.ReadingReminderEntity
+import com.shaarit.data.local.entity.RepeatInterval
+import com.shaarit.data.worker.QuickReminder
+import com.shaarit.data.worker.ReminderScheduler
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class ReminderWithLinkInfo(
+ val reminder: ReadingReminderEntity,
+ val linkTitle: String,
+ val linkUrl: String,
+ val siteName: String?,
+ val readingTime: Int?
+)
+
+@HiltViewModel
+class ReminderViewModel @Inject constructor(
+ private val reminderDao: ReminderDao,
+ private val linkDao: LinkDao,
+ private val reminderScheduler: ReminderScheduler
+) : ViewModel() {
+
+ val activeReminderCount: StateFlow = try {
+ reminderDao.getActiveReminderCount()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+ } catch (e: Exception) {
+ MutableStateFlow(0)
+ }
+
+ val linkIdsWithReminders: StateFlow> = try {
+ reminderDao.getLinkIdsWithActiveReminders()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+ } catch (e: Exception) {
+ MutableStateFlow(emptyList())
+ }
+
+ private val _remindersWithLinks = MutableStateFlow>(emptyList())
+ val remindersWithLinks: StateFlow> = _remindersWithLinks.asStateFlow()
+
+ init {
+ loadRemindersWithLinks()
+ }
+
+ private fun loadRemindersWithLinks() {
+ viewModelScope.launch {
+ try {
+ reminderDao.getAllReminders().collect { reminders ->
+ val result = reminders.mapNotNull { reminder ->
+ try {
+ val link = linkDao.getLinkById(reminder.linkId)
+ if (link != null) {
+ ReminderWithLinkInfo(
+ reminder = reminder,
+ linkTitle = link.title,
+ linkUrl = link.url,
+ siteName = link.siteName,
+ readingTime = link.readingTimeMinutes
+ )
+ } else null
+ } catch (e: Exception) {
+ null
+ }
+ }
+ _remindersWithLinks.value = result
+ }
+ } catch (e: Exception) {
+ _remindersWithLinks.value = emptyList()
+ }
+ }
+ }
+
+ fun scheduleReminder(linkId: Int, quickReminder: QuickReminder) {
+ viewModelScope.launch {
+ val remindAt = quickReminder.computeTimestamp()
+ val reminder = ReadingReminderEntity(
+ linkId = linkId,
+ remindAt = remindAt,
+ repeatInterval = RepeatInterval.NONE
+ )
+ val id = reminderDao.insert(reminder)
+ reminderScheduler.schedule(reminder.copy(id = id))
+ }
+ }
+
+ fun scheduleReminderAt(linkId: Int, timestamp: Long, repeatInterval: RepeatInterval = RepeatInterval.NONE) {
+ viewModelScope.launch {
+ val reminder = ReadingReminderEntity(
+ linkId = linkId,
+ remindAt = timestamp,
+ repeatInterval = repeatInterval
+ )
+ val id = reminderDao.insert(reminder)
+ reminderScheduler.schedule(reminder.copy(id = id))
+ }
+ }
+
+ fun dismissReminder(reminderId: Long) {
+ viewModelScope.launch {
+ reminderDao.markDismissed(reminderId)
+ reminderScheduler.cancel(reminderId)
+ }
+ }
+
+ fun deleteReminder(reminderId: Long) {
+ viewModelScope.launch {
+ reminderScheduler.cancel(reminderId)
+ reminderDao.delete(reminderId)
+ }
+ }
+
+ fun snoozeReminder(reminderId: Long) {
+ viewModelScope.launch {
+ val newTime = System.currentTimeMillis() + 3_600_000 // +1h
+ reminderDao.updateRemindAt(reminderId, newTime)
+ reminderScheduler.scheduleSnooze(reminderId)
+ }
+ }
+}
diff --git a/app/src/main/java/com/shaarit/presentation/reminders/RemindersScreen.kt b/app/src/main/java/com/shaarit/presentation/reminders/RemindersScreen.kt
new file mode 100644
index 0000000..ba2ace6
--- /dev/null
+++ b/app/src/main/java/com/shaarit/presentation/reminders/RemindersScreen.kt
@@ -0,0 +1,361 @@
+package com.shaarit.presentation.reminders
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Alarm
+import androidx.compose.material.icons.filled.AlarmOff
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.MenuBook
+import androidx.compose.material.icons.filled.Snooze
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RemindersScreen(
+ onNavigateBack: () -> Unit,
+ onNavigateToReader: (Int) -> Unit,
+ viewModel: ReminderViewModel = hiltViewModel()
+) {
+ val reminders by viewModel.remindersWithLinks.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Alarm,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Mes rappels")
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Filled.ArrowBack, contentDescription = "Retour")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+ }
+ ) { padding ->
+ if (reminders.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ Icons.Default.AlarmOff,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ modifier = Modifier.size(64.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Aucun rappel planifié",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Maintenez un lien dans le feed\npour programmer un rappel",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.padding(horizontal = 32.dp)
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Active reminders
+ val active = reminders.filter { !it.reminder.isDismissed }
+ val dismissed = reminders.filter { it.reminder.isDismissed }
+
+ if (active.isNotEmpty()) {
+ item {
+ Text(
+ text = "Actifs (${active.size})",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ }
+ items(active, key = { it.reminder.id }) { reminderInfo ->
+ ReminderCard(
+ reminderInfo = reminderInfo,
+ onReadClick = { onNavigateToReader(reminderInfo.reminder.linkId) },
+ onDismissClick = { viewModel.dismissReminder(reminderInfo.reminder.id) },
+ onSnoozeClick = { viewModel.snoozeReminder(reminderInfo.reminder.id) },
+ onDeleteClick = { viewModel.deleteReminder(reminderInfo.reminder.id) }
+ )
+ }
+ }
+
+ if (dismissed.isNotEmpty()) {
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Terminés (${dismissed.size})",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ }
+ items(dismissed.take(20), key = { it.reminder.id }) { reminderInfo ->
+ ReminderCard(
+ reminderInfo = reminderInfo,
+ onReadClick = { onNavigateToReader(reminderInfo.reminder.linkId) },
+ onDismissClick = null,
+ onSnoozeClick = null,
+ onDeleteClick = { viewModel.deleteReminder(reminderInfo.reminder.id) },
+ isDismissed = true
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ReminderCard(
+ reminderInfo: ReminderWithLinkInfo,
+ onReadClick: () -> Unit,
+ onDismissClick: (() -> Unit)?,
+ onSnoozeClick: (() -> Unit)?,
+ onDeleteClick: () -> Unit,
+ isDismissed: Boolean = false
+) {
+ val reminder = reminderInfo.reminder
+ val isPast = reminder.remindAt < System.currentTimeMillis()
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable(onClick = onReadClick),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isDismissed) {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ }
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = reminderInfo.linkTitle,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = if (isDismissed) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (reminderInfo.siteName != null) {
+ Text(
+ text = reminderInfo.siteName,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = " · ",
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ reminderInfo.readingTime?.let {
+ Text(
+ text = "${it}min",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Reminder time
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = if (isDismissed) Icons.Default.Check else Icons.Default.Alarm,
+ contentDescription = null,
+ tint = when {
+ isDismissed -> MaterialTheme.colorScheme.outline
+ isPast -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.primary
+ },
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = formatReminderTime(reminder.remindAt),
+ style = MaterialTheme.typography.labelMedium,
+ color = when {
+ isDismissed -> MaterialTheme.colorScheme.outline
+ isPast -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.primary
+ }
+ )
+ if (reminder.repeatInterval != com.shaarit.data.local.entity.RepeatInterval.NONE) {
+ Text(
+ text = " · ${repeatLabel(reminder.repeatInterval)}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Actions
+ if (!isDismissed) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ IconButton(onClick = onReadClick, modifier = Modifier.size(32.dp)) {
+ Icon(
+ Icons.Default.MenuBook,
+ contentDescription = "Lire",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ if (onDismissClick != null) {
+ IconButton(onClick = onDismissClick, modifier = Modifier.size(32.dp)) {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = "Marquer comme lu",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ if (onSnoozeClick != null) {
+ IconButton(onClick = onSnoozeClick, modifier = Modifier.size(32.dp)) {
+ Icon(
+ Icons.Default.Snooze,
+ contentDescription = "Rappeler dans 1h",
+ tint = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ IconButton(onClick = onDeleteClick, modifier = Modifier.size(32.dp)) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Supprimer",
+ tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun formatReminderTime(timestamp: Long): String {
+ val now = System.currentTimeMillis()
+ val diff = timestamp - now
+
+ return when {
+ diff < 0 -> {
+ val absDiff = -diff
+ val minutes = absDiff / 60_000
+ val hours = absDiff / 3_600_000
+ val days = absDiff / 86_400_000
+ when {
+ minutes < 60 -> "Il y a ${minutes}min"
+ hours < 24 -> "Il y a ${hours}h"
+ else -> "Il y a ${days}j"
+ }
+ }
+ diff < 3_600_000 -> "Dans ${diff / 60_000}min"
+ diff < 86_400_000 -> {
+ val sdf = SimpleDateFormat("'Aujourd\\'hui à' HH:mm", Locale.FRANCE)
+ sdf.format(Date(timestamp))
+ }
+ diff < 172_800_000 -> {
+ val sdf = SimpleDateFormat("'Demain à' HH:mm", Locale.FRANCE)
+ sdf.format(Date(timestamp))
+ }
+ else -> {
+ val sdf = SimpleDateFormat("EEE d MMM 'à' HH:mm", Locale.FRANCE)
+ sdf.format(Date(timestamp))
+ }
+ }
+}
+
+private fun repeatLabel(interval: com.shaarit.data.local.entity.RepeatInterval): String {
+ return when (interval) {
+ com.shaarit.data.local.entity.RepeatInterval.DAILY -> "Quotidien"
+ com.shaarit.data.local.entity.RepeatInterval.WEEKLY -> "Hebdomadaire"
+ com.shaarit.data.local.entity.RepeatInterval.MONTHLY -> "Mensuel"
+ com.shaarit.data.local.entity.RepeatInterval.NONE -> ""
+ }
+}
diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt
index 6fbb9d2..484dec2 100644
--- a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt
@@ -163,6 +163,16 @@ fun SettingsScreen(
)
}
+ // Widget Section
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ SettingsSection(title = "Widgets")
+ }
+
+ item {
+ WidgetLinkCountItem()
+ }
+
// Analytics Section
item {
Spacer(modifier = Modifier.height(16.dp))
@@ -1154,3 +1164,69 @@ private fun SecuritySettingsItem(
}
}
}
+
+@Composable
+private fun WidgetLinkCountItem() {
+ val context = LocalContext.current
+ var linkCount by remember { mutableIntStateOf(com.shaarit.widget.WidgetPreferences.getWidgetLinkCount(context)) }
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Widgets,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Liens dans le widget",
+ style = MaterialTheme.typography.bodyLarge
+ )
+ Text(
+ text = "Nombre de liens affichés dans le widget Récents",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "3",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Slider(
+ value = linkCount.toFloat(),
+ onValueChange = { linkCount = it.toInt() },
+ onValueChangeFinished = {
+ com.shaarit.widget.WidgetPreferences.setWidgetLinkCount(context, linkCount)
+ },
+ valueRange = 3f..20f,
+ steps = 16,
+ modifier = Modifier.weight(1f)
+ )
+ Text(
+ text = "20",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ Text(
+ text = "$linkCount",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.width(32.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt b/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt
index 3375995..57e0b84 100644
--- a/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt
+++ b/app/src/main/java/com/shaarit/widget/ShaarliWidgetProvider.kt
@@ -19,6 +19,8 @@ class ShaarliWidgetProvider : AppWidgetProvider() {
const val ACTION_ADD_LINK = "com.shaarit.widget.ACTION_ADD_LINK"
const val ACTION_REFRESH = "com.shaarit.widget.ACTION_REFRESH"
const val ACTION_RANDOM = "com.shaarit.widget.ACTION_RANDOM"
+ const val ACTION_SEARCH = "com.shaarit.widget.ACTION_SEARCH"
+ const val ACTION_CLEAR_SEARCH = "com.shaarit.widget.ACTION_CLEAR_SEARCH"
const val EXTRA_LINK_URL = "link_url"
}
@@ -61,6 +63,22 @@ class ShaarliWidgetProvider : AppWidgetProvider() {
}
context.startActivity(mainIntent)
}
+ ACTION_SEARCH -> {
+ // Ouvrir le dialogue de recherche
+ val searchIntent = Intent(context, WidgetSearchActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ context.startActivity(searchIntent)
+ }
+ ACTION_CLEAR_SEARCH -> {
+ // Effacer la recherche et rafraîchir
+ WidgetPreferences.clearSearchQuery(context)
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val componentName = android.content.ComponentName(context, ShaarliWidgetProvider::class.java)
+ val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
+ appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list)
+ onUpdate(context, appWidgetManager, appWidgetIds)
+ }
}
}
@@ -110,6 +128,42 @@ class ShaarliWidgetProvider : AppWidgetProvider() {
)
views.setOnClickPendingIntent(R.id.widget_btn_random, randomPendingIntent)
+ // Barre de recherche — clic ouvre le dialogue
+ val searchIntent = Intent(context, ShaarliWidgetProvider::class.java).apply {
+ action = ACTION_SEARCH
+ }
+ val searchPendingIntent = PendingIntent.getBroadcast(
+ context,
+ 3,
+ searchIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widget_search_bar, searchPendingIntent)
+
+ // Bouton effacer la recherche
+ val clearSearchIntent = Intent(context, ShaarliWidgetProvider::class.java).apply {
+ action = ACTION_CLEAR_SEARCH
+ }
+ val clearSearchPendingIntent = PendingIntent.getBroadcast(
+ context,
+ 4,
+ clearSearchIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ views.setOnClickPendingIntent(R.id.widget_btn_clear_search, clearSearchPendingIntent)
+
+ // Afficher la requête active ou le placeholder
+ val currentQuery = WidgetPreferences.getSearchQuery(context)
+ if (currentQuery.isNotBlank()) {
+ views.setTextViewText(R.id.widget_search_text, currentQuery)
+ views.setTextColor(R.id.widget_search_text, 0xFFFFFFFF.toInt())
+ views.setViewVisibility(R.id.widget_btn_clear_search, android.view.View.VISIBLE)
+ } else {
+ views.setTextViewText(R.id.widget_search_text, "Rechercher…")
+ views.setTextColor(R.id.widget_search_text, 0xFF94A3B8.toInt())
+ views.setViewVisibility(R.id.widget_btn_clear_search, android.view.View.GONE)
+ }
+
// Configuration de la liste (utilise un RemoteViewsService)
val serviceIntent = Intent(context, ShaarliWidgetService::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
diff --git a/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt b/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt
index 89b77f3..55c49f7 100644
--- a/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt
+++ b/app/src/main/java/com/shaarit/widget/ShaarliWidgetService.kt
@@ -5,7 +5,6 @@ import android.content.Intent
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import com.shaarit.R
-import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.database.ShaarliDatabase
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
@@ -26,9 +25,6 @@ class ShaarliWidgetItemFactory(
) : RemoteViewsService.RemoteViewsFactory {
private var links: List = emptyList()
- private val linkDao: LinkDao by lazy {
- ShaarliDatabase.getInstance(context).linkDao()
- }
override fun onCreate() {
// Initialisation
@@ -38,18 +34,31 @@ class ShaarliWidgetItemFactory(
// Charger les liens depuis la base de données
links = runBlocking {
try {
- linkDao.getAllLinks()
- .firstOrNull()
- ?.take(10) // Limiter à 10 liens
- ?.map { link ->
- WidgetLinkItem(
- id = link.id,
- title = link.title,
- url = link.url,
- tags = link.tags.take(3).joinToString(", ") // Max 3 tags
- )
- } ?: emptyList()
+ val db = ShaarliDatabase.getInstance(context)
+ val linkDao = db.linkDao()
+ val count = WidgetPreferences.getWidgetLinkCount(context)
+ val query = WidgetPreferences.getSearchQuery(context).trim().lowercase()
+ val allLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList()
+ val filtered = if (query.isNotBlank()) {
+ allLinks.filter { link ->
+ link.title.lowercase().contains(query) ||
+ link.url.lowercase().contains(query) ||
+ link.description.lowercase().contains(query) ||
+ link.tags.any { it.lowercase().contains(query) }
+ }
+ } else {
+ allLinks
+ }
+ filtered.take(count).map { link ->
+ WidgetLinkItem(
+ id = link.id,
+ title = link.title,
+ url = link.url,
+ tags = link.tags.take(3).joinToString(", ") // Max 3 tags
+ )
+ }
} catch (e: Exception) {
+ android.util.Log.e("ShaarliWidget", "Error loading links", e)
emptyList()
}
}
@@ -62,6 +71,9 @@ class ShaarliWidgetItemFactory(
override fun getCount(): Int = links.size
override fun getViewAt(position: Int): RemoteViews {
+ if (position < 0 || position >= links.size) {
+ return getLoadingView()
+ }
val link = links[position]
return RemoteViews(context.packageName, R.layout.widget_list_item).apply {
@@ -84,7 +96,13 @@ class ShaarliWidgetItemFactory(
}
}
- override fun getLoadingView(): RemoteViews? = null
+ override fun getLoadingView(): RemoteViews {
+ return RemoteViews(context.packageName, R.layout.widget_list_item).apply {
+ setTextViewText(R.id.item_title, "Chargement…")
+ setTextViewText(R.id.item_url, "")
+ setViewVisibility(R.id.item_tags, android.view.View.GONE)
+ }
+ }
override fun getViewTypeCount(): Int = 1
diff --git a/app/src/main/java/com/shaarit/widget/WidgetPreferences.kt b/app/src/main/java/com/shaarit/widget/WidgetPreferences.kt
new file mode 100644
index 0000000..4b92cc0
--- /dev/null
+++ b/app/src/main/java/com/shaarit/widget/WidgetPreferences.kt
@@ -0,0 +1,38 @@
+package com.shaarit.widget
+
+import android.content.Context
+import android.content.SharedPreferences
+
+/**
+ * Préférences pour les widgets (nombre de liens à afficher, etc.)
+ */
+object WidgetPreferences {
+ private const val PREFS_NAME = "shaarit_widget_prefs"
+ private const val KEY_WIDGET_LINK_COUNT = "widget_link_count"
+ private const val KEY_SEARCH_QUERY = "widget_search_query"
+ private const val DEFAULT_LINK_COUNT = 5
+
+ private fun getPrefs(context: Context): SharedPreferences {
+ return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
+ fun getWidgetLinkCount(context: Context): Int {
+ return getPrefs(context).getInt(KEY_WIDGET_LINK_COUNT, DEFAULT_LINK_COUNT)
+ }
+
+ fun setWidgetLinkCount(context: Context, count: Int) {
+ getPrefs(context).edit().putInt(KEY_WIDGET_LINK_COUNT, count.coerceIn(3, 20)).apply()
+ }
+
+ fun getSearchQuery(context: Context): String {
+ return getPrefs(context).getString(KEY_SEARCH_QUERY, "") ?: ""
+ }
+
+ fun setSearchQuery(context: Context, query: String) {
+ getPrefs(context).edit().putString(KEY_SEARCH_QUERY, query).apply()
+ }
+
+ fun clearSearchQuery(context: Context) {
+ getPrefs(context).edit().remove(KEY_SEARCH_QUERY).apply()
+ }
+}
diff --git a/app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt b/app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt
new file mode 100644
index 0000000..d35c7d2
--- /dev/null
+++ b/app/src/main/java/com/shaarit/widget/WidgetSearchActivity.kt
@@ -0,0 +1,75 @@
+package com.shaarit.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Bundle
+import android.widget.EditText
+import android.widget.LinearLayout
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import com.shaarit.R
+
+/**
+ * Activité transparente qui affiche un dialogue de recherche pour le widget ShaarIt.
+ * Quand l'utilisateur tape une requête, elle est sauvegardée dans WidgetPreferences
+ * et le widget est rafraîchi avec les résultats filtrés.
+ */
+class WidgetSearchActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val currentQuery = WidgetPreferences.getSearchQuery(this)
+
+ val editText = EditText(this).apply {
+ hint = "Rechercher dans les bookmarks…"
+ setText(currentQuery)
+ setSingleLine(true)
+ requestFocus()
+ }
+
+ val container = LinearLayout(this).apply {
+ setPadding(48, 32, 48, 0)
+ addView(editText, LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ))
+ }
+
+ AlertDialog.Builder(this)
+ .setTitle("Rechercher")
+ .setView(container)
+ .setPositiveButton("Rechercher") { _, _ ->
+ val query = editText.text.toString().trim()
+ WidgetPreferences.setSearchQuery(this, query)
+ refreshWidget()
+ finish()
+ }
+ .setNegativeButton("Annuler") { _, _ ->
+ finish()
+ }
+ .setNeutralButton("Effacer") { _, _ ->
+ WidgetPreferences.clearSearchQuery(this)
+ refreshWidget()
+ finish()
+ }
+ .setOnCancelListener {
+ finish()
+ }
+ .show()
+ }
+
+ private fun refreshWidget() {
+ val appWidgetManager = AppWidgetManager.getInstance(this)
+ val componentName = ComponentName(this, ShaarliWidgetProvider::class.java)
+ val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
+ appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.widget_list)
+
+ val updateIntent = Intent(this, ShaarliWidgetProvider::class.java).apply {
+ action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds)
+ }
+ sendBroadcast(updateIntent)
+ }
+}
diff --git a/app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt b/app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt
new file mode 100644
index 0000000..b906c95
--- /dev/null
+++ b/app/src/main/java/com/shaarit/widget/glance/QuickStatsWidget.kt
@@ -0,0 +1,129 @@
+package com.shaarit.widget.glance
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import com.shaarit.MainActivity
+
+/**
+ * Widget Glance affichant les statistiques rapides (2×1)
+ */
+class QuickStatsWidget : GlanceAppWidget() {
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val stats = WidgetDataProvider.getStats(context)
+
+ provideContent {
+ GlanceTheme {
+ QuickStatsContent(stats)
+ }
+ }
+ }
+}
+
+@Composable
+private fun QuickStatsContent(stats: WidgetDataProvider.WidgetStats) {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(GlanceTheme.colors.widgetBackground)
+ .padding(12.dp)
+ .cornerRadius(16.dp)
+ .clickable(actionStartActivity())
+ ) {
+ Text(
+ text = "\uD83D\uDCCA ShaarIt",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold
+ )
+ )
+ Spacer(modifier = GlanceModifier.height(6.dp))
+
+ // Total links
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = formatCount(stats.totalLinks),
+ style = TextStyle(
+ color = GlanceTheme.colors.primary,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold
+ )
+ )
+ Text(
+ text = " liens",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 13.sp
+ )
+ )
+ }
+
+ // This week
+ Text(
+ text = "${stats.linksThisWeek} cette semaine",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp
+ )
+ )
+
+ // Reading time
+ if (stats.totalReadingTimeMinutes > 0) {
+ val hours = stats.totalReadingTimeMinutes / 60
+ val readingText = if (hours > 0) {
+ "\uD83D\uDCDA ${hours}h de lecture"
+ } else {
+ "\uD83D\uDCDA ${stats.totalReadingTimeMinutes}min de lecture"
+ }
+ Text(
+ text = readingText,
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp
+ )
+ )
+ }
+ }
+}
+
+private fun formatCount(count: Int): String {
+ return when {
+ count >= 1_000_000 -> String.format("%.1fM", count / 1_000_000.0)
+ count >= 1_000 -> String.format("%.1fK", count / 1_000.0)
+ else -> count.toString()
+ }
+}
+
+/**
+ * Receiver pour le widget Quick Stats
+ */
+class QuickStatsWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = QuickStatsWidget()
+}
diff --git a/app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt b/app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt
new file mode 100644
index 0000000..fa336d7
--- /dev/null
+++ b/app/src/main/java/com/shaarit/widget/glance/RecentLinksWidget.kt
@@ -0,0 +1,180 @@
+package com.shaarit.widget.glance
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.lazy.LazyColumn
+import androidx.glance.appwidget.lazy.items
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import com.shaarit.MainActivity
+
+/**
+ * Widget Glance affichant les liens récents (4×2)
+ */
+class RecentLinksWidget : GlanceAppWidget() {
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val links = WidgetDataProvider.getRecentLinks(context)
+
+ provideContent {
+ GlanceTheme {
+ RecentLinksContent(links)
+ }
+ }
+ }
+}
+
+@Composable
+private fun RecentLinksContent(links: List) {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .background(GlanceTheme.colors.widgetBackground)
+ .padding(12.dp)
+ .cornerRadius(16.dp)
+ ) {
+ // Header
+ Row(
+ modifier = GlanceModifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "\uD83D\uDD16 ShaarIt — Récents",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold
+ ),
+ modifier = GlanceModifier.defaultWeight()
+ )
+ // Add button
+ Box(
+ modifier = GlanceModifier
+ .size(28.dp)
+ .clickable(actionStartActivity()),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "+",
+ style = TextStyle(
+ color = GlanceTheme.colors.primary,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold
+ )
+ )
+ }
+ Spacer(modifier = GlanceModifier.width(4.dp))
+ // Random button
+ Box(
+ modifier = GlanceModifier
+ .size(28.dp)
+ .clickable(actionStartActivity()),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "\uD83D\uDD00",
+ style = TextStyle(fontSize = 16.sp)
+ )
+ }
+ }
+
+ Spacer(modifier = GlanceModifier.height(8.dp))
+
+ if (links.isEmpty()) {
+ Box(
+ modifier = GlanceModifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Aucun lien",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 14.sp
+ )
+ )
+ }
+ } else {
+ LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
+ items(links, itemId = { it.id.toLong() }) { link ->
+ LinkItem(link)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LinkItem(link: WidgetDataProvider.WidgetLink) {
+ Column(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp, horizontal = 2.dp)
+ .clickable(actionStartActivity())
+ ) {
+ // Content type emoji + title
+ val emoji = getContentEmoji(link.url)
+ Text(
+ text = "$emoji ${link.title}",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium
+ ),
+ maxLines = 1
+ )
+ // Site name + relative time
+ val domain = link.siteName ?: WidgetDataProvider.extractDomain(link.url)
+ val relativeTime = WidgetDataProvider.formatRelativeTime(link.createdAt)
+ Text(
+ text = "$domain · $relativeTime",
+ style = TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 11.sp
+ ),
+ maxLines = 1
+ )
+ }
+}
+
+private fun getContentEmoji(url: String): String {
+ val lower = url.lowercase()
+ return when {
+ lower.contains("youtube.com") || lower.contains("youtu.be") || lower.contains("vimeo") -> "\uD83D\uDCF9"
+ lower.contains("github.com") || lower.contains("gitlab.com") -> "\uD83D\uDEE0\uFE0F"
+ lower.contains("spotify") || lower.contains("deezer") -> "\uD83C\uDFB5"
+ lower.contains("twitter.com") || lower.contains("x.com") || lower.contains("mastodon") -> "\uD83D\uDCAC"
+ lower.endsWith(".pdf") -> "\uD83D\uDCC4"
+ else -> "\uD83D\uDCC4"
+ }
+}
+
+/**
+ * Receiver pour le widget Liens Récents
+ */
+class RecentLinksWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = RecentLinksWidget()
+}
diff --git a/app/src/main/java/com/shaarit/widget/glance/WidgetDataProvider.kt b/app/src/main/java/com/shaarit/widget/glance/WidgetDataProvider.kt
new file mode 100644
index 0000000..f2d07b1
--- /dev/null
+++ b/app/src/main/java/com/shaarit/widget/glance/WidgetDataProvider.kt
@@ -0,0 +1,92 @@
+package com.shaarit.widget.glance
+
+import android.content.Context
+import com.shaarit.data.local.database.ShaarliDatabase
+import com.shaarit.data.local.entity.LinkEntity
+import kotlinx.coroutines.flow.firstOrNull
+
+/**
+ * Fournit les données depuis Room pour les widgets Glance
+ */
+object WidgetDataProvider {
+
+ data class WidgetLink(
+ val id: Int,
+ val title: String,
+ val url: String,
+ val siteName: String?,
+ val tags: List,
+ val createdAt: Long
+ )
+
+ data class WidgetStats(
+ val totalLinks: Int,
+ val linksThisWeek: Int,
+ val totalReadingTimeMinutes: Int
+ )
+
+ suspend fun getRecentLinks(context: Context, limit: Int? = null): List {
+ return try {
+ val count = limit ?: com.shaarit.widget.WidgetPreferences.getWidgetLinkCount(context)
+ val db = ShaarliDatabase.getInstance(context)
+ val links = db.linkDao().getAllLinks().firstOrNull() ?: emptyList()
+ links.take(count).map { it.toWidgetLink() }
+ } catch (e: Exception) {
+ emptyList()
+ }
+ }
+
+ suspend fun getStats(context: Context): WidgetStats {
+ return try {
+ val db = ShaarliDatabase.getInstance(context)
+ val linkDao = db.linkDao()
+ val totalLinks = linkDao.getAllLinks().firstOrNull()?.size ?: 0
+ val oneWeekAgo = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
+ val linksThisWeek = linkDao.getCountSince(oneWeekAgo)
+ val allLinks = linkDao.getAllLinksForStats()
+ val totalReadingTime = allLinks.sumOf { it.readingTimeMinutes ?: 0 }
+ WidgetStats(
+ totalLinks = totalLinks,
+ linksThisWeek = linksThisWeek,
+ totalReadingTimeMinutes = totalReadingTime
+ )
+ } catch (e: Exception) {
+ WidgetStats(0, 0, 0)
+ }
+ }
+
+ private fun LinkEntity.toWidgetLink(): WidgetLink {
+ return WidgetLink(
+ id = id,
+ title = title,
+ url = url,
+ siteName = siteName,
+ tags = tags,
+ createdAt = createdAt
+ )
+ }
+
+ fun formatRelativeTime(timestamp: Long): String {
+ val now = System.currentTimeMillis()
+ val diff = now - timestamp
+ val minutes = diff / 60_000
+ val hours = diff / 3_600_000
+ val days = diff / 86_400_000
+
+ return when {
+ minutes < 1 -> "à l'instant"
+ minutes < 60 -> "il y a ${minutes}min"
+ hours < 24 -> "il y a ${hours}h"
+ days < 7 -> "il y a ${days}j"
+ else -> "il y a ${days / 7}sem"
+ }
+ }
+
+ fun extractDomain(url: String): String {
+ return try {
+ java.net.URL(url).host.removePrefix("www.")
+ } catch (e: Exception) {
+ url
+ }
+ }
+}
diff --git a/app/src/main/java/com/shaarit/widget/glance/WidgetUpdateWorker.kt b/app/src/main/java/com/shaarit/widget/glance/WidgetUpdateWorker.kt
new file mode 100644
index 0000000..f30aab1
--- /dev/null
+++ b/app/src/main/java/com/shaarit/widget/glance/WidgetUpdateWorker.kt
@@ -0,0 +1,50 @@
+package com.shaarit.widget.glance
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import java.util.concurrent.TimeUnit
+
+/**
+ * Worker pour mettre à jour périodiquement les widgets Glance
+ */
+@HiltWorker
+class WidgetUpdateWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters
+) : CoroutineWorker(appContext, workerParams) {
+
+ companion object {
+ const val WORK_NAME = "widget_update_work"
+ private const val UPDATE_INTERVAL_MINUTES = 30L
+
+ fun schedule(context: Context) {
+ val request = PeriodicWorkRequestBuilder(
+ UPDATE_INTERVAL_MINUTES, TimeUnit.MINUTES
+ ).build()
+
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ request
+ )
+ }
+ }
+
+ override suspend fun doWork(): Result {
+ return try {
+ RecentLinksWidget().updateAll(applicationContext)
+ QuickStatsWidget().updateAll(applicationContext)
+ Result.success()
+ } catch (e: Exception) {
+ Result.retry()
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/widget_search_background.xml b/app/src/main/res/drawable/widget_search_background.xml
new file mode 100644
index 0000000..da235bc
--- /dev/null
+++ b/app/src/main/res/drawable/widget_search_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_shaarli.xml b/app/src/main/res/layout/widget_shaarli.xml
index eda4fae..416ef72 100644
--- a/app/src/main/res/layout/widget_shaarli.xml
+++ b/app/src/main/res/layout/widget_shaarli.xml
@@ -27,7 +27,7 @@
android:id="@+id/widget_btn_add"
android:layout_width="32dp"
android:layout_height="32dp"
- android:background="?attr/selectableItemBackgroundBorderless"
+ android:background="@android:color/transparent"
android:contentDescription="@string/add_link"
android:src="@android:drawable/ic_input_add"
android:tint="@android:color/white" />
@@ -36,7 +36,7 @@
android:id="@+id/widget_btn_refresh"
android:layout_width="32dp"
android:layout_height="32dp"
- android:background="?attr/selectableItemBackgroundBorderless"
+ android:background="@android:color/transparent"
android:contentDescription="@string/refresh"
android:src="@android:drawable/ic_popup_sync"
android:tint="@android:color/white" />
@@ -45,12 +45,54 @@
android:id="@+id/widget_btn_random"
android:layout_width="32dp"
android:layout_height="32dp"
- android:background="?attr/selectableItemBackgroundBorderless"
+ android:background="@android:color/transparent"
android:contentDescription="@string/random"
android:src="@android:drawable/ic_menu_sort_by_size"
android:tint="@android:color/white" />
+
+
+
+
+
+
+
+
+
+
Exportation réussie
Erreur d\'exportation
Sélectionner un fichier
+
+
+ ShaarIt — Récents
+ Affiche les derniers liens ajoutés
+ ShaarIt — Stats
+ Statistiques rapides de vos liens
+
+
+ Mode Lecture
+ Extraction de l\'article…
+ Impossible d\'extraire l\'article
+ Réessayer
+ Sans-serif
+ Serif
+ Monospace
+ Sombre
+ Sépia
+ Clair
+ Auto
+
+
+ Rappel de lecture
+ Rappels de lecture
+ Notifications pour les rappels de lecture planifiés
+ Dans 1 heure
+ Ce soir (20h)
+ Demain matin (9h)
+ Ce week-end
+ La semaine prochaine
+ Date personnalisée…
+ Rappeler de lire
+ Lu
+ Rappeler dans 1h
+ Mes rappels
+ Aucun rappel planifié
\ No newline at end of file
diff --git a/app/src/main/res/xml/widget_quick_stats_info.xml b/app/src/main/res/xml/widget_quick_stats_info.xml
new file mode 100644
index 0000000..d473150
--- /dev/null
+++ b/app/src/main/res/xml/widget_quick_stats_info.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/main/res/xml/widget_recent_links_info.xml b/app/src/main/res/xml/widget_recent_links_info.xml
new file mode 100644
index 0000000..5b66f5e
--- /dev/null
+++ b/app/src/main/res/xml/widget_recent_links_info.xml
@@ -0,0 +1,16 @@
+
+