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