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" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Share Intent --> <!-- Share Intent - Text -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent-filter> </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 --> <!-- App Shortcuts -->
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"

View File

@ -1,17 +1,22 @@
package com.shaarit package com.shaarit
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.shaarit.presentation.nav.AppNavGraph import com.shaarit.presentation.nav.AppNavGraph
import com.shaarit.ui.theme.ShaarItTheme import com.shaarit.ui.theme.ShaarItTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.BufferedReader
import java.io.InputStreamReader
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -21,23 +26,44 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Enable edge-to-edge mode for proper keyboard (IME) insets detection
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
ShaarItTheme { ShaarItTheme {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
val context = LocalContext.current val context = LocalContext.current
var shareUrl: String? = null var shareUrl: String? = null
var shareTitle: String? = null var shareTitle: String? = null
var shareDescription: String? = null
var shareTags: List<String>? = null
var deepLink: String? = null var deepLink: String? = null
var isFileShare = false
val activity = context as? androidx.activity.ComponentActivity val activity = context as? androidx.activity.ComponentActivity
val intent = activity?.intent val intent = activity?.intent
// Handle share intent // Handle share intent
if (intent?.action == android.content.Intent.ACTION_SEND && if (intent?.action == android.content.Intent.ACTION_SEND) {
intent.type == "text/plain" val mimeType = intent.type ?: ""
) {
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT) // Check if this is a file share (markdown or text file)
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT) 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 // Handle deep links from App Shortcuts
@ -54,10 +80,64 @@ class MainActivity : ComponentActivity() {
AppNavGraph( AppNavGraph(
shareUrl = shareUrl, shareUrl = shareUrl,
shareTitle = shareTitle, shareTitle = shareTitle,
shareDescription = shareDescription,
shareTags = shareTags,
isFileShare = isFileShare,
initialDeepLink = deepLink 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 saveApiSecret(secret: String)
fun getApiSecret(): String? fun getApiSecret(): String?
fun clearApiSecret() fun clearApiSecret()
fun setCollectionsConfigDirty(isDirty: Boolean)
fun isCollectionsConfigDirty(): Boolean
fun saveCollectionsConfigBookmarkId(id: Int)
fun getCollectionsConfigBookmarkId(): Int?
fun clearCollectionsConfigBookmarkId()
} }
@Singleton @Singleton
@ -82,9 +88,36 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
sharedPreferences.edit().remove(KEY_API_SECRET).apply() 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 { companion object {
private const val KEY_TOKEN = "jwt_token" private const val KEY_TOKEN = "jwt_token"
private const val KEY_BASE_URL = "base_url" private const val KEY_BASE_URL = "base_url"
private const val KEY_API_SECRET = "api_secret" 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 = "enabled_plugins") val enabledPlugins: List<String>? = null,
@Json(name = "default_private_links") val defaultPrivateLinks: Boolean? = 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") @Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC")
fun getAllCollections(): Flow<List<CollectionEntity>> 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") @Query("SELECT * FROM collections WHERE id = :id")
suspend fun getCollectionById(id: Long): CollectionEntity? 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") @Query("SELECT * FROM collections WHERE is_smart = 0 ORDER BY sort_order ASC")
fun getRegularCollections(): Flow<List<CollectionEntity>> fun getRegularCollections(): Flow<List<CollectionEntity>>
@ -61,6 +67,12 @@ interface CollectionDao {
@Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId") @Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId")
fun getLinkCountInCollection(collectionId: Long): Flow<Int> 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)") @Query("SELECT EXISTS(SELECT 1 FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId)")
suspend fun isLinkInCollection(collectionId: Long, linkId: Int): Boolean suspend fun isLinkInCollection(collectionId: Long, linkId: Int): Boolean
} }

View File

@ -71,6 +71,30 @@ interface LinkDao {
@RawQuery(observedEntities = [LinkEntity::class]) @RawQuery(observedEntities = [LinkEntity::class])
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity> 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 ====== // ====== Filtres temporels ======
@Query(""" @Query("""
@ -87,6 +111,11 @@ interface LinkDao {
""") """)
fun getLinksBetween(startTime: Long, endTime: Long): Flow<List<LinkEntity>> 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 ====== // ====== Filtres par statut ======
@Query("SELECT * FROM links WHERE is_private = 0 ORDER BY created_at DESC") @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.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.PagingSource
import androidx.paging.map import androidx.paging.map
import com.shaarit.data.api.ShaarliApi import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.dto.CreateLinkDto import com.shaarit.data.dto.CreateLinkDto
@ -48,13 +49,16 @@ constructor(
override fun getLinksStream( override fun getLinksStream(
searchTerm: String?, searchTerm: String?,
searchTags: String? searchTags: String?,
collectionId: Long?,
bookmarkFilter: com.shaarit.domain.model.BookmarkFilter
): Flow<PagingData<ShaarliLink>> { ): Flow<PagingData<ShaarliLink>> {
// Utiliser Room pour la pagination locale // Utiliser Room pour la pagination locale
return Pager( return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false), config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { pagingSourceFactory = {
when { when {
collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId)
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm) !searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
!searchTags.isNullOrBlank() -> { !searchTags.isNullOrBlank() -> {
val tags = val tags =
@ -66,7 +70,7 @@ constructor(
.distinct() .distinct()
if (tags.isEmpty()) { if (tags.isEmpty()) {
linkDao.getAllLinksPaged() buildFilteredQuery(bookmarkFilter)
} else { } else {
val whereClause = tags.joinToString(" AND ") { "tags LIKE ?" } val whereClause = tags.joinToString(" AND ") { "tags LIKE ?" }
val sql = val sql =
@ -75,7 +79,7 @@ constructor(
linkDao.getLinksByTags(SimpleSQLiteQuery(sql, args)) linkDao.getLinksByTags(SimpleSQLiteQuery(sql, args))
} }
} }
else -> linkDao.getAllLinksPaged() else -> buildFilteredQuery(bookmarkFilter)
} }
} }
).flow.map { pagingData -> ).flow.map { pagingData ->
@ -354,6 +358,94 @@ constructor(
// ====== Helpers ====== // ====== 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? { private fun parseExistingLink(errorBody: String?): LinkDto? {
if (errorBody.isNullOrBlank()) return null if (errorBody.isNullOrBlank()) return null
return try { return try {
@ -372,7 +464,7 @@ constructor(
description = description, description = description,
tags = tags, tags = tags,
isPrivate = isPrivate, 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, isPinned = isPinned,
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes, readingTime = readingTimeMinutes,
@ -400,7 +492,8 @@ constructor(
private fun parseDate(dateString: String?): Long { private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis() if (dateString.isNullOrBlank()) return System.currentTimeMillis()
return try { 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) { } catch (e: Exception) {
System.currentTimeMillis() System.currentTimeMillis()
} }

View File

@ -13,17 +13,24 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import com.shaarit.data.api.ShaarliApi import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.dto.CollectionConfigDto
import com.shaarit.data.dto.CreateLinkDto 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.LinkDao
import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.TagDao 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.LinkEntity
import com.shaarit.data.local.entity.SyncStatus import com.shaarit.data.local.entity.SyncStatus
import com.shaarit.data.local.entity.TagEntity import com.shaarit.data.local.entity.TagEntity
import com.shaarit.data.mapper.LinkMapper import com.shaarit.data.mapper.LinkMapper
import com.shaarit.data.mapper.TagMapper import com.shaarit.data.mapper.TagMapper
import com.shaarit.core.storage.TokenManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -41,11 +48,18 @@ class SyncManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val linkDao: LinkDao, private val linkDao: LinkDao,
private val tagDao: TagDao, private val tagDao: TagDao,
private val collectionDao: CollectionDao,
private val moshi: Moshi,
private val tokenManager: TokenManager,
private val api: ShaarliApi private val api: ShaarliApi
) { ) {
companion object { companion object {
private const val TAG = "SyncManager" private const val TAG = "SyncManager"
private const val SYNC_WORK_NAME = "shaarli_sync_work" 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) 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) * 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) 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 { } else {
// Filtrer les liens invalides (sans ID ou URL) et convertir en entités // Filtrer les liens invalides (sans ID ou URL) et convertir en entités
val validLinks = links.filter { dto -> 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") Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
@ -305,6 +440,94 @@ class SyncManager @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Erreur lors de la récupération des tags", e) 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 { 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 { interface LinkRepository {
fun getLinksStream( fun getLinksStream(
searchTerm: String? = null, 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>> ): Flow<PagingData<ShaarliLink>>
fun getLinkFlow(id: Int): Flow<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.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@ -15,16 +20,22 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale 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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.shaarit.ui.components.* import com.shaarit.ui.components.*
import com.shaarit.ui.theme.* import com.shaarit.ui.theme.*
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun AddLinkScreen( fun AddLinkScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
@ -43,9 +54,19 @@ fun AddLinkScreen(
val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState() val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState()
val extractedThumbnail by viewModel.extractedThumbnail.collectAsState() val extractedThumbnail by viewModel.extractedThumbnail.collectAsState()
val contentType by viewModel.contentType.collectAsState() val contentType by viewModel.contentType.collectAsState()
val contentTypeSelection by viewModel.contentTypeSelection.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } 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) { LaunchedEffect(uiState) {
when (val state = uiState) { when (val state = uiState) {
@ -111,13 +132,14 @@ fun AddLinkScreen(
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy)) brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
) )
) { ) {
// Contenu principal avec Scaffold
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
"Ajouter un lien", if (contentTypeSelection == ContentType.NOTE) "Nouvelle note" else "Nouveau lien",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@ -142,47 +164,78 @@ fun AddLinkScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.padding(16.dp) .padding(horizontal = 16.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()), .imePadding()
verticalArrangement = Arrangement.spacedBy(20.dp) .verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// URL Section avec extraction de métadonnées // Content Type Selection (compact)
GlassCard(modifier = Modifier.fillMaxWidth()) { GlassCard(
Column { modifier = Modifier.fillMaxWidth(),
SectionHeader(title = "URL", subtitle = "Requis") glowColor = CyanPrimary
Spacer(modifier = Modifier.height(12.dp)) ) {
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, value = url,
onValueChange = { viewModel.url.value = it }, onValueChange = { viewModel.url.value = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = "https://example.com", placeholder = { Text("https://example.com", color = TextMuted) },
leadingIcon = { singleLine = true,
Icon( keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
Icons.Default.Link,
contentDescription = null,
tint = CyanPrimary
)
},
trailingIcon = { trailingIcon = {
if (isExtractingMetadata) { if (isExtractingMetadata) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(20.dp), modifier = Modifier.size(18.dp),
color = CyanPrimary, color = CyanPrimary,
strokeWidth = 2.dp strokeWidth = 2.dp
) )
} }
} },
colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium
) )
// Aperçu des métadonnées extraites // Thumbnail preview
AnimatedVisibility( AnimatedVisibility(
visible = extractedThumbnail != null || contentType != null, visible = extractedThumbnail != null || contentType != null,
enter = expandVertically() + fadeIn() enter = expandVertically() + fadeIn()
) { ) {
Column(modifier = Modifier.padding(top = 16.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
// Type de contenu détecté
contentType?.let { type -> contentType?.let { type ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -202,21 +255,20 @@ fun AddLinkScreen(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Type: $type", text = type,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = TextSecondary color = TextSecondary
) )
} }
} }
// Thumbnail extrait
extractedThumbnail?.let { thumbnail -> extractedThumbnail?.let { thumbnail ->
AsyncImage( AsyncImage(
model = thumbnail, model = thumbnail,
contentDescription = "Aperçu", contentDescription = "Aperçu",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(120.dp) .height(100.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
@ -226,77 +278,163 @@ fun AddLinkScreen(
} }
} }
// Title Section // Title Section (compact)
GlassCard(modifier = Modifier.fillMaxWidth()) { CompactFieldCard(
Column { icon = Icons.Default.Title,
SectionHeader( label = if (contentTypeSelection == ContentType.NOTE) "Titre *" else "Titre"
title = "Titre", ) {
subtitle = "Optionnel - auto-extrait si vide" OutlinedTextField(
) value = title,
Spacer(modifier = Modifier.height(12.dp)) onValueChange = { viewModel.title.value = it },
PremiumTextField( modifier = Modifier.fillMaxWidth(),
value = title, placeholder = {
onValueChange = { viewModel.title.value = it }, Text(
modifier = Modifier.fillMaxWidth(), if (contentTypeSelection == ContentType.NOTE)
placeholder = "Titre de la page" "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 // Description Section - Markdown Editor (plus grand en mode Note)
GlassCard(modifier = Modifier.fillMaxWidth()) { GlassCard(
modifier = Modifier
.fillMaxWidth()
.then(
if (contentTypeSelection == ContentType.NOTE)
Modifier.heightIn(min = 400.dp)
else
Modifier
)
) {
Column { Column {
// Header avec titre et toggle édition/apercu
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
SectionHeader(title = "Description", subtitle = "Markdown supporté") Row(
verticalAlignment = Alignment.CenterVertically,
// Toggle pour l'éditeur Markdown horizontalArrangement = Arrangement.spacedBy(8.dp)
TextButton(onClick = { showMarkdownEditor = !showMarkdownEditor }) { ) {
Icon( Icon(
if (showMarkdownEditor) Icons.Default.Edit else Icons.Default.Preview, imageVector = Icons.Default.Description,
contentDescription = null, contentDescription = null,
tint = CyanPrimary tint = CyanPrimary,
) modifier = Modifier.size(20.dp)
Spacer(modifier = Modifier.width(4.dp))
Text(
if (showMarkdownEditor) "Simple" else "Markdown",
color = CyanPrimary
) )
Column {
Text(
text = if (contentTypeSelection == ContentType.NOTE)
"Contenu" else "Description",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
if (contentTypeSelection == ContentType.NOTE) {
Text(
text = "Markdown supporté",
style = MaterialTheme.typography.labelSmall,
color = TextMuted
)
}
}
}
// Toggle édition/apercu simple
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(
onClick = { showMarkdownPreview = false },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = "Éditer",
tint = if (!showMarkdownPreview) CyanPrimary else TextMuted,
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = { showMarkdownPreview = true },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Preview,
contentDescription = "Aperçu",
tint = if (showMarkdownPreview) CyanPrimary else TextMuted,
modifier = Modifier.size(18.dp)
)
}
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
if (showMarkdownEditor) { // Éditeur Markdown ou Aperçu
// Éditeur Markdown avancé if (showMarkdownPreview) {
MarkdownEditor( MarkdownPreview(
value = description, markdown = description,
onValueChange = { viewModel.description.value = it }, modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxWidth()
mode = EditorMode.SPLIT, .heightIn(
minHeight = 200.dp min = if (contentTypeSelection == ContentType.NOTE) 300.dp else 150.dp,
max = if (contentTypeSelection == ContentType.NOTE) 500.dp else 300.dp
)
) )
} else { } else {
// Champ texte simple SimpleMarkdownEditor(
PremiumTextField(
value = description, value = description,
onValueChange = { viewModel.description.value = it }, onValueChange = { viewModel.description.value = it },
modifier = Modifier.fillMaxWidth(), editorState = markdownEditorState,
placeholder = "Ajoutez une description...", modifier = Modifier
singleLine = false, .fillMaxWidth()
minLines = 3 .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 // Tags Section avec correction du clavier - se positionne au-dessus du clavier
GlassCard(modifier = Modifier.fillMaxWidth()) { GlassCard(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(tagsSectionBringIntoViewRequester)
) {
Column { 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)) Spacer(modifier = Modifier.height(12.dp))
@ -316,31 +454,57 @@ fun AddLinkScreen(
} }
} }
// New tag input // New tag input - fermer le clavier sur Done
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
PremiumTextField( OutlinedTextField(
value = newTagInput, value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) }, onValueChange = { viewModel.onNewTagInputChanged(it) },
modifier = Modifier.weight(1f), modifier = Modifier
placeholder = "Ajouter un tag..." .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( IconButton(
onClick = { viewModel.addNewTag() }, onClick = {
enabled = newTagInput.isNotBlank() viewModel.addNewTag()
focusManager.clearFocus()
},
enabled = newTagInput.isNotBlank(),
modifier = Modifier.size(40.dp)
) { ) {
Icon( Icon(
Icons.Default.Add, Icons.Default.Add,
contentDescription = "Ajouter tag", contentDescription = "Ajouter",
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
) )
} }
} }
// Tag suggestions // Tag suggestions - s'affichent au-dessus quand le clavier est ouvert
AnimatedVisibility( AnimatedVisibility(
visible = tagSuggestions.isNotEmpty(), visible = tagSuggestions.isNotEmpty(),
enter = expandVertically() + fadeIn(), enter = expandVertically() + fadeIn(),
@ -349,16 +513,21 @@ fun AddLinkScreen(
Column(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
Text( Text(
"Suggestions", "Suggestions",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = TextMuted, color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(
items(tagSuggestions.take(10)) { tag -> horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tagSuggestions.take(8)) { tag ->
TagChip( TagChip(
tag = tag.name, tag = tag.name,
isSelected = false, isSelected = false,
onClick = { viewModel.addTag(tag.name) }, onClick = {
viewModel.addTag(tag.name)
focusManager.clearFocus()
},
count = tag.occurrences count = tag.occurrences
) )
} }
@ -370,16 +539,18 @@ fun AddLinkScreen(
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) { if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
Text( Text(
"Tags populaires", "Populaires",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = TextMuted, color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items( items(
availableTags availableTags
.filter { it.name !in selectedTags } .filter { it.name !in selectedTags }
.take(10) .take(8)
) { tag -> ) { tag ->
TagChip( TagChip(
tag = tag.name, tag = tag.name,
@ -394,26 +565,22 @@ fun AddLinkScreen(
} }
} }
// Privacy Section // Privacy Section (compact)
GlassCard(modifier = Modifier.fillMaxWidth()) { CompactFieldCard(
icon = if (isPrivate) Icons.Default.Lock else Icons.Default.Public,
label = if (isPrivate) "Privé" else "Public",
onClick = { viewModel.isPrivate.value = !isPrivate }
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column { Text(
Text( if (isPrivate) "Seul vous pouvez voir" else "Visible par tous",
"Privé", style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.titleMedium, color = TextSecondary
fontWeight = FontWeight.SemiBold, )
color = TextPrimary
)
Text(
"Seul vous pouvez voir ce lien",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
Switch( Switch(
checked = isPrivate, checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it }, onCheckedChange = { viewModel.isPrivate.value = it },
@ -427,14 +594,21 @@ fun AddLinkScreen(
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(8.dp))
// Save Button // Save Button
GradientButton( GradientButton(
text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else "Enregistrer le lien", text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else
onClick = { viewModel.addLink() }, if (contentTypeSelection == ContentType.NOTE) "Enregistrer la note" else "Enregistrer le lien",
onClick = {
focusManager.clearFocus()
viewModel.addLink()
},
modifier = Modifier.fillMaxWidth(), 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) { 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) // Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
private val initialUrl: String? = savedStateHandle["url"] private val initialUrl: String? = savedStateHandle["url"]
private val initialTitle: String? = savedStateHandle["title"] 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) private val _uiState = MutableStateFlow<AddLinkUiState>(AddLinkUiState.Idle)
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
var url = MutableStateFlow(decodeUrlParam(initialUrl) ?: "") var url = MutableStateFlow(decodeUrlParam(initialUrl) ?: "")
var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "") var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "")
var description = MutableStateFlow("") var description = MutableStateFlow(decodeUrlParam(initialDescription) ?: "")
var isPrivate = MutableStateFlow(false) 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 // Extraction state
private val _isExtractingMetadata = MutableStateFlow(false) private val _isExtractingMetadata = MutableStateFlow(false)
val isExtractingMetadata = _isExtractingMetadata.asStateFlow() val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
@ -69,8 +76,22 @@ constructor(
loadAvailableTags() loadAvailableTags()
setupUrlMetadataExtraction() 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 // Si une URL initiale est fournie, extraire les métadonnées
if (!initialUrl.isNullOrBlank()) { if (!initialUrl.isNullOrBlank() && !isFileShare) {
extractMetadata(initialUrl) extractMetadata(initialUrl)
} }
} }
@ -199,14 +220,28 @@ constructor(
_uiState.value = AddLinkUiState.Loading _uiState.value = AddLinkUiState.Loading
val currentUrl = url.value val currentUrl = url.value
if (currentUrl.isBlank()) { val currentTitle = title.value
_uiState.value = AddLinkUiState.Error("URL is required")
return@launch // Validation based on content type
when (_contentTypeSelection.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required for bookmarks")
return@launch
}
}
ContentType.NOTE -> {
if (currentTitle.isBlank()) {
_uiState.value = AddLinkUiState.Error("Title is required for notes")
return@launch
}
}
} }
val result = val result =
linkRepository.addOrUpdateLink( linkRepository.addOrUpdateLink(
url = currentUrl, url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank())
"note://local/${System.currentTimeMillis()}" else currentUrl,
title = title.value.ifBlank { null }, title = title.value.ifBlank { null },
description = description.value.ifBlank { null }, description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null }, tags = _selectedTags.value.ifEmpty { null },
@ -237,16 +272,30 @@ constructor(
fun forceUpdateExistingLink() { fun forceUpdateExistingLink() {
viewModelScope.launch { viewModelScope.launch {
val currentUrl = url.value val currentUrl = url.value
if (currentUrl.isBlank()) { val currentTitle = title.value
_uiState.value = AddLinkUiState.Error("URL is required")
return@launch // Validation based on content type
when (_contentTypeSelection.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required for bookmarks")
return@launch
}
}
ContentType.NOTE -> {
if (currentTitle.isBlank()) {
_uiState.value = AddLinkUiState.Error("Title is required for notes")
return@launch
}
}
} }
_uiState.value = AddLinkUiState.Loading _uiState.value = AddLinkUiState.Loading
val result = val result =
linkRepository.addOrUpdateLink( linkRepository.addOrUpdateLink(
url = currentUrl, url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank())
"note://local/${System.currentTimeMillis()}" else currentUrl,
title = title.value.ifBlank { null }, title = title.value.ifBlank { null },
description = description.value.ifBlank { null }, description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null }, tags = _selectedTags.value.ifEmpty { null },
@ -273,6 +322,17 @@ constructor(
_uiState.value = AddLinkUiState.Idle _uiState.value = AddLinkUiState.Idle
conflictLinkId = null 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 { sealed class AddLinkUiState {
@ -282,3 +342,8 @@ sealed class AddLinkUiState {
data class Error(val message: String) : AddLinkUiState() data class Error(val message: String) : AddLinkUiState()
data class Conflict(val existingLinkId: Int, val existingTitle: 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 package com.shaarit.presentation.collections
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
@ -16,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -28,12 +33,15 @@ import com.shaarit.ui.theme.*
@Composable @Composable
fun CollectionsScreen( fun CollectionsScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onCollectionClick: (Long) -> Unit, onCollectionClick: (Long, Boolean, String?) -> Unit,
viewModel: CollectionsViewModel = hiltViewModel() viewModel: CollectionsViewModel = hiltViewModel()
) { ) {
val collections by viewModel.collections.collectAsState() val collections by viewModel.collections.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
val tags by viewModel.tags.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
var showEditDialog by remember { mutableStateOf<CollectionUiModel?>(null) }
var showDeleteConfirm by remember { mutableStateOf<CollectionUiModel?>(null) }
Box( Box(
modifier = Modifier modifier = Modifier
@ -109,7 +117,9 @@ fun CollectionsScreen(
items(collections) { collection -> items(collections) { collection ->
CollectionCard( CollectionCard(
collection = collection, collection = collection,
onClick = { onCollectionClick(collection.id) } onClick = { onCollectionClick(collection.id, collection.isSmart, collection.query) },
onEditClick = { showEditDialog = collection },
onDeleteClick = { showDeleteConfirm = collection }
) )
} }
} }
@ -122,83 +132,204 @@ fun CollectionsScreen(
if (showCreateDialog) { if (showCreateDialog) {
CreateCollectionDialog( CreateCollectionDialog(
onDismiss = { showCreateDialog = false }, onDismiss = { showCreateDialog = false },
onCreate = { name, description, icon, isSmart -> tags = tags.map { it.name },
viewModel.createCollection(name, description, icon, isSmart) onConfirm = { name, description, icon, isSmart, query ->
viewModel.createCollection(name, description, icon, isSmart, query)
showCreateDialog = false 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 @Composable
private fun CollectionCard( private fun CollectionCard(
collection: CollectionUiModel, collection: CollectionUiModel,
onClick: () -> Unit onClick: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit
) { ) {
var showMenu by remember { mutableStateOf(false) }
GlassCard( GlassCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f) .aspectRatio(1f)
.clickable(onClick = onClick)
) { ) {
Column( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier // Zone cliquable principale
.fillMaxSize() Column(
.padding(16.dp), modifier = Modifier
verticalArrangement = Arrangement.SpaceBetween .fillMaxSize()
) { .clickable(onClick = onClick)
// Icône et type .padding(16.dp),
Row( verticalArrangement = Arrangement.SpaceBetween
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) { ) {
Text( // Icône et type
text = collection.icon, Row(
fontSize = MaterialTheme.typography.headlineMedium.fontSize modifier = Modifier.fillMaxWidth(),
) horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
if (collection.isSmart) { ) {
Icon( Text(
imageVector = Icons.Default.AutoAwesome, text = collection.icon,
contentDescription = "Collection intelligente", fontSize = MaterialTheme.typography.headlineMedium.fontSize
tint = CyanPrimary,
modifier = Modifier.size(20.dp)
) )
}
}
// Nom et description if (collection.isSmart) {
Column { Icon(
Text( imageVector = Icons.Default.AutoAwesome,
text = collection.name, contentDescription = "Collection intelligente",
style = MaterialTheme.typography.titleMedium, tint = CyanPrimary,
fontWeight = FontWeight.Bold, modifier = Modifier.size(20.dp)
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
collection.description?.let { desc ->
if (desc.isNotBlank()) {
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp)
) )
} }
} }
// Nombre de liens // Nom et description
Text( Column {
text = "${collection.linkCount} lien${if (collection.linkCount > 1) "s" else ""}", Text(
style = MaterialTheme.typography.labelMedium, text = collection.name,
color = CyanPrimary, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp) fontWeight = FontWeight.Bold,
) color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
collection.description?.let { desc ->
if (desc.isNotBlank()) {
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp)
)
}
}
// Nombre de liens
Text(
text = "${collection.linkCount} lien${if (collection.linkCount > 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
color = CyanPrimary,
modifier = Modifier.padding(top = 8.dp)
)
}
}
// Menu options
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
IconButton(
onClick = { showMenu = true },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Options",
tint = TextSecondary,
modifier = Modifier.size(20.dp)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.background(CardBackground)
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
tint = CyanPrimary,
modifier = Modifier.size(18.dp)
)
Text("Modifier", color = TextPrimary)
}
},
onClick = {
showMenu = false
onEditClick()
}
)
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = ErrorRed,
modifier = Modifier.size(18.dp)
)
Text("Supprimer", color = ErrorRed)
}
},
onClick = {
showMenu = false
onDeleteClick()
}
)
}
} }
} }
} }
@ -257,105 +388,406 @@ private fun EmptyCollectionsView(onCreateClick: () -> Unit) {
@Composable @Composable
private fun CreateCollectionDialog( private fun CreateCollectionDialog(
onDismiss: () -> Unit, 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("") } CollectionDialogContent(
var description by remember { mutableStateOf("") } title = "Nouvelle collection",
var selectedIcon by remember { mutableStateOf("📁") } collection = null,
var isSmart by remember { mutableStateOf(false) } 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 icons = listOf("📁", "💼", "🏠", "📚", "", "🔥", "💡", "🎯", "📰", "🎬", "🎮", "🛒")
val isEdit = collection != null
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Nouvelle collection") }, containerColor = CardBackground,
title = {
Text(
title,
color = TextPrimary,
fontWeight = FontWeight.Bold
)
},
text = { text = {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Nom // Nom
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("Nom") }, label = { Text("Nom", color = TextSecondary) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = CyanPrimary,
unfocusedBorderColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
) )
// Description // Description
OutlinedTextField( OutlinedTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
label = { Text("Description (optionnel)") }, label = { Text("Description (optionnel)", color = TextSecondary) },
minLines = 2, minLines = 2,
maxLines = 3, maxLines = 3,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = CyanPrimary,
unfocusedBorderColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
) )
// Icône // Icône
Text("Icône", style = MaterialTheme.typography.labelMedium) Text(
"Icône",
style = MaterialTheme.typography.labelMedium,
color = TextSecondary
)
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
icons.forEach { icon -> icons.forEach { icon ->
val isSelected = icon == selectedIcon
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(44.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(10.dp))
.background( .background(
if (icon == selectedIcon) CyanPrimary.copy(alpha = 0.2f) if (isSelected) CyanPrimary.copy(alpha = 0.2f)
else CardBackgroundElevated 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 }, .clickable { selectedIcon = icon },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(icon, fontSize = MaterialTheme.typography.titleMedium.fontSize) Text(icon, fontSize = MaterialTheme.typography.titleLarge.fontSize)
} }
} }
} }
// Collection intelligente // Collection intelligente
Row( Card(
modifier = Modifier.fillMaxWidth(), onClick = { isSmart = !isSmart },
horizontalArrangement = Arrangement.SpaceBetween, colors = CardDefaults.cardColors(
verticalAlignment = Alignment.CenterVertically containerColor = if (isSmart) CyanPrimary.copy(alpha = 0.1f) else CardBackgroundElevated
),
border = if (isSmart) {
androidx.compose.foundation.BorderStroke(1.dp, CyanPrimary.copy(alpha = 0.3f))
} else null,
modifier = Modifier.fillMaxWidth()
) { ) {
Column(modifier = Modifier.weight(1f)) { Row(
Text( modifier = Modifier
"Collection intelligente", .fillMaxWidth()
style = MaterialTheme.typography.bodyMedium .padding(16.dp),
) horizontalArrangement = Arrangement.SpaceBetween,
Text( verticalAlignment = Alignment.CenterVertically
"Remplie automatiquement selon des critères", ) {
style = MaterialTheme.typography.bodySmall, Row(
color = TextSecondary verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = null,
tint = if (isSmart) CyanPrimary else TextSecondary
)
Column(modifier = Modifier.weight(1f)) {
Text(
"Collection intelligente",
style = MaterialTheme.typography.bodyMedium,
color = TextPrimary,
fontWeight = FontWeight.Medium
)
Text(
"Remplie automatiquement selon les tags sélectionnés",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
}
Switch(
checked = isSmart,
onCheckedChange = { isSmart = it },
colors = SwitchDefaults.colors(
checkedThumbColor = CyanPrimary,
checkedTrackColor = CyanPrimary.copy(alpha = 0.5f)
)
) )
} }
Switch( }
checked = isSmart,
onCheckedChange = { isSmart = it } if (isSmart) {
) // Section Tags sélectionnés
if (selectedTags.isNotEmpty()) {
Text(
"Tags sélectionnés (${selectedTags.size})",
style = MaterialTheme.typography.labelMedium,
color = TextSecondary
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
selectedTags.forEach { tag ->
SelectedTagChip(
tag = tag,
onRemove = { selectedTags = selectedTags - tag }
)
}
}
}
// Barre de recherche de tags
ExposedDropdownMenuBox(
expanded = showTagDropdown,
onExpandedChange = { showTagDropdown = it }
) {
OutlinedTextField(
value = tagSearch,
onValueChange = {
tagSearch = it
showTagDropdown = it.isNotBlank() || tags.isNotEmpty()
},
label = { Text("Ajouter des tags...", color = TextSecondary) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = CyanPrimary,
unfocusedBorderColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
),
trailingIcon = {
Icon(
imageVector = if (showTagDropdown) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = TextSecondary
)
}
)
val availableTags = remember(tags, tagSearch, selectedTags) {
tags
.filter { it !in selectedTags }
.filter {
if (tagSearch.isBlank()) true
else it.lowercase().contains(tagSearch.lowercase())
}
.take(15)
}
if (availableTags.isNotEmpty()) {
ExposedDropdownMenu(
expanded = showTagDropdown,
onDismissRequest = { showTagDropdown = false },
modifier = Modifier
.background(CardBackgroundElevated)
.heightIn(max = 250.dp)
) {
availableTags.forEach { tag ->
DropdownMenuItem(
text = {
Text(
"#$tag",
color = TextPrimary
)
},
onClick = {
selectedTags = selectedTags + tag
tagSearch = ""
showTagDropdown = false
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
tint = CyanPrimary
)
}
)
}
}
}
}
// Tags disponibles populaires
val popularTags = remember(tags, selectedTags) {
tags
.filter { it !in selectedTags }
.take(10)
}
if (popularTags.isNotEmpty()) {
Text(
"Tags populaires",
style = MaterialTheme.typography.labelMedium,
color = TextMuted
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
popularTags.forEach { tag ->
AvailableTagChip(
tag = tag,
onClick = { selectedTags = selectedTags + tag }
)
}
}
}
} }
} }
}, },
confirmButton = { confirmButton = {
TextButton( Button(
onClick = { onCreate(name, description, selectedIcon, isSmart) }, onClick = {
enabled = name.isNotBlank() 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 = { dismissButton = {
TextButton(onClick = onDismiss) { 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 // Modèle de données UI
data class CollectionUiModel( data class CollectionUiModel(
val id: Long, val id: Long,
@ -363,6 +795,7 @@ data class CollectionUiModel(
val description: String?, val description: String?,
val icon: String, val icon: String,
val isSmart: Boolean, val isSmart: Boolean,
val query: String?,
val linkCount: Int val linkCount: Int
) )

View File

@ -2,7 +2,11 @@ package com.shaarit.presentation.collections
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.shaarit.core.storage.TokenManager
import com.shaarit.data.local.dao.CollectionDao import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.TagDao
import com.shaarit.data.local.entity.TagEntity
import com.shaarit.data.local.entity.CollectionEntity import com.shaarit.data.local.entity.CollectionEntity
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@ -11,7 +15,10 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CollectionsViewModel @Inject constructor( 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() { ) : ViewModel() {
private val _collections = MutableStateFlow<List<CollectionUiModel>>(emptyList()) private val _collections = MutableStateFlow<List<CollectionUiModel>>(emptyList())
@ -20,6 +27,11 @@ class CollectionsViewModel @Inject constructor(
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
val tags: StateFlow<List<TagEntity>> =
tagDao
.getAllTags()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init { init {
loadCollections() loadCollections()
} }
@ -31,8 +43,14 @@ class CollectionsViewModel @Inject constructor(
collectionDao.getAllCollections() collectionDao.getAllCollections()
.map { entities -> .map { entities ->
entities.map { entity -> entities.map { entity ->
// Compter les liens dans chaque collection val count = when {
val count = 0 // TODO: Implémenter le comptage entity.isSmart -> getSmartCollectionLinkCount(entity)
else -> try {
collectionDao.getLinkCountInCollectionOnce(entity.id)
} catch (_: Exception) {
0
}
}
entity.toUiModel(count) 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 { viewModelScope.launch {
val entity = CollectionEntity( val entity = CollectionEntity(
name = name, name = name,
description = description, description = description,
icon = icon, icon = icon,
isSmart = isSmart isSmart = isSmart,
query = query
) )
collectionDao.insertCollection(entity) 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) { fun deleteCollection(id: Long) {
viewModelScope.launch { viewModelScope.launch {
collectionDao.deleteCollection(id) collectionDao.deleteCollection(id)
tokenManager.setCollectionsConfigDirty(true)
} }
} }
@ -71,6 +143,7 @@ class CollectionsViewModel @Inject constructor(
description = description, description = description,
icon = icon, icon = icon,
isSmart = isSmart, isSmart = isSmart,
query = query,
linkCount = linkCount linkCount = linkCount
) )
} }

View File

@ -2,31 +2,40 @@ package com.shaarit.presentation.edit
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items 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.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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.ui.components.GlassCard import coil.compose.AsyncImage
import com.shaarit.ui.components.GradientButton import com.shaarit.presentation.add.ContentType
import com.shaarit.ui.components.PremiumTextField import com.shaarit.ui.components.*
import com.shaarit.ui.components.SectionHeader
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.* import com.shaarit.ui.theme.*
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun EditLinkScreen( fun EditLinkScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
@ -41,8 +50,19 @@ fun EditLinkScreen(
val availableTags by viewModel.availableTags.collectAsState() val availableTags by viewModel.availableTags.collectAsState()
val isPrivate by viewModel.isPrivate.collectAsState() val isPrivate by viewModel.isPrivate.collectAsState()
val tagSuggestions by viewModel.tagSuggestions.collectAsState() val tagSuggestions by viewModel.tagSuggestions.collectAsState()
val contentType by viewModel.contentType.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } 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) { LaunchedEffect(uiState) {
when (val state = uiState) { when (val state = uiState) {
@ -60,9 +80,7 @@ fun EditLinkScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
colors = listOf(DeepNavy, DarkNavy)
)
) )
) { ) {
Scaffold( Scaffold(
@ -71,7 +89,7 @@ fun EditLinkScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
"Edit Link", if (contentType == ContentType.NOTE) "Modifier la note" else "Modifier le lien",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@ -80,7 +98,7 @@ fun EditLinkScreen(
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon( Icon(
Icons.Default.ArrowBack, Icons.Default.ArrowBack,
contentDescription = "Back", contentDescription = "Retour",
tint = TextPrimary tint = TextPrimary
) )
} }
@ -91,9 +109,7 @@ fun EditLinkScreen(
) )
) )
}, },
containerColor = android.graphics.Color.TRANSPARENT.let { containerColor = androidx.compose.ui.graphics.Color.Transparent
androidx.compose.ui.graphics.Color.Transparent
}
) { paddingValues -> ) { paddingValues ->
when (uiState) { when (uiState) {
is EditLinkUiState.Loading -> { is EditLinkUiState.Loading -> {
@ -107,7 +123,7 @@ fun EditLinkScreen(
CircularProgressIndicator(color = CyanPrimary) CircularProgressIndicator(color = CyanPrimary)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"Loading link...", "Chargement...",
color = TextSecondary, color = TextSecondary,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
@ -118,72 +134,222 @@ fun EditLinkScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.padding(16.dp) .padding(horizontal = 16.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()), .imePadding()
verticalArrangement = Arrangement.spacedBy(20.dp) .verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// URL Section // Content Type Selection (compact)
GlassCard(modifier = Modifier.fillMaxWidth()) { GlassCard(
Column { modifier = Modifier.fillMaxWidth(),
SectionHeader(title = "URL", subtitle = "Required") glowColor = CyanPrimary
Spacer(modifier = Modifier.height(12.dp)) ) {
PremiumTextField( 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, value = url,
onValueChange = { viewModel.url.value = it }, onValueChange = { viewModel.url.value = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = "https://example.com", placeholder = { Text("https://example.com", color = TextMuted) },
leadingIcon = { singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
}
}
// Title Section (compact)
CompactFieldCard(
icon = Icons.Default.Title,
label = if (contentType == ContentType.NOTE) "Titre *" else "Titre"
) {
OutlinedTextField(
value = title,
onValueChange = { viewModel.title.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
if (contentType == ContentType.NOTE)
"Titre de la note" else "Titre du lien",
color = TextMuted
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium
)
}
// Description Section - Markdown Editor (plus grand en mode Note)
GlassCard(
modifier = Modifier
.fillMaxWidth()
.then(
if (contentType == ContentType.NOTE)
Modifier.heightIn(min = 400.dp)
else
Modifier
)
) {
Column {
// Header avec titre et toggle édition/apercu
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon( Icon(
Icons.Default.Edit, imageVector = Icons.Default.Description,
contentDescription = null, contentDescription = null,
tint = CyanPrimary tint = CyanPrimary,
modifier = Modifier.size(20.dp)
) )
Column {
Text(
text = if (contentType == ContentType.NOTE)
"Contenu" else "Description",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
if (contentType == ContentType.NOTE) {
Text(
text = "Markdown supporté",
style = MaterialTheme.typography.labelSmall,
color = TextMuted
)
}
}
} }
)
}
}
// Title Section // Toggle édition/apercu simple
GlassCard(modifier = Modifier.fillMaxWidth()) { Row(
Column { horizontalArrangement = Arrangement.spacedBy(4.dp)
SectionHeader( ) {
title = "Title", IconButton(
subtitle = "Optional" 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)) Spacer(modifier = Modifier.height(12.dp))
PremiumTextField(
value = title, // Éditeur Markdown ou Aperçu
onValueChange = { viewModel.title.value = it }, if (showMarkdownPreview) {
modifier = Modifier.fillMaxWidth(), MarkdownPreview(
placeholder = "Page title" markdown = description,
) modifier = Modifier
.fillMaxWidth()
.heightIn(
min = if (contentType == ContentType.NOTE) 300.dp else 150.dp,
max = if (contentType == ContentType.NOTE) 500.dp else 300.dp
)
)
} else {
SimpleMarkdownEditor(
value = description,
onValueChange = { viewModel.description.value = it },
editorState = markdownEditorState,
modifier = Modifier
.fillMaxWidth()
.heightIn(
min = if (contentType == ContentType.NOTE) 300.dp else 150.dp,
max = if (contentType == ContentType.NOTE) 500.dp else 300.dp
),
isNoteMode = contentType == ContentType.NOTE,
placeholder = if (contentType == ContentType.NOTE)
"Écrivez votre note ici..."
else
"Ajoutez une description..."
)
}
} }
} }
// Description Section // Tags Section avec correction du clavier - se positionne au-dessus du clavier
GlassCard(modifier = Modifier.fillMaxWidth()) { GlassCard(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(tagsSectionBringIntoViewRequester)
) {
Column { Column {
SectionHeader( Row(
title = "Description", verticalAlignment = Alignment.CenterVertically,
subtitle = "Optional - Supports Markdown" horizontalArrangement = Arrangement.spacedBy(8.dp)
) ) {
Spacer(modifier = Modifier.height(12.dp)) Icon(
PremiumTextField( imageVector = Icons.Default.Tag,
value = description, contentDescription = null,
onValueChange = { viewModel.description.value = it }, tint = CyanPrimary,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.size(20.dp)
placeholder = "Add a description...", )
singleLine = false, Text(
minLines = 3 text = "Tags",
) style = MaterialTheme.typography.titleMedium,
} fontWeight = FontWeight.SemiBold,
} color = TextPrimary
)
// Tags Section }
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(title = "Tags", subtitle = "Organize your links")
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@ -203,27 +369,52 @@ fun EditLinkScreen(
} }
} }
// New tag input // New tag input - fermer le clavier sur Done
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
PremiumTextField( OutlinedTextField(
value = newTagInput, value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) }, onValueChange = { viewModel.onNewTagInputChanged(it) },
modifier = Modifier.weight(1f), modifier = Modifier
placeholder = "Add tag..." .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( IconButton(
onClick = { viewModel.addNewTag() }, onClick = {
enabled = newTagInput.isNotBlank() viewModel.addNewTag()
focusManager.clearFocus()
},
enabled = newTagInput.isNotBlank(),
modifier = Modifier.size(40.dp)
) { ) {
Icon( Icon(
Icons.Default.Add, Icons.Default.Add,
contentDescription = "Add tag", contentDescription = "Ajouter",
tint = if (newTagInput.isNotBlank()) CyanPrimary tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
else TextMuted
) )
} }
} }
@ -237,16 +428,21 @@ fun EditLinkScreen(
Column(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
Text( Text(
"Suggestions", "Suggestions",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = TextMuted, color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(
items(tagSuggestions.take(10)) { tag -> horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tagSuggestions.take(8)) { tag ->
TagChip( TagChip(
tag = tag.name, tag = tag.name,
isSelected = false, isSelected = false,
onClick = { viewModel.addTag(tag.name) }, onClick = {
viewModel.addTag(tag.name)
focusManager.clearFocus()
},
count = tag.occurrences count = tag.occurrences
) )
} }
@ -254,20 +450,22 @@ fun EditLinkScreen(
} }
} }
// Popular tags from existing // Popular tags
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) { if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
Text( Text(
"Popular tags", "Populaires",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
color = TextMuted, color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items( items(
availableTags availableTags
.filter { it.name !in selectedTags } .filter { it.name !in selectedTags }
.take(10) .take(8)
) { tag -> ) { tag ->
TagChip( TagChip(
tag = tag.name, tag = tag.name,
@ -282,26 +480,22 @@ fun EditLinkScreen(
} }
} }
// Privacy Section // Privacy Section (compact)
GlassCard(modifier = Modifier.fillMaxWidth()) { CompactFieldCard(
icon = if (isPrivate) Icons.Default.Lock else Icons.Default.Public,
label = if (isPrivate) "Privé" else "Public",
onClick = { viewModel.isPrivate.value = !isPrivate }
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column { Text(
Text( if (isPrivate) "Seul vous pouvez voir" else "Visible par tous",
"Private", style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.titleMedium, color = TextSecondary
fontWeight = FontWeight.SemiBold, )
color = TextPrimary
)
Text(
"Only you can see this link",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
Switch( Switch(
checked = isPrivate, checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it }, onCheckedChange = { viewModel.isPrivate.value = it },
@ -315,14 +509,21 @@ fun EditLinkScreen(
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(8.dp))
// Update Button // Update Button
GradientButton( GradientButton(
text = if (uiState is EditLinkUiState.Saving) "Saving..." else "Update Link", text = if (uiState is EditLinkUiState.Saving) "Enregistrement..." else
onClick = { viewModel.updateLink() }, if (contentType == ContentType.NOTE) "Enregistrer la note" else "Enregistrer les modifications",
onClick = {
focusManager.clearFocus()
viewModel.updateLink()
},
modifier = Modifier.fillMaxWidth(), 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) { 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 androidx.lifecycle.viewModelScope
import com.shaarit.domain.model.ShaarliTag import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
import com.shaarit.presentation.add.ContentType
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -29,6 +30,13 @@ constructor(
var description = MutableStateFlow("") var description = MutableStateFlow("")
var isPrivate = MutableStateFlow(false) 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()) private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
val selectedTags = _selectedTags.asStateFlow() val selectedTags = _selectedTags.asStateFlow()
@ -57,6 +65,12 @@ constructor(
description.value = link.description description.value = link.description
isPrivate.value = link.isPrivate isPrivate.value = link.isPrivate
_selectedTags.value = link.tags _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 _uiState.value = EditLinkUiState.Loaded
}, },
onFailure = { error -> 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) { fun onNewTagInputChanged(input: String) {
_newTagInput.value = input _newTagInput.value = input
updateTagSuggestions(input) updateTagSuggestions(input)
@ -125,15 +154,29 @@ constructor(
_uiState.value = EditLinkUiState.Saving _uiState.value = EditLinkUiState.Saving
val currentUrl = url.value val currentUrl = url.value
if (currentUrl.isBlank()) { val currentTitle = title.value
_uiState.value = EditLinkUiState.Error("URL is required")
return@launch // Validation based on content type
when (_contentType.value) {
ContentType.BOOKMARK -> {
if (currentUrl.isBlank()) {
_uiState.value = EditLinkUiState.Error("URL is required for bookmarks")
return@launch
}
}
ContentType.NOTE -> {
if (currentTitle.isBlank()) {
_uiState.value = EditLinkUiState.Error("Title is required for notes")
return@launch
}
}
} }
linkRepository.updateLink( linkRepository.updateLink(
id = linkId, id = linkId,
url = currentUrl, url = if (_contentType.value == ContentType.NOTE && currentUrl.isBlank())
title = title.value.ifBlank { null }, "note://local/${System.currentTimeMillis()}" else currentUrl,
title = currentTitle.ifBlank { null },
description = description.value.ifBlank { null }, description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null }, tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value 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.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn 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.data.sync.SyncManager
import com.shaarit.domain.model.BookmarkFilter
import com.shaarit.domain.model.ShaarliLink 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.model.ViewStyle
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -18,12 +27,24 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class Quadruple<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D
)
@HiltViewModel @HiltViewModel
class FeedViewModel @Inject constructor( class FeedViewModel @Inject constructor(
private val linkRepository: LinkRepository, 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() { ) : ViewModel() {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
@ -32,25 +53,53 @@ class FeedViewModel @Inject constructor(
private val _searchTags = MutableStateFlow<String?>(null) private val _searchTags = MutableStateFlow<String?>(null)
val searchTags = _searchTags.asStateFlow() val searchTags = _searchTags.asStateFlow()
private val _collectionId = MutableStateFlow<Long?>(null)
val collectionId = _collectionId.asStateFlow()
private val _viewStyle = MutableStateFlow(ViewStyle.LIST) private val _viewStyle = MutableStateFlow(ViewStyle.LIST)
val viewStyle = _viewStyle.asStateFlow() val viewStyle = _viewStyle.asStateFlow()
private val _bookmarkFilter = MutableStateFlow(BookmarkFilter.DEFAULT)
val bookmarkFilter = _bookmarkFilter.asStateFlow()
private val _refreshTrigger = MutableStateFlow(0) 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) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val pagedLinks: Flow<PagingData<ShaarliLink>> = val pagedLinks: Flow<PagingData<ShaarliLink>> =
combine(_searchQuery, _searchTags, _refreshTrigger) { query, tags, _ -> combine(_searchQuery, _searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { query, tags, collectionId, bookmarkFilter, _ ->
Pair(query, tags) Quadruple(query, tags, collectionId, bookmarkFilter)
} }
.debounce(300) // Debounce for 300ms .debounce(300) // Debounce for 300ms
.flatMapLatest { (query, tags) -> .flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
linkRepository.getLinksStream( linkRepository.getLinksStream(
searchTerm = if (query.isBlank()) null else query, searchTerm = if (query.isBlank()) null else query,
searchTags = tags searchTags = tags,
collectionId = collectionId,
bookmarkFilter = bookmarkFilter
) )
} }
.cachedIn(viewModelScope) .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) { fun onSearchQueryChanged(query: String) {
_searchQuery.value = query _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() { fun clearTagFilter() {
_searchTags.value = null _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) { fun deleteLink(id: Int) {
viewModelScope.launch { viewModelScope.launch {
linkRepository.deleteLink(id) 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() { fun refresh() {
syncManager.syncNow() syncManager.syncNow()
_refreshTrigger.value++ _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.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@ -42,6 +44,10 @@ fun ListViewItem(
link: ShaarliLink, link: ShaarliLink,
onTagClick: (String) -> Unit, onTagClick: (String) -> Unit,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onItemClick: (() -> Unit)? = null,
onItemLongClick: (() -> Unit)? = null,
selectionMode: Boolean = false,
isSelected: Boolean = false,
onViewClick: () -> Unit, onViewClick: () -> Unit,
onEditClick: (Int) -> Unit, onEditClick: (Int) -> Unit,
onDeleteClick: () -> 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 { Column {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -87,6 +98,12 @@ fun ListViewItem(
} }
Row { Row {
if (selectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClick?.invoke() }
)
}
// Pin button // Pin button
IconButton( IconButton(
onClick = { onTogglePin(link.id) }, onClick = { onTogglePin(link.id) },
@ -188,6 +205,10 @@ fun ListViewItem(
fun GridViewItem( fun GridViewItem(
link: ShaarliLink, link: ShaarliLink,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onItemClick: (() -> Unit)? = null,
onItemLongClick: (() -> Unit)? = null,
selectionMode: Boolean = false,
isSelected: Boolean = false,
onViewClick: () -> Unit, onViewClick: () -> Unit,
onEditClick: (Int) -> Unit, onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
@ -210,7 +231,9 @@ fun GridViewItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp), .height(200.dp),
onClick = { onLinkClick(link.url) } onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
onLongClick = onItemLongClick,
glowColor = if (isSelected) CyanPrimary else CyanPrimary
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -288,6 +311,12 @@ fun GridViewItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (selectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClick?.invoke() }
)
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
@ -364,9 +393,14 @@ fun GridViewItem(
* Compact view item - minimal info for dense lists * Compact view item - minimal info for dense lists
*/ */
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class)
fun CompactViewItem( fun CompactViewItem(
link: ShaarliLink, link: ShaarliLink,
onLinkClick: (String) -> Unit, onLinkClick: (String) -> Unit,
onItemClick: (() -> Unit)? = null,
onItemLongClick: (() -> Unit)? = null,
selectionMode: Boolean = false,
isSelected: Boolean = false,
onViewClick: () -> Unit, onViewClick: () -> Unit,
onEditClick: (Int) -> Unit, onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
@ -389,7 +423,10 @@ fun CompactViewItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { onLinkClick(link.url) }, .combinedClickable(
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
onLongClick = onItemLongClick
),
color = CardBackground.copy(alpha = 0.7f) color = CardBackground.copy(alpha = 0.7f)
) { ) {
Row( Row(
@ -399,6 +436,13 @@ fun CompactViewItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (selectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClick?.invoke() }
)
Spacer(modifier = Modifier.width(8.dp))
}
Row( Row(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@ -16,9 +16,21 @@ import java.net.URLEncoder
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
object Login : Screen("login") object Login : Screen("login")
object Feed : Screen("feed?tag={tag}") { object Feed : Screen("feed?tag={tag}&collectionId={collectionId}") {
fun createRoute(tag: String? = null): String { fun createRoute(tag: String? = null, collectionId: Long? = null): String {
return if (tag != null) "feed?tag=$tag" else "feed" 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}") object Add : Screen("add?url={url}&title={title}&isShare={isShare}")
@ -36,6 +48,9 @@ fun AppNavGraph(
startDestination: String = Screen.Login.route, startDestination: String = Screen.Login.route,
shareUrl: String? = null, shareUrl: String? = null,
shareTitle: String? = null, shareTitle: String? = null,
shareDescription: String? = null,
shareTags: List<String>? = null,
isFileShare: Boolean = false,
initialDeepLink: String? = null initialDeepLink: String? = null
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
@ -45,14 +60,22 @@ fun AppNavGraph(
composable(Screen.Login.route) { composable(Screen.Login.route) {
com.shaarit.presentation.auth.LoginScreen( com.shaarit.presentation.auth.LoginScreen(
onLoginSuccess = { 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 // Use proper URL encoding that handles spaces correctly
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8") val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
val encodedTitle = val encodedTitle =
if (shareTitle != null) { if (shareTitle != null) {
URLEncoder.encode(shareTitle, "UTF-8") URLEncoder.encode(shareTitle, "UTF-8")
} else "" } 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 } popUpTo(Screen.Login.route) { inclusive = true }
} }
} else if (initialDeepLink != null) { } else if (initialDeepLink != null) {
@ -70,12 +93,16 @@ fun AppNavGraph(
} }
composable( composable(
route = "feed?tag={tag}", route = "feed?tag={tag}&collectionId={collectionId}",
arguments = listOf( arguments = listOf(
navArgument("tag") { navArgument("tag") {
type = NavType.StringType type = NavType.StringType
nullable = true nullable = true
defaultValue = null defaultValue = null
},
navArgument("collectionId") {
type = NavType.LongType
defaultValue = -1L
} }
), ),
deepLinks = listOf( deepLinks = listOf(
@ -84,6 +111,9 @@ fun AppNavGraph(
) )
) { backStackEntry -> ) { backStackEntry ->
val tag = backStackEntry.arguments?.getString("tag") val tag = backStackEntry.arguments?.getString("tag")
val collectionId = backStackEntry.arguments
?.getLong("collectionId")
?.takeIf { it != -1L }
com.shaarit.presentation.feed.FeedScreen( com.shaarit.presentation.feed.FeedScreen(
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") }, onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
onNavigateToEdit = { linkId -> onNavigateToEdit = { linkId ->
@ -93,12 +123,13 @@ fun AppNavGraph(
onNavigateToCollections = { navController.navigate(Screen.Collections.route) }, onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
onNavigateToSettings = { navController.navigate(Screen.Settings.route) }, onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
onNavigateToRandom = { }, onNavigateToRandom = { },
initialTagFilter = tag initialTagFilter = tag,
initialCollectionId = collectionId
) )
} }
composable( 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( arguments = listOf(
navArgument("url") { navArgument("url") {
type = NavType.StringType type = NavType.StringType
@ -113,6 +144,20 @@ fun AppNavGraph(
navArgument("isShare") { navArgument("isShare") {
type = NavType.BoolType type = NavType.BoolType
defaultValue = false 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( deepLinks = listOf(
@ -165,9 +210,14 @@ fun AppNavGraph(
) { ) {
com.shaarit.presentation.collections.CollectionsScreen( com.shaarit.presentation.collections.CollectionsScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onCollectionClick = { collectionId -> onCollectionClick = { collectionId, isSmart, query ->
// Naviguer vers le feed avec le filtre de collection navController.navigate(
navController.navigate(Screen.Feed.createRoute()) { if (isSmart) {
Screen.Feed.createRoute(tag = query)
} else {
Screen.Feed.createRoute(collectionId = collectionId)
}
) {
popUpTo(Screen.Collections.route) { inclusive = true } 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -25,10 +27,12 @@ import androidx.compose.ui.unit.dp
import com.shaarit.ui.theme.* import com.shaarit.ui.theme.*
/** A glassmorphism-styled card with subtle border glow effect */ /** A glassmorphism-styled card with subtle border glow effect */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun GlassCard( fun GlassCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
glowColor: Color = CyanPrimary, glowColor: Color = CyanPrimary,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
@ -94,11 +98,12 @@ fun GlassCard(
) )
val finalModifier = val finalModifier =
if (onClick != null) { if (onClick != null || onLongClick != null) {
cardModifier.clickable( cardModifier.combinedClickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = null, indication = null,
onClick = onClick onClick = { onClick?.invoke() },
onLongClick = onLongClick
) )
} else { } else {
cardModifier cardModifier