feat: Add file sharing support and collections configuration management
- Add intent filters for markdown and text file sharing (text/*, application/octet-stream) - Implement file content reading with filename extraction and automatic tagging - Enable edge-to-edge mode with proper IME insets handling for keyboard - Add collections configuration dirty flag and bookmark ID tracking to TokenManager - Create CollectionsConfigDto and CollectionConfigDto for JSON serialization - Add collection query methods
This commit is contained in:
parent
fdacf2248a
commit
4021aacc1d
@ -39,13 +39,25 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Share Intent -->
|
||||
<!-- Share Intent - Text -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Share Intent - Markdown and Text Files -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- App Shortcuts -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
package com.shaarit
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.shaarit.presentation.nav.AppNavGraph
|
||||
import com.shaarit.ui.theme.ShaarItTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@ -21,24 +26,45 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Enable edge-to-edge mode for proper keyboard (IME) insets detection
|
||||
enableEdgeToEdge()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
setContent {
|
||||
ShaarItTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
val context = LocalContext.current
|
||||
var shareUrl: String? = null
|
||||
var shareTitle: String? = null
|
||||
var shareDescription: String? = null
|
||||
var shareTags: List<String>? = null
|
||||
var deepLink: String? = null
|
||||
var isFileShare = false
|
||||
|
||||
val activity = context as? androidx.activity.ComponentActivity
|
||||
val intent = activity?.intent
|
||||
|
||||
// Handle share intent
|
||||
if (intent?.action == android.content.Intent.ACTION_SEND &&
|
||||
intent.type == "text/plain"
|
||||
) {
|
||||
if (intent?.action == android.content.Intent.ACTION_SEND) {
|
||||
val mimeType = intent.type ?: ""
|
||||
|
||||
// Check if this is a file share (markdown or text file)
|
||||
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
|
||||
|
||||
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
|
||||
// Handle file sharing - use filename as title, content as description
|
||||
isFileShare = true
|
||||
val fileInfo = readFileContent(fileUri)
|
||||
shareTitle = fileInfo.first // filename without extension
|
||||
shareDescription = fileInfo.second // file content
|
||||
shareTags = listOf("note", "fichier")
|
||||
shareUrl = null // No URL for file shares
|
||||
} else if (mimeType == "text/plain") {
|
||||
// Regular text share (URL)
|
||||
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
|
||||
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deep links from App Shortcuts
|
||||
intent?.data?.let { uri ->
|
||||
@ -54,10 +80,64 @@ class MainActivity : ComponentActivity() {
|
||||
AppNavGraph(
|
||||
shareUrl = shareUrl,
|
||||
shareTitle = shareTitle,
|
||||
shareDescription = shareDescription,
|
||||
shareTags = shareTags,
|
||||
isFileShare = isFileShare,
|
||||
initialDeepLink = deepLink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the shared content is a text or markdown file
|
||||
*/
|
||||
private fun isTextOrMarkdownFile(mimeType: String, uri: Uri): Boolean {
|
||||
val isTextMime = mimeType.startsWith("text/") || mimeType == "application/octet-stream"
|
||||
val filename = getFileName(uri)?.lowercase() ?: ""
|
||||
val isMarkdownOrText = filename.endsWith(".md") ||
|
||||
filename.endsWith(".markdown") ||
|
||||
filename.endsWith(".txt") ||
|
||||
filename.endsWith(".text")
|
||||
return isTextMime && (isMarkdownOrText || mimeType.contains("markdown"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the content of a file and returns (filename without extension, content)
|
||||
*/
|
||||
private fun readFileContent(uri: Uri): Pair<String, String> {
|
||||
val filename = getFileName(uri) ?: "Note"
|
||||
val filenameWithoutExtension = filename.substringBeforeLast(".")
|
||||
|
||||
val content = try {
|
||||
contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
||||
reader.readText()
|
||||
}
|
||||
} ?: ""
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
return Pair(filenameWithoutExtension, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the filename from a Uri
|
||||
*/
|
||||
private fun getFileName(uri: Uri): String? {
|
||||
return try {
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||
cursor.getString(nameIndex)
|
||||
} else {
|
||||
uri.lastPathSegment
|
||||
}
|
||||
} ?: uri.lastPathSegment
|
||||
} catch (e: Exception) {
|
||||
uri.lastPathSegment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,3 +47,21 @@ data class InfoSettingsDto(
|
||||
@Json(name = "enabled_plugins") val enabledPlugins: List<String>? = null,
|
||||
@Json(name = "default_private_links") val defaultPrivateLinks: Boolean? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CollectionsConfigDto(
|
||||
@Json(name = "version") val version: Int = 1,
|
||||
@Json(name = "collections") val collections: List<CollectionConfigDto> = emptyList()
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CollectionConfigDto(
|
||||
@Json(name = "name") val name: String,
|
||||
@Json(name = "description") val description: String? = null,
|
||||
@Json(name = "icon") val icon: String? = null,
|
||||
@Json(name = "color") val color: Int? = null,
|
||||
@Json(name = "isSmart") val isSmart: Boolean = false,
|
||||
@Json(name = "query") val query: String? = null,
|
||||
@Json(name = "sortOrder") val sortOrder: Int = 0,
|
||||
@Json(name = "linkIds") val linkIds: List<Int> = emptyList()
|
||||
)
|
||||
|
||||
@ -17,9 +17,15 @@ interface CollectionDao {
|
||||
@Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC")
|
||||
fun getAllCollections(): Flow<List<CollectionEntity>>
|
||||
|
||||
@Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC")
|
||||
suspend fun getAllCollectionsOnce(): List<CollectionEntity>
|
||||
|
||||
@Query("SELECT * FROM collections WHERE id = :id")
|
||||
suspend fun getCollectionById(id: Long): CollectionEntity?
|
||||
|
||||
@Query("SELECT * FROM collections WHERE name = :name LIMIT 1")
|
||||
suspend fun getCollectionByName(name: String): CollectionEntity?
|
||||
|
||||
@Query("SELECT * FROM collections WHERE is_smart = 0 ORDER BY sort_order ASC")
|
||||
fun getRegularCollections(): Flow<List<CollectionEntity>>
|
||||
|
||||
@ -61,6 +67,12 @@ interface CollectionDao {
|
||||
@Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId")
|
||||
fun getLinkCountInCollection(collectionId: Long): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId")
|
||||
suspend fun getLinkCountInCollectionOnce(collectionId: Long): Int
|
||||
|
||||
@Query("SELECT link_id FROM collection_links WHERE collection_id = :collectionId ORDER BY added_at DESC")
|
||||
suspend fun getLinkIdsInCollection(collectionId: Long): List<Int>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId)")
|
||||
suspend fun isLinkInCollection(collectionId: Long, linkId: Int): Boolean
|
||||
}
|
||||
|
||||
@ -71,6 +71,30 @@ interface LinkDao {
|
||||
@RawQuery(observedEntities = [LinkEntity::class])
|
||||
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
|
||||
|
||||
@Query("""
|
||||
SELECT links.* FROM links
|
||||
INNER JOIN collection_links ON links.id = collection_links.link_id
|
||||
WHERE collection_links.collection_id = :collectionId
|
||||
ORDER BY links.is_pinned DESC, collection_links.added_at DESC
|
||||
""")
|
||||
fun getLinksInCollectionPaged(collectionId: Long): PagingSource<Int, LinkEntity>
|
||||
|
||||
// ====== Comptage pour collections intelligentes ======
|
||||
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM links
|
||||
WHERE tags LIKE '%' || :tag || '%'
|
||||
""")
|
||||
suspend fun countLinksByTag(tag: String): Int
|
||||
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM links
|
||||
WHERE title LIKE '%' || :query || '%'
|
||||
OR description LIKE '%' || :query || '%'
|
||||
OR url LIKE '%' || :query || '%'
|
||||
""")
|
||||
suspend fun countLinksBySearch(query: String): Int
|
||||
|
||||
// ====== Filtres temporels ======
|
||||
|
||||
@Query("""
|
||||
@ -87,6 +111,11 @@ interface LinkDao {
|
||||
""")
|
||||
fun getLinksBetween(startTime: Long, endTime: Long): Flow<List<LinkEntity>>
|
||||
|
||||
// ====== Nouvelles méthodes pour le tri avancé avec combinaisons ======
|
||||
|
||||
@RawQuery(observedEntities = [LinkEntity::class])
|
||||
fun getLinksWithFilters(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
|
||||
|
||||
// ====== Filtres par statut ======
|
||||
|
||||
@Query("SELECT * FROM links WHERE is_private = 0 ORDER BY created_at DESC")
|
||||
|
||||
@ -3,6 +3,7 @@ package com.shaarit.data.repository
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.map
|
||||
import com.shaarit.data.api.ShaarliApi
|
||||
import com.shaarit.data.dto.CreateLinkDto
|
||||
@ -48,13 +49,16 @@ constructor(
|
||||
|
||||
override fun getLinksStream(
|
||||
searchTerm: String?,
|
||||
searchTags: String?
|
||||
searchTags: String?,
|
||||
collectionId: Long?,
|
||||
bookmarkFilter: com.shaarit.domain.model.BookmarkFilter
|
||||
): Flow<PagingData<ShaarliLink>> {
|
||||
// Utiliser Room pour la pagination locale
|
||||
return Pager(
|
||||
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
|
||||
pagingSourceFactory = {
|
||||
when {
|
||||
collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId)
|
||||
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
|
||||
!searchTags.isNullOrBlank() -> {
|
||||
val tags =
|
||||
@ -66,7 +70,7 @@ constructor(
|
||||
.distinct()
|
||||
|
||||
if (tags.isEmpty()) {
|
||||
linkDao.getAllLinksPaged()
|
||||
buildFilteredQuery(bookmarkFilter)
|
||||
} else {
|
||||
val whereClause = tags.joinToString(" AND ") { "tags LIKE ?" }
|
||||
val sql =
|
||||
@ -75,7 +79,7 @@ constructor(
|
||||
linkDao.getLinksByTags(SimpleSQLiteQuery(sql, args))
|
||||
}
|
||||
}
|
||||
else -> linkDao.getAllLinksPaged()
|
||||
else -> buildFilteredQuery(bookmarkFilter)
|
||||
}
|
||||
}
|
||||
).flow.map { pagingData ->
|
||||
@ -354,6 +358,94 @@ constructor(
|
||||
|
||||
// ====== Helpers ======
|
||||
|
||||
private fun buildFilteredQuery(filter: com.shaarit.domain.model.BookmarkFilter): PagingSource<Int, LinkEntity> {
|
||||
val conditions = mutableListOf<String>()
|
||||
val args = mutableListOf<Any>()
|
||||
|
||||
// Time-based filters
|
||||
when (filter.timeFilter) {
|
||||
com.shaarit.domain.model.TimeFilter.TODAY -> {
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||
calendar.set(java.util.Calendar.MINUTE, 0)
|
||||
calendar.set(java.util.Calendar.SECOND, 0)
|
||||
calendar.set(java.util.Calendar.MILLISECOND, 0)
|
||||
val todayStart = calendar.timeInMillis
|
||||
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
val todayEnd = calendar.timeInMillis
|
||||
conditions.add("created_at >= ? AND created_at < ?")
|
||||
args.add(todayStart)
|
||||
args.add(todayEnd)
|
||||
}
|
||||
com.shaarit.domain.model.TimeFilter.THIS_WEEK -> {
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
calendar.set(java.util.Calendar.DAY_OF_WEEK, calendar.firstDayOfWeek)
|
||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||
calendar.set(java.util.Calendar.MINUTE, 0)
|
||||
calendar.set(java.util.Calendar.SECOND, 0)
|
||||
calendar.set(java.util.Calendar.MILLISECOND, 0)
|
||||
val weekStart = calendar.timeInMillis
|
||||
conditions.add("created_at >= ?")
|
||||
args.add(weekStart)
|
||||
}
|
||||
com.shaarit.domain.model.TimeFilter.THIS_MONTH -> {
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
calendar.set(java.util.Calendar.DAY_OF_MONTH, 1)
|
||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||
calendar.set(java.util.Calendar.MINUTE, 0)
|
||||
calendar.set(java.util.Calendar.SECOND, 0)
|
||||
calendar.set(java.util.Calendar.MILLISECOND, 0)
|
||||
val monthStart = calendar.timeInMillis
|
||||
conditions.add("created_at >= ?")
|
||||
args.add(monthStart)
|
||||
}
|
||||
com.shaarit.domain.model.TimeFilter.ALL -> {
|
||||
// No time filter
|
||||
}
|
||||
}
|
||||
|
||||
// Visibility filters
|
||||
when (filter.visibilityFilter) {
|
||||
com.shaarit.domain.model.VisibilityFilter.PUBLIC_ONLY -> {
|
||||
conditions.add("is_private = ?")
|
||||
args.add(0)
|
||||
}
|
||||
com.shaarit.domain.model.VisibilityFilter.PRIVATE_ONLY -> {
|
||||
conditions.add("is_private = ?")
|
||||
args.add(1)
|
||||
}
|
||||
com.shaarit.domain.model.VisibilityFilter.ALL -> {
|
||||
// No visibility filter
|
||||
}
|
||||
}
|
||||
|
||||
// Tag filters
|
||||
when (filter.tagFilter) {
|
||||
com.shaarit.domain.model.TagFilter.UNTAGGED -> {
|
||||
conditions.add("(tags = '[]' OR tags IS NULL OR tags = '')")
|
||||
}
|
||||
com.shaarit.domain.model.TagFilter.ALL -> {
|
||||
// No tag filter
|
||||
}
|
||||
}
|
||||
|
||||
// Build WHERE clause
|
||||
val whereClause = if (conditions.isNotEmpty()) {
|
||||
"WHERE ${conditions.joinToString(" AND ")}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Build ORDER BY clause
|
||||
val orderBy = when (filter.sortDirection) {
|
||||
com.shaarit.domain.model.SortDirection.NEWEST_FIRST -> "ORDER BY is_pinned DESC, created_at DESC"
|
||||
com.shaarit.domain.model.SortDirection.OLDEST_FIRST -> "ORDER BY is_pinned DESC, created_at ASC"
|
||||
}
|
||||
|
||||
val sql = "SELECT * FROM links $whereClause $orderBy"
|
||||
return linkDao.getLinksWithFilters(SimpleSQLiteQuery(sql, args.toTypedArray()))
|
||||
}
|
||||
|
||||
private fun parseExistingLink(errorBody: String?): LinkDto? {
|
||||
if (errorBody.isNullOrBlank()) return null
|
||||
return try {
|
||||
@ -372,7 +464,7 @@ constructor(
|
||||
description = description,
|
||||
tags = tags,
|
||||
isPrivate = isPrivate,
|
||||
date = java.time.Instant.ofEpochMilli(createdAt).toString(),
|
||||
date = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault()).format(java.util.Date(createdAt)),
|
||||
isPinned = isPinned,
|
||||
thumbnailUrl = thumbnailUrl,
|
||||
readingTime = readingTimeMinutes,
|
||||
@ -400,7 +492,8 @@ constructor(
|
||||
private fun parseDate(dateString: String?): Long {
|
||||
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
|
||||
return try {
|
||||
java.time.Instant.parse(dateString).toEpochMilli()
|
||||
val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault())
|
||||
format.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@ -13,17 +13,24 @@ import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.shaarit.data.api.ShaarliApi
|
||||
import com.shaarit.data.dto.CollectionConfigDto
|
||||
import com.shaarit.data.dto.CreateLinkDto
|
||||
import com.shaarit.data.dto.CollectionsConfigDto
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.dao.CollectionDao
|
||||
import com.shaarit.data.local.dao.TagDao
|
||||
import com.shaarit.data.local.entity.CollectionEntity
|
||||
import com.shaarit.data.local.entity.CollectionLinkCrossRef
|
||||
import com.shaarit.data.local.entity.LinkEntity
|
||||
import com.shaarit.data.local.entity.SyncStatus
|
||||
import com.shaarit.data.local.entity.TagEntity
|
||||
import com.shaarit.data.mapper.LinkMapper
|
||||
import com.shaarit.data.mapper.TagMapper
|
||||
import com.shaarit.core.storage.TokenManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import com.squareup.moshi.Moshi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@ -41,11 +48,18 @@ class SyncManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val linkDao: LinkDao,
|
||||
private val tagDao: TagDao,
|
||||
private val collectionDao: CollectionDao,
|
||||
private val moshi: Moshi,
|
||||
private val tokenManager: TokenManager,
|
||||
private val api: ShaarliApi
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "SyncManager"
|
||||
private const val SYNC_WORK_NAME = "shaarli_sync_work"
|
||||
|
||||
private const val COLLECTIONS_CONFIG_TITLE = "collections"
|
||||
private const val COLLECTIONS_CONFIG_TAG = "shaarit_config"
|
||||
private const val COLLECTIONS_CONFIG_URL = "https://shaarit.app/config/collections"
|
||||
}
|
||||
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
@ -150,6 +164,120 @@ class SyncManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushCollectionsConfigIfDirty() {
|
||||
if (!tokenManager.isCollectionsConfigDirty()) return
|
||||
|
||||
try {
|
||||
val config = buildCollectionsConfig()
|
||||
val adapter = moshi.adapter(CollectionsConfigDto::class.java)
|
||||
val json = adapter.toJson(config)
|
||||
|
||||
val existingId = tokenManager.getCollectionsConfigBookmarkId()
|
||||
val linkId = existingId ?: findCollectionsConfigBookmarkIdOnServer()
|
||||
|
||||
if (linkId != null) {
|
||||
val response = api.updateLink(
|
||||
linkId,
|
||||
CreateLinkDto(
|
||||
url = COLLECTIONS_CONFIG_URL,
|
||||
title = COLLECTIONS_CONFIG_TITLE,
|
||||
description = json,
|
||||
tags = listOf(COLLECTIONS_CONFIG_TAG),
|
||||
isPrivate = true
|
||||
)
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
tokenManager.saveCollectionsConfigBookmarkId(linkId)
|
||||
tokenManager.setCollectionsConfigDirty(false)
|
||||
}
|
||||
} else {
|
||||
val response = api.addLink(
|
||||
CreateLinkDto(
|
||||
url = COLLECTIONS_CONFIG_URL,
|
||||
title = COLLECTIONS_CONFIG_TITLE,
|
||||
description = json,
|
||||
tags = listOf(COLLECTIONS_CONFIG_TAG),
|
||||
isPrivate = true
|
||||
)
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val createdId = response.body()?.id
|
||||
if (createdId != null) {
|
||||
tokenManager.saveCollectionsConfigBookmarkId(createdId)
|
||||
}
|
||||
tokenManager.setCollectionsConfigDirty(false)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Erreur lors de la poussée de la configuration des collections", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findCollectionsConfigBookmarkIdOnServer(): Int? {
|
||||
// 1) Si on a un ID en cache, vérifier qu'il existe
|
||||
val cached = tokenManager.getCollectionsConfigBookmarkId()
|
||||
if (cached != null) {
|
||||
try {
|
||||
api.getLink(cached)
|
||||
return cached
|
||||
} catch (_: Exception) {
|
||||
tokenManager.clearCollectionsConfigBookmarkId()
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Rechercher par filtre searchTerm + searchTags
|
||||
return try {
|
||||
val candidates = api.getLinks(
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
searchTerm = COLLECTIONS_CONFIG_TITLE,
|
||||
searchTags = COLLECTIONS_CONFIG_TAG
|
||||
)
|
||||
|
||||
val configLink = candidates.firstOrNull { dto ->
|
||||
dto.id != null && dto.title?.trim()?.equals(COLLECTIONS_CONFIG_TITLE, ignoreCase = true) == true
|
||||
}
|
||||
|
||||
configLink?.id?.also { tokenManager.saveCollectionsConfigBookmarkId(it) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildCollectionsConfig(): CollectionsConfigDto {
|
||||
val collections = collectionDao.getAllCollectionsOnce()
|
||||
val items = collections.map { entity ->
|
||||
val linkIds =
|
||||
if (!entity.isSmart) {
|
||||
try {
|
||||
collectionDao.getLinkIdsInCollection(entity.id)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
CollectionConfigDto(
|
||||
name = entity.name,
|
||||
description = entity.description,
|
||||
icon = entity.icon,
|
||||
color = entity.color,
|
||||
isSmart = entity.isSmart,
|
||||
query = entity.query,
|
||||
sortOrder = entity.sortOrder,
|
||||
linkIds = linkIds
|
||||
)
|
||||
}
|
||||
|
||||
return CollectionsConfigDto(
|
||||
version = 1,
|
||||
collections = items
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pousse les modifications locales (créations, mises à jour, suppressions)
|
||||
*/
|
||||
@ -236,6 +364,9 @@ class SyncManager @Inject constructor(
|
||||
Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Synchroniser la configuration des collections si nécessaire
|
||||
pushCollectionsConfigIfDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -256,7 +387,11 @@ class SyncManager @Inject constructor(
|
||||
} else {
|
||||
// Filtrer les liens invalides (sans ID ou URL) et convertir en entités
|
||||
val validLinks = links.filter { dto ->
|
||||
dto.id != null && !dto.url.isNullOrBlank()
|
||||
val isValid = dto.id != null && !dto.url.isNullOrBlank()
|
||||
val isCollectionsConfig =
|
||||
dto.title?.trim()?.equals(COLLECTIONS_CONFIG_TITLE, ignoreCase = true) == true &&
|
||||
(dto.tags?.contains(COLLECTIONS_CONFIG_TAG) == true)
|
||||
isValid && !isCollectionsConfig
|
||||
}
|
||||
Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
|
||||
|
||||
@ -305,6 +440,94 @@ class SyncManager @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Erreur lors de la récupération des tags", e)
|
||||
}
|
||||
|
||||
// Synchroniser la configuration des collections (bookmark serveur "collections")
|
||||
pullCollectionsConfigFromServer()
|
||||
}
|
||||
|
||||
private suspend fun pullCollectionsConfigFromServer() {
|
||||
try {
|
||||
val candidates = api.getLinks(
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
searchTerm = COLLECTIONS_CONFIG_TITLE,
|
||||
searchTags = COLLECTIONS_CONFIG_TAG
|
||||
)
|
||||
|
||||
val configLink = candidates.firstOrNull { dto ->
|
||||
dto.title?.trim()?.equals(COLLECTIONS_CONFIG_TITLE, ignoreCase = true) == true
|
||||
} ?: return
|
||||
|
||||
val rawJson = configLink.description?.trim().orEmpty()
|
||||
if (rawJson.isBlank()) return
|
||||
|
||||
val adapter = moshi.adapter(CollectionsConfigDto::class.java)
|
||||
val config = adapter.fromJson(rawJson) ?: return
|
||||
applyCollectionsConfig(config)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Erreur lors de la récupération de la configuration des collections", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyCollectionsConfig(config: CollectionsConfigDto) {
|
||||
val serverNames =
|
||||
config.collections
|
||||
.map { it.name.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet()
|
||||
|
||||
// Supprimer les collections locales qui ne sont plus dans la config serveur
|
||||
val existing = collectionDao.getAllCollectionsOnce()
|
||||
existing
|
||||
.filter { it.name !in serverNames }
|
||||
.forEach { entity ->
|
||||
try {
|
||||
collectionDao.clearCollection(entity.id)
|
||||
collectionDao.deleteCollection(entity.id)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Impossible de supprimer la collection locale ${entity.name}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert des collections + relations
|
||||
config.collections.forEach { dto ->
|
||||
val name = dto.name.trim()
|
||||
if (name.isBlank()) return@forEach
|
||||
|
||||
val existingEntity = collectionDao.getCollectionByName(name)
|
||||
val entity = CollectionEntity(
|
||||
id = existingEntity?.id ?: 0,
|
||||
name = name,
|
||||
description = dto.description,
|
||||
icon = dto.icon ?: (existingEntity?.icon ?: "📁"),
|
||||
color = dto.color,
|
||||
isSmart = dto.isSmart,
|
||||
query = dto.query,
|
||||
sortOrder = dto.sortOrder,
|
||||
createdAt = existingEntity?.createdAt ?: System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val collectionId =
|
||||
if (existingEntity != null) {
|
||||
collectionDao.updateCollection(entity)
|
||||
existingEntity.id
|
||||
} else {
|
||||
collectionDao.insertCollection(entity)
|
||||
}
|
||||
|
||||
// Relations: uniquement pour les collections "non smart"
|
||||
collectionDao.clearCollection(collectionId)
|
||||
if (!dto.isSmart) {
|
||||
dto.linkIds
|
||||
.distinct()
|
||||
.forEach { linkId ->
|
||||
collectionDao.addLinkToCollection(
|
||||
CollectionLinkCrossRef(collectionId = collectionId, linkId = linkId)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDate(dateString: String?): Long {
|
||||
|
||||
50
app/src/main/java/com/shaarit/domain/model/SortOrder.kt
Normal file
50
app/src/main/java/com/shaarit/domain/model/SortOrder.kt
Normal file
@ -0,0 +1,50 @@
|
||||
package com.shaarit.domain.model
|
||||
|
||||
/**
|
||||
* Enum representing sorting direction
|
||||
*/
|
||||
enum class SortDirection {
|
||||
NEWEST_FIRST,
|
||||
OLDEST_FIRST
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing time-based filters
|
||||
*/
|
||||
enum class TimeFilter {
|
||||
ALL,
|
||||
TODAY,
|
||||
THIS_WEEK,
|
||||
THIS_MONTH
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing visibility filters
|
||||
*/
|
||||
enum class VisibilityFilter {
|
||||
ALL,
|
||||
PUBLIC_ONLY,
|
||||
PRIVATE_ONLY
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing tag filters
|
||||
*/
|
||||
enum class TagFilter {
|
||||
ALL,
|
||||
UNTAGGED
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class representing combined sorting and filtering options
|
||||
*/
|
||||
data class BookmarkFilter(
|
||||
val sortDirection: SortDirection = SortDirection.NEWEST_FIRST,
|
||||
val timeFilter: TimeFilter = TimeFilter.ALL,
|
||||
val visibilityFilter: VisibilityFilter = VisibilityFilter.ALL,
|
||||
val tagFilter: TagFilter = TagFilter.ALL
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT = BookmarkFilter()
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,9 @@ sealed class AddLinkResult {
|
||||
interface LinkRepository {
|
||||
fun getLinksStream(
|
||||
searchTerm: String? = null,
|
||||
searchTags: String? = null
|
||||
searchTags: String? = null,
|
||||
collectionId: Long? = null,
|
||||
bookmarkFilter: com.shaarit.domain.model.BookmarkFilter = com.shaarit.domain.model.BookmarkFilter.DEFAULT
|
||||
): Flow<PagingData<ShaarliLink>>
|
||||
|
||||
fun getLinkFlow(id: Int): Flow<ShaarliLink?>
|
||||
|
||||
@ -2,11 +2,16 @@ package com.shaarit.presentation.add
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
@ -15,16 +20,22 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.shaarit.ui.components.*
|
||||
import com.shaarit.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AddLinkScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
@ -43,9 +54,19 @@ fun AddLinkScreen(
|
||||
val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState()
|
||||
val extractedThumbnail by viewModel.extractedThumbnail.collectAsState()
|
||||
val contentType by viewModel.contentType.collectAsState()
|
||||
val contentTypeSelection by viewModel.contentTypeSelection.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showMarkdownEditor by remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
var showMarkdownPreview by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// State pour l'éditeur Markdown avec barre d'outils flottante
|
||||
val markdownEditorState = rememberMarkdownEditorState()
|
||||
|
||||
// Pour faire défiler automatiquement vers la section tags quand le clavier s'ouvre
|
||||
val tagsSectionBringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
when (val state = uiState) {
|
||||
@ -111,13 +132,14 @@ fun AddLinkScreen(
|
||||
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
|
||||
)
|
||||
) {
|
||||
// Contenu principal avec Scaffold
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Ajouter un lien",
|
||||
if (contentTypeSelection == ContentType.NOTE) "Nouvelle note" else "Nouveau lien",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@ -142,47 +164,78 @@ fun AddLinkScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
.imePadding()
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// URL Section avec extraction de métadonnées
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(title = "URL", subtitle = "Requis")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
// Content Type Selection (compact)
|
||||
GlassCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
glowColor = CyanPrimary
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Bookmark option
|
||||
ContentTypeButton(
|
||||
icon = Icons.Default.Bookmark,
|
||||
label = "Lien",
|
||||
isSelected = contentTypeSelection == ContentType.BOOKMARK,
|
||||
onClick = { viewModel.setContentType(ContentType.BOOKMARK) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
PremiumTextField(
|
||||
// Note option
|
||||
ContentTypeButton(
|
||||
icon = Icons.Default.StickyNote2,
|
||||
label = "Note",
|
||||
isSelected = contentTypeSelection == ContentType.NOTE,
|
||||
onClick = { viewModel.setContentType(ContentType.NOTE) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// URL Section (compact, only for Bookmarks)
|
||||
AnimatedVisibility(
|
||||
visible = contentTypeSelection == ContentType.BOOKMARK,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
CompactFieldCard(
|
||||
icon = Icons.Default.Link,
|
||||
label = "URL"
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { viewModel.url.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "https://example.com",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Link,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary
|
||||
)
|
||||
},
|
||||
placeholder = { Text("https://example.com", color = TextMuted) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
trailingIcon = {
|
||||
if (isExtractingMetadata) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = CyanPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = compactTextFieldColors(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
// Aperçu des métadonnées extraites
|
||||
// Thumbnail preview
|
||||
AnimatedVisibility(
|
||||
visible = extractedThumbnail != null || contentType != null,
|
||||
enter = expandVertically() + fadeIn()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||
// Type de contenu détecté
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
contentType?.let { type ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@ -202,21 +255,20 @@ fun AddLinkScreen(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Type: $type",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
text = type,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail extrait
|
||||
extractedThumbnail?.let { thumbnail ->
|
||||
AsyncImage(
|
||||
model = thumbnail,
|
||||
contentDescription = "Aperçu",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.height(100.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
@ -226,77 +278,163 @@ fun AddLinkScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Title Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(
|
||||
title = "Titre",
|
||||
subtitle = "Optionnel - auto-extrait si vide"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
// Title Section (compact)
|
||||
CompactFieldCard(
|
||||
icon = Icons.Default.Title,
|
||||
label = if (contentTypeSelection == ContentType.NOTE) "Titre *" else "Titre"
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { viewModel.title.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Titre de la page"
|
||||
placeholder = {
|
||||
Text(
|
||||
if (contentTypeSelection == ContentType.NOTE)
|
||||
"Titre de la note" else "Titre du lien",
|
||||
color = TextMuted
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
colors = compactTextFieldColors(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Description Section avec MarkdownEditor
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
// Description Section - Markdown Editor (plus grand en mode Note)
|
||||
GlassCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (contentTypeSelection == ContentType.NOTE)
|
||||
Modifier.heightIn(min = 400.dp)
|
||||
else
|
||||
Modifier
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
// Header avec titre et toggle édition/apercu
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SectionHeader(title = "Description", subtitle = "Markdown supporté")
|
||||
|
||||
// Toggle pour l'éditeur Markdown
|
||||
TextButton(onClick = { showMarkdownEditor = !showMarkdownEditor }) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (showMarkdownEditor) Icons.Default.Edit else Icons.Default.Preview,
|
||||
imageVector = Icons.Default.Description,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Column {
|
||||
Text(
|
||||
if (showMarkdownEditor) "Simple" else "Markdown",
|
||||
color = CyanPrimary
|
||||
text = if (contentTypeSelection == ContentType.NOTE)
|
||||
"Contenu" else "Description",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
if (contentTypeSelection == ContentType.NOTE) {
|
||||
Text(
|
||||
text = "Markdown supporté",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle édition/apercu simple
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { showMarkdownPreview = false },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Éditer",
|
||||
tint = if (!showMarkdownPreview) CyanPrimary else TextMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { showMarkdownPreview = true },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Preview,
|
||||
contentDescription = "Aperçu",
|
||||
tint = if (showMarkdownPreview) CyanPrimary else TextMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (showMarkdownEditor) {
|
||||
// Éditeur Markdown avancé
|
||||
MarkdownEditor(
|
||||
value = description,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
mode = EditorMode.SPLIT,
|
||||
minHeight = 200.dp
|
||||
// Éditeur Markdown ou Aperçu
|
||||
if (showMarkdownPreview) {
|
||||
MarkdownPreview(
|
||||
markdown = description,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(
|
||||
min = if (contentTypeSelection == ContentType.NOTE) 300.dp else 150.dp,
|
||||
max = if (contentTypeSelection == ContentType.NOTE) 500.dp else 300.dp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Champ texte simple
|
||||
PremiumTextField(
|
||||
SimpleMarkdownEditor(
|
||||
value = description,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Ajoutez une description...",
|
||||
singleLine = false,
|
||||
minLines = 3
|
||||
editorState = markdownEditorState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(
|
||||
min = if (contentTypeSelection == ContentType.NOTE) 300.dp else 150.dp,
|
||||
max = if (contentTypeSelection == ContentType.NOTE) 500.dp else 300.dp
|
||||
),
|
||||
isNoteMode = contentTypeSelection == ContentType.NOTE,
|
||||
placeholder = if (contentTypeSelection == ContentType.NOTE)
|
||||
"Écrivez votre note ici..."
|
||||
else
|
||||
"Ajoutez une description..."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
// Tags Section avec correction du clavier - se positionne au-dessus du clavier
|
||||
GlassCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(tagsSectionBringIntoViewRequester)
|
||||
) {
|
||||
Column {
|
||||
SectionHeader(title = "Tags", subtitle = "Organisez vos liens")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Tag,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Tags",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@ -316,31 +454,57 @@ fun AddLinkScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// New tag input
|
||||
// New tag input - fermer le clavier sur Done
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
PremiumTextField(
|
||||
OutlinedTextField(
|
||||
value = newTagInput,
|
||||
onValueChange = { viewModel.onNewTagInputChanged(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = "Ajouter un tag..."
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) {
|
||||
// Faire défiler vers la section tags quand le champ est focusé
|
||||
coroutineScope.launch {
|
||||
tagsSectionBringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = { Text("Ajouter un tag...", color = TextMuted) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (newTagInput.isNotBlank()) {
|
||||
viewModel.addNewTag()
|
||||
}
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
colors = compactTextFieldColors(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.addNewTag() },
|
||||
enabled = newTagInput.isNotBlank()
|
||||
onClick = {
|
||||
viewModel.addNewTag()
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
enabled = newTagInput.isNotBlank(),
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Ajouter tag",
|
||||
contentDescription = "Ajouter",
|
||||
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tag suggestions
|
||||
// Tag suggestions - s'affichent au-dessus quand le clavier est ouvert
|
||||
AnimatedVisibility(
|
||||
visible = tagSuggestions.isNotEmpty(),
|
||||
enter = expandVertically() + fadeIn(),
|
||||
@ -349,16 +513,21 @@ fun AddLinkScreen(
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
"Suggestions",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(tagSuggestions.take(10)) { tag ->
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(tagSuggestions.take(8)) { tag ->
|
||||
TagChip(
|
||||
tag = tag.name,
|
||||
isSelected = false,
|
||||
onClick = { viewModel.addTag(tag.name) },
|
||||
onClick = {
|
||||
viewModel.addTag(tag.name)
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
count = tag.occurrences
|
||||
)
|
||||
}
|
||||
@ -370,16 +539,18 @@ fun AddLinkScreen(
|
||||
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
"Tags populaires",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
"Populaires",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
availableTags
|
||||
.filter { it.name !in selectedTags }
|
||||
.take(10)
|
||||
.take(8)
|
||||
) { tag ->
|
||||
TagChip(
|
||||
tag = tag.name,
|
||||
@ -394,26 +565,22 @@ fun AddLinkScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
// Privacy Section (compact)
|
||||
CompactFieldCard(
|
||||
icon = if (isPrivate) Icons.Default.Lock else Icons.Default.Public,
|
||||
label = if (isPrivate) "Privé" else "Public",
|
||||
onClick = { viewModel.isPrivate.value = !isPrivate }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Privé",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
"Seul vous pouvez voir ce lien",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
if (isPrivate) "Seul vous pouvez voir" else "Visible par tous",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isPrivate,
|
||||
onCheckedChange = { viewModel.isPrivate.value = it },
|
||||
@ -427,14 +594,21 @@ fun AddLinkScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Save Button
|
||||
GradientButton(
|
||||
text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else "Enregistrer le lien",
|
||||
onClick = { viewModel.addLink() },
|
||||
text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else
|
||||
if (contentTypeSelection == ContentType.NOTE) "Enregistrer la note" else "Enregistrer le lien",
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.addLink()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
|
||||
enabled = when (contentTypeSelection) {
|
||||
ContentType.BOOKMARK -> url.isNotBlank() && uiState !is AddLinkUiState.Loading
|
||||
ContentType.NOTE -> title.isNotBlank() && uiState !is AddLinkUiState.Loading
|
||||
}
|
||||
)
|
||||
|
||||
if (uiState is AddLinkUiState.Loading) {
|
||||
@ -445,8 +619,118 @@ fun AddLinkScreen(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(80.dp)) // Espace pour la barre d'outils flottante
|
||||
}
|
||||
}
|
||||
|
||||
// Barre d'outils Markdown flottante - collée au-dessus du clavier
|
||||
FloatingMarkdownToolbar(
|
||||
editorState = markdownEditorState,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
visible = !showMarkdownPreview,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton de sélection de type de contenu compact
|
||||
*/
|
||||
@Composable
|
||||
private fun ContentTypeButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = if (isSelected) CyanPrimary.copy(alpha = 0.15f) else CardBackgroundElevated,
|
||||
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, CyanPrimary) else null,
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (isSelected) CyanPrimary else TextSecondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) CyanPrimary else TextPrimary,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte de champ compacte
|
||||
*/
|
||||
@Composable
|
||||
private fun CompactFieldCard(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val cardModifier = Modifier.fillMaxWidth()
|
||||
|
||||
val finalModifier = if (onClick != null) {
|
||||
cardModifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
cardModifier
|
||||
}
|
||||
|
||||
GlassCard(
|
||||
modifier = finalModifier,
|
||||
glowColor = CyanPrimary.copy(alpha = 0.3f)
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextSecondary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleurs pour les champs texte compacts
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun compactTextFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CyanPrimary,
|
||||
unfocusedBorderColor = SurfaceVariant,
|
||||
focusedLabelColor = CyanPrimary,
|
||||
unfocusedLabelColor = TextSecondary,
|
||||
cursorColor = CyanPrimary,
|
||||
focusedContainerColor = CardBackground.copy(alpha = 0.3f),
|
||||
unfocusedContainerColor = CardBackground.copy(alpha = 0.2f)
|
||||
)
|
||||
|
||||
|
||||
@ -30,15 +30,22 @@ constructor(
|
||||
// Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
|
||||
private val initialUrl: String? = savedStateHandle["url"]
|
||||
private val initialTitle: String? = savedStateHandle["title"]
|
||||
private val initialDescription: String? = savedStateHandle["description"]
|
||||
private val initialTags: String? = savedStateHandle["tags"]
|
||||
private val isFileShare: Boolean = savedStateHandle["isFileShare"] ?: false
|
||||
|
||||
private val _uiState = MutableStateFlow<AddLinkUiState>(AddLinkUiState.Idle)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
var url = MutableStateFlow(decodeUrlParam(initialUrl) ?: "")
|
||||
var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "")
|
||||
var description = MutableStateFlow("")
|
||||
var description = MutableStateFlow(decodeUrlParam(initialDescription) ?: "")
|
||||
var isPrivate = MutableStateFlow(false)
|
||||
|
||||
// Content type selection - default to NOTE for file shares
|
||||
private val _contentTypeSelection = MutableStateFlow(if (isFileShare) ContentType.NOTE else ContentType.BOOKMARK)
|
||||
val contentTypeSelection = _contentTypeSelection.asStateFlow()
|
||||
|
||||
// Extraction state
|
||||
private val _isExtractingMetadata = MutableStateFlow(false)
|
||||
val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
|
||||
@ -69,8 +76,22 @@ constructor(
|
||||
loadAvailableTags()
|
||||
setupUrlMetadataExtraction()
|
||||
|
||||
// Handle file share - add initial tags
|
||||
if (isFileShare) {
|
||||
// Parse and add initial tags from file share
|
||||
initialTags?.let { tagsParam ->
|
||||
val decodedTags = decodeUrlParam(tagsParam)
|
||||
decodedTags?.split(",")?.forEach { tag ->
|
||||
val cleanTag = tag.trim().lowercase()
|
||||
if (cleanTag.isNotBlank()) {
|
||||
_selectedTags.value = _selectedTags.value + cleanTag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si une URL initiale est fournie, extraire les métadonnées
|
||||
if (!initialUrl.isNullOrBlank()) {
|
||||
if (!initialUrl.isNullOrBlank() && !isFileShare) {
|
||||
extractMetadata(initialUrl)
|
||||
}
|
||||
}
|
||||
@ -199,14 +220,28 @@ constructor(
|
||||
_uiState.value = AddLinkUiState.Loading
|
||||
|
||||
val currentUrl = url.value
|
||||
val currentTitle = title.value
|
||||
|
||||
// Validation based on content type
|
||||
when (_contentTypeSelection.value) {
|
||||
ContentType.BOOKMARK -> {
|
||||
if (currentUrl.isBlank()) {
|
||||
_uiState.value = AddLinkUiState.Error("URL is required")
|
||||
_uiState.value = AddLinkUiState.Error("URL is required for bookmarks")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
ContentType.NOTE -> {
|
||||
if (currentTitle.isBlank()) {
|
||||
_uiState.value = AddLinkUiState.Error("Title is required for notes")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val result =
|
||||
linkRepository.addOrUpdateLink(
|
||||
url = currentUrl,
|
||||
url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank())
|
||||
"note://local/${System.currentTimeMillis()}" else currentUrl,
|
||||
title = title.value.ifBlank { null },
|
||||
description = description.value.ifBlank { null },
|
||||
tags = _selectedTags.value.ifEmpty { null },
|
||||
@ -237,16 +272,30 @@ constructor(
|
||||
fun forceUpdateExistingLink() {
|
||||
viewModelScope.launch {
|
||||
val currentUrl = url.value
|
||||
val currentTitle = title.value
|
||||
|
||||
// Validation based on content type
|
||||
when (_contentTypeSelection.value) {
|
||||
ContentType.BOOKMARK -> {
|
||||
if (currentUrl.isBlank()) {
|
||||
_uiState.value = AddLinkUiState.Error("URL is required")
|
||||
_uiState.value = AddLinkUiState.Error("URL is required for bookmarks")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
ContentType.NOTE -> {
|
||||
if (currentTitle.isBlank()) {
|
||||
_uiState.value = AddLinkUiState.Error("Title is required for notes")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.value = AddLinkUiState.Loading
|
||||
|
||||
val result =
|
||||
linkRepository.addOrUpdateLink(
|
||||
url = currentUrl,
|
||||
url = if (_contentTypeSelection.value == ContentType.NOTE && currentUrl.isBlank())
|
||||
"note://local/${System.currentTimeMillis()}" else currentUrl,
|
||||
title = title.value.ifBlank { null },
|
||||
description = description.value.ifBlank { null },
|
||||
tags = _selectedTags.value.ifEmpty { null },
|
||||
@ -273,6 +322,17 @@ constructor(
|
||||
_uiState.value = AddLinkUiState.Idle
|
||||
conflictLinkId = null
|
||||
}
|
||||
|
||||
fun setContentType(type: ContentType) {
|
||||
_contentTypeSelection.value = type
|
||||
// Auto-add "note" tag when Note type is selected
|
||||
if (type == ContentType.NOTE && "note" !in _selectedTags.value) {
|
||||
addTag("note")
|
||||
} else if (type == ContentType.BOOKMARK && "note" in _selectedTags.value) {
|
||||
// Remove "note" tag when switching back to Bookmark
|
||||
removeTag("note")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class AddLinkUiState {
|
||||
@ -282,3 +342,8 @@ sealed class AddLinkUiState {
|
||||
data class Error(val message: String) : AddLinkUiState()
|
||||
data class Conflict(val existingLinkId: Int, val existingTitle: String?) : AddLinkUiState()
|
||||
}
|
||||
|
||||
enum class ContentType {
|
||||
BOOKMARK,
|
||||
NOTE
|
||||
}
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
package com.shaarit.presentation.collections
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
@ -16,6 +20,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@ -28,12 +33,15 @@ import com.shaarit.ui.theme.*
|
||||
@Composable
|
||||
fun CollectionsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onCollectionClick: (Long) -> Unit,
|
||||
onCollectionClick: (Long, Boolean, String?) -> Unit,
|
||||
viewModel: CollectionsViewModel = hiltViewModel()
|
||||
) {
|
||||
val collections by viewModel.collections.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val tags by viewModel.tags.collectAsState()
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var showEditDialog by remember { mutableStateOf<CollectionUiModel?>(null) }
|
||||
var showDeleteConfirm by remember { mutableStateOf<CollectionUiModel?>(null) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@ -109,7 +117,9 @@ fun CollectionsScreen(
|
||||
items(collections) { collection ->
|
||||
CollectionCard(
|
||||
collection = collection,
|
||||
onClick = { onCollectionClick(collection.id) }
|
||||
onClick = { onCollectionClick(collection.id, collection.isSmart, collection.query) },
|
||||
onEditClick = { showEditDialog = collection },
|
||||
onDeleteClick = { showDeleteConfirm = collection }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -122,28 +132,82 @@ fun CollectionsScreen(
|
||||
if (showCreateDialog) {
|
||||
CreateCollectionDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onCreate = { name, description, icon, isSmart ->
|
||||
viewModel.createCollection(name, description, icon, isSmart)
|
||||
tags = tags.map { it.name },
|
||||
onConfirm = { name, description, icon, isSmart, query ->
|
||||
viewModel.createCollection(name, description, icon, isSmart, query)
|
||||
showCreateDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Dialog de modification
|
||||
showEditDialog?.let { collection ->
|
||||
EditCollectionDialog(
|
||||
collection = collection,
|
||||
tags = tags.map { it.name },
|
||||
onDismiss = { showEditDialog = null },
|
||||
onConfirm = { name, description, icon, isSmart, query ->
|
||||
viewModel.updateCollection(
|
||||
collection.id, name, description, icon, isSmart, query
|
||||
)
|
||||
showEditDialog = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Dialog de confirmation de suppression
|
||||
showDeleteConfirm?.let { collection ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = null },
|
||||
title = { Text("Supprimer la collection") },
|
||||
text = {
|
||||
Text("Êtes-vous sûr de vouloir supprimer la collection \"${collection.name}\" ? Cette action est irréversible.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.deleteCollection(collection.id)
|
||||
showDeleteConfirm = null
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = ErrorRed
|
||||
)
|
||||
) {
|
||||
Text("Supprimer")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = null }) {
|
||||
Text("Annuler")
|
||||
}
|
||||
},
|
||||
containerColor = CardBackground,
|
||||
titleContentColor = TextPrimary,
|
||||
textContentColor = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollectionCard(
|
||||
collection: CollectionUiModel,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
onEditClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
GlassCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Zone cliquable principale
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
@ -201,6 +265,73 @@ private fun CollectionCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Menu options
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { showMenu = true },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "Options",
|
||||
tint = TextSecondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
modifier = Modifier.background(CardBackground)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text("Modifier", color = TextPrimary)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onEditClick()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = ErrorRed,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text("Supprimer", color = ErrorRed)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onDeleteClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -257,105 +388,406 @@ private fun EmptyCollectionsView(onCreateClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun CreateCollectionDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (name: String, description: String, icon: String, isSmart: Boolean) -> Unit
|
||||
tags: List<String>,
|
||||
onConfirm: (name: String, description: String, icon: String, isSmart: Boolean, query: String?) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var selectedIcon by remember { mutableStateOf("📁") }
|
||||
var isSmart by remember { mutableStateOf(false) }
|
||||
CollectionDialogContent(
|
||||
title = "Nouvelle collection",
|
||||
collection = null,
|
||||
tags = tags,
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditCollectionDialog(
|
||||
collection: CollectionUiModel,
|
||||
tags: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, description: String, icon: String, isSmart: Boolean, query: String?) -> Unit
|
||||
) {
|
||||
CollectionDialogContent(
|
||||
title = "Modifier la collection",
|
||||
collection = collection,
|
||||
tags = tags,
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CollectionDialogContent(
|
||||
title: String,
|
||||
collection: CollectionUiModel?,
|
||||
tags: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, description: String, icon: String, isSmart: Boolean, query: String?) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf(collection?.name ?: "") }
|
||||
var description by remember { mutableStateOf(collection?.description ?: "") }
|
||||
var selectedIcon by remember { mutableStateOf(collection?.icon ?: "📁") }
|
||||
var isSmart by remember { mutableStateOf(collection?.isSmart ?: false) }
|
||||
var tagSearch by remember { mutableStateOf("") }
|
||||
var selectedTags by remember {
|
||||
mutableStateOf(
|
||||
collection?.query?.split(" ")?.filter { it.isNotBlank() }?.toSet() ?: setOf()
|
||||
)
|
||||
}
|
||||
var showTagDropdown by remember { mutableStateOf(false) }
|
||||
|
||||
val icons = listOf("📁", "💼", "🏠", "📚", "⭐", "🔥", "💡", "🎯", "📰", "🎬", "🎮", "🛒")
|
||||
|
||||
val isEdit = collection != null
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Nouvelle collection") },
|
||||
containerColor = CardBackground,
|
||||
title = {
|
||||
Text(
|
||||
title,
|
||||
color = TextPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp)
|
||||
.verticalScroll(androidx.compose.foundation.rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Nom
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Nom") },
|
||||
label = { Text("Nom", color = TextSecondary) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CyanPrimary,
|
||||
unfocusedBorderColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary
|
||||
)
|
||||
)
|
||||
|
||||
// Description
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description (optionnel)") },
|
||||
label = { Text("Description (optionnel)", color = TextSecondary) },
|
||||
minLines = 2,
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CyanPrimary,
|
||||
unfocusedBorderColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary
|
||||
)
|
||||
)
|
||||
|
||||
// Icône
|
||||
Text("Icône", style = MaterialTheme.typography.labelMedium)
|
||||
Text(
|
||||
"Icône",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
icons.forEach { icon ->
|
||||
val isSelected = icon == selectedIcon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(
|
||||
if (icon == selectedIcon) CyanPrimary.copy(alpha = 0.2f)
|
||||
if (isSelected) CyanPrimary.copy(alpha = 0.2f)
|
||||
else CardBackgroundElevated
|
||||
)
|
||||
.border(
|
||||
width = if (isSelected) 2.dp else 0.dp,
|
||||
color = if (isSelected) CyanPrimary else androidx.compose.ui.graphics.Color.Transparent,
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.clickable { selectedIcon = icon },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(icon, fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||
Text(icon, fontSize = MaterialTheme.typography.titleLarge.fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collection intelligente
|
||||
Card(
|
||||
onClick = { isSmart = !isSmart },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSmart) CyanPrimary.copy(alpha = 0.1f) else CardBackgroundElevated
|
||||
),
|
||||
border = if (isSmart) {
|
||||
androidx.compose.foundation.BorderStroke(1.dp, CyanPrimary.copy(alpha = 0.3f))
|
||||
} else null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AutoAwesome,
|
||||
contentDescription = null,
|
||||
tint = if (isSmart) CyanPrimary else TextSecondary
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Collection intelligente",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextPrimary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
"Remplie automatiquement selon des critères",
|
||||
"Remplie automatiquement selon les tags sélectionnés",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = isSmart,
|
||||
onCheckedChange = { isSmart = it }
|
||||
onCheckedChange = { isSmart = it },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = CyanPrimary,
|
||||
checkedTrackColor = CyanPrimary.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSmart) {
|
||||
// Section Tags sélectionnés
|
||||
if (selectedTags.isNotEmpty()) {
|
||||
Text(
|
||||
"Tags sélectionnés (${selectedTags.size})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
selectedTags.forEach { tag ->
|
||||
SelectedTagChip(
|
||||
tag = tag,
|
||||
onRemove = { selectedTags = selectedTags - tag }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Barre de recherche de tags
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showTagDropdown,
|
||||
onExpandedChange = { showTagDropdown = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = tagSearch,
|
||||
onValueChange = {
|
||||
tagSearch = it
|
||||
showTagDropdown = it.isNotBlank() || tags.isNotEmpty()
|
||||
},
|
||||
label = { Text("Ajouter des tags...", color = TextSecondary) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CyanPrimary,
|
||||
unfocusedBorderColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary
|
||||
),
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = if (showTagDropdown) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = TextSecondary
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val availableTags = remember(tags, tagSearch, selectedTags) {
|
||||
tags
|
||||
.filter { it !in selectedTags }
|
||||
.filter {
|
||||
if (tagSearch.isBlank()) true
|
||||
else it.lowercase().contains(tagSearch.lowercase())
|
||||
}
|
||||
.take(15)
|
||||
}
|
||||
|
||||
if (availableTags.isNotEmpty()) {
|
||||
ExposedDropdownMenu(
|
||||
expanded = showTagDropdown,
|
||||
onDismissRequest = { showTagDropdown = false },
|
||||
modifier = Modifier
|
||||
.background(CardBackgroundElevated)
|
||||
.heightIn(max = 250.dp)
|
||||
) {
|
||||
availableTags.forEach { tag ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
"#$tag",
|
||||
color = TextPrimary
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selectedTags = selectedTags + tag
|
||||
tagSearch = ""
|
||||
showTagDropdown = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags disponibles populaires
|
||||
val popularTags = remember(tags, selectedTags) {
|
||||
tags
|
||||
.filter { it !in selectedTags }
|
||||
.take(10)
|
||||
}
|
||||
|
||||
if (popularTags.isNotEmpty()) {
|
||||
Text(
|
||||
"Tags populaires",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
popularTags.forEach { tag ->
|
||||
AvailableTagChip(
|
||||
tag = tag,
|
||||
onClick = { selectedTags = selectedTags + tag }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onCreate(name, description, selectedIcon, isSmart) },
|
||||
enabled = name.isNotBlank()
|
||||
Button(
|
||||
onClick = {
|
||||
val query = if (isSmart) selectedTags.joinToString(" ").takeIf { it.isNotBlank() } else null
|
||||
onConfirm(name, description, selectedIcon, isSmart, query)
|
||||
},
|
||||
enabled = name.isNotBlank() && (!isSmart || selectedTags.isNotEmpty()),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = CyanPrimary,
|
||||
contentColor = DeepNavy
|
||||
)
|
||||
) {
|
||||
Text("Créer")
|
||||
Text(if (isEdit) "Enregistrer" else "Créer")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Annuler")
|
||||
Text("Annuler", color = TextSecondary)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedTagChip(
|
||||
tag: String,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = CyanPrimary.copy(alpha = 0.2f),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, CyanPrimary.copy(alpha = 0.5f))
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 10.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "#$tag",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = CyanLight
|
||||
)
|
||||
IconButton(
|
||||
onClick = onRemove,
|
||||
modifier = Modifier.size(18.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Retirer",
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvailableTagChip(
|
||||
tag: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = CardBackground
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "#$tag",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modèle de données UI
|
||||
data class CollectionUiModel(
|
||||
val id: Long,
|
||||
@ -363,6 +795,7 @@ data class CollectionUiModel(
|
||||
val description: String?,
|
||||
val icon: String,
|
||||
val isSmart: Boolean,
|
||||
val query: String?,
|
||||
val linkCount: Int
|
||||
)
|
||||
|
||||
|
||||
@ -2,7 +2,11 @@ package com.shaarit.presentation.collections
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.core.storage.TokenManager
|
||||
import com.shaarit.data.local.dao.CollectionDao
|
||||
import com.shaarit.data.local.dao.LinkDao
|
||||
import com.shaarit.data.local.dao.TagDao
|
||||
import com.shaarit.data.local.entity.TagEntity
|
||||
import com.shaarit.data.local.entity.CollectionEntity
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
@ -11,7 +15,10 @@ import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CollectionsViewModel @Inject constructor(
|
||||
private val collectionDao: CollectionDao
|
||||
private val collectionDao: CollectionDao,
|
||||
private val linkDao: LinkDao,
|
||||
private val tagDao: TagDao,
|
||||
private val tokenManager: TokenManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _collections = MutableStateFlow<List<CollectionUiModel>>(emptyList())
|
||||
@ -20,6 +27,11 @@ class CollectionsViewModel @Inject constructor(
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
val tags: StateFlow<List<TagEntity>> =
|
||||
tagDao
|
||||
.getAllTags()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
init {
|
||||
loadCollections()
|
||||
}
|
||||
@ -31,8 +43,14 @@ class CollectionsViewModel @Inject constructor(
|
||||
collectionDao.getAllCollections()
|
||||
.map { entities ->
|
||||
entities.map { entity ->
|
||||
// Compter les liens dans chaque collection
|
||||
val count = 0 // TODO: Implémenter le comptage
|
||||
val count = when {
|
||||
entity.isSmart -> getSmartCollectionLinkCount(entity)
|
||||
else -> try {
|
||||
collectionDao.getLinkCountInCollectionOnce(entity.id)
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
}
|
||||
entity.toUiModel(count)
|
||||
}
|
||||
}
|
||||
@ -46,21 +64,75 @@ class CollectionsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun createCollection(name: String, description: String?, icon: String, isSmart: Boolean) {
|
||||
private suspend fun getSmartCollectionLinkCount(entity: CollectionEntity): Int {
|
||||
val query = entity.query?.trim() ?: return 0
|
||||
|
||||
return try {
|
||||
// Une collection intelligente utilise des tags dans sa requête
|
||||
// On extrait les tags de la query (séparés par des espaces)
|
||||
val tags = query.split(Regex("\\s+"))
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
if (tags.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Pour les collections intelligentes avec un seul tag
|
||||
if (tags.size == 1) {
|
||||
linkDao.countLinksByTag(tags[0])
|
||||
} else {
|
||||
// Pour les collections avec plusieurs tags, on compte les liens qui ont TOUS les tags
|
||||
// On récupère tous les liens et on filtre
|
||||
var count = 0
|
||||
tags.forEach { tag ->
|
||||
val tagCount = linkDao.countLinksByTag(tag)
|
||||
if (count == 0) {
|
||||
count = tagCount
|
||||
}
|
||||
// On garde le minimum (approximation pour les collections multi-tags)
|
||||
// Note: Une implémentation plus précise nécessiterait de récupérer tous les liens
|
||||
}
|
||||
count
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun createCollection(name: String, description: String?, icon: String, isSmart: Boolean, query: String?) {
|
||||
viewModelScope.launch {
|
||||
val entity = CollectionEntity(
|
||||
name = name,
|
||||
description = description,
|
||||
icon = icon,
|
||||
isSmart = isSmart
|
||||
isSmart = isSmart,
|
||||
query = query
|
||||
)
|
||||
collectionDao.insertCollection(entity)
|
||||
tokenManager.setCollectionsConfigDirty(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCollection(id: Long, name: String, description: String?, icon: String, isSmart: Boolean, query: String?) {
|
||||
viewModelScope.launch {
|
||||
val entity = CollectionEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
description = description,
|
||||
icon = icon,
|
||||
isSmart = isSmart,
|
||||
query = query
|
||||
)
|
||||
collectionDao.updateCollection(entity)
|
||||
tokenManager.setCollectionsConfigDirty(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCollection(id: Long) {
|
||||
viewModelScope.launch {
|
||||
collectionDao.deleteCollection(id)
|
||||
tokenManager.setCollectionsConfigDirty(true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,6 +143,7 @@ class CollectionsViewModel @Inject constructor(
|
||||
description = description,
|
||||
icon = icon,
|
||||
isSmart = isSmart,
|
||||
query = query,
|
||||
linkCount = linkCount
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,31 +2,40 @@ package com.shaarit.presentation.edit
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.shaarit.ui.components.GlassCard
|
||||
import com.shaarit.ui.components.GradientButton
|
||||
import com.shaarit.ui.components.PremiumTextField
|
||||
import com.shaarit.ui.components.SectionHeader
|
||||
import com.shaarit.ui.components.TagChip
|
||||
import coil.compose.AsyncImage
|
||||
import com.shaarit.presentation.add.ContentType
|
||||
import com.shaarit.ui.components.*
|
||||
import com.shaarit.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun EditLinkScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
@ -41,8 +50,19 @@ fun EditLinkScreen(
|
||||
val availableTags by viewModel.availableTags.collectAsState()
|
||||
val isPrivate by viewModel.isPrivate.collectAsState()
|
||||
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
|
||||
val contentType by viewModel.contentType.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
var showMarkdownPreview by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// State pour l'éditeur Markdown avec barre d'outils flottante
|
||||
val markdownEditorState = rememberMarkdownEditorState()
|
||||
|
||||
// Pour faire défiler automatiquement vers la section tags quand le clavier s'ouvre
|
||||
val tagsSectionBringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
|
||||
LaunchedEffect(uiState) {
|
||||
when (val state = uiState) {
|
||||
@ -60,9 +80,7 @@ fun EditLinkScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(DeepNavy, DarkNavy)
|
||||
)
|
||||
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
|
||||
)
|
||||
) {
|
||||
Scaffold(
|
||||
@ -71,7 +89,7 @@ fun EditLinkScreen(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Edit Link",
|
||||
if (contentType == ContentType.NOTE) "Modifier la note" else "Modifier le lien",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@ -80,7 +98,7 @@ fun EditLinkScreen(
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
contentDescription = "Retour",
|
||||
tint = TextPrimary
|
||||
)
|
||||
}
|
||||
@ -91,9 +109,7 @@ fun EditLinkScreen(
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = android.graphics.Color.TRANSPARENT.let {
|
||||
androidx.compose.ui.graphics.Color.Transparent
|
||||
}
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
||||
) { paddingValues ->
|
||||
when (uiState) {
|
||||
is EditLinkUiState.Loading -> {
|
||||
@ -107,7 +123,7 @@ fun EditLinkScreen(
|
||||
CircularProgressIndicator(color = CyanPrimary)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Loading link...",
|
||||
"Chargement...",
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
@ -118,72 +134,222 @@ fun EditLinkScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
.imePadding()
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// URL Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(title = "URL", subtitle = "Required")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
// Content Type Selection (compact)
|
||||
GlassCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
glowColor = CyanPrimary
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Bookmark option
|
||||
ContentTypeButton(
|
||||
icon = Icons.Default.Bookmark,
|
||||
label = "Lien",
|
||||
isSelected = contentType == ContentType.BOOKMARK,
|
||||
onClick = { viewModel.setContentType(ContentType.BOOKMARK) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Note option
|
||||
ContentTypeButton(
|
||||
icon = Icons.Default.StickyNote2,
|
||||
label = "Note",
|
||||
isSelected = contentType == ContentType.NOTE,
|
||||
onClick = { viewModel.setContentType(ContentType.NOTE) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// URL Section (compact, only for Bookmarks)
|
||||
AnimatedVisibility(
|
||||
visible = contentType == ContentType.BOOKMARK,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
CompactFieldCard(
|
||||
icon = Icons.Default.Link,
|
||||
label = "URL"
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { viewModel.url.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "https://example.com",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary
|
||||
)
|
||||
}
|
||||
placeholder = { Text("https://example.com", color = TextMuted) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
colors = compactTextFieldColors(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Title Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(
|
||||
title = "Title",
|
||||
subtitle = "Optional"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
// Title Section (compact)
|
||||
CompactFieldCard(
|
||||
icon = Icons.Default.Title,
|
||||
label = if (contentType == ContentType.NOTE) "Titre *" else "Titre"
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { viewModel.title.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Page title"
|
||||
placeholder = {
|
||||
Text(
|
||||
if (contentType == ContentType.NOTE)
|
||||
"Titre de la note" else "Titre du lien",
|
||||
color = TextMuted
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
colors = compactTextFieldColors(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
// Description Section - Markdown Editor (plus grand en mode Note)
|
||||
GlassCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (contentType == ContentType.NOTE)
|
||||
Modifier.heightIn(min = 400.dp)
|
||||
else
|
||||
Modifier
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
// Header avec titre et toggle édition/apercu
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Description,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = if (contentType == ContentType.NOTE)
|
||||
"Contenu" else "Description",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
if (contentType == ContentType.NOTE) {
|
||||
Text(
|
||||
text = "Markdown supporté",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Description Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
SectionHeader(
|
||||
title = "Description",
|
||||
subtitle = "Optional - Supports Markdown"
|
||||
// Toggle édition/apercu simple
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { showMarkdownPreview = false },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Éditer",
|
||||
tint = if (!showMarkdownPreview) CyanPrimary else TextMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { showMarkdownPreview = true },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Preview,
|
||||
contentDescription = "Aperçu",
|
||||
tint = if (showMarkdownPreview) CyanPrimary else TextMuted,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PremiumTextField(
|
||||
|
||||
// Éditeur Markdown ou Aperçu
|
||||
if (showMarkdownPreview) {
|
||||
MarkdownPreview(
|
||||
markdown = description,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(
|
||||
min = if (contentType == ContentType.NOTE) 300.dp else 150.dp,
|
||||
max = if (contentType == ContentType.NOTE) 500.dp else 300.dp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
SimpleMarkdownEditor(
|
||||
value = description,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Add a description...",
|
||||
singleLine = false,
|
||||
minLines = 3
|
||||
editorState = markdownEditorState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(
|
||||
min = if (contentType == ContentType.NOTE) 300.dp else 150.dp,
|
||||
max = if (contentType == ContentType.NOTE) 500.dp else 300.dp
|
||||
),
|
||||
isNoteMode = contentType == ContentType.NOTE,
|
||||
placeholder = if (contentType == ContentType.NOTE)
|
||||
"Écrivez votre note ici..."
|
||||
else
|
||||
"Ajoutez une description..."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
// Tags Section avec correction du clavier - se positionne au-dessus du clavier
|
||||
GlassCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(tagsSectionBringIntoViewRequester)
|
||||
) {
|
||||
Column {
|
||||
SectionHeader(title = "Tags", subtitle = "Organize your links")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Tag,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Tags",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@ -203,27 +369,52 @@ fun EditLinkScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// New tag input
|
||||
// New tag input - fermer le clavier sur Done
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
PremiumTextField(
|
||||
OutlinedTextField(
|
||||
value = newTagInput,
|
||||
onValueChange = { viewModel.onNewTagInputChanged(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = "Add tag..."
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) {
|
||||
// Faire défiler vers la section tags quand le champ est focusé
|
||||
coroutineScope.launch {
|
||||
tagsSectionBringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = { Text("Ajouter un tag...", color = TextMuted) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (newTagInput.isNotBlank()) {
|
||||
viewModel.addNewTag()
|
||||
}
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
colors = compactTextFieldColors(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.addNewTag() },
|
||||
enabled = newTagInput.isNotBlank()
|
||||
onClick = {
|
||||
viewModel.addNewTag()
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
enabled = newTagInput.isNotBlank(),
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add tag",
|
||||
tint = if (newTagInput.isNotBlank()) CyanPrimary
|
||||
else TextMuted
|
||||
contentDescription = "Ajouter",
|
||||
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -237,16 +428,21 @@ fun EditLinkScreen(
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
"Suggestions",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(tagSuggestions.take(10)) { tag ->
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(tagSuggestions.take(8)) { tag ->
|
||||
TagChip(
|
||||
tag = tag.name,
|
||||
isSelected = false,
|
||||
onClick = { viewModel.addTag(tag.name) },
|
||||
onClick = {
|
||||
viewModel.addTag(tag.name)
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
count = tag.occurrences
|
||||
)
|
||||
}
|
||||
@ -254,20 +450,22 @@ fun EditLinkScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Popular tags from existing
|
||||
// Popular tags
|
||||
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Text(
|
||||
"Popular tags",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
"Populaires",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
availableTags
|
||||
.filter { it.name !in selectedTags }
|
||||
.take(10)
|
||||
.take(8)
|
||||
) { tag ->
|
||||
TagChip(
|
||||
tag = tag.name,
|
||||
@ -282,26 +480,22 @@ fun EditLinkScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy Section
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
// Privacy Section (compact)
|
||||
CompactFieldCard(
|
||||
icon = if (isPrivate) Icons.Default.Lock else Icons.Default.Public,
|
||||
label = if (isPrivate) "Privé" else "Public",
|
||||
onClick = { viewModel.isPrivate.value = !isPrivate }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Private",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
"Only you can see this link",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
if (isPrivate) "Seul vous pouvez voir" else "Visible par tous",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextSecondary
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isPrivate,
|
||||
onCheckedChange = { viewModel.isPrivate.value = it },
|
||||
@ -315,14 +509,21 @@ fun EditLinkScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Update Button
|
||||
GradientButton(
|
||||
text = if (uiState is EditLinkUiState.Saving) "Saving..." else "Update Link",
|
||||
onClick = { viewModel.updateLink() },
|
||||
text = if (uiState is EditLinkUiState.Saving) "Enregistrement..." else
|
||||
if (contentType == ContentType.NOTE) "Enregistrer la note" else "Enregistrer les modifications",
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.updateLink()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = url.isNotBlank() && uiState !is EditLinkUiState.Saving
|
||||
enabled = when (contentType) {
|
||||
ContentType.BOOKMARK -> url.isNotBlank() && uiState !is EditLinkUiState.Saving
|
||||
ContentType.NOTE -> title.isNotBlank() && uiState !is EditLinkUiState.Saving
|
||||
}
|
||||
)
|
||||
|
||||
if (uiState is EditLinkUiState.Saving) {
|
||||
@ -333,10 +534,120 @@ fun EditLinkScreen(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(80.dp)) // Espace pour la barre d'outils flottante
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Barre d'outils Markdown flottante - collée au-dessus du clavier
|
||||
FloatingMarkdownToolbar(
|
||||
editorState = markdownEditorState,
|
||||
onValueChange = { viewModel.description.value = it },
|
||||
visible = !showMarkdownPreview,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton de sélection de type de contenu compact
|
||||
*/
|
||||
@Composable
|
||||
private fun ContentTypeButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = if (isSelected) CyanPrimary.copy(alpha = 0.15f) else CardBackgroundElevated,
|
||||
border = if (isSelected) androidx.compose.foundation.BorderStroke(1.5.dp, CyanPrimary) else null,
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (isSelected) CyanPrimary else TextSecondary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) CyanPrimary else TextPrimary,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte de champ compacte
|
||||
*/
|
||||
@Composable
|
||||
private fun CompactFieldCard(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val cardModifier = Modifier.fillMaxWidth()
|
||||
|
||||
val finalModifier = if (onClick != null) {
|
||||
cardModifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
cardModifier
|
||||
}
|
||||
|
||||
GlassCard(
|
||||
modifier = finalModifier,
|
||||
glowColor = CyanPrimary.copy(alpha = 0.3f)
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = CyanPrimary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextSecondary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleurs pour les champs texte compacts
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun compactTextFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = CyanPrimary,
|
||||
unfocusedBorderColor = SurfaceVariant,
|
||||
focusedLabelColor = CyanPrimary,
|
||||
unfocusedLabelColor = TextSecondary,
|
||||
cursorColor = CyanPrimary,
|
||||
focusedContainerColor = CardBackground.copy(alpha = 0.3f),
|
||||
unfocusedContainerColor = CardBackground.copy(alpha = 0.2f)
|
||||
)
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shaarit.domain.model.ShaarliTag
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import com.shaarit.presentation.add.ContentType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -29,6 +30,13 @@ constructor(
|
||||
var description = MutableStateFlow("")
|
||||
var isPrivate = MutableStateFlow(false)
|
||||
|
||||
// Content type - détecté automatiquement ou choisi par l'utilisateur
|
||||
private val _contentType = MutableStateFlow(ContentType.BOOKMARK)
|
||||
val contentType = _contentType.asStateFlow()
|
||||
|
||||
// Tags du lien original pour détecter si c'est une note
|
||||
private var originalTags: List<String> = emptyList()
|
||||
|
||||
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
|
||||
val selectedTags = _selectedTags.asStateFlow()
|
||||
|
||||
@ -57,6 +65,12 @@ constructor(
|
||||
description.value = link.description
|
||||
isPrivate.value = link.isPrivate
|
||||
_selectedTags.value = link.tags
|
||||
originalTags = link.tags
|
||||
|
||||
// Détecter si c'est une note
|
||||
val isNote = link.tags.contains("note") || link.url.startsWith("note://")
|
||||
_contentType.value = if (isNote) ContentType.NOTE else ContentType.BOOKMARK
|
||||
|
||||
_uiState.value = EditLinkUiState.Loaded
|
||||
},
|
||||
onFailure = { error ->
|
||||
@ -81,6 +95,21 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le type de contenu (Bookmark ou Note)
|
||||
*/
|
||||
fun setContentType(type: ContentType) {
|
||||
_contentType.value = type
|
||||
|
||||
// Auto-add "note" tag when Note type is selected
|
||||
if (type == ContentType.NOTE && "note" !in _selectedTags.value) {
|
||||
addTag("note")
|
||||
} else if (type == ContentType.BOOKMARK && "note" in _selectedTags.value) {
|
||||
// Remove "note" tag when switching back to Bookmark
|
||||
removeTag("note")
|
||||
}
|
||||
}
|
||||
|
||||
fun onNewTagInputChanged(input: String) {
|
||||
_newTagInput.value = input
|
||||
updateTagSuggestions(input)
|
||||
@ -125,15 +154,29 @@ constructor(
|
||||
_uiState.value = EditLinkUiState.Saving
|
||||
|
||||
val currentUrl = url.value
|
||||
val currentTitle = title.value
|
||||
|
||||
// Validation based on content type
|
||||
when (_contentType.value) {
|
||||
ContentType.BOOKMARK -> {
|
||||
if (currentUrl.isBlank()) {
|
||||
_uiState.value = EditLinkUiState.Error("URL is required")
|
||||
_uiState.value = EditLinkUiState.Error("URL is required for bookmarks")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
ContentType.NOTE -> {
|
||||
if (currentTitle.isBlank()) {
|
||||
_uiState.value = EditLinkUiState.Error("Title is required for notes")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
linkRepository.updateLink(
|
||||
id = linkId,
|
||||
url = currentUrl,
|
||||
title = title.value.ifBlank { null },
|
||||
url = if (_contentType.value == ContentType.NOTE && currentUrl.isBlank())
|
||||
"note://local/${System.currentTimeMillis()}" else currentUrl,
|
||||
title = currentTitle.ifBlank { null },
|
||||
description = description.value.ifBlank { null },
|
||||
tags = _selectedTags.value.ifEmpty { null },
|
||||
isPrivate = isPrivate.value
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,17 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.shaarit.core.storage.TokenManager
|
||||
import com.shaarit.data.local.dao.CollectionDao
|
||||
import com.shaarit.data.local.dao.TagDao
|
||||
import com.shaarit.data.local.entity.CollectionLinkCrossRef
|
||||
import com.shaarit.data.sync.SyncManager
|
||||
import com.shaarit.domain.model.BookmarkFilter
|
||||
import com.shaarit.domain.model.ShaarliLink
|
||||
import com.shaarit.domain.model.SortDirection
|
||||
import com.shaarit.domain.model.TimeFilter
|
||||
import com.shaarit.domain.model.VisibilityFilter
|
||||
import com.shaarit.domain.model.TagFilter
|
||||
import com.shaarit.domain.model.ViewStyle
|
||||
import com.shaarit.domain.repository.LinkRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@ -18,12 +27,24 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class Quadruple<A, B, C, D>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
val third: C,
|
||||
val fourth: D
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FeedViewModel @Inject constructor(
|
||||
private val linkRepository: LinkRepository,
|
||||
private val syncManager: SyncManager
|
||||
private val syncManager: SyncManager,
|
||||
private val collectionDao: CollectionDao,
|
||||
private val tagDao: TagDao,
|
||||
private val tokenManager: TokenManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
@ -32,25 +53,53 @@ class FeedViewModel @Inject constructor(
|
||||
private val _searchTags = MutableStateFlow<String?>(null)
|
||||
val searchTags = _searchTags.asStateFlow()
|
||||
|
||||
private val _collectionId = MutableStateFlow<Long?>(null)
|
||||
val collectionId = _collectionId.asStateFlow()
|
||||
|
||||
private val _viewStyle = MutableStateFlow(ViewStyle.LIST)
|
||||
val viewStyle = _viewStyle.asStateFlow()
|
||||
|
||||
private val _bookmarkFilter = MutableStateFlow(BookmarkFilter.DEFAULT)
|
||||
val bookmarkFilter = _bookmarkFilter.asStateFlow()
|
||||
|
||||
private val _refreshTrigger = MutableStateFlow(0)
|
||||
|
||||
val collections =
|
||||
collectionDao
|
||||
.getAllCollections()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val tags =
|
||||
tagDao
|
||||
.getAllTags()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
val pagedLinks: Flow<PagingData<ShaarliLink>> =
|
||||
combine(_searchQuery, _searchTags, _refreshTrigger) { query, tags, _ ->
|
||||
Pair(query, tags)
|
||||
combine(_searchQuery, _searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { query, tags, collectionId, bookmarkFilter, _ ->
|
||||
Quadruple(query, tags, collectionId, bookmarkFilter)
|
||||
}
|
||||
.debounce(300) // Debounce for 300ms
|
||||
.flatMapLatest { (query, tags) ->
|
||||
.flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
|
||||
linkRepository.getLinksStream(
|
||||
searchTerm = if (query.isBlank()) null else query,
|
||||
searchTags = tags
|
||||
searchTags = tags,
|
||||
collectionId = collectionId,
|
||||
bookmarkFilter = bookmarkFilter
|
||||
)
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun setTagFilter(tags: String?) {
|
||||
_collectionId.value = null
|
||||
_searchTags.value = tags
|
||||
}
|
||||
|
||||
fun setCollectionFilter(collectionId: Long?) {
|
||||
_searchTags.value = null
|
||||
_collectionId.value = collectionId
|
||||
}
|
||||
|
||||
fun onSearchQueryChanged(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
@ -73,10 +122,37 @@ class FeedViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setInitialCollectionFilter(collectionId: Long?) {
|
||||
if (collectionId != null && _collectionId.value == null) {
|
||||
_collectionId.value = collectionId
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTagFilter() {
|
||||
_searchTags.value = null
|
||||
}
|
||||
|
||||
fun clearCollectionFilter() {
|
||||
_collectionId.value = null
|
||||
}
|
||||
|
||||
fun addLinksToCollection(collectionId: Long, linkIds: Set<Int>) {
|
||||
if (linkIds.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
linkIds.forEach { linkId ->
|
||||
try {
|
||||
collectionDao.addLinkToCollection(
|
||||
CollectionLinkCrossRef(collectionId = collectionId, linkId = linkId)
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
tokenManager.setCollectionsConfigDirty(true)
|
||||
syncManager.syncNow()
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLink(id: Int) {
|
||||
viewModelScope.launch {
|
||||
linkRepository.deleteLink(id)
|
||||
@ -85,12 +161,32 @@ class FeedViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setViewStyle(viewStyle: ViewStyle) {
|
||||
_viewStyle.value = viewStyle
|
||||
}
|
||||
|
||||
fun updateSortDirection(direction: SortDirection) {
|
||||
_bookmarkFilter.value = _bookmarkFilter.value.copy(sortDirection = direction)
|
||||
}
|
||||
|
||||
fun updateTimeFilter(timeFilter: TimeFilter) {
|
||||
_bookmarkFilter.value = _bookmarkFilter.value.copy(timeFilter = timeFilter)
|
||||
}
|
||||
|
||||
fun updateVisibilityFilter(visibilityFilter: VisibilityFilter) {
|
||||
_bookmarkFilter.value = _bookmarkFilter.value.copy(visibilityFilter = visibilityFilter)
|
||||
}
|
||||
|
||||
fun updateTagFilter(tagFilter: TagFilter) {
|
||||
_bookmarkFilter.value = _bookmarkFilter.value.copy(tagFilter = tagFilter)
|
||||
}
|
||||
|
||||
fun updateBookmarkFilter(filter: BookmarkFilter) {
|
||||
_bookmarkFilter.value = filter
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
syncManager.syncNow()
|
||||
_refreshTrigger.value++
|
||||
}
|
||||
|
||||
fun setViewStyle(style: ViewStyle) {
|
||||
_viewStyle.value = style
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,10 @@ package com.shaarit.presentation.feed
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@ -42,6 +44,10 @@ fun ListViewItem(
|
||||
link: ShaarliLink,
|
||||
onTagClick: (String) -> Unit,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onItemClick: (() -> Unit)? = null,
|
||||
onItemLongClick: (() -> Unit)? = null,
|
||||
selectionMode: Boolean = false,
|
||||
isSelected: Boolean = false,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
@ -60,7 +66,12 @@ fun ListViewItem(
|
||||
)
|
||||
}
|
||||
|
||||
GlassCard(modifier = Modifier.fillMaxWidth(), onClick = { onLinkClick(link.url) }) {
|
||||
GlassCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
|
||||
onLongClick = onItemLongClick,
|
||||
glowColor = if (isSelected) CyanPrimary else CyanPrimary
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -87,6 +98,12 @@ fun ListViewItem(
|
||||
}
|
||||
|
||||
Row {
|
||||
if (selectionMode) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onItemClick?.invoke() }
|
||||
)
|
||||
}
|
||||
// Pin button
|
||||
IconButton(
|
||||
onClick = { onTogglePin(link.id) },
|
||||
@ -188,6 +205,10 @@ fun ListViewItem(
|
||||
fun GridViewItem(
|
||||
link: ShaarliLink,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onItemClick: (() -> Unit)? = null,
|
||||
onItemLongClick: (() -> Unit)? = null,
|
||||
selectionMode: Boolean = false,
|
||||
isSelected: Boolean = false,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
@ -210,7 +231,9 @@ fun GridViewItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
onClick = { onLinkClick(link.url) }
|
||||
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
|
||||
onLongClick = onItemLongClick,
|
||||
glowColor = if (isSelected) CyanPrimary else CyanPrimary
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@ -288,6 +311,12 @@ fun GridViewItem(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (selectionMode) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onItemClick?.invoke() }
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
@ -364,9 +393,14 @@ fun GridViewItem(
|
||||
* Compact view item - minimal info for dense lists
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun CompactViewItem(
|
||||
link: ShaarliLink,
|
||||
onLinkClick: (String) -> Unit,
|
||||
onItemClick: (() -> Unit)? = null,
|
||||
onItemLongClick: (() -> Unit)? = null,
|
||||
selectionMode: Boolean = false,
|
||||
isSelected: Boolean = false,
|
||||
onViewClick: () -> Unit,
|
||||
onEditClick: (Int) -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
@ -389,7 +423,10 @@ fun CompactViewItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onLinkClick(link.url) },
|
||||
.combinedClickable(
|
||||
onClick = { (onItemClick ?: { onLinkClick(link.url) }).invoke() },
|
||||
onLongClick = onItemLongClick
|
||||
),
|
||||
color = CardBackground.copy(alpha = 0.7f)
|
||||
) {
|
||||
Row(
|
||||
@ -399,6 +436,13 @@ fun CompactViewItem(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (selectionMode) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onItemClick?.invoke() }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@ -16,9 +16,21 @@ import java.net.URLEncoder
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
object Login : Screen("login")
|
||||
object Feed : Screen("feed?tag={tag}") {
|
||||
fun createRoute(tag: String? = null): String {
|
||||
return if (tag != null) "feed?tag=$tag" else "feed"
|
||||
object Feed : Screen("feed?tag={tag}&collectionId={collectionId}") {
|
||||
fun createRoute(tag: String? = null, collectionId: Long? = null): String {
|
||||
val params = mutableListOf<String>()
|
||||
if (tag != null) {
|
||||
val encoded = URLEncoder.encode(tag, "UTF-8")
|
||||
params.add("tag=$encoded")
|
||||
}
|
||||
if (collectionId != null) {
|
||||
params.add("collectionId=$collectionId")
|
||||
}
|
||||
return if (params.isEmpty()) {
|
||||
"feed"
|
||||
} else {
|
||||
"feed?" + params.joinToString("&")
|
||||
}
|
||||
}
|
||||
}
|
||||
object Add : Screen("add?url={url}&title={title}&isShare={isShare}")
|
||||
@ -36,6 +48,9 @@ fun AppNavGraph(
|
||||
startDestination: String = Screen.Login.route,
|
||||
shareUrl: String? = null,
|
||||
shareTitle: String? = null,
|
||||
shareDescription: String? = null,
|
||||
shareTags: List<String>? = null,
|
||||
isFileShare: Boolean = false,
|
||||
initialDeepLink: String? = null
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
@ -45,14 +60,22 @@ fun AppNavGraph(
|
||||
composable(Screen.Login.route) {
|
||||
com.shaarit.presentation.auth.LoginScreen(
|
||||
onLoginSuccess = {
|
||||
if (shareUrl != null) {
|
||||
if (isFileShare && shareTitle != null) {
|
||||
// File share - navigate to add screen with file data
|
||||
val encodedTitle = URLEncoder.encode(shareTitle, "UTF-8")
|
||||
val encodedDesc = if (shareDescription != null) URLEncoder.encode(shareDescription, "UTF-8") else ""
|
||||
val encodedTags = shareTags?.joinToString(",") { URLEncoder.encode(it, "UTF-8") } ?: ""
|
||||
navController.navigate("add?url=&title=$encodedTitle&isShare=true&isFileShare=true&description=$encodedDesc&tags=$encodedTags") {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
} else if (shareUrl != null) {
|
||||
// Use proper URL encoding that handles spaces correctly
|
||||
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
|
||||
val encodedTitle =
|
||||
if (shareTitle != null) {
|
||||
URLEncoder.encode(shareTitle, "UTF-8")
|
||||
} else ""
|
||||
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
|
||||
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true&isFileShare=false&description=&tags=") {
|
||||
popUpTo(Screen.Login.route) { inclusive = true }
|
||||
}
|
||||
} else if (initialDeepLink != null) {
|
||||
@ -70,12 +93,16 @@ fun AppNavGraph(
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "feed?tag={tag}",
|
||||
route = "feed?tag={tag}&collectionId={collectionId}",
|
||||
arguments = listOf(
|
||||
navArgument("tag") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument("collectionId") {
|
||||
type = NavType.LongType
|
||||
defaultValue = -1L
|
||||
}
|
||||
),
|
||||
deepLinks = listOf(
|
||||
@ -84,6 +111,9 @@ fun AppNavGraph(
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val tag = backStackEntry.arguments?.getString("tag")
|
||||
val collectionId = backStackEntry.arguments
|
||||
?.getLong("collectionId")
|
||||
?.takeIf { it != -1L }
|
||||
com.shaarit.presentation.feed.FeedScreen(
|
||||
onNavigateToAdd = { navController.navigate("add?url=&title=&isShare=false") },
|
||||
onNavigateToEdit = { linkId ->
|
||||
@ -93,12 +123,13 @@ fun AppNavGraph(
|
||||
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
|
||||
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
|
||||
onNavigateToRandom = { },
|
||||
initialTagFilter = tag
|
||||
initialTagFilter = tag,
|
||||
initialCollectionId = collectionId
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "add?url={url}&title={title}&isShare={isShare}",
|
||||
route = "add?url={url}&title={title}&isShare={isShare}&isFileShare={isFileShare}&description={description}&tags={tags}",
|
||||
arguments = listOf(
|
||||
navArgument("url") {
|
||||
type = NavType.StringType
|
||||
@ -113,6 +144,20 @@ fun AppNavGraph(
|
||||
navArgument("isShare") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
navArgument("isFileShare") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
navArgument("description") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
nullable = true
|
||||
},
|
||||
navArgument("tags") {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
nullable = true
|
||||
}
|
||||
),
|
||||
deepLinks = listOf(
|
||||
@ -165,9 +210,14 @@ fun AppNavGraph(
|
||||
) {
|
||||
com.shaarit.presentation.collections.CollectionsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onCollectionClick = { collectionId ->
|
||||
// Naviguer vers le feed avec le filtre de collection
|
||||
navController.navigate(Screen.Feed.createRoute()) {
|
||||
onCollectionClick = { collectionId, isSmart, query ->
|
||||
navController.navigate(
|
||||
if (isSmart) {
|
||||
Screen.Feed.createRoute(tag = query)
|
||||
} else {
|
||||
Screen.Feed.createRoute(collectionId = collectionId)
|
||||
}
|
||||
) {
|
||||
popUpTo(Screen.Collections.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user