feat: Add file sharing support and collections configuration management

- Add intent filters for markdown and text file sharing (text/*, application/octet-stream)
- Implement file content reading with filename extraction and automatic tagging
- Enable edge-to-edge mode with proper IME insets handling for keyboard
- Add collections configuration dirty flag and bookmark ID tracking to TokenManager
- Create CollectionsConfigDto and CollectionConfigDto for JSON serialization
- Add collection query methods
This commit is contained in:
Bruno Charest 2026-01-30 20:33:20 -05:00
parent fdacf2248a
commit 4021aacc1d
22 changed files with 4188 additions and 657 deletions

View File

@ -39,13 +39,25 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Share Intent -->
<!-- Share Intent - Text -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Share Intent - Markdown and Text Files -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
</intent-filter>
<!-- App Shortcuts -->
<meta-data
android:name="android.app.shortcuts"

View File

@ -1,17 +1,22 @@
package com.shaarit
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.shaarit.presentation.nav.AppNavGraph
import com.shaarit.ui.theme.ShaarItTheme
import dagger.hilt.android.AndroidEntryPoint
import java.io.BufferedReader
import java.io.InputStreamReader
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@ -21,24 +26,45 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
// Enable edge-to-edge mode for proper keyboard (IME) insets detection
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ShaarItTheme {
// A surface container using the 'background' color from the theme
val context = LocalContext.current
var shareUrl: String? = null
var shareTitle: String? = null
var shareDescription: String? = null
var shareTags: List<String>? = 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"
) {
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<Uri>(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
intent?.data?.let { uri ->
@ -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<String, String> {
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
}
}
}

View File

@ -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"
}
}

View File

@ -47,3 +47,21 @@ data class InfoSettingsDto(
@Json(name = "enabled_plugins") val enabledPlugins: List<String>? = 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<CollectionConfigDto> = 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<Int> = emptyList()
)

View File

@ -17,9 +17,15 @@ interface CollectionDao {
@Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC")
fun getAllCollections(): Flow<List<CollectionEntity>>
@Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC")
suspend fun getAllCollectionsOnce(): List<CollectionEntity>
@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<List<CollectionEntity>>
@ -61,6 +67,12 @@ interface CollectionDao {
@Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId")
fun getLinkCountInCollection(collectionId: Long): Flow<Int>
@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<Int>
@Query("SELECT EXISTS(SELECT 1 FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId)")
suspend fun isLinkInCollection(collectionId: Long, linkId: Int): Boolean
}

View File

@ -71,6 +71,30 @@ interface LinkDao {
@RawQuery(observedEntities = [LinkEntity::class])
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
@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<Int, LinkEntity>
// ====== 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 ======
@Query("""
@ -87,6 +111,11 @@ interface LinkDao {
""")
fun getLinksBetween(startTime: Long, endTime: Long): Flow<List<LinkEntity>>
// ====== Nouvelles méthodes pour le tri avancé avec combinaisons ======
@RawQuery(observedEntities = [LinkEntity::class])
fun getLinksWithFilters(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
// ====== Filtres par statut ======
@Query("SELECT * FROM links WHERE is_private = 0 ORDER BY created_at DESC")

View File

@ -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<PagingData<ShaarliLink>> {
// 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<Int, LinkEntity> {
val conditions = mutableListOf<String>()
val args = mutableListOf<Any>()
// 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()
}

View File

@ -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)
@ -150,6 +164,120 @@ class SyncManager @Inject constructor(
}
}
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 {

View File

@ -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()
}
}

View File

@ -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<PagingData<ShaarliLink>>
fun getLinkFlow(id: Int): Flow<ShaarliLink?>

View File

@ -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(
// 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 = "Titre de la page"
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
tint = CyanPrimary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Column {
Text(
if (showMarkdownEditor) "Simple" else "Markdown",
color = CyanPrimary
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,
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)
)

View File

@ -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>(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
val currentTitle = title.value
// Validation based on content type
when (_contentTypeSelection.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required")
_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
val currentTitle = title.value
// Validation based on content type
when (_contentTypeSelection.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required")
_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
}

View File

@ -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<CollectionUiModel?>(null) }
var showDeleteConfirm by remember { mutableStateOf<CollectionUiModel?>(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,28 +132,82 @@ 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)
) {
Box(modifier = Modifier.fillMaxSize()) {
// Zone cliquable principale
Column(
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onClick)
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
@ -201,6 +265,73 @@ private fun CollectionCard(
)
}
}
// 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<String>,
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<String>,
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<String>,
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
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()
) {
Row(
modifier = Modifier.fillMaxWidth(),
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
style = MaterialTheme.typography.bodyMedium,
color = TextPrimary,
fontWeight = FontWeight.Medium
)
Text(
"Remplie automatiquement selon des critères",
"Remplie automatiquement selon les tags sélectionnés",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
}
Switch(
checked = isSmart,
onCheckedChange = { isSmart = it }
onCheckedChange = { isSmart = it },
colors = SwitchDefaults.colors(
checkedThumbColor = CyanPrimary,
checkedTrackColor = CyanPrimary.copy(alpha = 0.5f)
)
)
}
}
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
)

View File

@ -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<List<CollectionUiModel>>(emptyList())
@ -20,6 +27,11 @@ class CollectionsViewModel @Inject constructor(
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
val tags: StateFlow<List<TagEntity>> =
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
)
}

View File

@ -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 = {
Icon(
Icons.Default.Edit,
contentDescription = null,
tint = CyanPrimary
)
}
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
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(
title = "Title",
subtitle = "Optional"
)
Spacer(modifier = Modifier.height(12.dp))
PremiumTextField(
// 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 = "Page title"
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(
imageVector = Icons.Default.Description,
contentDescription = null,
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
)
}
}
}
// Description Section
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(
title = "Description",
subtitle = "Optional - Supports Markdown"
// 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(
// É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 },
modifier = Modifier.fillMaxWidth(),
placeholder = "Add a description...",
singleLine = false,
minLines = 3
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..."
)
}
}
}
// 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 = "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,
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)
)

View File

@ -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
@ -29,6 +30,13 @@ constructor(
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<String> = emptyList()
private val _selectedTags = MutableStateFlow<List<String>>(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
val currentTitle = title.value
// Validation based on content type
when (_contentType.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = EditLinkUiState.Error("URL is required")
_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

File diff suppressed because it is too large Load Diff

View File

@ -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<A, B, C, D>(
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<String?>(null)
val searchTags = _searchTags.asStateFlow()
private val _collectionId = MutableStateFlow<Long?>(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<PagingData<ShaarliLink>> =
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<Int>) {
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
}
}

View File

@ -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,
@ -210,7 +231,9 @@ fun GridViewItem(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
onClick = { onLinkClick(link.url) }
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,

View File

@ -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<String>()
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<String>? = 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 }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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