feat: Implement full synchronization manager, new link repository, various UI screens, and note background resources.

This commit is contained in:
Bruno Charest 2026-02-22 21:07:35 -05:00
parent 34964f7b8c
commit a05f7ce71c
51 changed files with 760 additions and 51 deletions

View File

@ -0,0 +1,228 @@
package com.shaarit.core.storage
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Définition d'un tag système prédéfini.
* @param tag Le nom du tag (ou pattern wildcard comme "note-color-*")
* @param label Nom affiché à l'utilisateur
* @param description Courte description du rôle du tag
* @param isWildcard Si true, le tag utilise un pattern wildcard (ex: "note-color-*")
* @param defaultHidden Si true, le tag est caché par défaut
*/
data class PresetSystemTag(
val tag: String,
val label: String,
val description: String,
val isWildcard: Boolean = false,
val defaultHidden: Boolean = true
)
@Singleton
class HiddenTagsPreferences @Inject constructor(
@ApplicationContext private val context: Context
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("hidden_tags_prefs", Context.MODE_PRIVATE)
companion object {
private const val KEY_PRESET_PREFIX = "preset_hidden_"
private const val KEY_CUSTOM_TAGS = "custom_hidden_tags"
/**
* Liste des tags systèmes prédéfinis utilisés par l'application.
*/
val PRESET_SYSTEM_TAGS = listOf(
PresetSystemTag(
tag = "note",
label = "note",
description = "Notes - Identifiant interne des notes",
defaultHidden = true
),
PresetSystemTag(
tag = "shaarli-pin",
label = "shaarli-pin",
description = "Épinglé - Garde les favoris en haut",
defaultHidden = true
),
PresetSystemTag(
tag = "note-color-*",
label = "note-color-*",
description = "Couleurs des Notes - Wildcard pour les couleurs",
isWildcard = true,
defaultHidden = true
),
PresetSystemTag(
tag = "notebg-*",
label = "notebg-*",
description = "Fonds des Notes - Wildcard pour les arrière-plans",
isWildcard = true,
defaultHidden = true
),
PresetSystemTag(
tag = "notefilter-*",
label = "notefilter-*",
description = "Filtres des Notes - Wildcard pour les filtres",
isWildcard = true,
defaultHidden = true
),
PresetSystemTag(
tag = "readitlater",
label = "readitlater",
description = "À lire plus tard - Liste de lecture temporaire",
defaultHidden = true
),
PresetSystemTag(
tag = "shaarli-archive",
label = "shaarli-archive",
description = "Archivé - Notes archivées",
defaultHidden = false
),
PresetSystemTag(
tag = "todo",
label = "todo",
description = "Tâches - Identifiant interne des tâches",
defaultHidden = true
),
PresetSystemTag(
tag = "brain-dump",
label = "brain-dump",
description = "Brain Dump - Capture rapide d'idées",
defaultHidden = true
)
)
}
// État réactif pour les presets
private val _presetStates = MutableStateFlow(loadAllPresetStates())
val presetStates: StateFlow<Map<String, Boolean>> = _presetStates.asStateFlow()
// État réactif pour les tags custom
private val _customHiddenTags = MutableStateFlow(loadCustomHiddenTags())
val customHiddenTags: StateFlow<Set<String>> = _customHiddenTags.asStateFlow()
/**
* Charge l'état (caché ou non) de chaque tag preset depuis SharedPreferences.
*/
private fun loadAllPresetStates(): Map<String, Boolean> {
return PRESET_SYSTEM_TAGS.associate { preset ->
preset.tag to prefs.getBoolean(
KEY_PRESET_PREFIX + preset.tag,
preset.defaultHidden
)
}
}
/**
* Charge la liste des tags custom cachés.
*/
private fun loadCustomHiddenTags(): Set<String> {
return prefs.getStringSet(KEY_CUSTOM_TAGS, emptySet()) ?: emptySet()
}
/**
* Active/désactive le masquage d'un tag preset.
*/
fun setPresetHidden(tag: String, hidden: Boolean) {
prefs.edit().putBoolean(KEY_PRESET_PREFIX + tag, hidden).apply()
_presetStates.value = loadAllPresetStates()
}
/**
* Vérifie si un tag preset est caché.
*/
fun isPresetHidden(tag: String): Boolean {
return _presetStates.value[tag] ?: PRESET_SYSTEM_TAGS.find { it.tag == tag }?.defaultHidden ?: false
}
/**
* Ajoute un tag custom à la liste des tags cachés.
*/
fun addCustomHiddenTag(tag: String) {
val normalizedTag = tag.trim().lowercase()
if (normalizedTag.isBlank()) return
val currentTags = loadCustomHiddenTags().toMutableSet()
currentTags.add(normalizedTag)
prefs.edit().putStringSet(KEY_CUSTOM_TAGS, currentTags).apply()
_customHiddenTags.value = currentTags
}
/**
* Supprime un tag custom de la liste des tags cachés.
*/
fun removeCustomHiddenTag(tag: String) {
val currentTags = loadCustomHiddenTags().toMutableSet()
currentTags.remove(tag)
prefs.edit().putStringSet(KEY_CUSTOM_TAGS, currentTags).apply()
_customHiddenTags.value = currentTags
}
/**
* Remet tous les presets à leur valeur par défaut et supprime tous les tags custom.
*/
fun resetToDefaults() {
val editor = prefs.edit()
PRESET_SYSTEM_TAGS.forEach { preset ->
editor.putBoolean(KEY_PRESET_PREFIX + preset.tag, preset.defaultHidden)
}
editor.putStringSet(KEY_CUSTOM_TAGS, emptySet())
editor.apply()
_presetStates.value = loadAllPresetStates()
_customHiddenTags.value = emptySet()
}
/**
* Détermine si un tag donné doit être caché (en vérifiant les presets ET les custom).
* Supporte les wildcards (ex: "note-color-*" match "note-color-red").
*/
fun isTagHidden(tagName: String): Boolean {
val normalizedTag = tagName.lowercase()
// Vérifier les presets
for (preset in PRESET_SYSTEM_TAGS) {
val isHidden = _presetStates.value[preset.tag] ?: preset.defaultHidden
if (!isHidden) continue
if (preset.isWildcard) {
val prefix = preset.tag.removeSuffix("*")
if (normalizedTag.startsWith(prefix)) return true
} else {
if (normalizedTag == preset.tag) return true
}
}
// Vérifier les custom
for (customTag in _customHiddenTags.value) {
if (customTag.endsWith("*")) {
val prefix = customTag.removeSuffix("*")
if (normalizedTag.startsWith(prefix)) return true
} else {
if (normalizedTag == customTag) return true
}
}
return false
}
/**
* Retourne tous les tags actuellement cachés (pour debug/info).
*/
fun getAllHiddenTagPatterns(): List<String> {
val result = mutableListOf<String>()
for (preset in PRESET_SYSTEM_TAGS) {
val isHidden = _presetStates.value[preset.tag] ?: preset.defaultHidden
if (isHidden) result.add(preset.tag)
}
result.addAll(_customHiddenTags.value)
return result
}
}

View File

@ -212,6 +212,7 @@ constructor(
description = description ?: "", description = description ?: "",
tags = tags ?: emptyList(), tags = tags ?: emptyList(),
isPrivate = isPrivate, isPrivate = isPrivate,
isPinned = tags?.contains("shaarli-pin") ?: false,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING_CREATE syncStatus = SyncStatus.PENDING_CREATE
@ -255,6 +256,7 @@ constructor(
description = description ?: existing.description, description = description ?: existing.description,
tags = tags ?: existing.tags, tags = tags ?: existing.tags,
isPrivate = isPrivate, isPrivate = isPrivate,
isPinned = tags?.contains("shaarli-pin") ?: existing.isPinned,
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING_UPDATE syncStatus = SyncStatus.PENDING_UPDATE
) )
@ -318,6 +320,7 @@ constructor(
description = description ?: existing.description, description = description ?: existing.description,
tags = tags ?: existing.tags, tags = tags ?: existing.tags,
isPrivate = isPrivate, isPrivate = isPrivate,
isPinned = tags?.contains("shaarli-pin") ?: existing.isPinned,
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
syncStatus = if (existing.syncStatus == SyncStatus.PENDING_CREATE) { syncStatus = if (existing.syncStatus == SyncStatus.PENDING_CREATE) {
SyncStatus.PENDING_CREATE SyncStatus.PENDING_CREATE
@ -572,6 +575,7 @@ constructor(
description = description ?: "", description = description ?: "",
tags = tags ?: emptyList(), tags = tags ?: emptyList(),
isPrivate = isPrivate ?: false, isPrivate = isPrivate ?: false,
isPinned = tags?.contains("shaarli-pin") ?: false,
createdAt = parseDate(created), createdAt = parseDate(created),
updatedAt = parseDate(updated), updatedAt = parseDate(updated),
syncStatus = SyncStatus.SYNCED syncStatus = SyncStatus.SYNCED

View File

@ -451,7 +451,7 @@ class SyncManager @Inject constructor(
description = dto.description ?: "", description = dto.description ?: "",
tags = dto.tags ?: emptyList(), tags = dto.tags ?: emptyList(),
isPrivate = dto.isPrivate ?: false, isPrivate = dto.isPrivate ?: false,
isPinned = existing?.isPinned ?: false, isPinned = dto.tags?.contains("shaarli-pin") ?: false,
createdAt = parseDate(dto.created), createdAt = parseDate(dto.created),
updatedAt = if (serverUpdatedAt > 0L) serverUpdatedAt else existing?.updatedAt ?: syncStartTime, updatedAt = if (serverUpdatedAt > 0L) serverUpdatedAt else existing?.updatedAt ?: syncStartTime,
syncStatus = SyncStatus.SYNCED, syncStatus = SyncStatus.SYNCED,

View File

@ -458,6 +458,41 @@ fun AddLinkScreen(
} }
} }
// Background Selection Section (Compact)
AnimatedVisibility(
visible = contentTypeSelection == ContentType.NOTE,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
CompactFieldCard(
icon = Icons.Default.Image,
label = "Arrière-plan"
) {
val noteBackgrounds = listOf("Aucun", "cafe", "codes", "dune", "feuilleligne", "feuillequadrille", "fleurs", "foret", "grid", "journal", "lecture", "legumes", "montagnes", "ocean", "radio", "sports", "vague1", "vague2", "ville", "voyage")
val currentBgTag = selectedTags.firstOrNull { it.lowercase().startsWith("notebg-") }?.lowercase()?.removePrefix("notebg-") ?: "Aucun"
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) {
items(noteBackgrounds) { bgName ->
val isSelected = currentBgTag == bgName
FilterChip(
selected = isSelected,
onClick = {
val existingBgTags = selectedTags.filter { it.lowercase().startsWith("notebg-") }
existingBgTags.forEach { viewModel.removeTag(it) }
if (bgName != "Aucun") {
viewModel.addTag("notebg-$bgName")
}
},
label = { Text(if (bgName == "Aucun") "Aucun" else bgName.replaceFirstChar { it.uppercase() }) }
)
}
}
}
}
// Tags Section avec correction du clavier - se positionne au-dessus du clavier // Tags Section avec correction du clavier - se positionne au-dessus du clavier
GlassCard( GlassCard(
modifier = Modifier modifier = Modifier

View File

@ -391,6 +391,41 @@ fun EditLinkScreen(
} }
} }
// Background Selection Section (Compact)
AnimatedVisibility(
visible = contentType == ContentType.NOTE,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
CompactFieldCard(
icon = Icons.Default.Image,
label = "Arrière-plan"
) {
val noteBackgrounds = listOf("Aucun", "cafe", "codes", "dune", "feuilleligne", "feuillequadrille", "fleurs", "foret", "grid", "journal", "lecture", "legumes", "montagnes", "ocean", "radio", "sports", "vague1", "vague2", "ville", "voyage")
val currentBgTag = selectedTags.firstOrNull { it.lowercase().startsWith("notebg-") }?.lowercase()?.removePrefix("notebg-") ?: "Aucun"
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) {
items(noteBackgrounds) { bgName ->
val isSelected = currentBgTag == bgName
FilterChip(
selected = isSelected,
onClick = {
val existingBgTags = selectedTags.filter { it.lowercase().startsWith("notebg-") }
existingBgTags.forEach { viewModel.removeTag(it) }
if (bgName != "Aucun") {
viewModel.addTag("notebg-$bgName")
}
},
label = { Text(if (bgName == "Aucun") "Aucun" else bgName.replaceFirstChar { it.uppercase() }) }
)
}
}
}
}
// Tags Section avec correction du clavier - se positionne au-dessus du clavier // Tags Section avec correction du clavier - se positionne au-dessus du clavier
GlassCard( GlassCard(
modifier = Modifier modifier = Modifier

View File

@ -297,7 +297,7 @@ fun FeedScreen(
val bookmarkFilter by viewModel.bookmarkFilter.collectAsState() val bookmarkFilter by viewModel.bookmarkFilter.collectAsState()
val collections by viewModel.collections.collectAsState() val collections by viewModel.collections.collectAsState()
val tags by viewModel.tags.collectAsState() val visibleTags by viewModel.visibleTags.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@ -728,8 +728,8 @@ fun FeedScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Display all tags ordered by bookmark count (highest first) // Display all visible tags ordered by bookmark count (highest first)
tags.forEach { tag -> visibleTags.forEach { tag ->
val isSelected = searchTags?.split(" ")?.contains(tag.name) == true val isSelected = searchTags?.split(" ")?.contains(tag.name) == true
DrawerTagChip( DrawerTagChip(
tag = tag.name, tag = tag.name,

View File

@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import com.shaarit.core.storage.HiddenTagsPreferences
import com.shaarit.core.storage.TokenManager import com.shaarit.core.storage.TokenManager
import com.shaarit.data.local.dao.CollectionDao import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.TagDao import com.shaarit.data.local.dao.TagDao
@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -46,7 +49,8 @@ class FeedViewModel @Inject constructor(
private val collectionDao: CollectionDao, private val collectionDao: CollectionDao,
private val tagDao: TagDao, private val tagDao: TagDao,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val linkDao: LinkDao private val linkDao: LinkDao,
val hiddenTagsPreferences: HiddenTagsPreferences
) : ViewModel() { ) : ViewModel() {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
@ -76,6 +80,11 @@ class FeedViewModel @Inject constructor(
.getAllTags() .getAllTags()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
// Tags filtered: hide system/custom hidden tags from the drawer
val visibleTags = combine(tags, hiddenTagsPreferences.presetStates, hiddenTagsPreferences.customHiddenTags) { tagList, _, _ ->
tagList.filter { !hiddenTagsPreferences.isTagHidden(it.name) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val pagedLinks: Flow<PagingData<ShaarliLink>> = run { val pagedLinks: Flow<PagingData<ShaarliLink>> = run {
val debouncedSearch = _searchQuery.debounce(300) val debouncedSearch = _searchQuery.debounce(300)
@ -93,6 +102,12 @@ class FeedViewModel @Inject constructor(
bookmarkFilter = bookmarkFilter bookmarkFilter = bookmarkFilter
) )
} }
// Filter hidden tags from each link's tag list for display purposes
.map { pagingData: PagingData<ShaarliLink> ->
pagingData.map { link: ShaarliLink ->
link.copy(tags = link.tags.filter { tag -> !hiddenTagsPreferences.isTagHidden(tag) })
}
}
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
} }

View File

@ -113,10 +113,22 @@ fun ListViewItem(
) )
} }
val context = LocalContext.current
val backgroundResId = remember(link.tags) {
val bgTag = link.tags.firstOrNull { it.lowercase().startsWith("notebg-") }
if (bgTag != null) {
val theme = bgTag.lowercase().removePrefix("notebg-")
val resName = "note_bg_$theme"
val resId = context.resources.getIdentifier(resName, "drawable", context.packageName)
if (resId != 0) resId else null
} else null
}
GlassCard( GlassCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() }, onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
onLongClick = onItemLongClick, onLongClick = onItemLongClick,
backgroundImage = backgroundResId,
glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary
) { ) {
Column { Column {
@ -338,15 +350,33 @@ fun GridViewItem(
) )
} }
val context = LocalContext.current
val backgroundResId = remember(link.tags) {
val bgTag = link.tags.firstOrNull { it.lowercase().startsWith("notebg-") }
if (bgTag != null) {
val theme = bgTag.lowercase().removePrefix("notebg-")
val resName = "note_bg_$theme"
val resId = context.resources.getIdentifier(resName, "drawable", context.packageName)
if (resId != 0) resId else null
} else null
}
val baseModifier = Modifier.fillMaxWidth()
val cardModifier = if (backgroundResId != null) {
baseModifier.height(240.dp)
} else {
baseModifier
}
GlassCard( GlassCard(
modifier = Modifier modifier = cardModifier,
.fillMaxWidth(),
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() }, onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
onLongClick = onItemLongClick, onLongClick = onItemLongClick,
backgroundImage = backgroundResId,
glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary glowColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxSize()
) { ) {
// Selection checkbox // Selection checkbox
if (selectionMode) { if (selectionMode) {
@ -441,7 +471,7 @@ fun GridViewItem(
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.weight(1f).heightIn(min = 8.dp))
// Metadata row // Metadata row
Row( Row(

View File

@ -36,7 +36,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.core.storage.BiometricAuthManager import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.BiometricAvailability import com.shaarit.core.storage.BiometricAvailability
import com.shaarit.core.storage.HiddenTagsPreferences
import com.shaarit.core.storage.LockTimeout import com.shaarit.core.storage.LockTimeout
import com.shaarit.core.storage.PresetSystemTag
import com.shaarit.core.storage.SecurityPreferences import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.core.storage.TimezonePreferences import com.shaarit.core.storage.TimezonePreferences
import com.shaarit.data.export.BookmarkImporter import com.shaarit.data.export.BookmarkImporter
@ -149,6 +151,18 @@ fun SettingsScreen(
) )
} }
// Hidden Tags Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Tags cachés")
}
item {
HiddenTagsSettingsItem(
hiddenTagsPreferences = viewModel.hiddenTagsPreferences
)
}
// Security Section // Security Section
item { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -1320,3 +1334,328 @@ private fun WidgetLinkCountItem() {
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HiddenTagsSettingsItem(
hiddenTagsPreferences: HiddenTagsPreferences
) {
var showDialog by remember { mutableStateOf(false) }
val presetStates by hiddenTagsPreferences.presetStates.collectAsState()
val customTags by hiddenTagsPreferences.customHiddenTags.collectAsState()
val hiddenCount = presetStates.count { it.value } + customTags.size
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { showDialog = true }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Tags cachés",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "$hiddenCount tag(s) masqué(s)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showDialog) {
HiddenTagsDialog(
hiddenTagsPreferences = hiddenTagsPreferences,
onDismiss = { showDialog = false }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HiddenTagsDialog(
hiddenTagsPreferences: HiddenTagsPreferences,
onDismiss: () -> Unit
) {
val presetStates by hiddenTagsPreferences.presetStates.collectAsState()
val customTags by hiddenTagsPreferences.customHiddenTags.collectAsState()
var customTagInput by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
title = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Tags cachés",
style = MaterialTheme.typography.headlineSmall
)
}
},
text = {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Info banner
item {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Les tags cachés sont des tags fonctionnels utilisés par l'application qui restent invisibles. Ils sont toujours opérationnels pour les fonctions internes.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Preset System Tags Section
item {
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "🏷️",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tags systèmes prédéfinis",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Activer/désactiver la visibilité des tags système",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Preset tag items
items(
count = HiddenTagsPreferences.PRESET_SYSTEM_TAGS.size,
key = { HiddenTagsPreferences.PRESET_SYSTEM_TAGS[it].tag }
) { index ->
val preset = HiddenTagsPreferences.PRESET_SYSTEM_TAGS[index]
val isHidden = presetStates[preset.tag] ?: preset.defaultHidden
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
hiddenTagsPreferences.setPresetHidden(preset.tag, !isHidden)
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isHidden,
onCheckedChange = { checked ->
hiddenTagsPreferences.setPresetHidden(preset.tag, checked)
},
colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.primary,
uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = preset.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = preset.description,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Custom Hidden Tags Section
item {
Spacer(modifier = Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "👁️",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tags personnalisés cachés",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Ajoutez vos propres tags à masquer (wildcard: note-*)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Input field for custom tags
item {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = customTagInput,
onValueChange = { customTagInput = it },
modifier = Modifier.weight(1f),
placeholder = {
Text(
"Nom du tag...",
style = MaterialTheme.typography.bodySmall
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
shape = RoundedCornerShape(12.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(8.dp))
FilledTonalButton(
onClick = {
if (customTagInput.isNotBlank()) {
hiddenTagsPreferences.addCustomHiddenTag(customTagInput)
customTagInput = ""
}
},
enabled = customTagInput.isNotBlank()
) {
Icon(
Icons.Default.Add,
contentDescription = "Ajouter",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Ajouter")
}
}
}
// Custom tags list (chips)
if (customTags.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
// Using FlowRow equivalent with wrapping
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
customTags.sorted().forEach { tag ->
InputChip(
selected = true,
onClick = { },
label = { Text(tag, style = MaterialTheme.typography.bodySmall) },
trailingIcon = {
IconButton(
onClick = { hiddenTagsPreferences.removeCustomHiddenTag(tag) },
modifier = Modifier.size(18.dp)
) {
Icon(
Icons.Default.Close,
contentDescription = "Supprimer",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.error
)
}
},
shape = RoundedCornerShape(20.dp),
colors = InputChipDefaults.inputChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
}
}
}
// Reset to defaults
item {
Spacer(modifier = Modifier.height(20.dp))
OutlinedButton(
onClick = { hiddenTagsPreferences.resetToDefaults() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Réinitialiser par défaut")
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Fermer")
}
}
)
}

View File

@ -4,6 +4,7 @@ import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.shaarit.core.storage.BiometricAuthManager import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.HiddenTagsPreferences
import com.shaarit.core.storage.SecurityPreferences import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.core.storage.TimezonePreferences import com.shaarit.core.storage.TimezonePreferences
import com.shaarit.core.storage.TokenManager import com.shaarit.core.storage.TokenManager
@ -37,7 +38,8 @@ class SettingsViewModel @Inject constructor(
val themePreferences: ThemePreferences, val themePreferences: ThemePreferences,
val securityPreferences: SecurityPreferences, val securityPreferences: SecurityPreferences,
val biometricAuthManager: BiometricAuthManager, val biometricAuthManager: BiometricAuthManager,
val timezonePreferences: TimezonePreferences val timezonePreferences: TimezonePreferences,
val hiddenTagsPreferences: HiddenTagsPreferences
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) private val _uiState = MutableStateFlow(SettingsUiState())

View File

@ -2,6 +2,7 @@ package com.shaarit.presentation.tags
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.shaarit.core.storage.HiddenTagsPreferences
import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ShaarliTag import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
@ -12,7 +13,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class TagsViewModel @Inject constructor(private val linkRepository: LinkRepository) : ViewModel() { class TagsViewModel @Inject constructor(
private val linkRepository: LinkRepository,
private val hiddenTagsPreferences: HiddenTagsPreferences
) : ViewModel() {
private val _uiState = MutableStateFlow<TagsUiState>(TagsUiState.Loading) private val _uiState = MutableStateFlow<TagsUiState>(TagsUiState.Loading)
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
@ -62,11 +66,14 @@ class TagsViewModel @Inject constructor(private val linkRepository: LinkReposito
if (state !is TagsUiState.Success) return emptyList() if (state !is TagsUiState.Success) return emptyList()
val query = _searchQuery.value.lowercase() val query = _searchQuery.value.lowercase()
return if (query.isBlank()) { val filteredBySearch = if (query.isBlank()) {
state.tags state.tags
} else { } else {
state.tags.filter { it.name.lowercase().contains(query) } state.tags.filter { it.name.lowercase().contains(query) }
} }
// Filter out hidden tags
return filteredBySearch.filter { !hiddenTagsPreferences.isTagHidden(it.name) }
} }
fun onTagSelected(tag: ShaarliTag) { fun onTagSelected(tag: ShaarliTag) {

View File

@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.ui.draw.paint
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@ -34,6 +35,7 @@ fun GlassCard(
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
glowColor: Color = MaterialTheme.colorScheme.primary, glowColor: Color = MaterialTheme.colorScheme.primary,
backgroundImage: Int? = null,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
@ -59,43 +61,26 @@ fun GlassCard(
animationSpec = tween(150) animationSpec = tween(150)
) )
val cardModifier = val glassBackground = Brush.verticalGradient(
modifier colors = listOf(
.graphicsLayer { MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (backgroundImage != null) 0.6f else 0.95f),
scaleX = animatedScale MaterialTheme.colorScheme.primaryContainer.copy(alpha = if (backgroundImage != null) 0.5f else 0.9f)
scaleY = animatedScale )
}
.shadow(
elevation = animatedElevation,
shape = RoundedCornerShape(16.dp),
ambientColor = glowColor.copy(alpha = 0.1f),
spotColor = glowColor.copy(alpha = 0.2f)
) )
.clip(RoundedCornerShape(16.dp))
.background( val cardModifier =
brush = modifier
Brush.verticalGradient( .graphicsLayer {
colors = scaleX = animatedScale
listOf( scaleY = animatedScale
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), }
MaterialTheme.colorScheme.primaryContainer.copy( .shadow(
alpha = 0.9f elevation = animatedElevation,
) shape = RoundedCornerShape(16.dp),
) ambientColor = glowColor.copy(alpha = 0.1f),
spotColor = glowColor.copy(alpha = 0.2f)
) )
) .clip(RoundedCornerShape(16.dp))
.border(
width = 1.dp,
brush =
Brush.linearGradient(
colors =
listOf(
borderColor,
borderColor.copy(alpha = 0.1f)
)
),
shape = RoundedCornerShape(16.dp)
)
val finalModifier = val finalModifier =
if (onClick != null || onLongClick != null) { if (onClick != null || onLongClick != null) {
@ -109,7 +94,36 @@ fun GlassCard(
cardModifier cardModifier
} }
Column(modifier = finalModifier.padding(16.dp), content = content) Box(modifier = finalModifier) {
if (backgroundImage != null) {
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(id = backgroundImage),
contentDescription = null,
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
alignment = Alignment.BottomCenter,
modifier = Modifier.matchParentSize()
)
}
// Background and border
Box(
modifier = Modifier
.matchParentSize()
.background(brush = glassBackground)
.border(
width = 1.dp,
brush = Brush.linearGradient(
colors = listOf(borderColor, borderColor.copy(alpha = 0.1f))
),
shape = RoundedCornerShape(16.dp)
)
)
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
content = content
)
}
} }
/** Premium gradient button with glow effect */ /** Premium gradient button with glow effect */

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@ -1,3 +1,3 @@
#Sun Feb 15 10:38:54 2026 #Sun Feb 22 14:59:52 2026
VERSION_NAME=2.1.8 VERSION_NAME=2.5.1
VERSION_CODE=23 VERSION_CODE=28