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