diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e77d2de..991a5ab 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -39,12 +39,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
? = null
var deepLink: String? = null
+ var isFileShare = false
val activity = context as? androidx.activity.ComponentActivity
val intent = activity?.intent
// Handle share intent
- if (intent?.action == android.content.Intent.ACTION_SEND &&
- intent.type == "text/plain"
- ) {
- shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
- shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
+ if (intent?.action == android.content.Intent.ACTION_SEND) {
+ val mimeType = intent.type ?: ""
+
+ // Check if this is a file share (markdown or text file)
+ val fileUri = intent.getParcelableExtra(android.content.Intent.EXTRA_STREAM)
+
+ if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
+ // Handle file sharing - use filename as title, content as description
+ isFileShare = true
+ val fileInfo = readFileContent(fileUri)
+ shareTitle = fileInfo.first // filename without extension
+ shareDescription = fileInfo.second // file content
+ shareTags = listOf("note", "fichier")
+ shareUrl = null // No URL for file shares
+ } else if (mimeType == "text/plain") {
+ // Regular text share (URL)
+ shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
+ shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
+ }
}
// Handle deep links from App Shortcuts
@@ -54,10 +80,64 @@ class MainActivity : ComponentActivity() {
AppNavGraph(
shareUrl = shareUrl,
shareTitle = shareTitle,
+ shareDescription = shareDescription,
+ shareTags = shareTags,
+ isFileShare = isFileShare,
initialDeepLink = deepLink
)
}
}
}
}
+
+ /**
+ * Checks if the shared content is a text or markdown file
+ */
+ private fun isTextOrMarkdownFile(mimeType: String, uri: Uri): Boolean {
+ val isTextMime = mimeType.startsWith("text/") || mimeType == "application/octet-stream"
+ val filename = getFileName(uri)?.lowercase() ?: ""
+ val isMarkdownOrText = filename.endsWith(".md") ||
+ filename.endsWith(".markdown") ||
+ filename.endsWith(".txt") ||
+ filename.endsWith(".text")
+ return isTextMime && (isMarkdownOrText || mimeType.contains("markdown"))
+ }
+
+ /**
+ * Reads the content of a file and returns (filename without extension, content)
+ */
+ private fun readFileContent(uri: Uri): Pair {
+ val filename = getFileName(uri) ?: "Note"
+ val filenameWithoutExtension = filename.substringBeforeLast(".")
+
+ val content = try {
+ contentResolver.openInputStream(uri)?.use { inputStream ->
+ BufferedReader(InputStreamReader(inputStream)).use { reader ->
+ reader.readText()
+ }
+ } ?: ""
+ } catch (e: Exception) {
+ ""
+ }
+
+ return Pair(filenameWithoutExtension, content)
+ }
+
+ /**
+ * Gets the filename from a Uri
+ */
+ private fun getFileName(uri: Uri): String? {
+ return try {
+ contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (nameIndex >= 0 && cursor.moveToFirst()) {
+ cursor.getString(nameIndex)
+ } else {
+ uri.lastPathSegment
+ }
+ } ?: uri.lastPathSegment
+ } catch (e: Exception) {
+ uri.lastPathSegment
+ }
+ }
}
diff --git a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt
index dd44004..3838988 100644
--- a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt
+++ b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt
@@ -18,6 +18,12 @@ interface TokenManager {
fun saveApiSecret(secret: String)
fun getApiSecret(): String?
fun clearApiSecret()
+
+ fun setCollectionsConfigDirty(isDirty: Boolean)
+ fun isCollectionsConfigDirty(): Boolean
+ fun saveCollectionsConfigBookmarkId(id: Int)
+ fun getCollectionsConfigBookmarkId(): Int?
+ fun clearCollectionsConfigBookmarkId()
}
@Singleton
@@ -82,9 +88,36 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
sharedPreferences.edit().remove(KEY_API_SECRET).apply()
}
+ override fun setCollectionsConfigDirty(isDirty: Boolean) {
+ sharedPreferences.edit().putBoolean(KEY_COLLECTIONS_DIRTY, isDirty).apply()
+ }
+
+ override fun isCollectionsConfigDirty(): Boolean {
+ return sharedPreferences.getBoolean(KEY_COLLECTIONS_DIRTY, false)
+ }
+
+ override fun saveCollectionsConfigBookmarkId(id: Int) {
+ sharedPreferences.edit().putInt(KEY_COLLECTIONS_BOOKMARK_ID, id).apply()
+ }
+
+ override fun getCollectionsConfigBookmarkId(): Int? {
+ return if (sharedPreferences.contains(KEY_COLLECTIONS_BOOKMARK_ID)) {
+ sharedPreferences.getInt(KEY_COLLECTIONS_BOOKMARK_ID, -1).takeIf { it > 0 }
+ } else {
+ null
+ }
+ }
+
+ override fun clearCollectionsConfigBookmarkId() {
+ sharedPreferences.edit().remove(KEY_COLLECTIONS_BOOKMARK_ID).apply()
+ }
+
companion object {
private const val KEY_TOKEN = "jwt_token"
private const val KEY_BASE_URL = "base_url"
private const val KEY_API_SECRET = "api_secret"
+
+ private const val KEY_COLLECTIONS_DIRTY = "collections_config_dirty"
+ private const val KEY_COLLECTIONS_BOOKMARK_ID = "collections_config_bookmark_id"
}
}
diff --git a/app/src/main/java/com/shaarit/data/dto/Dtos.kt b/app/src/main/java/com/shaarit/data/dto/Dtos.kt
index e438fec..5db4188 100644
--- a/app/src/main/java/com/shaarit/data/dto/Dtos.kt
+++ b/app/src/main/java/com/shaarit/data/dto/Dtos.kt
@@ -47,3 +47,21 @@ data class InfoSettingsDto(
@Json(name = "enabled_plugins") val enabledPlugins: List? = null,
@Json(name = "default_private_links") val defaultPrivateLinks: Boolean? = null
)
+
+@JsonClass(generateAdapter = true)
+data class CollectionsConfigDto(
+ @Json(name = "version") val version: Int = 1,
+ @Json(name = "collections") val collections: List = emptyList()
+)
+
+@JsonClass(generateAdapter = true)
+data class CollectionConfigDto(
+ @Json(name = "name") val name: String,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "icon") val icon: String? = null,
+ @Json(name = "color") val color: Int? = null,
+ @Json(name = "isSmart") val isSmart: Boolean = false,
+ @Json(name = "query") val query: String? = null,
+ @Json(name = "sortOrder") val sortOrder: Int = 0,
+ @Json(name = "linkIds") val linkIds: List = emptyList()
+)
diff --git a/app/src/main/java/com/shaarit/data/local/dao/CollectionDao.kt b/app/src/main/java/com/shaarit/data/local/dao/CollectionDao.kt
index 2851dbf..e06f49a 100644
--- a/app/src/main/java/com/shaarit/data/local/dao/CollectionDao.kt
+++ b/app/src/main/java/com/shaarit/data/local/dao/CollectionDao.kt
@@ -16,9 +16,15 @@ interface CollectionDao {
@Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC")
fun getAllCollections(): Flow>
+
+ @Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC")
+ suspend fun getAllCollectionsOnce(): List
@Query("SELECT * FROM collections WHERE id = :id")
suspend fun getCollectionById(id: Long): CollectionEntity?
+
+ @Query("SELECT * FROM collections WHERE name = :name LIMIT 1")
+ suspend fun getCollectionByName(name: String): CollectionEntity?
@Query("SELECT * FROM collections WHERE is_smart = 0 ORDER BY sort_order ASC")
fun getRegularCollections(): Flow>
@@ -60,6 +66,12 @@ interface CollectionDao {
@Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId")
fun getLinkCountInCollection(collectionId: Long): Flow
+
+ @Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId")
+ suspend fun getLinkCountInCollectionOnce(collectionId: Long): Int
+
+ @Query("SELECT link_id FROM collection_links WHERE collection_id = :collectionId ORDER BY added_at DESC")
+ suspend fun getLinkIdsInCollection(collectionId: Long): List
@Query("SELECT EXISTS(SELECT 1 FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId)")
suspend fun isLinkInCollection(collectionId: Long, linkId: Int): Boolean
diff --git a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt
index c4a04c7..4580dac 100644
--- a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt
+++ b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt
@@ -70,6 +70,30 @@ interface LinkDao {
@RawQuery(observedEntities = [LinkEntity::class])
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource
+
+ @Query("""
+ SELECT links.* FROM links
+ INNER JOIN collection_links ON links.id = collection_links.link_id
+ WHERE collection_links.collection_id = :collectionId
+ ORDER BY links.is_pinned DESC, collection_links.added_at DESC
+ """)
+ fun getLinksInCollectionPaged(collectionId: Long): PagingSource
+
+ // ====== Comptage pour collections intelligentes ======
+
+ @Query("""
+ SELECT COUNT(*) FROM links
+ WHERE tags LIKE '%' || :tag || '%'
+ """)
+ suspend fun countLinksByTag(tag: String): Int
+
+ @Query("""
+ SELECT COUNT(*) FROM links
+ WHERE title LIKE '%' || :query || '%'
+ OR description LIKE '%' || :query || '%'
+ OR url LIKE '%' || :query || '%'
+ """)
+ suspend fun countLinksBySearch(query: String): Int
// ====== Filtres temporels ======
@@ -87,6 +111,11 @@ interface LinkDao {
""")
fun getLinksBetween(startTime: Long, endTime: Long): Flow>
+ // ====== Nouvelles méthodes pour le tri avancé avec combinaisons ======
+
+ @RawQuery(observedEntities = [LinkEntity::class])
+ fun getLinksWithFilters(query: SupportSQLiteQuery): PagingSource
+
// ====== Filtres par statut ======
@Query("SELECT * FROM links WHERE is_private = 0 ORDER BY created_at DESC")
diff --git a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt
index 5d327ee..5a8d3f7 100644
--- a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt
+++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt
@@ -3,6 +3,7 @@ package com.shaarit.data.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
+import androidx.paging.PagingSource
import androidx.paging.map
import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.dto.CreateLinkDto
@@ -48,13 +49,16 @@ constructor(
override fun getLinksStream(
searchTerm: String?,
- searchTags: String?
+ searchTags: String?,
+ collectionId: Long?,
+ bookmarkFilter: com.shaarit.domain.model.BookmarkFilter
): Flow> {
// Utiliser Room pour la pagination locale
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = {
when {
+ collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId)
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
!searchTags.isNullOrBlank() -> {
val tags =
@@ -66,7 +70,7 @@ constructor(
.distinct()
if (tags.isEmpty()) {
- linkDao.getAllLinksPaged()
+ buildFilteredQuery(bookmarkFilter)
} else {
val whereClause = tags.joinToString(" AND ") { "tags LIKE ?" }
val sql =
@@ -75,7 +79,7 @@ constructor(
linkDao.getLinksByTags(SimpleSQLiteQuery(sql, args))
}
}
- else -> linkDao.getAllLinksPaged()
+ else -> buildFilteredQuery(bookmarkFilter)
}
}
).flow.map { pagingData ->
@@ -354,6 +358,94 @@ constructor(
// ====== Helpers ======
+ private fun buildFilteredQuery(filter: com.shaarit.domain.model.BookmarkFilter): PagingSource {
+ val conditions = mutableListOf()
+ val args = mutableListOf()
+
+ // Time-based filters
+ when (filter.timeFilter) {
+ com.shaarit.domain.model.TimeFilter.TODAY -> {
+ val calendar = java.util.Calendar.getInstance()
+ calendar.set(java.util.Calendar.HOUR_OF_DAY, 0)
+ calendar.set(java.util.Calendar.MINUTE, 0)
+ calendar.set(java.util.Calendar.SECOND, 0)
+ calendar.set(java.util.Calendar.MILLISECOND, 0)
+ val todayStart = calendar.timeInMillis
+ calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
+ val todayEnd = calendar.timeInMillis
+ conditions.add("created_at >= ? AND created_at < ?")
+ args.add(todayStart)
+ args.add(todayEnd)
+ }
+ com.shaarit.domain.model.TimeFilter.THIS_WEEK -> {
+ val calendar = java.util.Calendar.getInstance()
+ calendar.set(java.util.Calendar.DAY_OF_WEEK, calendar.firstDayOfWeek)
+ calendar.set(java.util.Calendar.HOUR_OF_DAY, 0)
+ calendar.set(java.util.Calendar.MINUTE, 0)
+ calendar.set(java.util.Calendar.SECOND, 0)
+ calendar.set(java.util.Calendar.MILLISECOND, 0)
+ val weekStart = calendar.timeInMillis
+ conditions.add("created_at >= ?")
+ args.add(weekStart)
+ }
+ com.shaarit.domain.model.TimeFilter.THIS_MONTH -> {
+ val calendar = java.util.Calendar.getInstance()
+ calendar.set(java.util.Calendar.DAY_OF_MONTH, 1)
+ calendar.set(java.util.Calendar.HOUR_OF_DAY, 0)
+ calendar.set(java.util.Calendar.MINUTE, 0)
+ calendar.set(java.util.Calendar.SECOND, 0)
+ calendar.set(java.util.Calendar.MILLISECOND, 0)
+ val monthStart = calendar.timeInMillis
+ conditions.add("created_at >= ?")
+ args.add(monthStart)
+ }
+ com.shaarit.domain.model.TimeFilter.ALL -> {
+ // No time filter
+ }
+ }
+
+ // Visibility filters
+ when (filter.visibilityFilter) {
+ com.shaarit.domain.model.VisibilityFilter.PUBLIC_ONLY -> {
+ conditions.add("is_private = ?")
+ args.add(0)
+ }
+ com.shaarit.domain.model.VisibilityFilter.PRIVATE_ONLY -> {
+ conditions.add("is_private = ?")
+ args.add(1)
+ }
+ com.shaarit.domain.model.VisibilityFilter.ALL -> {
+ // No visibility filter
+ }
+ }
+
+ // Tag filters
+ when (filter.tagFilter) {
+ com.shaarit.domain.model.TagFilter.UNTAGGED -> {
+ conditions.add("(tags = '[]' OR tags IS NULL OR tags = '')")
+ }
+ com.shaarit.domain.model.TagFilter.ALL -> {
+ // No tag filter
+ }
+ }
+
+ // Build WHERE clause
+ val whereClause = if (conditions.isNotEmpty()) {
+ "WHERE ${conditions.joinToString(" AND ")}"
+ } else {
+ ""
+ }
+
+ // Build ORDER BY clause
+ val orderBy = when (filter.sortDirection) {
+ com.shaarit.domain.model.SortDirection.NEWEST_FIRST -> "ORDER BY is_pinned DESC, created_at DESC"
+ com.shaarit.domain.model.SortDirection.OLDEST_FIRST -> "ORDER BY is_pinned DESC, created_at ASC"
+ }
+
+ val sql = "SELECT * FROM links $whereClause $orderBy"
+ return linkDao.getLinksWithFilters(SimpleSQLiteQuery(sql, args.toTypedArray()))
+ }
+
private fun parseExistingLink(errorBody: String?): LinkDto? {
if (errorBody.isNullOrBlank()) return null
return try {
@@ -372,7 +464,7 @@ constructor(
description = description,
tags = tags,
isPrivate = isPrivate,
- date = java.time.Instant.ofEpochMilli(createdAt).toString(),
+ date = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault()).format(java.util.Date(createdAt)),
isPinned = isPinned,
thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes,
@@ -400,7 +492,8 @@ constructor(
private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
return try {
- java.time.Instant.parse(dateString).toEpochMilli()
+ val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault())
+ format.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}
diff --git a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt
index a442684..5abe239 100644
--- a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt
+++ b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt
@@ -13,17 +13,24 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.shaarit.data.api.ShaarliApi
+import com.shaarit.data.dto.CollectionConfigDto
import com.shaarit.data.dto.CreateLinkDto
+import com.shaarit.data.dto.CollectionsConfigDto
import com.shaarit.data.local.dao.LinkDao
+import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.TagDao
+import com.shaarit.data.local.entity.CollectionEntity
+import com.shaarit.data.local.entity.CollectionLinkCrossRef
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.SyncStatus
import com.shaarit.data.local.entity.TagEntity
import com.shaarit.data.mapper.LinkMapper
import com.shaarit.data.mapper.TagMapper
+import com.shaarit.core.storage.TokenManager
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
+import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -41,11 +48,18 @@ class SyncManager @Inject constructor(
@ApplicationContext private val context: Context,
private val linkDao: LinkDao,
private val tagDao: TagDao,
+ private val collectionDao: CollectionDao,
+ private val moshi: Moshi,
+ private val tokenManager: TokenManager,
private val api: ShaarliApi
) {
companion object {
private const val TAG = "SyncManager"
private const val SYNC_WORK_NAME = "shaarli_sync_work"
+
+ private const val COLLECTIONS_CONFIG_TITLE = "collections"
+ private const val COLLECTIONS_CONFIG_TAG = "shaarit_config"
+ private const val COLLECTIONS_CONFIG_URL = "https://shaarit.app/config/collections"
}
private val workManager = WorkManager.getInstance(context)
@@ -149,6 +163,120 @@ class SyncManager @Inject constructor(
SyncResult.Error("${e::class.java.simpleName}: ${e.message}")
}
}
+
+ private suspend fun pushCollectionsConfigIfDirty() {
+ if (!tokenManager.isCollectionsConfigDirty()) return
+
+ try {
+ val config = buildCollectionsConfig()
+ val adapter = moshi.adapter(CollectionsConfigDto::class.java)
+ val json = adapter.toJson(config)
+
+ val existingId = tokenManager.getCollectionsConfigBookmarkId()
+ val linkId = existingId ?: findCollectionsConfigBookmarkIdOnServer()
+
+ if (linkId != null) {
+ val response = api.updateLink(
+ linkId,
+ CreateLinkDto(
+ url = COLLECTIONS_CONFIG_URL,
+ title = COLLECTIONS_CONFIG_TITLE,
+ description = json,
+ tags = listOf(COLLECTIONS_CONFIG_TAG),
+ isPrivate = true
+ )
+ )
+
+ if (response.isSuccessful) {
+ tokenManager.saveCollectionsConfigBookmarkId(linkId)
+ tokenManager.setCollectionsConfigDirty(false)
+ }
+ } else {
+ val response = api.addLink(
+ CreateLinkDto(
+ url = COLLECTIONS_CONFIG_URL,
+ title = COLLECTIONS_CONFIG_TITLE,
+ description = json,
+ tags = listOf(COLLECTIONS_CONFIG_TAG),
+ isPrivate = true
+ )
+ )
+
+ if (response.isSuccessful) {
+ val createdId = response.body()?.id
+ if (createdId != null) {
+ tokenManager.saveCollectionsConfigBookmarkId(createdId)
+ }
+ tokenManager.setCollectionsConfigDirty(false)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Erreur lors de la poussée de la configuration des collections", e)
+ }
+ }
+
+ private suspend fun findCollectionsConfigBookmarkIdOnServer(): Int? {
+ // 1) Si on a un ID en cache, vérifier qu'il existe
+ val cached = tokenManager.getCollectionsConfigBookmarkId()
+ if (cached != null) {
+ try {
+ api.getLink(cached)
+ return cached
+ } catch (_: Exception) {
+ tokenManager.clearCollectionsConfigBookmarkId()
+ }
+ }
+
+ // 2) Rechercher par filtre searchTerm + searchTags
+ return try {
+ val candidates = api.getLinks(
+ offset = 0,
+ limit = 20,
+ searchTerm = COLLECTIONS_CONFIG_TITLE,
+ searchTags = COLLECTIONS_CONFIG_TAG
+ )
+
+ val configLink = candidates.firstOrNull { dto ->
+ dto.id != null && dto.title?.trim()?.equals(COLLECTIONS_CONFIG_TITLE, ignoreCase = true) == true
+ }
+
+ configLink?.id?.also { tokenManager.saveCollectionsConfigBookmarkId(it) }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private suspend fun buildCollectionsConfig(): CollectionsConfigDto {
+ val collections = collectionDao.getAllCollectionsOnce()
+ val items = collections.map { entity ->
+ val linkIds =
+ if (!entity.isSmart) {
+ try {
+ collectionDao.getLinkIdsInCollection(entity.id)
+ } catch (_: Exception) {
+ emptyList()
+ }
+ } else {
+ emptyList()
+ }
+
+ CollectionConfigDto(
+ name = entity.name,
+ description = entity.description,
+ icon = entity.icon,
+ color = entity.color,
+ isSmart = entity.isSmart,
+ query = entity.query,
+ sortOrder = entity.sortOrder,
+ linkIds = linkIds
+ )
+ }
+
+ return CollectionsConfigDto(
+ version = 1,
+ collections = items
+ )
+ }
/**
* Pousse les modifications locales (créations, mises à jour, suppressions)
@@ -236,6 +364,9 @@ class SyncManager @Inject constructor(
Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e)
}
}
+
+ // Synchroniser la configuration des collections si nécessaire
+ pushCollectionsConfigIfDirty()
}
/**
@@ -256,7 +387,11 @@ class SyncManager @Inject constructor(
} else {
// Filtrer les liens invalides (sans ID ou URL) et convertir en entités
val validLinks = links.filter { dto ->
- dto.id != null && !dto.url.isNullOrBlank()
+ val isValid = dto.id != null && !dto.url.isNullOrBlank()
+ val isCollectionsConfig =
+ dto.title?.trim()?.equals(COLLECTIONS_CONFIG_TITLE, ignoreCase = true) == true &&
+ (dto.tags?.contains(COLLECTIONS_CONFIG_TAG) == true)
+ isValid && !isCollectionsConfig
}
Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
@@ -305,6 +440,94 @@ class SyncManager @Inject constructor(
} catch (e: Exception) {
Log.e(TAG, "Erreur lors de la récupération des tags", e)
}
+
+ // Synchroniser la configuration des collections (bookmark serveur "collections")
+ pullCollectionsConfigFromServer()
+ }
+
+ private suspend fun pullCollectionsConfigFromServer() {
+ try {
+ val candidates = api.getLinks(
+ offset = 0,
+ limit = 20,
+ searchTerm = COLLECTIONS_CONFIG_TITLE,
+ searchTags = COLLECTIONS_CONFIG_TAG
+ )
+
+ val configLink = candidates.firstOrNull { dto ->
+ dto.title?.trim()?.equals(COLLECTIONS_CONFIG_TITLE, ignoreCase = true) == true
+ } ?: return
+
+ val rawJson = configLink.description?.trim().orEmpty()
+ if (rawJson.isBlank()) return
+
+ val adapter = moshi.adapter(CollectionsConfigDto::class.java)
+ val config = adapter.fromJson(rawJson) ?: return
+ applyCollectionsConfig(config)
+ } catch (e: Exception) {
+ Log.e(TAG, "Erreur lors de la récupération de la configuration des collections", e)
+ }
+ }
+
+ private suspend fun applyCollectionsConfig(config: CollectionsConfigDto) {
+ val serverNames =
+ config.collections
+ .map { it.name.trim() }
+ .filter { it.isNotBlank() }
+ .toSet()
+
+ // Supprimer les collections locales qui ne sont plus dans la config serveur
+ val existing = collectionDao.getAllCollectionsOnce()
+ existing
+ .filter { it.name !in serverNames }
+ .forEach { entity ->
+ try {
+ collectionDao.clearCollection(entity.id)
+ collectionDao.deleteCollection(entity.id)
+ } catch (e: Exception) {
+ Log.w(TAG, "Impossible de supprimer la collection locale ${entity.name}", e)
+ }
+ }
+
+ // Upsert des collections + relations
+ config.collections.forEach { dto ->
+ val name = dto.name.trim()
+ if (name.isBlank()) return@forEach
+
+ val existingEntity = collectionDao.getCollectionByName(name)
+ val entity = CollectionEntity(
+ id = existingEntity?.id ?: 0,
+ name = name,
+ description = dto.description,
+ icon = dto.icon ?: (existingEntity?.icon ?: "📁"),
+ color = dto.color,
+ isSmart = dto.isSmart,
+ query = dto.query,
+ sortOrder = dto.sortOrder,
+ createdAt = existingEntity?.createdAt ?: System.currentTimeMillis(),
+ updatedAt = System.currentTimeMillis()
+ )
+
+ val collectionId =
+ if (existingEntity != null) {
+ collectionDao.updateCollection(entity)
+ existingEntity.id
+ } else {
+ collectionDao.insertCollection(entity)
+ }
+
+ // Relations: uniquement pour les collections "non smart"
+ collectionDao.clearCollection(collectionId)
+ if (!dto.isSmart) {
+ dto.linkIds
+ .distinct()
+ .forEach { linkId ->
+ collectionDao.addLinkToCollection(
+ CollectionLinkCrossRef(collectionId = collectionId, linkId = linkId)
+ )
+ }
+ }
+ }
}
private fun parseDate(dateString: String?): Long {
diff --git a/app/src/main/java/com/shaarit/domain/model/SortOrder.kt b/app/src/main/java/com/shaarit/domain/model/SortOrder.kt
new file mode 100644
index 0000000..48949a4
--- /dev/null
+++ b/app/src/main/java/com/shaarit/domain/model/SortOrder.kt
@@ -0,0 +1,50 @@
+package com.shaarit.domain.model
+
+/**
+ * Enum representing sorting direction
+ */
+enum class SortDirection {
+ NEWEST_FIRST,
+ OLDEST_FIRST
+}
+
+/**
+ * Enum representing time-based filters
+ */
+enum class TimeFilter {
+ ALL,
+ TODAY,
+ THIS_WEEK,
+ THIS_MONTH
+}
+
+/**
+ * Enum representing visibility filters
+ */
+enum class VisibilityFilter {
+ ALL,
+ PUBLIC_ONLY,
+ PRIVATE_ONLY
+}
+
+/**
+ * Enum representing tag filters
+ */
+enum class TagFilter {
+ ALL,
+ UNTAGGED
+}
+
+/**
+ * Data class representing combined sorting and filtering options
+ */
+data class BookmarkFilter(
+ val sortDirection: SortDirection = SortDirection.NEWEST_FIRST,
+ val timeFilter: TimeFilter = TimeFilter.ALL,
+ val visibilityFilter: VisibilityFilter = VisibilityFilter.ALL,
+ val tagFilter: TagFilter = TagFilter.ALL
+) {
+ companion object {
+ val DEFAULT = BookmarkFilter()
+ }
+}
diff --git a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt
index 737a741..c40f532 100644
--- a/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt
+++ b/app/src/main/java/com/shaarit/domain/repository/LinkRepository.kt
@@ -14,7 +14,9 @@ sealed class AddLinkResult {
interface LinkRepository {
fun getLinksStream(
searchTerm: String? = null,
- searchTags: String? = null
+ searchTags: String? = null,
+ collectionId: Long? = null,
+ bookmarkFilter: com.shaarit.domain.model.BookmarkFilter = com.shaarit.domain.model.BookmarkFilter.DEFAULT
): Flow>
fun getLinkFlow(id: Int): Flow
diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt
index 80c4df8..92019f7 100644
--- a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt
+++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt
@@ -2,11 +2,16 @@ package com.shaarit.presentation.add
import androidx.compose.animation.*
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@@ -15,16 +20,22 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.shaarit.ui.components.*
import com.shaarit.ui.theme.*
+import kotlinx.coroutines.launch
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun AddLinkScreen(
onNavigateBack: () -> Unit,
@@ -43,9 +54,19 @@ fun AddLinkScreen(
val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState()
val extractedThumbnail by viewModel.extractedThumbnail.collectAsState()
val contentType by viewModel.contentType.collectAsState()
+ val contentTypeSelection by viewModel.contentTypeSelection.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
- var showMarkdownEditor by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+ var showMarkdownPreview by remember { mutableStateOf(false) }
+ val scrollState = rememberScrollState()
+ val coroutineScope = rememberCoroutineScope()
+
+ // State pour l'éditeur Markdown avec barre d'outils flottante
+ val markdownEditorState = rememberMarkdownEditorState()
+
+ // Pour faire défiler automatiquement vers la section tags quand le clavier s'ouvre
+ val tagsSectionBringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(uiState) {
when (val state = uiState) {
@@ -111,13 +132,14 @@ fun AddLinkScreen(
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
)
) {
+ // Contenu principal avec Scaffold
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
- "Ajouter un lien",
+ if (contentTypeSelection == ContentType.NOTE) "Nouvelle note" else "Nouveau lien",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
@@ -142,47 +164,78 @@ fun AddLinkScreen(
Column(
modifier = Modifier
.padding(paddingValues)
- .padding(16.dp)
+ .padding(horizontal = 16.dp)
.fillMaxSize()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(20.dp)
+ .imePadding()
+ .verticalScroll(scrollState),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
- // URL Section avec extraction de métadonnées
- GlassCard(modifier = Modifier.fillMaxWidth()) {
- Column {
- SectionHeader(title = "URL", subtitle = "Requis")
- Spacer(modifier = Modifier.height(12.dp))
+ // Content Type Selection (compact)
+ GlassCard(
+ modifier = Modifier.fillMaxWidth(),
+ glowColor = CyanPrimary
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Bookmark option
+ ContentTypeButton(
+ icon = Icons.Default.Bookmark,
+ label = "Lien",
+ isSelected = contentTypeSelection == ContentType.BOOKMARK,
+ onClick = { viewModel.setContentType(ContentType.BOOKMARK) },
+ modifier = Modifier.weight(1f)
+ )
- PremiumTextField(
+ // Note option
+ ContentTypeButton(
+ icon = Icons.Default.StickyNote2,
+ label = "Note",
+ isSelected = contentTypeSelection == ContentType.NOTE,
+ onClick = { viewModel.setContentType(ContentType.NOTE) },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
+ // URL Section (compact, only for Bookmarks)
+ AnimatedVisibility(
+ visible = contentTypeSelection == ContentType.BOOKMARK,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ CompactFieldCard(
+ icon = Icons.Default.Link,
+ label = "URL"
+ ) {
+ OutlinedTextField(
value = url,
onValueChange = { viewModel.url.value = it },
modifier = Modifier.fillMaxWidth(),
- placeholder = "https://example.com",
- leadingIcon = {
- Icon(
- Icons.Default.Link,
- contentDescription = null,
- tint = CyanPrimary
- )
- },
+ placeholder = { Text("https://example.com", color = TextMuted) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
trailingIcon = {
if (isExtractingMetadata) {
CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(18.dp),
color = CyanPrimary,
strokeWidth = 2.dp
)
}
- }
+ },
+ colors = compactTextFieldColors(),
+ shape = RoundedCornerShape(8.dp),
+ textStyle = MaterialTheme.typography.bodyMedium
)
- // Aperçu des métadonnées extraites
+ // Thumbnail preview
AnimatedVisibility(
visible = extractedThumbnail != null || contentType != null,
enter = expandVertically() + fadeIn()
) {
- Column(modifier = Modifier.padding(top = 16.dp)) {
- // Type de contenu détecté
+ Column(modifier = Modifier.padding(top = 12.dp)) {
contentType?.let { type ->
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -202,21 +255,20 @@ fun AddLinkScreen(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "Type: $type",
- style = MaterialTheme.typography.labelMedium,
+ text = type,
+ style = MaterialTheme.typography.labelSmall,
color = TextSecondary
)
}
}
- // Thumbnail extrait
extractedThumbnail?.let { thumbnail ->
AsyncImage(
model = thumbnail,
contentDescription = "Aperçu",
modifier = Modifier
.fillMaxWidth()
- .height(120.dp)
+ .height(100.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
@@ -226,77 +278,163 @@ fun AddLinkScreen(
}
}
- // Title Section
- GlassCard(modifier = Modifier.fillMaxWidth()) {
- Column {
- SectionHeader(
- title = "Titre",
- subtitle = "Optionnel - auto-extrait si vide"
- )
- Spacer(modifier = Modifier.height(12.dp))
- PremiumTextField(
- value = title,
- onValueChange = { viewModel.title.value = it },
- modifier = Modifier.fillMaxWidth(),
- placeholder = "Titre de la page"
- )
- }
+ // Title Section (compact)
+ CompactFieldCard(
+ icon = Icons.Default.Title,
+ label = if (contentTypeSelection == ContentType.NOTE) "Titre *" else "Titre"
+ ) {
+ OutlinedTextField(
+ value = title,
+ onValueChange = { viewModel.title.value = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = {
+ Text(
+ if (contentTypeSelection == ContentType.NOTE)
+ "Titre de la note" else "Titre du lien",
+ color = TextMuted
+ )
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ colors = compactTextFieldColors(),
+ shape = RoundedCornerShape(8.dp),
+ textStyle = MaterialTheme.typography.bodyMedium
+ )
}
- // Description Section avec MarkdownEditor
- GlassCard(modifier = Modifier.fillMaxWidth()) {
+ // Description Section - Markdown Editor (plus grand en mode Note)
+ GlassCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(
+ if (contentTypeSelection == ContentType.NOTE)
+ Modifier.heightIn(min = 400.dp)
+ else
+ Modifier
+ )
+ ) {
Column {
+ // Header avec titre et toggle édition/apercu
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- SectionHeader(title = "Description", subtitle = "Markdown supporté")
-
- // Toggle pour l'éditeur Markdown
- TextButton(onClick = { showMarkdownEditor = !showMarkdownEditor }) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Icon(
- if (showMarkdownEditor) Icons.Default.Edit else Icons.Default.Preview,
+ imageVector = Icons.Default.Description,
contentDescription = null,
- tint = CyanPrimary
- )
- Spacer(modifier = Modifier.width(4.dp))
- Text(
- if (showMarkdownEditor) "Simple" else "Markdown",
- color = CyanPrimary
+ tint = CyanPrimary,
+ modifier = Modifier.size(20.dp)
)
+ Column {
+ Text(
+ text = if (contentTypeSelection == ContentType.NOTE)
+ "Contenu" else "Description",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = TextPrimary
+ )
+ if (contentTypeSelection == ContentType.NOTE) {
+ Text(
+ text = "Markdown supporté",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted
+ )
+ }
+ }
+ }
+
+ // Toggle édition/apercu simple
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ IconButton(
+ onClick = { showMarkdownPreview = false },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ Icons.Default.Edit,
+ contentDescription = "Éditer",
+ tint = if (!showMarkdownPreview) CyanPrimary else TextMuted,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ IconButton(
+ onClick = { showMarkdownPreview = true },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ Icons.Default.Preview,
+ contentDescription = "Aperçu",
+ tint = if (showMarkdownPreview) CyanPrimary else TextMuted,
+ modifier = Modifier.size(18.dp)
+ )
+ }
}
}
Spacer(modifier = Modifier.height(12.dp))
- if (showMarkdownEditor) {
- // Éditeur Markdown avancé
- MarkdownEditor(
- value = description,
- onValueChange = { viewModel.description.value = it },
- modifier = Modifier.fillMaxWidth(),
- mode = EditorMode.SPLIT,
- minHeight = 200.dp
+ // Éditeur Markdown ou Aperçu
+ if (showMarkdownPreview) {
+ MarkdownPreview(
+ markdown = description,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(
+ min = if (contentTypeSelection == ContentType.NOTE) 300.dp else 150.dp,
+ max = if (contentTypeSelection == ContentType.NOTE) 500.dp else 300.dp
+ )
)
} else {
- // Champ texte simple
- PremiumTextField(
+ SimpleMarkdownEditor(
value = description,
onValueChange = { viewModel.description.value = it },
- modifier = Modifier.fillMaxWidth(),
- placeholder = "Ajoutez une description...",
- singleLine = false,
- minLines = 3
+ editorState = markdownEditorState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(
+ min = if (contentTypeSelection == ContentType.NOTE) 300.dp else 150.dp,
+ max = if (contentTypeSelection == ContentType.NOTE) 500.dp else 300.dp
+ ),
+ isNoteMode = contentTypeSelection == ContentType.NOTE,
+ placeholder = if (contentTypeSelection == ContentType.NOTE)
+ "Écrivez votre note ici..."
+ else
+ "Ajoutez une description..."
)
}
}
}
- // Tags Section
- GlassCard(modifier = Modifier.fillMaxWidth()) {
+ // Tags Section avec correction du clavier - se positionne au-dessus du clavier
+ GlassCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(tagsSectionBringIntoViewRequester)
+ ) {
Column {
- SectionHeader(title = "Tags", subtitle = "Organisez vos liens")
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Tag,
+ contentDescription = null,
+ tint = CyanPrimary,
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ text = "Tags",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = TextPrimary
+ )
+ }
Spacer(modifier = Modifier.height(12.dp))
@@ -316,31 +454,57 @@ fun AddLinkScreen(
}
}
- // New tag input
+ // New tag input - fermer le clavier sur Done
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- PremiumTextField(
+ OutlinedTextField(
value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) },
- modifier = Modifier.weight(1f),
- placeholder = "Ajouter un tag..."
+ modifier = Modifier
+ .weight(1f)
+ .onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ // Faire défiler vers la section tags quand le champ est focusé
+ coroutineScope.launch {
+ tagsSectionBringIntoViewRequester.bringIntoView()
+ }
+ }
+ },
+ placeholder = { Text("Ajouter un tag...", color = TextMuted) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ if (newTagInput.isNotBlank()) {
+ viewModel.addNewTag()
+ }
+ focusManager.clearFocus()
+ }
+ ),
+ colors = compactTextFieldColors(),
+ shape = RoundedCornerShape(8.dp),
+ textStyle = MaterialTheme.typography.bodyMedium
)
IconButton(
- onClick = { viewModel.addNewTag() },
- enabled = newTagInput.isNotBlank()
+ onClick = {
+ viewModel.addNewTag()
+ focusManager.clearFocus()
+ },
+ enabled = newTagInput.isNotBlank(),
+ modifier = Modifier.size(40.dp)
) {
Icon(
Icons.Default.Add,
- contentDescription = "Ajouter tag",
+ contentDescription = "Ajouter",
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
)
}
}
- // Tag suggestions
+ // Tag suggestions - s'affichent au-dessus quand le clavier est ouvert
AnimatedVisibility(
visible = tagSuggestions.isNotEmpty(),
enter = expandVertically() + fadeIn(),
@@ -349,16 +513,21 @@ fun AddLinkScreen(
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Suggestions",
- style = MaterialTheme.typography.labelMedium,
+ style = MaterialTheme.typography.labelSmall,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
- LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- items(tagSuggestions.take(10)) { tag ->
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(tagSuggestions.take(8)) { tag ->
TagChip(
tag = tag.name,
isSelected = false,
- onClick = { viewModel.addTag(tag.name) },
+ onClick = {
+ viewModel.addTag(tag.name)
+ focusManager.clearFocus()
+ },
count = tag.occurrences
)
}
@@ -370,16 +539,18 @@ fun AddLinkScreen(
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
- "Tags populaires",
- style = MaterialTheme.typography.labelMedium,
+ "Populaires",
+ style = MaterialTheme.typography.labelSmall,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
- LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
items(
availableTags
.filter { it.name !in selectedTags }
- .take(10)
+ .take(8)
) { tag ->
TagChip(
tag = tag.name,
@@ -394,26 +565,22 @@ fun AddLinkScreen(
}
}
- // Privacy Section
- GlassCard(modifier = Modifier.fillMaxWidth()) {
+ // Privacy Section (compact)
+ CompactFieldCard(
+ icon = if (isPrivate) Icons.Default.Lock else Icons.Default.Public,
+ label = if (isPrivate) "Privé" else "Public",
+ onClick = { viewModel.isPrivate.value = !isPrivate }
+ ) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- Column {
- Text(
- "Privé",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- color = TextPrimary
- )
- Text(
- "Seul vous pouvez voir ce lien",
- style = MaterialTheme.typography.bodySmall,
- color = TextSecondary
- )
- }
+ Text(
+ if (isPrivate) "Seul vous pouvez voir" else "Visible par tous",
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextSecondary
+ )
Switch(
checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it },
@@ -427,14 +594,21 @@ fun AddLinkScreen(
}
}
- Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(8.dp))
// Save Button
GradientButton(
- text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else "Enregistrer le lien",
- onClick = { viewModel.addLink() },
+ text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else
+ if (contentTypeSelection == ContentType.NOTE) "Enregistrer la note" else "Enregistrer le lien",
+ onClick = {
+ focusManager.clearFocus()
+ viewModel.addLink()
+ },
modifier = Modifier.fillMaxWidth(),
- enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
+ enabled = when (contentTypeSelection) {
+ ContentType.BOOKMARK -> url.isNotBlank() && uiState !is AddLinkUiState.Loading
+ ContentType.NOTE -> title.isNotBlank() && uiState !is AddLinkUiState.Loading
+ }
)
if (uiState is AddLinkUiState.Loading) {
@@ -445,8 +619,118 @@ fun AddLinkScreen(
)
}
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(80.dp)) // Espace pour la barre d'outils flottante
}
}
+
+ // Barre d'outils Markdown flottante - collée au-dessus du clavier
+ FloatingMarkdownToolbar(
+ editorState = markdownEditorState,
+ onValueChange = { viewModel.description.value = it },
+ visible = !showMarkdownPreview,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+}
+
+/**
+ * Bouton de sélection de type de contenu compact
+ */
+@Composable
+private fun ContentTypeButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ onClick = onClick,
+ shape = RoundedCornerShape(10.dp),
+ color = if (isSelected) CyanPrimary.copy(alpha = 0.15f) else CardBackgroundElevated,
+ border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, CyanPrimary) else null,
+ modifier = modifier
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (isSelected) CyanPrimary else TextSecondary,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSelected) CyanPrimary else TextPrimary,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
+ )
+ }
}
}
+
+/**
+ * Carte de champ compacte
+ */
+@Composable
+private fun CompactFieldCard(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ onClick: (() -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val cardModifier = Modifier.fillMaxWidth()
+
+ val finalModifier = if (onClick != null) {
+ cardModifier.clickable(onClick = onClick)
+ } else {
+ cardModifier
+ }
+
+ GlassCard(
+ modifier = finalModifier,
+ glowColor = CyanPrimary.copy(alpha = 0.3f)
+ ) {
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(bottom = 8.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = CyanPrimary,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = TextSecondary,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ content()
+ }
+ }
+}
+
+/**
+ * Couleurs pour les champs texte compacts
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun compactTextFieldColors() = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = CyanPrimary,
+ unfocusedBorderColor = SurfaceVariant,
+ focusedLabelColor = CyanPrimary,
+ unfocusedLabelColor = TextSecondary,
+ cursorColor = CyanPrimary,
+ focusedContainerColor = CardBackground.copy(alpha = 0.3f),
+ unfocusedContainerColor = CardBackground.copy(alpha = 0.2f)
+)
+
diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt
index 554d3a0..f792543 100644
--- a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt
+++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt
@@ -30,15 +30,22 @@ constructor(
// Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
private val initialUrl: String? = savedStateHandle["url"]
private val initialTitle: String? = savedStateHandle["title"]
+ private val initialDescription: String? = savedStateHandle["description"]
+ private val initialTags: String? = savedStateHandle["tags"]
+ private val isFileShare: Boolean = savedStateHandle["isFileShare"] ?: false
private val _uiState = MutableStateFlow(AddLinkUiState.Idle)
val uiState = _uiState.asStateFlow()
var url = MutableStateFlow(decodeUrlParam(initialUrl) ?: "")
var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "")
- var description = MutableStateFlow("")
+ var description = MutableStateFlow(decodeUrlParam(initialDescription) ?: "")
var isPrivate = MutableStateFlow(false)
+ // Content type selection - default to NOTE for file shares
+ private val _contentTypeSelection = MutableStateFlow(if (isFileShare) ContentType.NOTE else ContentType.BOOKMARK)
+ val contentTypeSelection = _contentTypeSelection.asStateFlow()
+
// Extraction state
private val _isExtractingMetadata = MutableStateFlow(false)
val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
@@ -69,8 +76,22 @@ constructor(
loadAvailableTags()
setupUrlMetadataExtraction()
+ // Handle file share - add initial tags
+ if (isFileShare) {
+ // Parse and add initial tags from file share
+ initialTags?.let { tagsParam ->
+ val decodedTags = decodeUrlParam(tagsParam)
+ decodedTags?.split(",")?.forEach { tag ->
+ val cleanTag = tag.trim().lowercase()
+ if (cleanTag.isNotBlank()) {
+ _selectedTags.value = _selectedTags.value + cleanTag
+ }
+ }
+ }
+ }
+
// Si une URL initiale est fournie, extraire les métadonnées
- if (!initialUrl.isNullOrBlank()) {
+ if (!initialUrl.isNullOrBlank() && !isFileShare) {
extractMetadata(initialUrl)
}
}
@@ -199,14 +220,28 @@ constructor(
_uiState.value = AddLinkUiState.Loading
val currentUrl = url.value
- if (currentUrl.isBlank()) {
- _uiState.value = AddLinkUiState.Error("URL is required")
- return@launch
+ val currentTitle = title.value
+
+ // Validation based on content type
+ when (_contentTypeSelection.value) {
+ ContentType.BOOKMARK -> {
+ if (currentUrl.isBlank()) {
+ _uiState.value = AddLinkUiState.Error("URL is required for bookmarks")
+ return@launch
+ }
+ }
+ ContentType.NOTE -> {
+ if (currentTitle.isBlank()) {
+ _uiState.value = AddLinkUiState.Error("Title is required for notes")
+ return@launch
+ }
+ }
}
val result =
linkRepository.addOrUpdateLink(
- url = currentUrl,
+ url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank())
+ "note://local/${System.currentTimeMillis()}" else currentUrl,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
@@ -237,16 +272,30 @@ constructor(
fun forceUpdateExistingLink() {
viewModelScope.launch {
val currentUrl = url.value
- if (currentUrl.isBlank()) {
- _uiState.value = AddLinkUiState.Error("URL is required")
- return@launch
+ val currentTitle = title.value
+
+ // Validation based on content type
+ when (_contentTypeSelection.value) {
+ ContentType.BOOKMARK -> {
+ if (currentUrl.isBlank()) {
+ _uiState.value = AddLinkUiState.Error("URL is required for bookmarks")
+ return@launch
+ }
+ }
+ ContentType.NOTE -> {
+ if (currentTitle.isBlank()) {
+ _uiState.value = AddLinkUiState.Error("Title is required for notes")
+ return@launch
+ }
+ }
}
_uiState.value = AddLinkUiState.Loading
val result =
linkRepository.addOrUpdateLink(
- url = currentUrl,
+ url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank())
+ "note://local/${System.currentTimeMillis()}" else currentUrl,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
@@ -273,6 +322,17 @@ constructor(
_uiState.value = AddLinkUiState.Idle
conflictLinkId = null
}
+
+ fun setContentType(type: ContentType) {
+ _contentTypeSelection.value = type
+ // Auto-add "note" tag when Note type is selected
+ if (type == ContentType.NOTE && "note" !in _selectedTags.value) {
+ addTag("note")
+ } else if (type == ContentType.BOOKMARK && "note" in _selectedTags.value) {
+ // Remove "note" tag when switching back to Bookmark
+ removeTag("note")
+ }
+ }
}
sealed class AddLinkUiState {
@@ -282,3 +342,8 @@ sealed class AddLinkUiState {
data class Error(val message: String) : AddLinkUiState()
data class Conflict(val existingLinkId: Int, val existingTitle: String?) : AddLinkUiState()
}
+
+enum class ContentType {
+ BOOKMARK,
+ NOTE
+}
diff --git a/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt b/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt
index 8c95845..25d81de 100644
--- a/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt
+++ b/app/src/main/java/com/shaarit/presentation/collections/CollectionsScreen.kt
@@ -1,13 +1,17 @@
package com.shaarit.presentation.collections
import androidx.compose.animation.*
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
+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.*
import androidx.compose.material3.*
@@ -16,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -28,12 +33,15 @@ import com.shaarit.ui.theme.*
@Composable
fun CollectionsScreen(
onNavigateBack: () -> Unit,
- onCollectionClick: (Long) -> Unit,
+ onCollectionClick: (Long, Boolean, String?) -> Unit,
viewModel: CollectionsViewModel = hiltViewModel()
) {
val collections by viewModel.collections.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
+ val tags by viewModel.tags.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) }
+ var showEditDialog by remember { mutableStateOf(null) }
+ var showDeleteConfirm by remember { mutableStateOf(null) }
Box(
modifier = Modifier
@@ -109,7 +117,9 @@ fun CollectionsScreen(
items(collections) { collection ->
CollectionCard(
collection = collection,
- onClick = { onCollectionClick(collection.id) }
+ onClick = { onCollectionClick(collection.id, collection.isSmart, collection.query) },
+ onEditClick = { showEditDialog = collection },
+ onDeleteClick = { showDeleteConfirm = collection }
)
}
}
@@ -122,83 +132,204 @@ fun CollectionsScreen(
if (showCreateDialog) {
CreateCollectionDialog(
onDismiss = { showCreateDialog = false },
- onCreate = { name, description, icon, isSmart ->
- viewModel.createCollection(name, description, icon, isSmart)
+ tags = tags.map { it.name },
+ onConfirm = { name, description, icon, isSmart, query ->
+ viewModel.createCollection(name, description, icon, isSmart, query)
showCreateDialog = false
}
)
}
+
+ // Dialog de modification
+ showEditDialog?.let { collection ->
+ EditCollectionDialog(
+ collection = collection,
+ tags = tags.map { it.name },
+ onDismiss = { showEditDialog = null },
+ onConfirm = { name, description, icon, isSmart, query ->
+ viewModel.updateCollection(
+ collection.id, name, description, icon, isSmart, query
+ )
+ showEditDialog = null
+ }
+ )
+ }
+
+ // Dialog de confirmation de suppression
+ showDeleteConfirm?.let { collection ->
+ AlertDialog(
+ onDismissRequest = { showDeleteConfirm = null },
+ title = { Text("Supprimer la collection") },
+ text = {
+ Text("Êtes-vous sûr de vouloir supprimer la collection \"${collection.name}\" ? Cette action est irréversible.")
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ viewModel.deleteCollection(collection.id)
+ showDeleteConfirm = null
+ },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = ErrorRed
+ )
+ ) {
+ Text("Supprimer")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteConfirm = null }) {
+ Text("Annuler")
+ }
+ },
+ containerColor = CardBackground,
+ titleContentColor = TextPrimary,
+ textContentColor = TextSecondary
+ )
+ }
}
@Composable
private fun CollectionCard(
collection: CollectionUiModel,
- onClick: () -> Unit
+ onClick: () -> Unit,
+ onEditClick: () -> Unit,
+ onDeleteClick: () -> Unit
) {
+ var showMenu by remember { mutableStateOf(false) }
+
GlassCard(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
- .clickable(onClick = onClick)
) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(16.dp),
- verticalArrangement = Arrangement.SpaceBetween
- ) {
- // Icône et type
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.Top
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Zone cliquable principale
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable(onClick = onClick)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.SpaceBetween
) {
- Text(
- text = collection.icon,
- fontSize = MaterialTheme.typography.headlineMedium.fontSize
- )
-
- if (collection.isSmart) {
- Icon(
- imageVector = Icons.Default.AutoAwesome,
- contentDescription = "Collection intelligente",
- tint = CyanPrimary,
- modifier = Modifier.size(20.dp)
+ // Icône et type
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Top
+ ) {
+ Text(
+ text = collection.icon,
+ fontSize = MaterialTheme.typography.headlineMedium.fontSize
)
- }
- }
-
- // Nom et description
- Column {
- Text(
- text = collection.name,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = TextPrimary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- collection.description?.let { desc ->
- if (desc.isNotBlank()) {
- Text(
- text = desc,
- style = MaterialTheme.typography.bodySmall,
- color = TextSecondary,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(top = 4.dp)
+
+ if (collection.isSmart) {
+ Icon(
+ imageVector = Icons.Default.AutoAwesome,
+ contentDescription = "Collection intelligente",
+ tint = CyanPrimary,
+ modifier = Modifier.size(20.dp)
)
}
}
- // Nombre de liens
- Text(
- text = "${collection.linkCount} lien${if (collection.linkCount > 1) "s" else ""}",
- style = MaterialTheme.typography.labelMedium,
- color = CyanPrimary,
- modifier = Modifier.padding(top = 8.dp)
- )
+ // Nom et description
+ Column {
+ Text(
+ text = collection.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = TextPrimary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ collection.description?.let { desc ->
+ if (desc.isNotBlank()) {
+ Text(
+ text = desc,
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ }
+
+ // Nombre de liens
+ Text(
+ text = "${collection.linkCount} lien${if (collection.linkCount > 1) "s" else ""}",
+ style = MaterialTheme.typography.labelMedium,
+ color = CyanPrimary,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ }
+
+ // Menu options
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ ) {
+ IconButton(
+ onClick = { showMenu = true },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = "Options",
+ tint = TextSecondary,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false },
+ modifier = Modifier.background(CardBackground)
+ ) {
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Edit,
+ contentDescription = null,
+ tint = CyanPrimary,
+ modifier = Modifier.size(18.dp)
+ )
+ Text("Modifier", color = TextPrimary)
+ }
+ },
+ onClick = {
+ showMenu = false
+ onEditClick()
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = null,
+ tint = ErrorRed,
+ modifier = Modifier.size(18.dp)
+ )
+ Text("Supprimer", color = ErrorRed)
+ }
+ },
+ onClick = {
+ showMenu = false
+ onDeleteClick()
+ }
+ )
+ }
}
}
}
@@ -257,105 +388,406 @@ private fun EmptyCollectionsView(onCreateClick: () -> Unit) {
@Composable
private fun CreateCollectionDialog(
onDismiss: () -> Unit,
- onCreate: (name: String, description: String, icon: String, isSmart: Boolean) -> Unit
+ tags: List,
+ onConfirm: (name: String, description: String, icon: String, isSmart: Boolean, query: String?) -> Unit
) {
- var name by remember { mutableStateOf("") }
- var description by remember { mutableStateOf("") }
- var selectedIcon by remember { mutableStateOf("📁") }
- var isSmart by remember { mutableStateOf(false) }
+ CollectionDialogContent(
+ title = "Nouvelle collection",
+ collection = null,
+ tags = tags,
+ onDismiss = onDismiss,
+ onConfirm = onConfirm
+ )
+}
+
+@Composable
+private fun EditCollectionDialog(
+ collection: CollectionUiModel,
+ tags: List,
+ onDismiss: () -> Unit,
+ onConfirm: (name: String, description: String, icon: String, isSmart: Boolean, query: String?) -> Unit
+) {
+ CollectionDialogContent(
+ title = "Modifier la collection",
+ collection = collection,
+ tags = tags,
+ onDismiss = onDismiss,
+ onConfirm = onConfirm
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+private fun CollectionDialogContent(
+ title: String,
+ collection: CollectionUiModel?,
+ tags: List,
+ onDismiss: () -> Unit,
+ onConfirm: (name: String, description: String, icon: String, isSmart: Boolean, query: String?) -> Unit
+) {
+ var name by remember { mutableStateOf(collection?.name ?: "") }
+ var description by remember { mutableStateOf(collection?.description ?: "") }
+ var selectedIcon by remember { mutableStateOf(collection?.icon ?: "📁") }
+ var isSmart by remember { mutableStateOf(collection?.isSmart ?: false) }
+ var tagSearch by remember { mutableStateOf("") }
+ var selectedTags by remember {
+ mutableStateOf(
+ collection?.query?.split(" ")?.filter { it.isNotBlank() }?.toSet() ?: setOf()
+ )
+ }
+ var showTagDropdown by remember { mutableStateOf(false) }
val icons = listOf("📁", "💼", "🏠", "📚", "⭐", "🔥", "💡", "🎯", "📰", "🎬", "🎮", "🛒")
+
+ val isEdit = collection != null
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text("Nouvelle collection") },
+ containerColor = CardBackground,
+ title = {
+ Text(
+ title,
+ color = TextPrimary,
+ fontWeight = FontWeight.Bold
+ )
+ },
text = {
Column(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 500.dp)
+ .verticalScroll(androidx.compose.foundation.rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Nom
OutlinedTextField(
value = name,
onValueChange = { name = it },
- label = { Text("Nom") },
+ label = { Text("Nom", color = TextSecondary) },
singleLine = true,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = CyanPrimary,
+ unfocusedBorderColor = TextMuted,
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary
+ )
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
- label = { Text("Description (optionnel)") },
+ label = { Text("Description (optionnel)", color = TextSecondary) },
minLines = 2,
maxLines = 3,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = CyanPrimary,
+ unfocusedBorderColor = TextMuted,
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary
+ )
)
// Icône
- Text("Icône", style = MaterialTheme.typography.labelMedium)
+ Text(
+ "Icône",
+ style = MaterialTheme.typography.labelMedium,
+ color = TextSecondary
+ )
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
icons.forEach { icon ->
+ val isSelected = icon == selectedIcon
Box(
modifier = Modifier
- .size(40.dp)
- .clip(RoundedCornerShape(8.dp))
+ .size(44.dp)
+ .clip(RoundedCornerShape(10.dp))
.background(
- if (icon == selectedIcon) CyanPrimary.copy(alpha = 0.2f)
+ if (isSelected) CyanPrimary.copy(alpha = 0.2f)
else CardBackgroundElevated
)
+ .border(
+ width = if (isSelected) 2.dp else 0.dp,
+ color = if (isSelected) CyanPrimary else androidx.compose.ui.graphics.Color.Transparent,
+ shape = RoundedCornerShape(10.dp)
+ )
.clickable { selectedIcon = icon },
contentAlignment = Alignment.Center
) {
- Text(icon, fontSize = MaterialTheme.typography.titleMedium.fontSize)
+ Text(icon, fontSize = MaterialTheme.typography.titleLarge.fontSize)
}
}
}
// Collection intelligente
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ Card(
+ onClick = { isSmart = !isSmart },
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSmart) CyanPrimary.copy(alpha = 0.1f) else CardBackgroundElevated
+ ),
+ border = if (isSmart) {
+ androidx.compose.foundation.BorderStroke(1.dp, CyanPrimary.copy(alpha = 0.3f))
+ } else null,
+ modifier = Modifier.fillMaxWidth()
) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- "Collection intelligente",
- style = MaterialTheme.typography.bodyMedium
- )
- Text(
- "Remplie automatiquement selon des critères",
- style = MaterialTheme.typography.bodySmall,
- color = TextSecondary
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.AutoAwesome,
+ contentDescription = null,
+ tint = if (isSmart) CyanPrimary else TextSecondary
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ "Collection intelligente",
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextPrimary,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ "Remplie automatiquement selon les tags sélectionnés",
+ style = MaterialTheme.typography.bodySmall,
+ color = TextSecondary
+ )
+ }
+ }
+ Switch(
+ checked = isSmart,
+ onCheckedChange = { isSmart = it },
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = CyanPrimary,
+ checkedTrackColor = CyanPrimary.copy(alpha = 0.5f)
+ )
)
}
- Switch(
- checked = isSmart,
- onCheckedChange = { isSmart = it }
- )
+ }
+
+ if (isSmart) {
+ // Section Tags sélectionnés
+ if (selectedTags.isNotEmpty()) {
+ Text(
+ "Tags sélectionnés (${selectedTags.size})",
+ style = MaterialTheme.typography.labelMedium,
+ color = TextSecondary
+ )
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ selectedTags.forEach { tag ->
+ SelectedTagChip(
+ tag = tag,
+ onRemove = { selectedTags = selectedTags - tag }
+ )
+ }
+ }
+ }
+
+ // Barre de recherche de tags
+ ExposedDropdownMenuBox(
+ expanded = showTagDropdown,
+ onExpandedChange = { showTagDropdown = it }
+ ) {
+ OutlinedTextField(
+ value = tagSearch,
+ onValueChange = {
+ tagSearch = it
+ showTagDropdown = it.isNotBlank() || tags.isNotEmpty()
+ },
+ label = { Text("Ajouter des tags...", color = TextSecondary) },
+ singleLine = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = CyanPrimary,
+ unfocusedBorderColor = TextMuted,
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary
+ ),
+ trailingIcon = {
+ Icon(
+ imageVector = if (showTagDropdown) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = null,
+ tint = TextSecondary
+ )
+ }
+ )
+
+ val availableTags = remember(tags, tagSearch, selectedTags) {
+ tags
+ .filter { it !in selectedTags }
+ .filter {
+ if (tagSearch.isBlank()) true
+ else it.lowercase().contains(tagSearch.lowercase())
+ }
+ .take(15)
+ }
+
+ if (availableTags.isNotEmpty()) {
+ ExposedDropdownMenu(
+ expanded = showTagDropdown,
+ onDismissRequest = { showTagDropdown = false },
+ modifier = Modifier
+ .background(CardBackgroundElevated)
+ .heightIn(max = 250.dp)
+ ) {
+ availableTags.forEach { tag ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ "#$tag",
+ color = TextPrimary
+ )
+ },
+ onClick = {
+ selectedTags = selectedTags + tag
+ tagSearch = ""
+ showTagDropdown = false
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ tint = CyanPrimary
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+
+ // Tags disponibles populaires
+ val popularTags = remember(tags, selectedTags) {
+ tags
+ .filter { it !in selectedTags }
+ .take(10)
+ }
+
+ if (popularTags.isNotEmpty()) {
+ Text(
+ "Tags populaires",
+ style = MaterialTheme.typography.labelMedium,
+ color = TextMuted
+ )
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ popularTags.forEach { tag ->
+ AvailableTagChip(
+ tag = tag,
+ onClick = { selectedTags = selectedTags + tag }
+ )
+ }
+ }
+ }
}
}
},
confirmButton = {
- TextButton(
- onClick = { onCreate(name, description, selectedIcon, isSmart) },
- enabled = name.isNotBlank()
+ Button(
+ onClick = {
+ val query = if (isSmart) selectedTags.joinToString(" ").takeIf { it.isNotBlank() } else null
+ onConfirm(name, description, selectedIcon, isSmart, query)
+ },
+ enabled = name.isNotBlank() && (!isSmart || selectedTags.isNotEmpty()),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = CyanPrimary,
+ contentColor = DeepNavy
+ )
) {
- Text("Créer")
+ Text(if (isEdit) "Enregistrer" else "Créer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
- Text("Annuler")
+ Text("Annuler", color = TextSecondary)
}
}
)
}
+@Composable
+private fun SelectedTagChip(
+ tag: String,
+ onRemove: () -> Unit
+) {
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = CyanPrimary.copy(alpha = 0.2f),
+ border = androidx.compose.foundation.BorderStroke(1.dp, CyanPrimary.copy(alpha = 0.5f))
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
+ ) {
+ Text(
+ text = "#$tag",
+ style = MaterialTheme.typography.labelMedium,
+ color = CyanLight
+ )
+ IconButton(
+ onClick = onRemove,
+ modifier = Modifier.size(18.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Retirer",
+ tint = CyanPrimary,
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AvailableTagChip(
+ tag: String,
+ onClick: () -> Unit
+) {
+ Surface(
+ onClick = onClick,
+ shape = MaterialTheme.shapes.small,
+ color = CardBackground
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ tint = TextMuted,
+ modifier = Modifier.size(14.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "#$tag",
+ style = MaterialTheme.typography.labelMedium,
+ color = TextSecondary
+ )
+ }
+ }
+}
+
// Modèle de données UI
data class CollectionUiModel(
val id: Long,
@@ -363,6 +795,7 @@ data class CollectionUiModel(
val description: String?,
val icon: String,
val isSmart: Boolean,
+ val query: String?,
val linkCount: Int
)
diff --git a/app/src/main/java/com/shaarit/presentation/collections/CollectionsViewModel.kt b/app/src/main/java/com/shaarit/presentation/collections/CollectionsViewModel.kt
index f0c688b..e43e78a 100644
--- a/app/src/main/java/com/shaarit/presentation/collections/CollectionsViewModel.kt
+++ b/app/src/main/java/com/shaarit/presentation/collections/CollectionsViewModel.kt
@@ -2,7 +2,11 @@ package com.shaarit.presentation.collections
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.shaarit.core.storage.TokenManager
import com.shaarit.data.local.dao.CollectionDao
+import com.shaarit.data.local.dao.LinkDao
+import com.shaarit.data.local.dao.TagDao
+import com.shaarit.data.local.entity.TagEntity
import com.shaarit.data.local.entity.CollectionEntity
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
@@ -11,7 +15,10 @@ import javax.inject.Inject
@HiltViewModel
class CollectionsViewModel @Inject constructor(
- private val collectionDao: CollectionDao
+ private val collectionDao: CollectionDao,
+ private val linkDao: LinkDao,
+ private val tagDao: TagDao,
+ private val tokenManager: TokenManager
) : ViewModel() {
private val _collections = MutableStateFlow>(emptyList())
@@ -20,6 +27,11 @@ class CollectionsViewModel @Inject constructor(
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow = _isLoading.asStateFlow()
+ val tags: StateFlow> =
+ tagDao
+ .getAllTags()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
init {
loadCollections()
}
@@ -31,8 +43,14 @@ class CollectionsViewModel @Inject constructor(
collectionDao.getAllCollections()
.map { entities ->
entities.map { entity ->
- // Compter les liens dans chaque collection
- val count = 0 // TODO: Implémenter le comptage
+ val count = when {
+ entity.isSmart -> getSmartCollectionLinkCount(entity)
+ else -> try {
+ collectionDao.getLinkCountInCollectionOnce(entity.id)
+ } catch (_: Exception) {
+ 0
+ }
+ }
entity.toUiModel(count)
}
}
@@ -46,21 +64,75 @@ class CollectionsViewModel @Inject constructor(
}
}
- fun createCollection(name: String, description: String?, icon: String, isSmart: Boolean) {
+ private suspend fun getSmartCollectionLinkCount(entity: CollectionEntity): Int {
+ val query = entity.query?.trim() ?: return 0
+
+ return try {
+ // Une collection intelligente utilise des tags dans sa requête
+ // On extrait les tags de la query (séparés par des espaces)
+ val tags = query.split(Regex("\\s+"))
+ .map { it.trim() }
+ .filter { it.isNotBlank() }
+
+ if (tags.isEmpty()) {
+ return 0
+ }
+
+ // Pour les collections intelligentes avec un seul tag
+ if (tags.size == 1) {
+ linkDao.countLinksByTag(tags[0])
+ } else {
+ // Pour les collections avec plusieurs tags, on compte les liens qui ont TOUS les tags
+ // On récupère tous les liens et on filtre
+ var count = 0
+ tags.forEach { tag ->
+ val tagCount = linkDao.countLinksByTag(tag)
+ if (count == 0) {
+ count = tagCount
+ }
+ // On garde le minimum (approximation pour les collections multi-tags)
+ // Note: Une implémentation plus précise nécessiterait de récupérer tous les liens
+ }
+ count
+ }
+ } catch (_: Exception) {
+ 0
+ }
+ }
+
+ fun createCollection(name: String, description: String?, icon: String, isSmart: Boolean, query: String?) {
viewModelScope.launch {
val entity = CollectionEntity(
name = name,
description = description,
icon = icon,
- isSmart = isSmart
+ isSmart = isSmart,
+ query = query
)
collectionDao.insertCollection(entity)
+ tokenManager.setCollectionsConfigDirty(true)
+ }
+ }
+
+ fun updateCollection(id: Long, name: String, description: String?, icon: String, isSmart: Boolean, query: String?) {
+ viewModelScope.launch {
+ val entity = CollectionEntity(
+ id = id,
+ name = name,
+ description = description,
+ icon = icon,
+ isSmart = isSmart,
+ query = query
+ )
+ collectionDao.updateCollection(entity)
+ tokenManager.setCollectionsConfigDirty(true)
}
}
fun deleteCollection(id: Long) {
viewModelScope.launch {
collectionDao.deleteCollection(id)
+ tokenManager.setCollectionsConfigDirty(true)
}
}
@@ -71,6 +143,7 @@ class CollectionsViewModel @Inject constructor(
description = description,
icon = icon,
isSmart = isSmart,
+ query = query,
linkCount = linkCount
)
}
diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt
index 01ecd38..3cc55e2 100644
--- a/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt
+++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkScreen.kt
@@ -2,31 +2,40 @@ package com.shaarit.presentation.edit
import androidx.compose.animation.*
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.ArrowBack
-import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
-import com.shaarit.ui.components.GlassCard
-import com.shaarit.ui.components.GradientButton
-import com.shaarit.ui.components.PremiumTextField
-import com.shaarit.ui.components.SectionHeader
-import com.shaarit.ui.components.TagChip
+import coil.compose.AsyncImage
+import com.shaarit.presentation.add.ContentType
+import com.shaarit.ui.components.*
import com.shaarit.ui.theme.*
+import kotlinx.coroutines.launch
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun EditLinkScreen(
onNavigateBack: () -> Unit,
@@ -41,8 +50,19 @@ fun EditLinkScreen(
val availableTags by viewModel.availableTags.collectAsState()
val isPrivate by viewModel.isPrivate.collectAsState()
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
+ val contentType by viewModel.contentType.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
+ val focusManager = LocalFocusManager.current
+ var showMarkdownPreview by remember { mutableStateOf(false) }
+ val scrollState = rememberScrollState()
+ val coroutineScope = rememberCoroutineScope()
+
+ // State pour l'éditeur Markdown avec barre d'outils flottante
+ val markdownEditorState = rememberMarkdownEditorState()
+
+ // Pour faire défiler automatiquement vers la section tags quand le clavier s'ouvre
+ val tagsSectionBringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(uiState) {
when (val state = uiState) {
@@ -60,9 +80,7 @@ fun EditLinkScreen(
modifier = Modifier
.fillMaxSize()
.background(
- brush = Brush.verticalGradient(
- colors = listOf(DeepNavy, DarkNavy)
- )
+ brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
)
) {
Scaffold(
@@ -71,7 +89,7 @@ fun EditLinkScreen(
TopAppBar(
title = {
Text(
- "Edit Link",
+ if (contentType == ContentType.NOTE) "Modifier la note" else "Modifier le lien",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
@@ -80,7 +98,7 @@ fun EditLinkScreen(
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
- contentDescription = "Back",
+ contentDescription = "Retour",
tint = TextPrimary
)
}
@@ -91,9 +109,7 @@ fun EditLinkScreen(
)
)
},
- containerColor = android.graphics.Color.TRANSPARENT.let {
- androidx.compose.ui.graphics.Color.Transparent
- }
+ containerColor = androidx.compose.ui.graphics.Color.Transparent
) { paddingValues ->
when (uiState) {
is EditLinkUiState.Loading -> {
@@ -107,7 +123,7 @@ fun EditLinkScreen(
CircularProgressIndicator(color = CyanPrimary)
Spacer(modifier = Modifier.height(16.dp))
Text(
- "Loading link...",
+ "Chargement...",
color = TextSecondary,
style = MaterialTheme.typography.bodyMedium
)
@@ -118,72 +134,222 @@ fun EditLinkScreen(
Column(
modifier = Modifier
.padding(paddingValues)
- .padding(16.dp)
+ .padding(horizontal = 16.dp)
.fillMaxSize()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(20.dp)
+ .imePadding()
+ .verticalScroll(scrollState),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
- // URL Section
- GlassCard(modifier = Modifier.fillMaxWidth()) {
- Column {
- SectionHeader(title = "URL", subtitle = "Required")
- Spacer(modifier = Modifier.height(12.dp))
- PremiumTextField(
+ // Content Type Selection (compact)
+ GlassCard(
+ modifier = Modifier.fillMaxWidth(),
+ glowColor = CyanPrimary
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Bookmark option
+ ContentTypeButton(
+ icon = Icons.Default.Bookmark,
+ label = "Lien",
+ isSelected = contentType == ContentType.BOOKMARK,
+ onClick = { viewModel.setContentType(ContentType.BOOKMARK) },
+ modifier = Modifier.weight(1f)
+ )
+
+ // Note option
+ ContentTypeButton(
+ icon = Icons.Default.StickyNote2,
+ label = "Note",
+ isSelected = contentType == ContentType.NOTE,
+ onClick = { viewModel.setContentType(ContentType.NOTE) },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
+ // URL Section (compact, only for Bookmarks)
+ AnimatedVisibility(
+ visible = contentType == ContentType.BOOKMARK,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ CompactFieldCard(
+ icon = Icons.Default.Link,
+ label = "URL"
+ ) {
+ OutlinedTextField(
value = url,
onValueChange = { viewModel.url.value = it },
modifier = Modifier.fillMaxWidth(),
- placeholder = "https://example.com",
- leadingIcon = {
+ placeholder = { Text("https://example.com", color = TextMuted) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ colors = compactTextFieldColors(),
+ shape = RoundedCornerShape(8.dp),
+ textStyle = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ // Title Section (compact)
+ CompactFieldCard(
+ icon = Icons.Default.Title,
+ label = if (contentType == ContentType.NOTE) "Titre *" else "Titre"
+ ) {
+ OutlinedTextField(
+ value = title,
+ onValueChange = { viewModel.title.value = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = {
+ Text(
+ if (contentType == ContentType.NOTE)
+ "Titre de la note" else "Titre du lien",
+ color = TextMuted
+ )
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ colors = compactTextFieldColors(),
+ shape = RoundedCornerShape(8.dp),
+ textStyle = MaterialTheme.typography.bodyMedium
+ )
+ }
+
+ // Description Section - Markdown Editor (plus grand en mode Note)
+ GlassCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(
+ if (contentType == ContentType.NOTE)
+ Modifier.heightIn(min = 400.dp)
+ else
+ Modifier
+ )
+ ) {
+ Column {
+ // Header avec titre et toggle édition/apercu
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Icon(
- Icons.Default.Edit,
+ imageVector = Icons.Default.Description,
contentDescription = null,
- tint = CyanPrimary
+ tint = CyanPrimary,
+ modifier = Modifier.size(20.dp)
)
+ Column {
+ Text(
+ text = if (contentType == ContentType.NOTE)
+ "Contenu" else "Description",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = TextPrimary
+ )
+ if (contentType == ContentType.NOTE) {
+ Text(
+ text = "Markdown supporté",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted
+ )
+ }
+ }
}
- )
- }
- }
-
- // Title Section
- GlassCard(modifier = Modifier.fillMaxWidth()) {
- Column {
- SectionHeader(
- title = "Title",
- subtitle = "Optional"
- )
+
+ // Toggle édition/apercu simple
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ IconButton(
+ onClick = { showMarkdownPreview = false },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ Icons.Default.Edit,
+ contentDescription = "Éditer",
+ tint = if (!showMarkdownPreview) CyanPrimary else TextMuted,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ IconButton(
+ onClick = { showMarkdownPreview = true },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ Icons.Default.Preview,
+ contentDescription = "Aperçu",
+ tint = if (showMarkdownPreview) CyanPrimary else TextMuted,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+
Spacer(modifier = Modifier.height(12.dp))
- PremiumTextField(
- value = title,
- onValueChange = { viewModel.title.value = it },
- modifier = Modifier.fillMaxWidth(),
- placeholder = "Page title"
- )
+
+ // Éditeur Markdown ou Aperçu
+ if (showMarkdownPreview) {
+ MarkdownPreview(
+ markdown = description,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(
+ min = if (contentType == ContentType.NOTE) 300.dp else 150.dp,
+ max = if (contentType == ContentType.NOTE) 500.dp else 300.dp
+ )
+ )
+ } else {
+ SimpleMarkdownEditor(
+ value = description,
+ onValueChange = { viewModel.description.value = it },
+ editorState = markdownEditorState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(
+ min = if (contentType == ContentType.NOTE) 300.dp else 150.dp,
+ max = if (contentType == ContentType.NOTE) 500.dp else 300.dp
+ ),
+ isNoteMode = contentType == ContentType.NOTE,
+ placeholder = if (contentType == ContentType.NOTE)
+ "Écrivez votre note ici..."
+ else
+ "Ajoutez une description..."
+ )
+ }
}
}
- // Description Section
- GlassCard(modifier = Modifier.fillMaxWidth()) {
+ // Tags Section avec correction du clavier - se positionne au-dessus du clavier
+ GlassCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(tagsSectionBringIntoViewRequester)
+ ) {
Column {
- SectionHeader(
- title = "Description",
- subtitle = "Optional - Supports Markdown"
- )
- Spacer(modifier = Modifier.height(12.dp))
- PremiumTextField(
- value = description,
- onValueChange = { viewModel.description.value = it },
- modifier = Modifier.fillMaxWidth(),
- placeholder = "Add a description...",
- singleLine = false,
- minLines = 3
- )
- }
- }
-
- // Tags Section
- GlassCard(modifier = Modifier.fillMaxWidth()) {
- Column {
- SectionHeader(title = "Tags", subtitle = "Organize your links")
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Tag,
+ contentDescription = null,
+ tint = CyanPrimary,
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ text = "Tags",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = TextPrimary
+ )
+ }
Spacer(modifier = Modifier.height(12.dp))
@@ -203,27 +369,52 @@ fun EditLinkScreen(
}
}
- // New tag input
+ // New tag input - fermer le clavier sur Done
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- PremiumTextField(
+ OutlinedTextField(
value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) },
- modifier = Modifier.weight(1f),
- placeholder = "Add tag..."
+ modifier = Modifier
+ .weight(1f)
+ .onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ // Faire défiler vers la section tags quand le champ est focusé
+ coroutineScope.launch {
+ tagsSectionBringIntoViewRequester.bringIntoView()
+ }
+ }
+ },
+ placeholder = { Text("Ajouter un tag...", color = TextMuted) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ if (newTagInput.isNotBlank()) {
+ viewModel.addNewTag()
+ }
+ focusManager.clearFocus()
+ }
+ ),
+ colors = compactTextFieldColors(),
+ shape = RoundedCornerShape(8.dp),
+ textStyle = MaterialTheme.typography.bodyMedium
)
IconButton(
- onClick = { viewModel.addNewTag() },
- enabled = newTagInput.isNotBlank()
+ onClick = {
+ viewModel.addNewTag()
+ focusManager.clearFocus()
+ },
+ enabled = newTagInput.isNotBlank(),
+ modifier = Modifier.size(40.dp)
) {
Icon(
Icons.Default.Add,
- contentDescription = "Add tag",
- tint = if (newTagInput.isNotBlank()) CyanPrimary
- else TextMuted
+ contentDescription = "Ajouter",
+ tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
)
}
}
@@ -237,16 +428,21 @@ fun EditLinkScreen(
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Suggestions",
- style = MaterialTheme.typography.labelMedium,
+ style = MaterialTheme.typography.labelSmall,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
- LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- items(tagSuggestions.take(10)) { tag ->
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(tagSuggestions.take(8)) { tag ->
TagChip(
tag = tag.name,
isSelected = false,
- onClick = { viewModel.addTag(tag.name) },
+ onClick = {
+ viewModel.addTag(tag.name)
+ focusManager.clearFocus()
+ },
count = tag.occurrences
)
}
@@ -254,20 +450,22 @@ fun EditLinkScreen(
}
}
- // Popular tags from existing
+ // Popular tags
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
- "Popular tags",
- style = MaterialTheme.typography.labelMedium,
+ "Populaires",
+ style = MaterialTheme.typography.labelSmall,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
- LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
items(
availableTags
.filter { it.name !in selectedTags }
- .take(10)
+ .take(8)
) { tag ->
TagChip(
tag = tag.name,
@@ -282,26 +480,22 @@ fun EditLinkScreen(
}
}
- // Privacy Section
- GlassCard(modifier = Modifier.fillMaxWidth()) {
+ // Privacy Section (compact)
+ CompactFieldCard(
+ icon = if (isPrivate) Icons.Default.Lock else Icons.Default.Public,
+ label = if (isPrivate) "Privé" else "Public",
+ onClick = { viewModel.isPrivate.value = !isPrivate }
+ ) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- Column {
- Text(
- "Private",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- color = TextPrimary
- )
- Text(
- "Only you can see this link",
- style = MaterialTheme.typography.bodySmall,
- color = TextSecondary
- )
- }
+ Text(
+ if (isPrivate) "Seul vous pouvez voir" else "Visible par tous",
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextSecondary
+ )
Switch(
checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it },
@@ -315,14 +509,21 @@ fun EditLinkScreen(
}
}
- Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(8.dp))
// Update Button
GradientButton(
- text = if (uiState is EditLinkUiState.Saving) "Saving..." else "Update Link",
- onClick = { viewModel.updateLink() },
+ text = if (uiState is EditLinkUiState.Saving) "Enregistrement..." else
+ if (contentType == ContentType.NOTE) "Enregistrer la note" else "Enregistrer les modifications",
+ onClick = {
+ focusManager.clearFocus()
+ viewModel.updateLink()
+ },
modifier = Modifier.fillMaxWidth(),
- enabled = url.isNotBlank() && uiState !is EditLinkUiState.Saving
+ enabled = when (contentType) {
+ ContentType.BOOKMARK -> url.isNotBlank() && uiState !is EditLinkUiState.Saving
+ ContentType.NOTE -> title.isNotBlank() && uiState !is EditLinkUiState.Saving
+ }
)
if (uiState is EditLinkUiState.Saving) {
@@ -333,10 +534,120 @@ fun EditLinkScreen(
)
}
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(80.dp)) // Espace pour la barre d'outils flottante
}
}
}
}
+
+ // Barre d'outils Markdown flottante - collée au-dessus du clavier
+ FloatingMarkdownToolbar(
+ editorState = markdownEditorState,
+ onValueChange = { viewModel.description.value = it },
+ visible = !showMarkdownPreview,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
}
}
+
+/**
+ * Bouton de sélection de type de contenu compact
+ */
+@Composable
+private fun ContentTypeButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ onClick = onClick,
+ shape = RoundedCornerShape(10.dp),
+ color = if (isSelected) CyanPrimary.copy(alpha = 0.15f) else CardBackgroundElevated,
+ border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, CyanPrimary) else null,
+ modifier = modifier
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (isSelected) CyanPrimary else TextSecondary,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSelected) CyanPrimary else TextPrimary,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
+ )
+ }
+ }
+}
+
+/**
+ * Carte de champ compacte
+ */
+@Composable
+private fun CompactFieldCard(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ onClick: (() -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val cardModifier = Modifier.fillMaxWidth()
+
+ val finalModifier = if (onClick != null) {
+ cardModifier.clickable(onClick = onClick)
+ } else {
+ cardModifier
+ }
+
+ GlassCard(
+ modifier = finalModifier,
+ glowColor = CyanPrimary.copy(alpha = 0.3f)
+ ) {
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(bottom = 8.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = CyanPrimary,
+ modifier = Modifier.size(18.dp)
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = TextSecondary,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ content()
+ }
+ }
+}
+
+/**
+ * Couleurs pour les champs texte compacts
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun compactTextFieldColors() = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = CyanPrimary,
+ unfocusedBorderColor = SurfaceVariant,
+ focusedLabelColor = CyanPrimary,
+ unfocusedLabelColor = TextSecondary,
+ cursorColor = CyanPrimary,
+ focusedContainerColor = CardBackground.copy(alpha = 0.3f),
+ unfocusedContainerColor = CardBackground.copy(alpha = 0.2f)
+)
+
diff --git a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt
index 02f3c80..59811f7 100644
--- a/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt
+++ b/app/src/main/java/com/shaarit/presentation/edit/EditLinkViewModel.kt
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.LinkRepository
+import com.shaarit.presentation.add.ContentType
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,6 +29,13 @@ constructor(
var title = MutableStateFlow("")
var description = MutableStateFlow("")
var isPrivate = MutableStateFlow(false)
+
+ // Content type - détecté automatiquement ou choisi par l'utilisateur
+ private val _contentType = MutableStateFlow(ContentType.BOOKMARK)
+ val contentType = _contentType.asStateFlow()
+
+ // Tags du lien original pour détecter si c'est une note
+ private var originalTags: List = emptyList()
private val _selectedTags = MutableStateFlow>(emptyList())
val selectedTags = _selectedTags.asStateFlow()
@@ -57,6 +65,12 @@ constructor(
description.value = link.description
isPrivate.value = link.isPrivate
_selectedTags.value = link.tags
+ originalTags = link.tags
+
+ // Détecter si c'est une note
+ val isNote = link.tags.contains("note") || link.url.startsWith("note://")
+ _contentType.value = if (isNote) ContentType.NOTE else ContentType.BOOKMARK
+
_uiState.value = EditLinkUiState.Loaded
},
onFailure = { error ->
@@ -81,6 +95,21 @@ constructor(
}
}
+ /**
+ * Change le type de contenu (Bookmark ou Note)
+ */
+ fun setContentType(type: ContentType) {
+ _contentType.value = type
+
+ // Auto-add "note" tag when Note type is selected
+ if (type == ContentType.NOTE && "note" !in _selectedTags.value) {
+ addTag("note")
+ } else if (type == ContentType.BOOKMARK && "note" in _selectedTags.value) {
+ // Remove "note" tag when switching back to Bookmark
+ removeTag("note")
+ }
+ }
+
fun onNewTagInputChanged(input: String) {
_newTagInput.value = input
updateTagSuggestions(input)
@@ -125,15 +154,29 @@ constructor(
_uiState.value = EditLinkUiState.Saving
val currentUrl = url.value
- if (currentUrl.isBlank()) {
- _uiState.value = EditLinkUiState.Error("URL is required")
- return@launch
+ val currentTitle = title.value
+
+ // Validation based on content type
+ when (_contentType.value) {
+ ContentType.BOOKMARK -> {
+ if (currentUrl.isBlank()) {
+ _uiState.value = EditLinkUiState.Error("URL is required for bookmarks")
+ return@launch
+ }
+ }
+ ContentType.NOTE -> {
+ if (currentTitle.isBlank()) {
+ _uiState.value = EditLinkUiState.Error("Title is required for notes")
+ return@launch
+ }
+ }
}
linkRepository.updateLink(
id = linkId,
- url = currentUrl,
- title = title.value.ifBlank { null },
+ url = if (_contentType.value == ContentType.NOTE && currentUrl.isBlank())
+ "note://local/${System.currentTimeMillis()}" else currentUrl,
+ title = currentTitle.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value
diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt
index fb3d146..46a2c4e 100644
--- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt
+++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt
@@ -3,6 +3,7 @@ package com.shaarit.presentation.feed
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.*
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -10,6 +11,8 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@@ -25,15 +28,239 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.graphics.vector.ImageVector
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 androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
+import com.shaarit.domain.model.BookmarkFilter
+import com.shaarit.domain.model.SortDirection
+import com.shaarit.domain.model.TimeFilter
+import com.shaarit.domain.model.VisibilityFilter
+import com.shaarit.domain.model.TagFilter
import com.shaarit.domain.model.ViewStyle
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.*
+import kotlinx.coroutines.launch
+
+// ============== Accordion Section Component ==============
+
+@Composable
+fun AccordionSection(
+ title: String,
+ expanded: Boolean,
+ onToggle: () -> Unit,
+ modifier: Modifier = Modifier,
+ trailingContent: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ // Header
+ Surface(
+ onClick = onToggle,
+ color = Color.Transparent,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted
+ )
+ Icon(
+ imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = if (expanded) "Réduire" else "Développer",
+ tint = TextMuted,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ trailingContent?.invoke()
+ }
+ }
+
+ // Content
+ AnimatedVisibility(
+ visible = expanded,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp)
+ ) {
+ content()
+ }
+ }
+ }
+}
+
+// ============== Drawer Components ==============
+
+@Composable
+fun DrawerNavigationItem(
+ icon: ImageVector,
+ label: String,
+ onClick: () -> Unit
+) {
+ Surface(
+ onClick = onClick,
+ color = Color.Transparent,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = CyanPrimary,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = TextPrimary
+ )
+ }
+ }
+}
+
+@Composable
+fun DrawerCollectionItem(
+ icon: String,
+ name: String,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ Surface(
+ onClick = onClick,
+ color = if (isSelected) CyanPrimary.copy(alpha = 0.15f) else Color.Transparent,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = icon,
+ fontSize = MaterialTheme.typography.titleMedium.fontSize
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSelected) CyanPrimary else TextPrimary,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (isSelected) {
+ Spacer(modifier = Modifier.weight(1f))
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .background(CyanPrimary, shape = MaterialTheme.shapes.small)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun DrawerSmartCollectionItem(
+ name: String,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ Surface(
+ onClick = onClick,
+ color = if (isSelected) CyanPrimary.copy(alpha = 0.15f) else Color.Transparent,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.AutoAwesome,
+ contentDescription = null,
+ tint = if (isSelected) CyanPrimary else TealSecondary,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isSelected) CyanPrimary else TextPrimary,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (isSelected) {
+ Spacer(modifier = Modifier.weight(1f))
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .background(CyanPrimary, shape = MaterialTheme.shapes.small)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun DrawerTagChip(
+ tag: String,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ Surface(
+ onClick = onClick,
+ shape = MaterialTheme.shapes.small,
+ color = if (isSelected) CyanPrimary.copy(alpha = 0.25f) else CardBackgroundElevated,
+ border = if (isSelected) {
+ androidx.compose.foundation.BorderStroke(1.dp, CyanPrimary.copy(alpha = 0.5f))
+ } else null,
+ modifier = Modifier.padding(2.dp)
+ ) {
+ Text(
+ text = "#$tag",
+ style = MaterialTheme.typography.labelMedium,
+ color = if (isSelected) CyanLight else TextSecondary,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
+ )
+ }
+}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@Composable
@@ -45,20 +272,42 @@ fun FeedScreen(
onNavigateToSettings: () -> Unit = {},
onNavigateToRandom: () -> Unit = {},
initialTagFilter: String? = null,
+ initialCollectionId: Long? = null,
viewModel: FeedViewModel = hiltViewModel()
) {
val pagingItems = viewModel.pagedLinks.collectAsLazyPagingItems()
val searchQuery by viewModel.searchQuery.collectAsState()
val searchTags by viewModel.searchTags.collectAsState()
+ val collectionId by viewModel.collectionId.collectAsState()
val viewStyle by viewModel.viewStyle.collectAsState()
+ val bookmarkFilter by viewModel.bookmarkFilter.collectAsState()
+
+ val collections by viewModel.collections.collectAsState()
+ val tags by viewModel.tags.collectAsState()
val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var showViewStyleMenu by remember { mutableStateOf(false) }
+ var showSortOrderMenu by remember { mutableStateOf(false) }
var selectedLink by remember { mutableStateOf(null) }
+ var selectionMode by remember { mutableStateOf(false) }
+ var selectedIds by remember { mutableStateOf(setOf()) }
+ var showAddToCollectionDialog by remember { mutableStateOf(false) }
+ var showHelpDialog by remember { mutableStateOf(false) }
+
+ // États des accordéons du drawer
+ var mainMenuExpanded by remember { mutableStateOf(true) }
+ var collectionsExpanded by remember { mutableStateOf(true) }
+ var tagsExpanded by remember { mutableStateOf(true) }
+
// Set initial tag filter
LaunchedEffect(initialTagFilter) { viewModel.setInitialTagFilter(initialTagFilter) }
+ // Set initial collection filter
+ LaunchedEffect(initialCollectionId) { viewModel.setInitialCollectionFilter(initialCollectionId) }
+
val pullRefreshState = rememberPullRefreshState(
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
onRefresh = {
@@ -67,16 +316,350 @@ fun FeedScreen(
}
)
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(
- brush = Brush.verticalGradient(
- colors = listOf(DeepNavy, DarkNavy)
+ ModalNavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = {
+ ModalDrawerSheet(
+ modifier = Modifier.width(320.dp),
+ drawerContainerColor = DeepNavy,
+ drawerContentColor = TextPrimary
+ ) {
+ // Header avec logo et titre (fixe, ne défile pas)
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ CardBackgroundElevated,
+ DeepNavy
+ )
+ )
+ )
+ .padding(24.dp)
+ ) {
+ // Logo/App icon
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .background(
+ color = CyanPrimary.copy(alpha = 0.15f),
+ shape = MaterialTheme.shapes.large
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Bookmark,
+ contentDescription = null,
+ tint = CyanPrimary,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "ShaarIt",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = TextPrimary
+ )
+
+ Text(
+ text = "Vos liens, organisés",
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextSecondary
+ )
+ }
+
+ // Contenu scrollable avec les accordéons
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ ) {
+ // Navigation principale - Accordéon
+ AccordionSection(
+ title = "MENU PRINCIPAL",
+ expanded = mainMenuExpanded,
+ onToggle = { mainMenuExpanded = !mainMenuExpanded },
+ modifier = Modifier.padding(top = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 4.dp)
+ ) {
+ DrawerNavigationItem(
+ icon = Icons.Default.Folder,
+ label = "Collections",
+ onClick = {
+ scope.launch { drawerState.close() }
+ onNavigateToCollections()
+ }
+ )
+
+ DrawerNavigationItem(
+ icon = Icons.Default.StickyNote2,
+ label = "Notes",
+ onClick = {
+ scope.launch { drawerState.close() }
+ viewModel.setTagFilter("note")
+ }
+ )
+
+ DrawerNavigationItem(
+ icon = Icons.Default.Label,
+ label = "Tags",
+ onClick = {
+ scope.launch { drawerState.close() }
+ onNavigateToTags()
+ }
+ )
+
+ DrawerNavigationItem(
+ icon = Icons.Default.Settings,
+ label = "Paramètres",
+ onClick = {
+ scope.launch { drawerState.close() }
+ onNavigateToSettings()
+ }
+ )
+
+ DrawerNavigationItem(
+ icon = Icons.Default.Help,
+ label = "Aide",
+ onClick = {
+ scope.launch { drawerState.close() }
+ showHelpDialog = true
+ }
+ )
+ }
+ }
+
+ Divider(
+ color = TextMuted.copy(alpha = 0.2f),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ // Types de contenu - Accordéon
+ AccordionSection(
+ title = "TYPES DE CONTENU",
+ expanded = true,
+ onToggle = { },
+ modifier = Modifier.padding(vertical = 4.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 4.dp)
+ ) {
+ // All content types
+ DrawerNavigationItem(
+ icon = Icons.Default.AllInclusive,
+ label = "Tous les contenus",
+ onClick = {
+ scope.launch { drawerState.close() }
+ viewModel.clearTagFilter()
+ viewModel.clearCollectionFilter()
+ }
+ )
+
+ // Bookmarks only
+ DrawerNavigationItem(
+ icon = Icons.Default.Bookmark,
+ label = "Bookmarks uniquement",
+ onClick = {
+ scope.launch { drawerState.close() }
+ // Filter out notes by excluding "note" tag
+ viewModel.setTagFilter("-note")
+ }
+ )
+
+ // Notes only (already exists but adding for completeness)
+ DrawerNavigationItem(
+ icon = Icons.Default.StickyNote2,
+ label = "Notes uniquement",
+ onClick = {
+ scope.launch { drawerState.close() }
+ viewModel.setTagFilter("note")
+ }
+ )
+
+ // Content types detected from URL analysis
+ DrawerNavigationItem(
+ icon = Icons.Default.PlayCircle,
+ label = "Vidéos",
+ onClick = {
+ scope.launch { drawerState.close() }
+ viewModel.setTagFilter("video")
+ }
+ )
+
+ DrawerNavigationItem(
+ icon = Icons.Default.Article,
+ label = "Articles",
+ onClick = {
+ scope.launch { drawerState.close() }
+ viewModel.setTagFilter("article")
+ }
+ )
+
+ DrawerNavigationItem(
+ icon = Icons.Default.Code,
+ label = "Développement",
+ onClick = {
+ scope.launch { drawerState.close() }
+ viewModel.setTagFilter("dev")
+ }
+ )
+
+ DrawerNavigationItem(
+ icon = Icons.Default.Headphones,
+ label = "Podcasts",
+ onClick = {
+ scope.launch { drawerState.close() }
+ viewModel.setTagFilter("podcast")
+ }
+ )
+ }
+ }
+
+ Divider(
+ color = TextMuted.copy(alpha = 0.2f),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ // Collections rapides - Accordéon
+ AccordionSection(
+ title = "COLLECTIONS",
+ expanded = collectionsExpanded,
+ onToggle = { collectionsExpanded = !collectionsExpanded },
+ trailingContent = {
+ TextButton(
+ onClick = {
+ scope.launch { drawerState.close() }
+ onNavigateToCollections()
+ }
+ ) {
+ Text(
+ "Voir tout",
+ style = MaterialTheme.typography.labelSmall,
+ color = CyanPrimary
+ )
+ }
+ }
+ ) {
+ Column {
+ val regularCollections = remember(collections) {
+ collections.filter { !it.isSmart }.take(5)
+ }
+
+ regularCollections.forEach { c ->
+ val isSelected = collectionId == c.id
+ DrawerCollectionItem(
+ icon = c.icon,
+ name = c.name,
+ isSelected = isSelected,
+ onClick = {
+ viewModel.setCollectionFilter(c.id)
+ scope.launch { drawerState.close() }
+ }
+ )
+ }
+
+ val smartCollections = remember(collections) {
+ collections.filter { it.isSmart }.take(3)
+ }
+
+ if (smartCollections.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ smartCollections.forEach { c ->
+ val query = c.query?.trim()
+ val isSelected = query != null && searchTags?.trim() == query
+ DrawerSmartCollectionItem(
+ name = c.name,
+ isSelected = isSelected,
+ onClick = {
+ viewModel.setTagFilter(query)
+ scope.launch { drawerState.close() }
+ }
+ )
+ }
+ }
+ }
+ }
+
+ Divider(
+ color = TextMuted.copy(alpha = 0.2f),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+
+ // Tags populaires - Accordéon
+ AccordionSection(
+ title = "TAGS POPULAIRES",
+ expanded = tagsExpanded,
+ onToggle = { tagsExpanded = !tagsExpanded },
+ trailingContent = {
+ TextButton(
+ onClick = {
+ scope.launch { drawerState.close() }
+ onNavigateToTags()
+ }
+ ) {
+ Text(
+ "Voir tout",
+ style = MaterialTheme.typography.labelSmall,
+ color = CyanPrimary
+ )
+ }
+ }
+ ) {
+ FlowRow(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Display all tags ordered by bookmark count (highest first)
+ tags.forEach { tag ->
+ val isSelected = searchTags?.split(" ")?.contains(tag.name) == true
+ DrawerTagChip(
+ tag = tag.name,
+ isSelected = isSelected,
+ onClick = {
+ viewModel.setTagFilter(tag.name)
+ scope.launch { drawerState.close() }
+ }
+ )
+ }
+ }
+ }
+
+ // Espace en bas pour le padding
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Footer (fixe, en bas)
+ Text(
+ text = "© 2026 ShaarIt",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted.copy(alpha = 0.6f),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
- )
+ }
+ }
) {
- Scaffold(
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(DeepNavy, DarkNavy)
+ )
+ )
+ ) {
+ Scaffold(
topBar = {
Column {
TopAppBar(
@@ -88,7 +671,62 @@ fun FeedScreen(
color = TextPrimary
)
},
+ navigationIcon = {
+ if (selectionMode) {
+ IconButton(
+ onClick = {
+ selectionMode = false
+ selectedIds = emptySet()
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Exit selection",
+ tint = CyanPrimary
+ )
+ }
+ } else {
+ IconButton(onClick = { scope.launch { drawerState.open() } }) {
+ Icon(
+ imageVector = Icons.Default.Menu,
+ contentDescription = "Menu",
+ tint = CyanPrimary
+ )
+ }
+ }
+ },
actions = {
+ if (selectionMode) {
+ Text(
+ text = selectedIds.size.toString(),
+ color = TextSecondary,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+ IconButton(
+ onClick = { showAddToCollectionDialog = true },
+ enabled = selectedIds.isNotEmpty()
+ ) {
+ Icon(
+ imageVector = Icons.Default.Folder,
+ contentDescription = "Add to collection",
+ tint = if (selectedIds.isNotEmpty()) CyanPrimary else TextMuted
+ )
+ }
+ IconButton(
+ onClick = {
+ selectedIds = emptySet()
+ selectionMode = false
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Cancel selection",
+ tint = TextSecondary
+ )
+ }
+ return@TopAppBar
+ }
+
// Refresh Button
IconButton(onClick = {
viewModel.refresh()
@@ -189,40 +827,316 @@ fun FeedScreen(
}
}
- // Random button
- IconButton(onClick = onNavigateToRandom) {
- Icon(
- imageVector = Icons.Default.Shuffle,
- contentDescription = "Random link",
- tint = CyanPrimary
- )
- }
-
- // Collections button
- IconButton(onClick = onNavigateToCollections) {
- Icon(
- imageVector = Icons.Default.Folder,
- contentDescription = "Collections",
- tint = CyanPrimary
- )
- }
-
- // Tags button
- IconButton(onClick = onNavigateToTags) {
- Icon(
- imageVector = Icons.Default.Label,
- contentDescription = "Tags",
- tint = CyanPrimary
- )
- }
-
- // Settings button
- IconButton(onClick = onNavigateToSettings) {
- Icon(
- imageVector = Icons.Default.Settings,
- contentDescription = "Settings",
- tint = CyanPrimary
- )
+ // Filter Selector
+ Box {
+ IconButton(onClick = { showSortOrderMenu = true }) {
+ Icon(
+ imageVector = Icons.Default.FilterList,
+ contentDescription = "Filters",
+ tint = CyanPrimary
+ )
+ }
+
+ DropdownMenu(
+ expanded = showSortOrderMenu,
+ onDismissRequest = { showSortOrderMenu = false },
+ modifier = Modifier.background(CardBackground)
+ ) {
+ // Sort Direction Section
+ Text(
+ text = "TRI",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.ArrowDownward,
+ contentDescription = null,
+ tint = if (bookmarkFilter.sortDirection == SortDirection.NEWEST_FIRST) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Plus récent d'abord",
+ color = if (bookmarkFilter.sortDirection == SortDirection.NEWEST_FIRST) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateSortDirection(SortDirection.NEWEST_FIRST)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.ArrowUpward,
+ contentDescription = null,
+ tint = if (bookmarkFilter.sortDirection == SortDirection.OLDEST_FIRST) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Plus ancien d'abord",
+ color = if (bookmarkFilter.sortDirection == SortDirection.OLDEST_FIRST) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateSortDirection(SortDirection.OLDEST_FIRST)
+ }
+ )
+
+ Divider(color = TextMuted.copy(alpha = 0.2f))
+
+ // Time Filter Section
+ Text(
+ text = "PÉRIODE",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.AllInclusive,
+ contentDescription = null,
+ tint = if (bookmarkFilter.timeFilter == TimeFilter.ALL) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Tous les bookmarks",
+ color = if (bookmarkFilter.timeFilter == TimeFilter.ALL) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateTimeFilter(TimeFilter.ALL)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Today,
+ contentDescription = null,
+ tint = if (bookmarkFilter.timeFilter == TimeFilter.TODAY) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Aujourd'hui",
+ color = if (bookmarkFilter.timeFilter == TimeFilter.TODAY) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateTimeFilter(TimeFilter.TODAY)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.DateRange,
+ contentDescription = null,
+ tint = if (bookmarkFilter.timeFilter == TimeFilter.THIS_WEEK) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Cette semaine",
+ color = if (bookmarkFilter.timeFilter == TimeFilter.THIS_WEEK) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateTimeFilter(TimeFilter.THIS_WEEK)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.CalendarMonth,
+ contentDescription = null,
+ tint = if (bookmarkFilter.timeFilter == TimeFilter.THIS_MONTH) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Ce mois-ci",
+ color = if (bookmarkFilter.timeFilter == TimeFilter.THIS_MONTH) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateTimeFilter(TimeFilter.THIS_MONTH)
+ }
+ )
+
+ Divider(color = TextMuted.copy(alpha = 0.2f))
+
+ // Visibility Filter Section
+ Text(
+ text = "VISIBILITÉ",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Visibility,
+ contentDescription = null,
+ tint = if (bookmarkFilter.visibilityFilter == VisibilityFilter.ALL) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Publics et Privés",
+ color = if (bookmarkFilter.visibilityFilter == VisibilityFilter.ALL) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateVisibilityFilter(VisibilityFilter.ALL)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Public,
+ contentDescription = null,
+ tint = if (bookmarkFilter.visibilityFilter == VisibilityFilter.PUBLIC_ONLY) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Publics uniquement",
+ color = if (bookmarkFilter.visibilityFilter == VisibilityFilter.PUBLIC_ONLY) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateVisibilityFilter(VisibilityFilter.PUBLIC_ONLY)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Lock,
+ contentDescription = null,
+ tint = if (bookmarkFilter.visibilityFilter == VisibilityFilter.PRIVATE_ONLY) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Privés uniquement",
+ color = if (bookmarkFilter.visibilityFilter == VisibilityFilter.PRIVATE_ONLY) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateVisibilityFilter(VisibilityFilter.PRIVATE_ONLY)
+ }
+ )
+
+ Divider(color = TextMuted.copy(alpha = 0.2f))
+
+ // Tag Filter Section
+ Text(
+ text = "TAGS",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Label,
+ contentDescription = null,
+ tint = if (bookmarkFilter.tagFilter == TagFilter.ALL) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Tous les tags",
+ color = if (bookmarkFilter.tagFilter == TagFilter.ALL) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateTagFilter(TagFilter.ALL)
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.LabelOff,
+ contentDescription = null,
+ tint = if (bookmarkFilter.tagFilter == TagFilter.UNTAGGED) CyanPrimary else TextSecondary
+ )
+ Text(
+ "Sans tags",
+ color = if (bookmarkFilter.tagFilter == TagFilter.UNTAGGED) CyanPrimary else TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateTagFilter(TagFilter.UNTAGGED)
+ }
+ )
+
+ Divider(color = TextMuted.copy(alpha = 0.2f))
+
+ // Reset button
+ DropdownMenuItem(
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Refresh,
+ contentDescription = null,
+ tint = TextSecondary
+ )
+ Text(
+ "Réinitialiser les filtres",
+ color = TextPrimary
+ )
+ }
+ },
+ onClick = {
+ viewModel.updateBookmarkFilter(BookmarkFilter.DEFAULT)
+ showSortOrderMenu = false
+ }
+ )
+ }
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -233,13 +1147,13 @@ fun FeedScreen(
// Search Bar or Tag Filter
AnimatedContent(
- targetState = searchTags != null,
+ targetState = (searchTags != null) || (collectionId != null),
transitionSpec = {
fadeIn() + slideInVertically() togetherWith
fadeOut() + slideOutVertically()
}
) { hasTagFilter ->
- if (hasTagFilter && searchTags != null) {
+ if (hasTagFilter && (searchTags != null || collectionId != null)) {
// Tag filter chip
Row(
modifier = Modifier
@@ -256,6 +1170,17 @@ fun FeedScreen(
modifier = Modifier.padding(top = 6.dp)
)
+ if (collectionId != null) {
+ AssistChip(
+ onClick = { viewModel.clearCollectionFilter() },
+ label = { Text("Collection #$collectionId") },
+ colors = AssistChipDefaults.assistChipColors(
+ containerColor = CardBackground,
+ labelColor = CyanPrimary
+ )
+ )
+ }
+
val selectedTags =
remember(searchTags) {
searchTags
@@ -415,6 +1340,28 @@ fun FeedScreen(
if (link != null) {
GridViewItem(
link = link,
+ onItemClick = {
+ if (selectionMode) {
+ selectedIds =
+ if (selectedIds.contains(link.id)) selectedIds - link.id
+ else selectedIds + link.id
+ } else {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
+ context.startActivity(intent)
+ }
+ },
+ onItemLongClick = {
+ if (!selectionMode) {
+ selectionMode = true
+ selectedIds = setOf(link.id)
+ } else {
+ selectedIds =
+ if (selectedIds.contains(link.id)) selectedIds - link.id
+ else selectedIds + link.id
+ }
+ },
+ selectionMode = selectionMode,
+ isSelected = selectedIds.contains(link.id),
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
@@ -452,6 +1399,28 @@ fun FeedScreen(
if (link != null) {
CompactViewItem(
link = link,
+ onItemClick = {
+ if (selectionMode) {
+ selectedIds =
+ if (selectedIds.contains(link.id)) selectedIds - link.id
+ else selectedIds + link.id
+ } else {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
+ context.startActivity(intent)
+ }
+ },
+ onItemLongClick = {
+ if (!selectionMode) {
+ selectionMode = true
+ selectedIds = setOf(link.id)
+ } else {
+ selectedIds =
+ if (selectedIds.contains(link.id)) selectedIds - link.id
+ else selectedIds + link.id
+ }
+ },
+ selectionMode = selectionMode,
+ isSelected = selectedIds.contains(link.id),
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
@@ -489,6 +1458,28 @@ fun FeedScreen(
if (link != null) {
ListViewItem(
link = link,
+ onItemClick = {
+ if (selectionMode) {
+ selectedIds =
+ if (selectedIds.contains(link.id)) selectedIds - link.id
+ else selectedIds + link.id
+ } else {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
+ context.startActivity(intent)
+ }
+ },
+ onItemLongClick = {
+ if (!selectionMode) {
+ selectionMode = true
+ selectedIds = setOf(link.id)
+ } else {
+ selectedIds =
+ if (selectedIds.contains(link.id)) selectedIds - link.id
+ else selectedIds + link.id
+ }
+ },
+ selectionMode = selectionMode,
+ isSelected = selectedIds.contains(link.id),
onTagClick = viewModel::onTagClicked,
onLinkClick = { url ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
@@ -541,5 +1532,64 @@ fun FeedScreen(
}
)
}
+
+ if (showAddToCollectionDialog) {
+ val regularCollections = remember(collections) { collections.filter { !it.isSmart } }
+ AlertDialog(
+ onDismissRequest = { showAddToCollectionDialog = false },
+ title = { Text("Ajouter à une collection") },
+ text = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ if (regularCollections.isEmpty()) {
+ Text("Aucune collection disponible.")
+ } else {
+ regularCollections.forEach { c ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ viewModel.addLinksToCollection(c.id, selectedIds)
+ showAddToCollectionDialog = false
+ selectionMode = false
+ selectedIds = emptySet()
+ }
+ .padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(c.icon)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(c.name)
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { showAddToCollectionDialog = false }) {
+ Text("Fermer")
+ }
+ }
+ )
+ }
+
+ if (showHelpDialog) {
+ AlertDialog(
+ onDismissRequest = { showHelpDialog = false },
+ title = { Text("Aide") },
+ text = {
+ Text(
+ "- Appui long sur un bookmark: active la sélection multiple\n" +
+ "- Bouton dossier: ajoute les éléments sélectionnés à une collection\n" +
+ "- Le menu (☰) permet de filtrer par collection ou tag"
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = { showHelpDialog = false }) {
+ Text("OK")
+ }
+ }
+ )
+ }
}
}
+}
diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt
index 5763db5..a60d9a5 100644
--- a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt
+++ b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt
@@ -4,8 +4,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
+import com.shaarit.core.storage.TokenManager
+import com.shaarit.data.local.dao.CollectionDao
+import com.shaarit.data.local.dao.TagDao
+import com.shaarit.data.local.entity.CollectionLinkCrossRef
import com.shaarit.data.sync.SyncManager
+import com.shaarit.domain.model.BookmarkFilter
import com.shaarit.domain.model.ShaarliLink
+import com.shaarit.domain.model.SortDirection
+import com.shaarit.domain.model.TimeFilter
+import com.shaarit.domain.model.VisibilityFilter
+import com.shaarit.domain.model.TagFilter
import com.shaarit.domain.model.ViewStyle
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -18,12 +27,24 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.launch
+data class Quadruple(
+ val first: A,
+ val second: B,
+ val third: C,
+ val fourth: D
+)
+
@HiltViewModel
class FeedViewModel @Inject constructor(
private val linkRepository: LinkRepository,
- private val syncManager: SyncManager
+ private val syncManager: SyncManager,
+ private val collectionDao: CollectionDao,
+ private val tagDao: TagDao,
+ private val tokenManager: TokenManager
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
@@ -32,25 +53,53 @@ class FeedViewModel @Inject constructor(
private val _searchTags = MutableStateFlow(null)
val searchTags = _searchTags.asStateFlow()
+ private val _collectionId = MutableStateFlow(null)
+ val collectionId = _collectionId.asStateFlow()
+
private val _viewStyle = MutableStateFlow(ViewStyle.LIST)
val viewStyle = _viewStyle.asStateFlow()
+ private val _bookmarkFilter = MutableStateFlow(BookmarkFilter.DEFAULT)
+ val bookmarkFilter = _bookmarkFilter.asStateFlow()
+
private val _refreshTrigger = MutableStateFlow(0)
+ val collections =
+ collectionDao
+ .getAllCollections()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ val tags =
+ tagDao
+ .getAllTags()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val pagedLinks: Flow> =
- combine(_searchQuery, _searchTags, _refreshTrigger) { query, tags, _ ->
- Pair(query, tags)
+ combine(_searchQuery, _searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { query, tags, collectionId, bookmarkFilter, _ ->
+ Quadruple(query, tags, collectionId, bookmarkFilter)
}
.debounce(300) // Debounce for 300ms
- .flatMapLatest { (query, tags) ->
+ .flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
linkRepository.getLinksStream(
searchTerm = if (query.isBlank()) null else query,
- searchTags = tags
+ searchTags = tags,
+ collectionId = collectionId,
+ bookmarkFilter = bookmarkFilter
)
}
.cachedIn(viewModelScope)
+ fun setTagFilter(tags: String?) {
+ _collectionId.value = null
+ _searchTags.value = tags
+ }
+
+ fun setCollectionFilter(collectionId: Long?) {
+ _searchTags.value = null
+ _collectionId.value = collectionId
+ }
+
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
@@ -73,10 +122,37 @@ class FeedViewModel @Inject constructor(
}
}
+ fun setInitialCollectionFilter(collectionId: Long?) {
+ if (collectionId != null && _collectionId.value == null) {
+ _collectionId.value = collectionId
+ }
+ }
+
fun clearTagFilter() {
_searchTags.value = null
}
+ fun clearCollectionFilter() {
+ _collectionId.value = null
+ }
+
+ fun addLinksToCollection(collectionId: Long, linkIds: Set) {
+ if (linkIds.isEmpty()) return
+ viewModelScope.launch {
+ linkIds.forEach { linkId ->
+ try {
+ collectionDao.addLinkToCollection(
+ CollectionLinkCrossRef(collectionId = collectionId, linkId = linkId)
+ )
+ } catch (_: Exception) {
+ }
+ }
+ tokenManager.setCollectionsConfigDirty(true)
+ syncManager.syncNow()
+ _refreshTrigger.value++
+ }
+ }
+
fun deleteLink(id: Int) {
viewModelScope.launch {
linkRepository.deleteLink(id)
@@ -85,12 +161,32 @@ class FeedViewModel @Inject constructor(
}
}
+ fun setViewStyle(viewStyle: ViewStyle) {
+ _viewStyle.value = viewStyle
+ }
+
+ fun updateSortDirection(direction: SortDirection) {
+ _bookmarkFilter.value = _bookmarkFilter.value.copy(sortDirection = direction)
+ }
+
+ fun updateTimeFilter(timeFilter: TimeFilter) {
+ _bookmarkFilter.value = _bookmarkFilter.value.copy(timeFilter = timeFilter)
+ }
+
+ fun updateVisibilityFilter(visibilityFilter: VisibilityFilter) {
+ _bookmarkFilter.value = _bookmarkFilter.value.copy(visibilityFilter = visibilityFilter)
+ }
+
+ fun updateTagFilter(tagFilter: TagFilter) {
+ _bookmarkFilter.value = _bookmarkFilter.value.copy(tagFilter = tagFilter)
+ }
+
+ fun updateBookmarkFilter(filter: BookmarkFilter) {
+ _bookmarkFilter.value = filter
+ }
+
fun refresh() {
syncManager.syncNow()
_refreshTrigger.value++
}
-
- fun setViewStyle(style: ViewStyle) {
- _viewStyle.value = style
- }
}
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 b063656..21c0c7b 100644
--- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
+++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
@@ -2,8 +2,10 @@ package com.shaarit.presentation.feed
import android.content.Intent
import android.net.Uri
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -42,6 +44,10 @@ fun ListViewItem(
link: ShaarliLink,
onTagClick: (String) -> Unit,
onLinkClick: (String) -> Unit,
+ onItemClick: (() -> Unit)? = null,
+ onItemLongClick: (() -> Unit)? = null,
+ selectionMode: Boolean = false,
+ isSelected: Boolean = false,
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
@@ -60,7 +66,12 @@ fun ListViewItem(
)
}
- GlassCard(modifier = Modifier.fillMaxWidth(), onClick = { onLinkClick(link.url) }) {
+ GlassCard(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
+ onLongClick = onItemLongClick,
+ glowColor = if (isSelected) CyanPrimary else CyanPrimary
+ ) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -87,6 +98,12 @@ fun ListViewItem(
}
Row {
+ if (selectionMode) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onItemClick?.invoke() }
+ )
+ }
// Pin button
IconButton(
onClick = { onTogglePin(link.id) },
@@ -188,6 +205,10 @@ fun ListViewItem(
fun GridViewItem(
link: ShaarliLink,
onLinkClick: (String) -> Unit,
+ onItemClick: (() -> Unit)? = null,
+ onItemLongClick: (() -> Unit)? = null,
+ selectionMode: Boolean = false,
+ isSelected: Boolean = false,
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
@@ -209,8 +230,10 @@ fun GridViewItem(
GlassCard(
modifier = Modifier
.fillMaxWidth()
- .height(200.dp),
- onClick = { onLinkClick(link.url) }
+ .height(200.dp),
+ onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
+ onLongClick = onItemLongClick,
+ glowColor = if (isSelected) CyanPrimary else CyanPrimary
) {
Column(
modifier = Modifier.fillMaxSize(),
@@ -288,6 +311,12 @@ fun GridViewItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
+ if (selectionMode) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onItemClick?.invoke() }
+ )
+ }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
@@ -364,9 +393,14 @@ fun GridViewItem(
* Compact view item - minimal info for dense lists
*/
@Composable
+@OptIn(ExperimentalFoundationApi::class)
fun CompactViewItem(
link: ShaarliLink,
onLinkClick: (String) -> Unit,
+ onItemClick: (() -> Unit)? = null,
+ onItemLongClick: (() -> Unit)? = null,
+ selectionMode: Boolean = false,
+ isSelected: Boolean = false,
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
@@ -389,7 +423,10 @@ fun CompactViewItem(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
- .clickable { onLinkClick(link.url) },
+ .combinedClickable(
+ onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
+ onLongClick = onItemLongClick
+ ),
color = CardBackground.copy(alpha = 0.7f)
) {
Row(
@@ -399,6 +436,13 @@ fun CompactViewItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
+ if (selectionMode) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onItemClick?.invoke() }
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
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 4a750c1..067ca02 100644
--- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt
+++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt
@@ -16,9 +16,21 @@ import java.net.URLEncoder
sealed class Screen(val route: String) {
object Login : Screen("login")
- object Feed : Screen("feed?tag={tag}") {
- fun createRoute(tag: String? = null): String {
- return if (tag != null) "feed?tag=$tag" else "feed"
+ object Feed : Screen("feed?tag={tag}&collectionId={collectionId}") {
+ fun createRoute(tag: String? = null, collectionId: Long? = null): String {
+ val params = mutableListOf()
+ if (tag != null) {
+ val encoded = URLEncoder.encode(tag, "UTF-8")
+ params.add("tag=$encoded")
+ }
+ if (collectionId != null) {
+ params.add("collectionId=$collectionId")
+ }
+ return if (params.isEmpty()) {
+ "feed"
+ } else {
+ "feed?" + params.joinToString("&")
+ }
}
}
object Add : Screen("add?url={url}&title={title}&isShare={isShare}")
@@ -36,6 +48,9 @@ fun AppNavGraph(
startDestination: String = Screen.Login.route,
shareUrl: String? = null,
shareTitle: String? = null,
+ shareDescription: String? = null,
+ shareTags: List? = null,
+ isFileShare: Boolean = false,
initialDeepLink: String? = null
) {
val navController = rememberNavController()
@@ -45,14 +60,22 @@ fun AppNavGraph(
composable(Screen.Login.route) {
com.shaarit.presentation.auth.LoginScreen(
onLoginSuccess = {
- if (shareUrl != null) {
+ if (isFileShare && shareTitle != null) {
+ // File share - navigate to add screen with file data
+ val encodedTitle = URLEncoder.encode(shareTitle, "UTF-8")
+ val encodedDesc = if (shareDescription != null) URLEncoder.encode(shareDescription, "UTF-8") else ""
+ val encodedTags = shareTags?.joinToString(",") { URLEncoder.encode(it, "UTF-8") } ?: ""
+ navController.navigate("add?url=&title=$encodedTitle&isShare=true&isFileShare=true&description=$encodedDesc&tags=$encodedTags") {
+ popUpTo(Screen.Login.route) { inclusive = true }
+ }
+ } else if (shareUrl != null) {
// Use proper URL encoding that handles spaces correctly
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
val encodedTitle =
if (shareTitle != null) {
URLEncoder.encode(shareTitle, "UTF-8")
} else ""
- navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
+ navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true&isFileShare=false&description=&tags=") {
popUpTo(Screen.Login.route) { inclusive = true }
}
} else if (initialDeepLink != null) {
@@ -70,12 +93,16 @@ fun AppNavGraph(
}
composable(
- route = "feed?tag={tag}",
+ route = "feed?tag={tag}&collectionId={collectionId}",
arguments = listOf(
navArgument("tag") {
type = NavType.StringType
nullable = true
defaultValue = null
+ },
+ navArgument("collectionId") {
+ type = NavType.LongType
+ defaultValue = -1L
}
),
deepLinks = listOf(
@@ -84,6 +111,9 @@ fun AppNavGraph(
)
) { backStackEntry ->
val tag = backStackEntry.arguments?.getString("tag")
+ val collectionId = backStackEntry.arguments
+ ?.getLong("collectionId")
+ ?.takeIf { it != -1L }
com.shaarit.presentation.feed.FeedScreen(
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
onNavigateToEdit = { linkId ->
@@ -93,12 +123,13 @@ fun AppNavGraph(
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
onNavigateToRandom = { },
- initialTagFilter = tag
+ initialTagFilter = tag,
+ initialCollectionId = collectionId
)
}
composable(
- route = "add?url={url}&title={title}&isShare={isShare}",
+ route = "add?url={url}&title={title}&isShare={isShare}&isFileShare={isFileShare}&description={description}&tags={tags}",
arguments = listOf(
navArgument("url") {
type = NavType.StringType
@@ -113,6 +144,20 @@ fun AppNavGraph(
navArgument("isShare") {
type = NavType.BoolType
defaultValue = false
+ },
+ navArgument("isFileShare") {
+ type = NavType.BoolType
+ defaultValue = false
+ },
+ navArgument("description") {
+ type = NavType.StringType
+ defaultValue = ""
+ nullable = true
+ },
+ navArgument("tags") {
+ type = NavType.StringType
+ defaultValue = ""
+ nullable = true
}
),
deepLinks = listOf(
@@ -165,9 +210,14 @@ fun AppNavGraph(
) {
com.shaarit.presentation.collections.CollectionsScreen(
onNavigateBack = { navController.popBackStack() },
- onCollectionClick = { collectionId ->
- // Naviguer vers le feed avec le filtre de collection
- navController.navigate(Screen.Feed.createRoute()) {
+ onCollectionClick = { collectionId, isSmart, query ->
+ navController.navigate(
+ if (isSmart) {
+ Screen.Feed.createRoute(tag = query)
+ } else {
+ Screen.Feed.createRoute(collectionId = collectionId)
+ }
+ ) {
popUpTo(Screen.Collections.route) { inclusive = true }
}
}
diff --git a/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt b/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt
index 4671644..344f0db 100644
--- a/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt
+++ b/app/src/main/java/com/shaarit/ui/components/MarkdownEditor.kt
@@ -1,37 +1,49 @@
package com.shaarit.ui.components
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.ExperimentalAnimationApi
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.with
+import androidx.compose.animation.*
+import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material.icons.filled.Fullscreen
-import androidx.compose.material.icons.filled.FullscreenExit
-import androidx.compose.material.icons.filled.Preview
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
-import com.shaarit.ui.theme.CardBackground
-import com.shaarit.ui.theme.CardBackgroundElevated
-import com.shaarit.ui.theme.CyanPrimary
-import com.shaarit.ui.theme.TextPrimary
-import com.shaarit.ui.theme.TextSecondary
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
+import com.shaarit.ui.theme.*
import dev.jeziellago.compose.markdowntext.MarkdownText
+import java.text.SimpleDateFormat
+import java.util.*
/**
* Modes d'affichage de l'éditeur Markdown
@@ -43,222 +55,207 @@ enum class EditorMode {
}
/**
- * Éditeur Markdown complet avec preview temps réel
+ * Type d'action pour les outils Markdown
*/
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
+enum class MarkdownToolType {
+ INSERT, // Insère prefix + suffix autour de la sélection
+ BLOCK, // Insère au début de la ligne
+ SPECIAL // Action spéciale (timestamp, undo, etc.)
+}
+
+/**
+ * Bouton de formatage Markdown avec support complet style Markor
+ */
+data class MarkdownTool(
+ val icon: ImageVector,
+ val label: String,
+ val prefix: String,
+ val suffix: String = "",
+ val isBlock: Boolean = false,
+ val toolType: MarkdownToolType = if (isBlock) MarkdownToolType.BLOCK else MarkdownToolType.INSERT,
+ val specialAction: String? = null
+)
+
+/**
+ * Liste complète des outils Markdown inspirée de Markor
+ * Organisée pour un défilement horizontal fluide
+ */
+val markdownTools = listOf(
+ // Undo/Redo (Screenshot 1)
+ MarkdownTool(Icons.Default.Undo, "Annuler", "", toolType = MarkdownToolType.SPECIAL, specialAction = "undo"),
+ MarkdownTool(Icons.Default.Redo, "Rétablir", "", toolType = MarkdownToolType.SPECIAL, specialAction = "redo"),
+
+ // Formatage de base (Screenshot 1 & 2)
+ MarkdownTool(Icons.Default.FormatBold, "Gras", "**", "**"),
+ MarkdownTool(Icons.Default.FormatItalic, "Italique", "_", "_"),
+ MarkdownTool(Icons.Default.FormatStrikethrough, "Barré", "~~", "~~"),
+ MarkdownTool(Icons.Default.FormatUnderlined, "Souligné", "", ""),
+
+ // Titres (Screenshot 1 & 2)
+ MarkdownTool(Icons.Default.LooksOne, "Titre 1", "# ", "", isBlock = true),
+ MarkdownTool(Icons.Default.LooksTwo, "Titre 2", "## ", "", isBlock = true),
+ MarkdownTool(Icons.Default.Looks3, "Titre 3", "### ", "", isBlock = true),
+ MarkdownTool(Icons.Default.Looks4, "Titre 4", "#### ", "", isBlock = true),
+ MarkdownTool(Icons.Default.Looks5, "Titre 5", "##### ", "", isBlock = true),
+ MarkdownTool(Icons.Default.Looks6, "Titre 6", "###### ", "", isBlock = true),
+
+ // Listes (Screenshot 1 & 3 & 4)
+ MarkdownTool(Icons.Default.FormatListBulleted, "Liste à puces", "- ", "", isBlock = true),
+ MarkdownTool(Icons.Default.FormatListNumbered, "Liste numérotée", "1. ", "", isBlock = true),
+ MarkdownTool(Icons.Default.CheckBox, "Tâche", "- [ ] ", "", isBlock = true),
+ MarkdownTool(Icons.Default.CheckBoxOutlineBlank, "Tâche vide", "- [ ] ", "", isBlock = true),
+ MarkdownTool(Icons.Outlined.CheckBox, "Tâche cochée", "- [x] ", "", isBlock = true),
+
+ // Code (Screenshot 1)
+ MarkdownTool(Icons.Default.Code, "Code inline", "`", "`"),
+ MarkdownTool(Icons.Default.DataObject, "Bloc de code", "\n```\n", "\n```\n"),
+
+ // Citations et blocs (Screenshot 2 & 4)
+ MarkdownTool(Icons.Default.FormatQuote, "Citation", "> ", "", isBlock = true),
+ MarkdownTool(Icons.Default.HorizontalRule, "Ligne horizontale", "\n---\n", ""),
+
+ // Liens et médias (Screenshot 3)
+ MarkdownTool(Icons.Default.Link, "Lien", "[", "](url)"),
+ MarkdownTool(Icons.Default.Image, "Image", ""),
+ MarkdownTool(Icons.Default.AttachFile, "Fichier", "[", "](fichier)"),
+
+ // Tableau (Screenshot 4)
+ MarkdownTool(Icons.Default.TableChart, "Tableau", "\n| Col1 | Col2 | Col3 |\n|------|------|------|\n| ", " | | |\n"),
+
+ // Indentation (Screenshot 3 & 4)
+ MarkdownTool(Icons.Default.FormatIndentIncrease, "Indenter", " ", "", isBlock = true),
+ MarkdownTool(Icons.Default.FormatIndentDecrease, "Désindenter", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "unindent"),
+
+ // Flèches de navigation (Screenshot 3 & 4)
+ MarkdownTool(Icons.Default.KeyboardArrowUp, "Ligne haut", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "move_up"),
+ MarkdownTool(Icons.Default.KeyboardArrowDown, "Ligne bas", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "move_down"),
+ MarkdownTool(Icons.Default.KeyboardArrowLeft, "Curseur gauche", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "cursor_left"),
+ MarkdownTool(Icons.Default.KeyboardArrowRight, "Curseur droite", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "cursor_right"),
+
+ // Horodatage (Screenshot 1)
+ MarkdownTool(Icons.Default.Schedule, "Date/Heure", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "timestamp"),
+
+ // Divers (Screenshot 2 & 3 & 4)
+ MarkdownTool(Icons.Default.ContentCopy, "Copier tout", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "copy_all"),
+ MarkdownTool(Icons.Default.Delete, "Effacer ligne", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "delete_line"),
+ MarkdownTool(Icons.Default.SelectAll, "Tout sélectionner", "", "", toolType = MarkdownToolType.SPECIAL, specialAction = "select_all"),
+
+ // Texte spécial (Screenshot 4)
+ MarkdownTool(Icons.Default.Superscript, "Exposant", "", ""),
+ MarkdownTool(Icons.Default.Subscript, "Indice", "", ""),
+ MarkdownTool(Icons.Default.FormatColorText, "Couleur", "", ""),
+
+ // Caractères spéciaux
+ MarkdownTool(Icons.Default.KeyboardReturn, "Saut de ligne", " \n", ""),
+ MarkdownTool(Icons.Default.SpaceBar, "Espace insécable", " ", "")
+)
+
+/**
+ * State holder pour l'éditeur Markdown avec barre d'outils flottante
+ */
+class MarkdownEditorState {
+ var textFieldValue by mutableStateOf(TextFieldValue(""))
+ internal set
+ var isFocused by mutableStateOf(false)
+ internal set
+ internal val undoStack = mutableStateListOf()
+ internal val redoStack = mutableStateListOf()
+
+ fun updateText(newValue: String) {
+ if (textFieldValue.text != newValue) {
+ textFieldValue = TextFieldValue(newValue, selection = TextRange(newValue.length))
+ }
+ }
+}
+
@Composable
-fun MarkdownEditor(
+fun rememberMarkdownEditorState(): MarkdownEditorState {
+ return remember { MarkdownEditorState() }
+}
+
+/**
+ * Éditeur Markdown simplifié et efficace, inspiré de Markor
+ * Version avec barre d'outils flottante séparée
+ *
+ * @param value Le texte markdown actuel
+ * @param onValueChange Callback quand le texte change
+ * @param editorState State partagé pour la barre d'outils flottante
+ * @param modifier Modifier Compose
+ * @param isNoteMode Si true, l'éditeur est optimisé pour les notes (plus grand)
+ * @param placeholder Texte de placeholder
+ * @param readOnly Mode lecture seule
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SimpleMarkdownEditor(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
- mode: EditorMode = EditorMode.SPLIT,
- onModeChange: ((EditorMode) -> Unit)? = null,
- placeholder: String = "Commencez à écrire en Markdown...",
- minHeight: androidx.compose.ui.unit.Dp = 200.dp,
+ editorState: MarkdownEditorState? = null,
+ isNoteMode: Boolean = false,
+ placeholder: String = "Commencez à écrire...",
readOnly: Boolean = false
) {
- var currentMode by remember { mutableStateOf(mode) }
- var textFieldValue by remember { mutableStateOf(TextFieldValue(value)) }
- var isFullscreen by remember { mutableStateOf(false) }
+ // Utiliser l'état externe ou créer un état local
+ val internalState = editorState ?: rememberMarkdownEditorState()
+ val focusRequester = remember { FocusRequester() }
// Synchroniser avec le value externe
LaunchedEffect(value) {
- if (textFieldValue.text != value) {
- textFieldValue = TextFieldValue(value)
+ if (internalState.textFieldValue.text != value) {
+ internalState.textFieldValue = TextFieldValue(value, selection = TextRange(value.length))
}
}
- Column(modifier = modifier) {
- // Barre d'outils
- if (!readOnly) {
- EditorToolbar(
- currentMode = currentMode,
- isFullscreen = isFullscreen,
- onModeChange = { newMode ->
- currentMode = newMode
- onModeChange?.invoke(newMode)
- },
- onFullscreenToggle = { isFullscreen = !isFullscreen },
- onInsert = { insertion ->
- val newText = textFieldValue.text + insertion
- textFieldValue = TextFieldValue(newText)
- onValueChange(newText)
- }
- )
-
- Spacer(modifier = Modifier.height(8.dp))
- }
-
- // Contenu de l'éditeur
- AnimatedContent(
- targetState = currentMode,
- transitionSpec = { fadeIn() with fadeOut() },
- label = "editor_mode"
- ) { targetMode ->
- when (targetMode) {
- EditorMode.EDIT -> EditOnlyView(
- value = textFieldValue,
- onValueChange = {
- textFieldValue = it
- onValueChange(it.text)
- },
- placeholder = placeholder,
- minHeight = minHeight,
- readOnly = readOnly
- )
-
- EditorMode.PREVIEW -> PreviewOnlyView(
- markdown = textFieldValue.text,
- minHeight = minHeight
- )
-
- EditorMode.SPLIT -> SplitView(
- value = textFieldValue,
- onValueChange = {
- textFieldValue = it
- onValueChange(it.text)
- },
- placeholder = placeholder,
- minHeight = minHeight,
- readOnly = readOnly
- )
- }
- }
- }
-}
-
-/**
- * Barre d'outils de l'éditeur
- */
-@Composable
-private fun EditorToolbar(
- currentMode: EditorMode,
- isFullscreen: Boolean,
- onModeChange: (EditorMode) -> Unit,
- onFullscreenToggle: () -> Unit,
- onInsert: (String) -> Unit
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(CardBackground, RoundedCornerShape(8.dp))
- .padding(horizontal = 8.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Sélecteur de mode
- Row {
- EditorModeButton(
- icon = Icons.Default.Edit,
- label = "Éditer",
- isSelected = currentMode == EditorMode.EDIT,
- onClick = { onModeChange(EditorMode.EDIT) }
- )
-
- EditorModeButton(
- icon = Icons.Default.Preview,
- label = "Aperçu",
- isSelected = currentMode == EditorMode.PREVIEW,
- onClick = { onModeChange(EditorMode.PREVIEW) }
- )
-
- EditorModeButton(
- icon = null,
- label = "Split",
- isSelected = currentMode == EditorMode.SPLIT,
- onClick = { onModeChange(EditorMode.SPLIT) }
- )
- }
-
- // Raccourcis de formatage
- Row {
- TextButton(onClick = { onInsert("**texte en gras**") }) {
- Text("B", fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)
- }
- TextButton(onClick = { onInsert("*texte en italique*") }) {
- Text("I", fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)
- }
- TextButton(onClick = { onInsert("`code`") }) {
- Text("<>", style = MaterialTheme.typography.bodySmall)
- }
- TextButton(onClick = { onInsert("\n- ") }) {
- Text("•")
- }
- TextButton(onClick = { onInsert("\n> citation") }) {
- Text("❝")
- }
- }
-
- // Bouton plein écran
- IconButton(onClick = onFullscreenToggle) {
- Icon(
- imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
- contentDescription = if (isFullscreen) "Quitter plein écran" else "Plein écran"
- )
- }
- }
-}
-
-@Composable
-private fun EditorModeButton(
- icon: androidx.compose.ui.graphics.vector.ImageVector?,
- label: String,
- isSelected: Boolean,
- onClick: () -> Unit
-) {
- TextButton(
- onClick = onClick,
- colors = ButtonDefaults.textButtonColors(
- contentColor = if (isSelected) CyanPrimary else TextSecondary
- )
- ) {
- if (icon != null) {
- Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
- Spacer(modifier = Modifier.width(4.dp))
- }
- Text(label, style = MaterialTheme.typography.labelMedium)
- }
-}
-
-/**
- * Vue édition uniquement
- */
-@Composable
-private fun EditOnlyView(
- value: TextFieldValue,
- onValueChange: (TextFieldValue) -> Unit,
- placeholder: String,
- minHeight: androidx.compose.ui.unit.Dp,
- readOnly: Boolean
-) {
- val scrollState = rememberScrollState()
-
+ // Zone d'édition principale (sans la toolbar - elle sera flottante)
Box(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = minHeight)
- .background(CardBackgroundElevated, RoundedCornerShape(8.dp))
- .border(1.dp, CyanPrimary.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
+ modifier = modifier
+ .background(CardBackgroundElevated, RoundedCornerShape(12.dp))
+ .border(
+ width = if (internalState.isFocused) 2.dp else 1.dp,
+ color = if (internalState.isFocused) CyanPrimary else CyanPrimary.copy(alpha = 0.2f),
+ shape = RoundedCornerShape(12.dp)
+ )
.padding(16.dp)
) {
+ val scrollState = rememberScrollState()
+
BasicTextField(
- value = value,
- onValueChange = onValueChange,
+ value = internalState.textFieldValue,
+ onValueChange = { newValue ->
+ if (newValue.text != internalState.textFieldValue.text) {
+ // Sauvegarder pour undo
+ if (internalState.undoStack.isEmpty() || internalState.undoStack.last().text != internalState.textFieldValue.text) {
+ internalState.undoStack.add(internalState.textFieldValue)
+ if (internalState.undoStack.size > 50) internalState.undoStack.removeAt(0)
+ }
+ internalState.redoStack.clear()
+ }
+ internalState.textFieldValue = newValue
+ onValueChange(newValue.text)
+ },
modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState),
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .focusRequester(focusRequester)
+ .onFocusChanged { internalState.isFocused = it.isFocused },
textStyle = TextStyle(
color = TextPrimary,
- fontSize = MaterialTheme.typography.bodyLarge.fontSize
+ fontSize = if (isNoteMode) 17.sp else 15.sp,
+ lineHeight = if (isNoteMode) 26.sp else 22.sp
),
cursorBrush = SolidColor(CyanPrimary),
readOnly = readOnly,
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.Sentences,
+ imeAction = ImeAction.Default
+ ),
decorationBox = { innerTextField ->
- if (value.text.isEmpty()) {
+ if (internalState.textFieldValue.text.isEmpty()) {
Text(
text = placeholder,
- color = TextSecondary,
- style = MaterialTheme.typography.bodyLarge
+ color = TextSecondary.copy(alpha = 0.6f),
+ fontSize = if (isNoteMode) 17.sp else 15.sp
)
}
innerTextField()
@@ -268,27 +265,392 @@ private fun EditOnlyView(
}
/**
- * Vue aperçu uniquement
+ * Barre d'outils Markdown flottante - à placer au niveau de l'écran
+ * Se positionne automatiquement au-dessus du clavier
*/
@Composable
-private fun PreviewOnlyView(
+fun FloatingMarkdownToolbar(
+ editorState: MarkdownEditorState,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ visible: Boolean = true
+) {
+ // Fonctions helper pour les actions
+ fun saveToUndo() {
+ if (editorState.undoStack.isEmpty() || editorState.undoStack.last().text != editorState.textFieldValue.text) {
+ editorState.undoStack.add(editorState.textFieldValue)
+ if (editorState.undoStack.size > 50) editorState.undoStack.removeAt(0)
+ }
+ }
+
+ fun executeSpecialAction(action: String) {
+ when (action) {
+ "undo" -> {
+ if (editorState.undoStack.isNotEmpty()) {
+ editorState.redoStack.add(editorState.textFieldValue)
+ editorState.textFieldValue = editorState.undoStack.removeLast()
+ onValueChange(editorState.textFieldValue.text)
+ }
+ }
+ "redo" -> {
+ if (editorState.redoStack.isNotEmpty()) {
+ editorState.undoStack.add(editorState.textFieldValue)
+ editorState.textFieldValue = editorState.redoStack.removeLast()
+ onValueChange(editorState.textFieldValue.text)
+ }
+ }
+ "timestamp" -> {
+ saveToUndo()
+ val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date())
+ val newValue = insertMarkdownAtSelection(editorState.textFieldValue, timestamp, "", false)
+ editorState.textFieldValue = newValue
+ onValueChange(newValue.text)
+ }
+ "delete_line" -> {
+ saveToUndo()
+ val newValue = deleteCurrentLine(editorState.textFieldValue)
+ editorState.textFieldValue = newValue
+ onValueChange(newValue.text)
+ }
+ "select_all" -> {
+ editorState.textFieldValue = editorState.textFieldValue.copy(
+ selection = TextRange(0, editorState.textFieldValue.text.length)
+ )
+ }
+ "cursor_left" -> {
+ val newPos = maxOf(0, editorState.textFieldValue.selection.start - 1)
+ editorState.textFieldValue = editorState.textFieldValue.copy(selection = TextRange(newPos))
+ }
+ "cursor_right" -> {
+ val newPos = minOf(editorState.textFieldValue.text.length, editorState.textFieldValue.selection.end + 1)
+ editorState.textFieldValue = editorState.textFieldValue.copy(selection = TextRange(newPos))
+ }
+ "move_up" -> {
+ saveToUndo()
+ val newValue = moveLineUp(editorState.textFieldValue)
+ editorState.textFieldValue = newValue
+ onValueChange(newValue.text)
+ }
+ "move_down" -> {
+ saveToUndo()
+ val newValue = moveLineDown(editorState.textFieldValue)
+ editorState.textFieldValue = newValue
+ onValueChange(newValue.text)
+ }
+ "unindent" -> {
+ saveToUndo()
+ val newValue = unindentLine(editorState.textFieldValue)
+ editorState.textFieldValue = newValue
+ onValueChange(newValue.text)
+ }
+ }
+ }
+
+ // Utiliser les insets standard pour un positionnement robuste
+ // L'union de IME et NavigationBars assure que la toolbar est toujours au-dessus du plus haut des deux
+ val insets = WindowInsets.ime.union(WindowInsets.navigationBars)
+
+ AnimatedVisibility(
+ visible = visible && editorState.isFocused,
+ enter = slideInVertically(
+ initialOffsetY = { it },
+ animationSpec = tween(durationMillis = 200)
+ ) + fadeIn(animationSpec = tween(durationMillis = 150)),
+ exit = slideOutVertically(
+ targetOffsetY = { it },
+ animationSpec = tween(durationMillis = 150)
+ ) + fadeOut(animationSpec = tween(durationMillis = 100)),
+ modifier = modifier.windowInsetsPadding(insets)
+ ) {
+ Surface(
+ color = CardBackground,
+ shadowElevation = 16.dp,
+ tonalElevation = 8.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ contentPadding = PaddingValues(horizontal = 4.dp)
+ ) {
+ items(markdownTools) { tool ->
+ MarkorToolButton(
+ tool = tool,
+ onClick = {
+ when (tool.toolType) {
+ MarkdownToolType.SPECIAL -> {
+ tool.specialAction?.let { executeSpecialAction(it) }
+ }
+ else -> {
+ saveToUndo()
+ editorState.redoStack.clear()
+ val newValue = insertMarkdownAtSelection(
+ editorState.textFieldValue,
+ tool.prefix,
+ tool.suffix,
+ tool.isBlock
+ )
+ editorState.textFieldValue = newValue
+ onValueChange(newValue.text)
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Barre d'outils Markdown style Markor avec défilement horizontal
+ * Positionnée juste au-dessus du clavier
+ */
+@Composable
+fun MarkorStyleToolbar(
+ onToolClick: (MarkdownTool) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ color = CardBackground.copy(alpha = 0.95f),
+ shadowElevation = 8.dp,
+ modifier = modifier.fillMaxWidth()
+ ) {
+ LazyRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ contentPadding = PaddingValues(horizontal = 4.dp)
+ ) {
+ items(markdownTools) { tool ->
+ MarkorToolButton(
+ tool = tool,
+ onClick = { onToolClick(tool) }
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Bouton d'outil Markdown style Markor - compact et tactile
+ */
+@Composable
+private fun MarkorToolButton(
+ tool: MarkdownTool,
+ onClick: () -> Unit
+) {
+ Surface(
+ onClick = onClick,
+ shape = RoundedCornerShape(8.dp),
+ color = CardBackgroundElevated,
+ modifier = Modifier.size(42.dp)
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Icon(
+ imageVector = tool.icon,
+ contentDescription = tool.label,
+ tint = TextSecondary,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ }
+}
+
+/**
+ * Supprime la ligne courante
+ */
+private fun deleteCurrentLine(textFieldValue: TextFieldValue): TextFieldValue {
+ val text = textFieldValue.text
+ val selection = textFieldValue.selection
+
+ if (text.isEmpty()) return textFieldValue
+
+ val lineStart = text.lastIndexOf('\n', maxOf(0, selection.start - 1)).let {
+ if (it == -1) 0 else it + 1
+ }
+ val lineEnd = text.indexOf('\n', selection.start).let {
+ if (it == -1) text.length else it + 1
+ }
+
+ val newText = text.removeRange(lineStart, minOf(lineEnd, text.length))
+ val newCursor = minOf(lineStart, newText.length)
+
+ return TextFieldValue(newText, TextRange(newCursor))
+}
+
+/**
+ * Déplace la ligne courante vers le haut
+ */
+private fun moveLineUp(textFieldValue: TextFieldValue): TextFieldValue {
+ val text = textFieldValue.text
+ val selection = textFieldValue.selection
+
+ val lines = text.split("\n").toMutableList()
+ if (lines.size < 2) return textFieldValue
+
+ // Trouver la ligne courante
+ var charCount = 0
+ var currentLineIndex = 0
+ for ((index, line) in lines.withIndex()) {
+ if (charCount + line.length >= selection.start) {
+ currentLineIndex = index
+ break
+ }
+ charCount += line.length + 1
+ }
+
+ if (currentLineIndex == 0) return textFieldValue
+
+ // Échanger les lignes
+ val temp = lines[currentLineIndex]
+ lines[currentLineIndex] = lines[currentLineIndex - 1]
+ lines[currentLineIndex - 1] = temp
+
+ val newText = lines.joinToString("\n")
+ return TextFieldValue(newText, selection)
+}
+
+/**
+ * Déplace la ligne courante vers le bas
+ */
+private fun moveLineDown(textFieldValue: TextFieldValue): TextFieldValue {
+ val text = textFieldValue.text
+ val selection = textFieldValue.selection
+
+ val lines = text.split("\n").toMutableList()
+ if (lines.size < 2) return textFieldValue
+
+ // Trouver la ligne courante
+ var charCount = 0
+ var currentLineIndex = 0
+ for ((index, line) in lines.withIndex()) {
+ if (charCount + line.length >= selection.start) {
+ currentLineIndex = index
+ break
+ }
+ charCount += line.length + 1
+ }
+
+ if (currentLineIndex >= lines.size - 1) return textFieldValue
+
+ // Échanger les lignes
+ val temp = lines[currentLineIndex]
+ lines[currentLineIndex] = lines[currentLineIndex + 1]
+ lines[currentLineIndex + 1] = temp
+
+ val newText = lines.joinToString("\n")
+ return TextFieldValue(newText, selection)
+}
+
+/**
+ * Désindente la ligne courante
+ */
+private fun unindentLine(textFieldValue: TextFieldValue): TextFieldValue {
+ val text = textFieldValue.text
+ val selection = textFieldValue.selection
+
+ val lineStart = text.lastIndexOf('\n', maxOf(0, selection.start - 1)).let {
+ if (it == -1) 0 else it + 1
+ }
+
+ // Vérifier si la ligne commence par des espaces ou une tabulation
+ val lineContent = text.substring(lineStart)
+ val newLineContent = when {
+ lineContent.startsWith(" ") -> lineContent.removePrefix(" ")
+ lineContent.startsWith("\t") -> lineContent.removePrefix("\t")
+ lineContent.startsWith(" ") -> lineContent.removePrefix(" ")
+ else -> return textFieldValue
+ }
+
+ val newText = text.substring(0, lineStart) + newLineContent
+ val removedChars = lineContent.length - newLineContent.length
+ val newCursor = maxOf(lineStart, selection.start - removedChars)
+
+ return TextFieldValue(newText, TextRange(newCursor))
+}
+
+/**
+ * Insère du markdown à la position du curseur
+ */
+private fun insertMarkdownAtSelection(
+ textFieldValue: TextFieldValue,
+ prefix: String,
+ suffix: String,
+ isBlock: Boolean
+): TextFieldValue {
+ val text = textFieldValue.text
+ val selection = textFieldValue.selection
+
+ val before = text.substring(0, selection.start)
+ val selected = if (selection.start != selection.end) {
+ text.substring(selection.start, selection.end)
+ } else ""
+ val after = text.substring(selection.end)
+
+ val newText = if (isBlock) {
+ // Pour les blocs (listes, titres, etc.), insérer au début de la ligne
+ val lineStart = before.lastIndexOf('\n').let {
+ if (it == -1) 0 else it + 1
+ }
+ val beforeLine = text.substring(0, lineStart)
+ val currentLine = text.substring(lineStart, selection.start)
+ val afterSelection = text.substring(selection.end)
+
+ if (currentLine.startsWith(prefix)) {
+ // Désactiver le formatage si déjà présent
+ beforeLine + currentLine.removePrefix(prefix) + afterSelection
+ } else {
+ beforeLine + prefix + currentLine + afterSelection
+ }
+ } else {
+ // Pour le formatage inline (gras, italique, etc.)
+ if (selected.isNotEmpty()) {
+ before + prefix + selected + suffix + after
+ } else {
+ before + prefix + suffix + after
+ }
+ }
+
+ val cursorOffset = if (isBlock) {
+ newText.length - after.length
+ } else {
+ before.length + prefix.length + if (selected.isNotEmpty()) selected.length + suffix.length else 0
+ }
+
+ return TextFieldValue(
+ text = newText,
+ selection = TextRange(cursorOffset)
+ )
+}
+
+/**
+ * Vue aperçu Markdown simple
+ */
+@Composable
+fun MarkdownPreview(
markdown: String,
- minHeight: androidx.compose.ui.unit.Dp
+ modifier: Modifier = Modifier
) {
Box(
- modifier = Modifier
+ modifier = modifier
.fillMaxWidth()
- .heightIn(min = minHeight)
- .background(CardBackgroundElevated, RoundedCornerShape(8.dp))
- .border(1.dp, CyanPrimary.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
+ .background(CardBackgroundElevated, RoundedCornerShape(12.dp))
+ .border(1.dp, CyanPrimary.copy(alpha = 0.2f), RoundedCornerShape(12.dp))
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
if (markdown.isBlank()) {
Text(
- text = "Rien à prévisualiser...",
- color = TextSecondary,
- style = MaterialTheme.typography.bodyLarge
+ text = "Aucun contenu à prévisualiser...",
+ color = TextSecondary.copy(alpha = 0.6f),
+ style = MaterialTheme.typography.bodyMedium
)
} else {
MarkdownText(
@@ -301,41 +663,204 @@ private fun PreviewOnlyView(
}
/**
- * Vue split édition + aperçu
+ * Éditeur Markdown complet avec toggle édition/apercu
+ * Version simplifiée et plus efficace
*/
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun SplitView(
- value: TextFieldValue,
- onValueChange: (TextFieldValue) -> Unit,
- placeholder: String,
- minHeight: androidx.compose.ui.unit.Dp,
- readOnly: Boolean
+fun MarkdownEditor(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ mode: EditorMode = EditorMode.EDIT,
+ onModeChange: ((EditorMode) -> Unit)? = null,
+ placeholder: String = "Commencez à écrire...",
+ minHeight: androidx.compose.ui.unit.Dp = 200.dp,
+ readOnly: Boolean = false,
+ isNoteMode: Boolean = false
) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- // Zone d'édition
- Box(modifier = Modifier.weight(1f)) {
- EditOnlyView(
- value = value,
- onValueChange = onValueChange,
- placeholder = placeholder,
- minHeight = minHeight,
- readOnly = readOnly
- )
+ var currentMode by remember { mutableStateOf(mode) }
+
+ Column(modifier = modifier) {
+ // Barre de mode (Éditer / Aperçu)
+ if (!readOnly) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ ModeToggleButton(
+ icon = Icons.Default.Edit,
+ label = "Éditer",
+ isSelected = currentMode == EditorMode.EDIT,
+ onClick = {
+ currentMode = EditorMode.EDIT
+ onModeChange?.invoke(EditorMode.EDIT)
+ },
+ modifier = Modifier.weight(1f)
+ )
+ ModeToggleButton(
+ icon = Icons.Default.Preview,
+ label = "Aperçu",
+ isSelected = currentMode == EditorMode.PREVIEW,
+ onClick = {
+ currentMode = EditorMode.PREVIEW
+ onModeChange?.invoke(EditorMode.PREVIEW)
+ },
+ modifier = Modifier.weight(1f)
+ )
+ ModeToggleButton(
+ icon = Icons.Default.ViewSidebar,
+ label = "Split",
+ isSelected = currentMode == EditorMode.SPLIT,
+ onClick = {
+ currentMode = EditorMode.SPLIT
+ onModeChange?.invoke(EditorMode.SPLIT)
+ },
+ modifier = Modifier.weight(1f)
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
}
- // Zone de preview
- Box(modifier = Modifier.weight(1f)) {
- PreviewOnlyView(
- markdown = value.text,
- minHeight = minHeight
+ // Contenu selon le mode
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = if (isNoteMode) 350.dp else minHeight)
+ ) {
+ when (currentMode) {
+ EditorMode.EDIT -> {
+ SimpleMarkdownEditor(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier.fillMaxSize(),
+ isNoteMode = isNoteMode,
+ placeholder = placeholder,
+ readOnly = readOnly
+ )
+ }
+ EditorMode.PREVIEW -> {
+ MarkdownPreview(
+ markdown = value,
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ EditorMode.SPLIT -> {
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ SimpleMarkdownEditor(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier.weight(1f),
+ isNoteMode = isNoteMode,
+ placeholder = placeholder,
+ readOnly = readOnly
+ )
+ MarkdownPreview(
+ markdown = value,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Bouton de sélection de mode (Éditer/Aperçu/Split)
+ */
+@Composable
+private fun ModeToggleButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ onClick = onClick,
+ shape = RoundedCornerShape(8.dp),
+ color = if (isSelected) CyanPrimary.copy(alpha = 0.15f) else CardBackground,
+ border = if (isSelected) androidx.compose.foundation.BorderStroke(1.dp, CyanPrimary) else null,
+ modifier = modifier
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (isSelected) CyanPrimary else TextSecondary,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = label,
+ color = if (isSelected) CyanPrimary else TextSecondary,
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
)
}
}
}
+/**
+ * Composant d'aide Markdown affichant les raccourcis
+ */
+@Composable
+fun MarkdownHelp(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(CardBackground.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
+ .padding(12.dp)
+ ) {
+ Text(
+ text = "Formatage rapide :",
+ style = MaterialTheme.typography.labelSmall,
+ color = TextMuted,
+ fontWeight = FontWeight.Medium
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.horizontalScroll(androidx.compose.foundation.rememberScrollState())
+ ) {
+ HelpChip("**gras**")
+ HelpChip("*italique*")
+ HelpChip("`code`")
+ HelpChip("# titre")
+ HelpChip("- liste")
+ HelpChip("[lien](url)")
+ }
+ }
+}
+
+/**
+ * Chip d'aide pour le markdown
+ */
+@Composable
+private fun HelpChip(text: String) {
+ Surface(
+ color = CardBackgroundElevated,
+ shape = RoundedCornerShape(4.dp)
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelSmall,
+ color = TextSecondary,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
+ )
+ }
+}
+
+
/**
* Mode lecture distraction-free pour les longues notes
*/
diff --git a/app/src/main/java/com/shaarit/ui/components/PremiumComponents.kt b/app/src/main/java/com/shaarit/ui/components/PremiumComponents.kt
index 058401a..e5c1c94 100644
--- a/app/src/main/java/com/shaarit/ui/components/PremiumComponents.kt
+++ b/app/src/main/java/com/shaarit/ui/components/PremiumComponents.kt
@@ -7,6 +7,8 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
@@ -25,10 +27,12 @@ import androidx.compose.ui.unit.dp
import com.shaarit.ui.theme.*
/** A glassmorphism-styled card with subtle border glow effect */
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GlassCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
+ onLongClick: (() -> Unit)? = null,
glowColor: Color = CyanPrimary,
content: @Composable ColumnScope.() -> Unit
) {
@@ -94,11 +98,12 @@ fun GlassCard(
)
val finalModifier =
- if (onClick != null) {
- cardModifier.clickable(
+ if (onClick != null || onLongClick != null) {
+ cardModifier.combinedClickable(
interactionSource = interactionSource,
indication = null,
- onClick = onClick
+ onClick = { onClick?.invoke() },
+ onLongClick = onLongClick
)
} else {
cardModifier