docs: Comprehensive README update with new features and architecture details

- Add extensive documentation for offline mode, sync, collections, and dashboard features
- Document new Material You theming, OLED mode, and widget capabilities
- Include detailed sections on metadata extraction, Markdown editor, and import/export
- Update technology stack versions (Kotlin 2.0.0, Hilt 2.51.1, Retrofit 2.11.0, Room 2.6.1)
- Simplify installation and compilation instructions
- Add user guide with first-time setup and
This commit is contained in:
2026-01-29 13:14:47 -05:00
parent a9475c16b1
commit 7277342d4a
55 changed files with 6255 additions and 697 deletions
+32 -3
View File
@@ -16,7 +16,19 @@
android:theme="@style/Theme.ShaarIt"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- Disable default WorkManager initialization to use HiltWorkerFactory -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -26,14 +38,31 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Share Intent -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- App Shortcuts -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<!-- Quick Settings Tile -->
<service
android:name=".service.AddLinkTileService"
android:exported="true"
android:label="@string/tile_add_link"
android:icon="@drawable/ic_launcher_foreground"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
</application>
</manifest>
</manifest>
+20 -19
View File
@@ -6,11 +6,8 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.shaarit.presentation.nav.AppNavGraph
import com.shaarit.ui.theme.ShaarItTheme
@@ -30,33 +27,37 @@ class MainActivity : ComponentActivity() {
val context = LocalContext.current
var shareUrl: String? = null
var shareTitle: String? = null
var deepLink: String? = null
val activity = context as? androidx.activity.ComponentActivity
val intent = activity?.intent
// Handle share intent
if (intent?.action == android.content.Intent.ACTION_SEND &&
intent.type == "text/plain"
intent.type == "text/plain"
) {
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
}
// Handle deep links from App Shortcuts
intent?.data?.let { uri ->
if (uri.scheme == "shaarit") {
deepLink = uri.toString()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) { AppNavGraph(shareUrl = shareUrl, shareTitle = shareTitle) }
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavGraph(
shareUrl = shareUrl,
shareTitle = shareTitle,
initialDeepLink = deepLink
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(text = "Hello $name!", modifier = modifier)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ShaarItTheme { Greeting("Android") }
}
+11 -1
View File
@@ -1,6 +1,16 @@
package com.shaarit
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp class ShaarItApp : Application()
@HiltAndroidApp
class ShaarItApp : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
}
@@ -0,0 +1,45 @@
package com.shaarit.core.di
import android.content.Context
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.database.ShaarliDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Module Hilt pour la base de données Room
*/
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): ShaarliDatabase {
return ShaarliDatabase.getInstance(context)
}
@Provides
@Singleton
fun provideLinkDao(database: ShaarliDatabase): LinkDao {
return database.linkDao()
}
@Provides
@Singleton
fun provideTagDao(database: ShaarliDatabase): TagDao {
return database.tagDao()
}
@Provides
@Singleton
fun provideCollectionDao(database: ShaarliDatabase): CollectionDao {
return database.collectionDao()
}
}
@@ -11,13 +11,13 @@ data class LoginResponseDto(@Json(name = "token") val token: String)
@JsonClass(generateAdapter = true)
data class LinkDto(
@Json(name = "id") val id: Int,
@Json(name = "url") val url: String,
@Json(name = "id") val id: Int?,
@Json(name = "url") val url: String?,
@Json(name = "shorturl") val shortUrl: String?,
@Json(name = "title") val title: String?,
@Json(name = "description") val description: String?,
@Json(name = "tags") val tags: List<String>?,
@Json(name = "private") val isPrivate: Boolean,
@Json(name = "private") val isPrivate: Boolean?,
@Json(name = "created") val created: String?,
@Json(name = "updated") val updated: String?
)
@@ -0,0 +1,193 @@
package com.shaarit.data.export
import android.content.Context
import android.net.Uri
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.model.ShaarliLink
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.OutputStreamWriter
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BookmarkExporter @Inject constructor(
@ApplicationContext private val context: Context,
private val linkDao: LinkDao
) {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
/**
* Exporte tous les liens au format JSON
*/
suspend fun exportToJson(uri: Uri): Result<Int> = withContext(Dispatchers.IO) {
try {
val links = linkDao.getAllLinksForStats()
val exportData = ExportData(
version = 1,
exportedAt = System.currentTimeMillis(),
count = links.size,
links = links.map { entity ->
ExportedLink(
id = entity.id,
url = entity.url,
title = entity.title,
description = entity.description,
tags = entity.tags,
private = entity.isPrivate,
createdAt = entity.createdAt,
siteName = entity.siteName,
thumbnailUrl = entity.thumbnailUrl,
readingTimeMinutes = entity.readingTimeMinutes ?: 0,
contentType = entity.contentType.name
)
}
)
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
OutputStreamWriter(outputStream).use { writer ->
writer.write(json.encodeToString(exportData))
}
} ?: throw IllegalStateException("Cannot open output stream")
Result.success(links.size)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Exporte tous les liens au format CSV (compatible avec Netscape bookmarks)
*/
suspend fun exportToCsv(uri: Uri): Result<Int> = withContext(Dispatchers.IO) {
try {
val links = linkDao.getAllLinksForStats()
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
OutputStreamWriter(outputStream).use { writer ->
// En-tête CSV
writer.write("URL,Title,Description,Tags,Private,CreatedAt\n")
// Données
links.forEach { entity ->
val line = buildString {
append(escapeCsv(entity.url))
append(",")
append(escapeCsv(entity.title))
append(",")
append(escapeCsv(entity.description))
append(",")
append(escapeCsv(entity.tags.joinToString(",")))
append(",")
append(if (entity.isPrivate) "1" else "0")
append(",")
append(dateFormat.format(Date(entity.createdAt)))
append("\n")
}
writer.write(line)
}
}
} ?: throw IllegalStateException("Cannot open output stream")
Result.success(links.size)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Exporte au format HTML (format Netscape/Chrome bookmarks)
*/
suspend fun exportToHtml(uri: Uri): Result<Int> = withContext(Dispatchers.IO) {
try {
val links = linkDao.getAllLinksForStats()
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
OutputStreamWriter(outputStream).use { writer ->
writer.write("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n")
writer.write("<!-- This is an automatically generated file.\n")
writer.write(" It will be read and overwritten.\n")
writer.write(" DO NOT EDIT! -->\n")
writer.write("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n")
writer.write("<TITLE>Bookmarks</TITLE>\n")
writer.write("<H1>Bookmarks</H1>\n")
writer.write("<DL><p>\n")
writer.write(" <DT><H3 ADD_DATE=\"${System.currentTimeMillis() / 1000}\" LAST_MODIFIED=\"${System.currentTimeMillis() / 1000}\">ShaarIt Export</H3>\n")
writer.write(" <DL><p>\n")
links.forEach { entity ->
val addDate = entity.createdAt / 1000
val tags = entity.tags.joinToString(",")
val private = if (entity.isPrivate) "PRIVATE=\"1\"" else ""
writer.write(" <DT><A HREF=\"${escapeHtml(entity.url)}\" ADD_DATE=\"$addDate\" $private TAGS=\"${escapeHtml(tags)}\">${escapeHtml(entity.title)}</A>\n")
if (entity.description.isNotBlank()) {
writer.write(" <DD>${escapeHtml(entity.description)}\n")
}
}
writer.write(" </DL><p>\n")
writer.write("</DL><p>\n")
}
} ?: throw IllegalStateException("Cannot open output stream")
Result.success(links.size)
} catch (e: Exception) {
Result.failure(e)
}
}
private fun escapeCsv(value: String): String {
return if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
"\"${value.replace("\"", "\"\"")}\""
} else {
value
}
}
private fun escapeHtml(value: String): String {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
}
}
@Serializable
data class ExportData(
val version: Int,
val exportedAt: Long,
val count: Int,
val links: List<ExportedLink>
)
@Serializable
data class ExportedLink(
val id: Int,
val url: String,
val title: String,
val description: String,
val tags: List<String>,
val private: Boolean,
val createdAt: Long,
val siteName: String?,
val thumbnailUrl: String?,
val readingTimeMinutes: Int,
val contentType: String
)
@@ -0,0 +1,213 @@
package com.shaarit.data.export
import android.content.Context
import android.net.Uri
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.entity.ContentType
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.SyncStatus
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.io.BufferedReader
import java.io.InputStreamReader
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BookmarkImporter @Inject constructor(
@ApplicationContext private val context: Context,
private val linkDao: LinkDao
) {
private val json = Json {
ignoreUnknownKeys = true
}
data class ImportResult(
val importedCount: Int,
val skippedCount: Int,
val errors: List<String>
)
/**
* Importe des liens depuis un fichier HTML (format Netscape/Chrome bookmarks)
*/
suspend fun importFromHtml(uri: Uri): Result<ImportResult> = withContext(Dispatchers.IO) {
try {
val html = context.contentResolver.openInputStream(uri)?.use { stream ->
BufferedReader(InputStreamReader(stream)).readText()
} ?: throw IllegalStateException("Cannot open input stream")
val document = Jsoup.parse(html)
val links = parseNetscapeBookmarks(document)
var imported = 0
var skipped = 0
val errors = mutableListOf<String>()
links.forEach { bookmark ->
try {
// Vérifier si le lien existe déjà
val existing = linkDao.getLinkByUrl(bookmark.url)
if (existing != null) {
skipped++
return@forEach
}
val entity = LinkEntity(
id = 0,
url = bookmark.url,
title = bookmark.title,
description = bookmark.description ?: "",
tags = bookmark.tags,
isPrivate = bookmark.isPrivate,
isPinned = false,
createdAt = bookmark.addDate ?: System.currentTimeMillis(),
updatedAt = bookmark.addDate ?: System.currentTimeMillis(),
siteName = extractDomain(bookmark.url),
thumbnailUrl = null,
readingTimeMinutes = estimateReadingTime(bookmark.description),
contentType = detectContentType(bookmark.url),
syncStatus = SyncStatus.PENDING_CREATE
)
linkDao.insertLink(entity)
imported++
} catch (e: Exception) {
errors.add("Erreur pour ${bookmark.url}: ${e.message}")
}
}
Result.success(ImportResult(imported, skipped, errors))
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Importe des liens depuis un fichier JSON exporté par ShaarIt
*/
suspend fun importFromJson(uri: Uri): Result<ImportResult> = withContext(Dispatchers.IO) {
try {
val jsonContent = context.contentResolver.openInputStream(uri)?.use { stream ->
BufferedReader(InputStreamReader(stream)).readText()
} ?: throw IllegalStateException("Cannot open input stream")
val exportData = json.decodeFromString<ExportData>(jsonContent)
var imported = 0
var skipped = 0
val errors = mutableListOf<String>()
exportData.links.forEach { link ->
try {
val existing = linkDao.getLinkByUrl(link.url)
if (existing != null) {
skipped++
return@forEach
}
val entity = LinkEntity(
id = 0,
url = link.url,
title = link.title,
description = link.description,
tags = link.tags,
isPrivate = link.private,
isPinned = false,
createdAt = link.createdAt,
updatedAt = System.currentTimeMillis(),
siteName = link.siteName,
thumbnailUrl = link.thumbnailUrl,
readingTimeMinutes = link.readingTimeMinutes,
contentType = ContentType.valueOf(link.contentType),
syncStatus = SyncStatus.PENDING_CREATE
)
linkDao.insertLink(entity)
imported++
} catch (e: Exception) {
errors.add("Erreur pour ${link.url}: ${e.message}")
}
}
Result.success(ImportResult(imported, skipped, errors))
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseNetscapeBookmarks(document: Document): List<ParsedBookmark> {
val bookmarks = mutableListOf<ParsedBookmark>()
// Recherche les éléments <A> qui contiennent les bookmarks
document.select("dt > a, DT > A").forEach { element ->
val href = element.attr("href")
if (href.isBlank() || href.startsWith("javascript:") || href.startsWith("data:")) {
return@forEach
}
val title = element.text()
val addDate = element.attr("add_date").toLongOrNull()?.times(1000) // Convertir secondes en ms
val tags = element.attr("tags").split(",").map { it.trim() }.filter { it.isNotBlank() }
val isPrivate = element.attr("private") == "1"
// Récupérer la description depuis l'élément <DD> suivant
val description = element.parent()?.nextElementSibling()
?.takeIf { it.tagName().equals("dd", ignoreCase = true) }
?.text() ?: ""
bookmarks.add(
ParsedBookmark(
url = href,
title = title,
description = description,
tags = tags,
isPrivate = isPrivate,
addDate = addDate
)
)
}
return bookmarks
}
private fun extractDomain(url: String): String {
return try {
val uri = java.net.URI(url)
uri.host?.removePrefix("www.") ?: url
} catch (e: Exception) {
url
}
}
private fun estimateReadingTime(description: String?): Int {
val words = description?.split(Regex("\\s+"))?.size ?: 0
return (words / 200).coerceAtLeast(1) // 200 mots par minute
}
private fun detectContentType(url: String): ContentType {
return when {
url.contains("youtube.com") || url.contains("youtu.be") ||
url.contains("vimeo.com") -> ContentType.VIDEO
url.contains("github.com") || url.contains("gitlab.com") -> ContentType.REPOSITORY
url.contains("soundcloud.com") || url.contains("spotify.com") -> ContentType.PODCAST
url.matches(Regex(".*\\.(jpg|jpeg|png|gif|webp)$", RegexOption.IGNORE_CASE)) -> ContentType.IMAGE
else -> ContentType.ARTICLE
}
}
private data class ParsedBookmark(
val url: String,
val title: String,
val description: String?,
val tags: List<String>,
val isPrivate: Boolean,
val addDate: Long?
)
}
@@ -0,0 +1,67 @@
package com.shaarit.data.local.converter
import androidx.room.TypeConverter
import com.shaarit.data.local.entity.ContentType
import com.shaarit.data.local.entity.SyncStatus
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* TypeConverters pour Room
*/
class Converters {
private val json = Json { ignoreUnknownKeys = true }
// ====== List<String> (Tags) ======
@TypeConverter
fun fromStringList(value: List<String>): String {
return try {
json.encodeToString(value)
} catch (e: Exception) {
"[]"
}
}
@TypeConverter
fun toStringList(value: String): List<String> {
return try {
json.decodeFromString<List<String>>(value)
} catch (e: Exception) {
emptyList()
}
}
// ====== SyncStatus ======
@TypeConverter
fun fromSyncStatus(status: SyncStatus): String {
return status.name
}
@TypeConverter
fun toSyncStatus(value: String): SyncStatus {
return try {
SyncStatus.valueOf(value)
} catch (e: Exception) {
SyncStatus.SYNCED
}
}
// ====== ContentType ======
@TypeConverter
fun fromContentType(type: ContentType): String {
return type.name
}
@TypeConverter
fun toContentType(value: String): ContentType {
return try {
ContentType.valueOf(value)
} catch (e: Exception) {
ContentType.UNKNOWN
}
}
}
@@ -0,0 +1,66 @@
package com.shaarit.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.shaarit.data.local.entity.CollectionEntity
import com.shaarit.data.local.entity.CollectionLinkCrossRef
import com.shaarit.data.local.entity.LinkEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CollectionDao {
@Query("SELECT * FROM collections ORDER BY sort_order ASC, created_at DESC")
fun getAllCollections(): Flow<List<CollectionEntity>>
@Query("SELECT * FROM collections WHERE id = :id")
suspend fun getCollectionById(id: Long): CollectionEntity?
@Query("SELECT * FROM collections WHERE is_smart = 0 ORDER BY sort_order ASC")
fun getRegularCollections(): Flow<List<CollectionEntity>>
@Query("SELECT * FROM collections WHERE is_smart = 1 ORDER BY sort_order ASC")
fun getSmartCollections(): Flow<List<CollectionEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCollection(collection: CollectionEntity): Long
@Update
suspend fun updateCollection(collection: CollectionEntity)
@Query("DELETE FROM collections WHERE id = :id")
suspend fun deleteCollection(id: Long)
@Query("UPDATE collections SET sort_order = :order WHERE id = :id")
suspend fun updateSortOrder(id: Long, order: Int)
// ====== Relations Collection-Links ======
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addLinkToCollection(crossRef: CollectionLinkCrossRef)
@Query("DELETE FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId")
suspend fun removeLinkFromCollection(collectionId: Long, linkId: Int)
@Query("DELETE FROM collection_links WHERE collection_id = :collectionId")
suspend fun clearCollection(collectionId: Long)
@Transaction
@Query("""
SELECT links.* FROM links
INNER JOIN collection_links ON links.id = collection_links.link_id
WHERE collection_links.collection_id = :collectionId
ORDER BY collection_links.added_at DESC
""")
fun getLinksInCollection(collectionId: Long): Flow<List<LinkEntity>>
@Query("SELECT COUNT(*) FROM collection_links WHERE collection_id = :collectionId")
fun getLinkCountInCollection(collectionId: Long): Flow<Int>
@Query("SELECT EXISTS(SELECT 1 FROM collection_links WHERE collection_id = :collectionId AND link_id = :linkId)")
suspend fun isLinkInCollection(collectionId: Long, linkId: Int): Boolean
}
@@ -0,0 +1,183 @@
package com.shaarit.data.local.dao
import androidx.paging.PagingSource
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Update
import androidx.sqlite.db.SupportSQLiteQuery
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.LinkFtsEntity
import com.shaarit.data.local.entity.SyncStatus
import kotlinx.coroutines.flow.Flow
@Dao
interface LinkDao {
// ====== Requêtes de base ======
@Query("SELECT * FROM links ORDER BY is_pinned DESC, created_at DESC")
fun getAllLinks(): Flow<List<LinkEntity>>
@Query("SELECT * FROM links ORDER BY is_pinned DESC, created_at DESC")
fun getAllLinksPaged(): PagingSource<Int, LinkEntity>
@Query("SELECT * FROM links WHERE id = :id")
suspend fun getLinkById(id: Int): LinkEntity?
@Query("SELECT * FROM links WHERE url = :url")
suspend fun getLinkByUrl(url: String): LinkEntity?
@Query("SELECT * FROM links WHERE id = :id")
fun getLinkByIdFlow(id: Int): Flow<LinkEntity?>
// ====== Recherche et filtres ======
@Query("""
SELECT * FROM links
WHERE title LIKE '%' || :query || '%'
OR description LIKE '%' || :query || '%'
OR url LIKE '%' || :query || '%'
ORDER BY is_pinned DESC, created_at DESC
""")
fun searchLinks(query: String): PagingSource<Int, LinkEntity>
@Query("""
SELECT links.* FROM links
INNER JOIN links_fts ON links.id = links_fts.rowid
WHERE links_fts MATCH :query
ORDER BY links.is_pinned DESC, links.created_at DESC
""")
fun searchLinksFullText(query: String): PagingSource<Int, LinkEntity>
@Query("""
SELECT * FROM links
WHERE tags LIKE '%' || :tag || '%'
ORDER BY is_pinned DESC, created_at DESC
""")
fun getLinksByTag(tag: String): PagingSource<Int, LinkEntity>
@Query("""
SELECT * FROM links
WHERE tags LIKE '%' || :tag1 || '%' AND tags LIKE '%' || :tag2 || '%'
ORDER BY is_pinned DESC, created_at DESC
""")
fun getLinksByMultipleTags(tag1: String, tag2: String): PagingSource<Int, LinkEntity>
// ====== Filtres temporels ======
@Query("""
SELECT * FROM links
WHERE created_at >= :timestamp
ORDER BY is_pinned DESC, created_at DESC
""")
fun getLinksSince(timestamp: Long): Flow<List<LinkEntity>>
@Query("""
SELECT * FROM links
WHERE created_at BETWEEN :startTime AND :endTime
ORDER BY is_pinned DESC, created_at DESC
""")
fun getLinksBetween(startTime: Long, endTime: Long): Flow<List<LinkEntity>>
// ====== Filtres par statut ======
@Query("SELECT * FROM links WHERE is_private = 0 ORDER BY created_at DESC")
fun getPublicLinks(): PagingSource<Int, LinkEntity>
@Query("SELECT * FROM links WHERE is_private = 1 ORDER BY created_at DESC")
fun getPrivateLinks(): PagingSource<Int, LinkEntity>
@Query("SELECT * FROM links WHERE is_pinned = 1 ORDER BY created_at DESC")
fun getPinnedLinks(): Flow<List<LinkEntity>>
// ====== Sync ======
@Query("SELECT * FROM links WHERE sync_status = :status")
suspend fun getLinksBySyncStatus(status: SyncStatus): List<LinkEntity>
@Query("SELECT * FROM links WHERE sync_status != 'SYNCED'")
fun getUnsyncedLinks(): Flow<List<LinkEntity>>
@Query("SELECT COUNT(*) FROM links WHERE sync_status != 'SYNCED'")
fun getUnsyncedCount(): Flow<Int>
@Query("UPDATE links SET sync_status = :status WHERE id = :id")
suspend fun updateSyncStatus(id: Int, status: SyncStatus)
@Query("UPDATE links SET sync_status = 'SYNCED', local_modified_at = :timestamp WHERE id = :id")
suspend fun markAsSynced(id: Int, timestamp: Long = System.currentTimeMillis())
// ====== Insert / Update / Delete ======
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLink(link: LinkEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLinks(links: List<LinkEntity>)
@Update
suspend fun updateLink(link: LinkEntity)
@Query("UPDATE links SET is_pinned = :isPinned, sync_status = :syncStatus, local_modified_at = :timestamp WHERE id = :id")
suspend fun updatePinStatus(id: Int, isPinned: Boolean, syncStatus: SyncStatus = SyncStatus.PENDING_UPDATE, timestamp: Long = System.currentTimeMillis())
@Query("DELETE FROM links WHERE id = :id")
suspend fun deleteLink(id: Int)
@Query("UPDATE links SET sync_status = 'PENDING_DELETE' WHERE id = :id")
suspend fun markForDeletion(id: Int)
@Query("DELETE FROM links WHERE sync_status = 'PENDING_DELETE'")
suspend fun deletePendingDeletions()
// ====== Opérations en masse ======
@Query("DELETE FROM links")
suspend fun clearAll()
@Transaction
suspend fun replaceAll(links: List<LinkEntity>) {
clearAll()
insertLinks(links)
}
// ====== Statistiques ======
@Query("SELECT COUNT(*) FROM links")
fun getTotalCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM links WHERE is_private = 1")
fun getPrivateCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM links WHERE created_at >= :timestamp")
suspend fun getCountSince(timestamp: Long): Int
@Query("SELECT DISTINCT site_name FROM links WHERE site_name IS NOT NULL ORDER BY site_name")
suspend fun getAllSites(): List<String>
@Query("SELECT site_name, COUNT(*) as count FROM links WHERE site_name IS NOT NULL GROUP BY site_name ORDER BY count DESC LIMIT :limit")
suspend fun getTopSites(limit: Int = 10): List<SiteCount>
@Query("SELECT content_type, COUNT(*) as count FROM links GROUP BY content_type ORDER BY count DESC")
suspend fun getContentTypeDistribution(): List<ContentTypeCount>
// ====== Pour les statistiques ======
@Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'")
suspend fun getAllLinksForStats(): List<LinkEntity>
}
data class SiteCount(
@ColumnInfo(name = "site_name") val siteName: String,
@ColumnInfo(name = "count") val count: Int
)
data class ContentTypeCount(
@ColumnInfo(name = "content_type") val contentType: String,
@ColumnInfo(name = "count") val count: Int
)
@@ -0,0 +1,61 @@
package com.shaarit.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.shaarit.data.local.entity.TagEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface TagDao {
@Query("SELECT * FROM tags ORDER BY occurrences DESC, name ASC")
fun getAllTags(): Flow<List<TagEntity>>
@Query("SELECT * FROM tags ORDER BY occurrences DESC, name ASC")
suspend fun getAllTagsOnce(): List<TagEntity>
@Query("SELECT * FROM tags WHERE name = :name")
suspend fun getTagByName(name: String): TagEntity?
@Query("SELECT * FROM tags WHERE name LIKE '%' || :query || '%' ORDER BY occurrences DESC")
suspend fun searchTags(query: String): List<TagEntity>
@Query("SELECT * FROM tags WHERE is_favorite = 1 ORDER BY name ASC")
fun getFavoriteTags(): Flow<List<TagEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTag(tag: TagEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTags(tags: List<TagEntity>)
@Update
suspend fun updateTag(tag: TagEntity)
@Query("UPDATE tags SET occurrences = occurrences + 1, last_used_at = :timestamp WHERE name = :name")
suspend fun incrementOccurrences(name: String, timestamp: Long = System.currentTimeMillis())
@Query("UPDATE tags SET occurrences = occurrences - 1 WHERE name = :name")
suspend fun decrementOccurrences(name: String)
@Query("UPDATE tags SET is_favorite = :isFavorite WHERE name = :name")
suspend fun setFavorite(name: String, isFavorite: Boolean)
@Query("DELETE FROM tags WHERE name = :name")
suspend fun deleteTag(name: String)
@Query("DELETE FROM tags")
suspend fun clearAll()
@Query("SELECT COUNT(*) FROM tags")
fun getTotalCount(): Flow<Int>
@Query("SELECT * FROM tags ORDER BY last_used_at DESC LIMIT :limit")
suspend fun getRecentlyUsed(limit: Int = 10): List<TagEntity>
@Query("SELECT * FROM tags ORDER BY occurrences DESC LIMIT :limit")
suspend fun getMostPopular(limit: Int = 10): List<TagEntity>
}
@@ -0,0 +1,63 @@
package com.shaarit.data.local.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.shaarit.data.local.converter.Converters
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.CollectionEntity
import com.shaarit.data.local.entity.CollectionLinkCrossRef
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.LinkFtsEntity
import com.shaarit.data.local.entity.LinkTagCrossRef
import com.shaarit.data.local.entity.TagEntity
/**
* Database Room principale pour le cache offline de ShaarIt
*/
@Database(
entities = [
LinkEntity::class,
LinkFtsEntity::class,
TagEntity::class,
LinkTagCrossRef::class,
CollectionEntity::class,
CollectionLinkCrossRef::class
],
version = 1,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class ShaarliDatabase : RoomDatabase() {
abstract fun linkDao(): LinkDao
abstract fun tagDao(): TagDao
abstract fun collectionDao(): CollectionDao
companion object {
private const val DATABASE_NAME = "shaarli.db"
@Volatile
private var instance: ShaarliDatabase? = null
fun getInstance(context: Context): ShaarliDatabase {
return instance ?: synchronized(this) {
instance ?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): ShaarliDatabase {
return Room.databaseBuilder(
context.applicationContext,
ShaarliDatabase::class.java,
DATABASE_NAME
)
.fallbackToDestructiveMigration()
.build()
}
}
}
@@ -0,0 +1,68 @@
package com.shaarit.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entité représentant une collection/playlist de liens
*/
@Entity(
tableName = "collections",
indices = [
Index(value = ["name"]),
Index(value = ["is_smart"]),
Index(value = ["sort_order"])
]
)
data class CollectionEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
val id: Long = 0,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "description")
val description: String? = null,
@ColumnInfo(name = "icon")
val icon: String = "📁", // Emoji ou nom d'icône
@ColumnInfo(name = "color")
val color: Int? = null,
@ColumnInfo(name = "is_smart")
val isSmart: Boolean = false, // Collection intelligente avec requête
@ColumnInfo(name = "query")
val query: String? = null, // Requête pour collection intelligente (ex: "tag:work")
@ColumnInfo(name = "sort_order")
val sortOrder: Int = 0,
@ColumnInfo(name = "created_at")
val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at")
val updatedAt: Long = System.currentTimeMillis()
)
/**
* Relation entre collection et liens
*/
@Entity(
tableName = "collection_links",
primaryKeys = ["collection_id", "link_id"]
)
data class CollectionLinkCrossRef(
@ColumnInfo(name = "collection_id")
val collectionId: Long,
@ColumnInfo(name = "link_id")
val linkId: Int,
@ColumnInfo(name = "added_at")
val addedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,114 @@
package com.shaarit.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entité Room représentant un lien Shaarli en cache local.
* Supporte FTS4 pour la recherche full-text.
*/
@Entity(
tableName = "links",
indices = [
Index(value = ["sync_status"]),
Index(value = ["is_private"]),
Index(value = ["created_at"]),
Index(value = ["is_pinned"]),
Index(value = ["url"], unique = true)
]
)
data class LinkEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Int,
@ColumnInfo(name = "url")
val url: String,
@ColumnInfo(name = "title")
val title: String,
@ColumnInfo(name = "description")
val description: String,
@ColumnInfo(name = "tags")
val tags: List<String>,
@ColumnInfo(name = "is_private")
val isPrivate: Boolean,
@ColumnInfo(name = "is_pinned")
val isPinned: Boolean = false,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
@ColumnInfo(name = "sync_status")
val syncStatus: SyncStatus = SyncStatus.SYNCED,
@ColumnInfo(name = "local_modified_at")
val localModifiedAt: Long = System.currentTimeMillis(),
// Métadonnées enrichies
@ColumnInfo(name = "thumbnail_url")
val thumbnailUrl: String? = null,
@ColumnInfo(name = "reading_time_minutes")
val readingTimeMinutes: Int? = null,
@ColumnInfo(name = "content_type")
val contentType: ContentType = ContentType.UNKNOWN,
@ColumnInfo(name = "site_name")
val siteName: String? = null,
@ColumnInfo(name = "excerpt")
val excerpt: String? = null
)
/**
* Statut de synchronisation d'un lien
*/
enum class SyncStatus {
SYNCED, // Synchronisé avec le serveur
PENDING_CREATE, // En attente de création sur le serveur
PENDING_UPDATE, // En attente de mise à jour sur le serveur
PENDING_DELETE, // En attente de suppression sur le serveur
CONFLICT // Conflit de synchronisation détecté
}
/**
* Type de contenu détecté
*/
enum class ContentType {
UNKNOWN,
ARTICLE,
VIDEO,
PODCAST,
IMAGE,
PDF,
REPOSITORY, // GitHub, GitLab, etc.
DOCUMENT, // Google Docs, Notion, etc.
SOCIAL, // Twitter, Mastodon, etc.
SHOPPING, // Amazon, etc.
NEWSLETTER
}
/**
* Entité FTS4 pour la recherche full-text
*/
@Fts4(contentEntity = LinkEntity::class)
@Entity(tableName = "links_fts")
data class LinkFtsEntity(
val url: String,
val title: String,
val description: String,
val tags: String, // Concatenated tags
val excerpt: String?
)
@@ -0,0 +1,49 @@
package com.shaarit.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Entité Room représentant un tag Shaarli en cache local.
*/
@Entity(
tableName = "tags",
indices = [
Index(value = ["name"], unique = true),
Index(value = ["occurrences"])
]
)
data class TagEntity(
@PrimaryKey
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "occurrences")
val occurrences: Int = 0,
@ColumnInfo(name = "last_used_at")
val lastUsedAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "color")
val color: Int? = null, // Couleur personnalisée pour le tag
@ColumnInfo(name = "is_favorite")
val isFavorite: Boolean = false
)
/**
* Relation many-to-many entre liens et tags (si besoin de relations complexes)
*/
@Entity(
tableName = "link_tag_cross_ref",
primaryKeys = ["link_id", "tag_name"]
)
data class LinkTagCrossRef(
@ColumnInfo(name = "link_id")
val linkId: Int,
@ColumnInfo(name = "tag_name")
val tagName: String
)
@@ -6,14 +6,16 @@ import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ShaarliTag
object LinkMapper {
fun toDomain(dto: LinkDto): ShaarliLink {
fun toDomain(dto: LinkDto): ShaarliLink? {
val id = dto.id ?: return null
val url = dto.url ?: return null
return ShaarliLink(
id = dto.id,
url = dto.url,
title = dto.title ?: dto.url,
id = id,
url = url,
title = dto.title ?: url,
description = dto.description ?: "",
tags = dto.tags ?: emptyList(),
isPrivate = dto.isPrivate,
isPrivate = dto.isPrivate ?: false,
date = dto.created ?: ""
)
}
@@ -0,0 +1,282 @@
package com.shaarit.data.metadata
import android.util.Log
import com.shaarit.data.local.entity.ContentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.net.URL
import javax.inject.Inject
import javax.inject.Singleton
/**
* Service d'extraction de métadonnées pour enrichir les liens
* Utilise Jsoup pour parser le HTML et extraire les métadonnées OpenGraph
*/
@Singleton
class LinkMetadataExtractor @Inject constructor() {
companion object {
private const val TAG = "LinkMetadataExtractor"
private const val TIMEOUT_MS = 10000
private const val MAX_DESCRIPTION_LENGTH = 300
}
/**
* Extrait les métadonnées d'une URL
*/
suspend fun extract(url: String): LinkMetadata = withContext(Dispatchers.IO) {
try {
val doc = Jsoup.connect(url)
.timeout(TIMEOUT_MS)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.followRedirects(true)
.get()
LinkMetadata(
url = url,
title = extractTitle(doc),
description = extractDescription(doc),
thumbnailUrl = extractThumbnail(doc, url),
siteName = extractSiteName(doc, url),
contentType = detectContentType(doc, url),
readingTime = estimateReadingTime(doc),
faviconUrl = extractFavicon(doc, url)
)
} catch (e: Exception) {
Log.e(TAG, "Erreur extraction métadonnées pour $url", e)
// Retourner des métadonnées par défaut basées sur l'URL
LinkMetadata(
url = url,
title = extractTitleFromUrl(url),
description = "",
thumbnailUrl = null,
siteName = extractDomain(url),
contentType = detectContentTypeFromUrl(url),
readingTime = null,
faviconUrl = null
)
}
}
/**
* Extrait le titre de la page
*/
private fun extractTitle(doc: Document): String? {
// Priorité aux balises OpenGraph
val ogTitle = doc.select("meta[property=og:title]").attr("content")
if (ogTitle.isNotBlank()) return ogTitle.trim()
// Fallback sur la balise title
val titleTag = doc.select("title").text()
if (titleTag.isNotBlank()) return titleTag.trim()
// Fallback sur twitter:title
val twitterTitle = doc.select("meta[name=twitter:title]").attr("content")
if (twitterTitle.isNotBlank()) return twitterTitle.trim()
return null
}
/**
* Extrait la description de la page
*/
private fun extractDescription(doc: Document): String? {
// Priorité aux balises OpenGraph
val ogDescription = doc.select("meta[property=og:description]").attr("content")
if (ogDescription.isNotBlank()) {
return ogDescription.trim().take(MAX_DESCRIPTION_LENGTH)
}
// Fallback sur meta description
val metaDescription = doc.select("meta[name=description]").attr("content")
if (metaDescription.isNotBlank()) {
return metaDescription.trim().take(MAX_DESCRIPTION_LENGTH)
}
// Fallback sur twitter:description
val twitterDescription = doc.select("meta[name=twitter:description]").attr("content")
if (twitterDescription.isNotBlank()) {
return twitterDescription.trim().take(MAX_DESCRIPTION_LENGTH)
}
// Essayer d'extraire le premier paragraphe pertinent
val firstParagraph = doc.select("p").firstOrNull()?.text()
if (!firstParagraph.isNullOrBlank() && firstParagraph.length > 50) {
return firstParagraph.take(MAX_DESCRIPTION_LENGTH)
}
return null
}
/**
* Extrait l'image thumbnail
*/
private fun extractThumbnail(doc: Document, baseUrl: String): String? {
// Priorité aux balises OpenGraph
val ogImage = doc.select("meta[property=og:image]").attr("content")
if (ogImage.isNotBlank()) {
return resolveUrl(ogImage, baseUrl)
}
// Fallback sur twitter:image
val twitterImage = doc.select("meta[name=twitter:image]").attr("content")
if (twitterImage.isNotBlank()) {
return resolveUrl(twitterImage, baseUrl)
}
// Rechercher une image principale
val mainImage = doc.select("img[src~=(?i)\\.(png|jpe?g|gif|webp)]").firstOrNull()
if (mainImage != null) {
val src = mainImage.attr("src")
if (src.isNotBlank()) {
return resolveUrl(src, baseUrl)
}
}
return null
}
/**
* Extrait le nom du site
*/
private fun extractSiteName(doc: Document, baseUrl: String): String? {
val ogSiteName = doc.select("meta[property=og:site_name]").attr("content")
if (ogSiteName.isNotBlank()) return ogSiteName.trim()
val applicationName = doc.select("meta[name=application-name]").attr("content")
if (applicationName.isNotBlank()) return applicationName.trim()
return extractDomain(baseUrl)
}
/**
* Extrait l'URL du favicon
*/
private fun extractFavicon(doc: Document, baseUrl: String): String? {
// Chercher le favicon déclaré
val favicon = doc.select("link[rel~=(?i)icon]").attr("href")
if (favicon.isNotBlank()) {
return resolveUrl(favicon, baseUrl)
}
// Favicon par défaut
return "${extractBaseUrl(baseUrl)}/favicon.ico"
}
/**
* Détecte le type de contenu
*/
private fun detectContentType(doc: Document, url: String): ContentType {
val ogType = doc.select("meta[property=og:type]").attr("content")
return when {
ogType == "video" || url.contains(Regex("youtube\\.com|vimeo\\.com|dailymotion")) -> ContentType.VIDEO
ogType == "article" || doc.select("article").isNotEmpty() -> ContentType.ARTICLE
url.contains(Regex("github\\.com|gitlab\\.com|bitbucket")) -> ContentType.REPOSITORY
url.contains(Regex("docs\\.google\\.com|notion\\.so|confluence")) -> ContentType.DOCUMENT
url.contains(Regex("twitter\\.com|x\\.com|mastodon")) -> ContentType.SOCIAL
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER
doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor|soundcloud")) -> ContentType.PODCAST
url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN
}
}
/**
* Détecte le type de contenu uniquement depuis l'URL
*/
private fun detectContentTypeFromUrl(url: String): ContentType {
return when {
url.contains(Regex("youtube\\.com|youtu\\.be|vimeo|dailymotion")) -> ContentType.VIDEO
url.contains(Regex("github\\.com|gitlab")) -> ContentType.REPOSITORY
url.contains(Regex("docs\\.google|notion\\.so")) -> ContentType.DOCUMENT
url.contains(Regex("twitter\\.com|x\\.com|mastodon")) -> ContentType.SOCIAL
url.contains(Regex("amazon|ebay")) -> ContentType.SHOPPING
url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN
}
}
/**
* Estime le temps de lecture
*/
private fun estimateReadingTime(doc: Document): Int? {
val text = doc.body()?.text() ?: return null
val wordCount = text.split(Regex("\\s+")).size
// Moyenne de 200 mots par minute
val minutes = (wordCount / 200.0).toInt()
return if (minutes > 0) minutes else 1
}
/**
* Extrait le domaine d'une URL
*/
private fun extractDomain(url: String): String? {
return try {
URL(url).host.removePrefix("www.")
} catch (e: Exception) {
null
}
}
/**
* Extrait la base d'une URL (protocol + host)
*/
private fun extractBaseUrl(url: String): String? {
return try {
val urlObj = URL(url)
"${urlObj.protocol}://${urlObj.host}"
} catch (e: Exception) {
null
}
}
/**
* Extrait un titre depuis l'URL si pas d'autre option
*/
private fun extractTitleFromUrl(url: String): String? {
return try {
val path = URL(url).path
path.trim('/').split('/').lastOrNull()
?.replace('-', ' ')
?.replace('_', ' ')
?.capitalize()
} catch (e: Exception) {
null
}
}
/**
* Résout une URL relative en URL absolue
*/
private fun resolveUrl(url: String, baseUrl: String): String {
return if (url.startsWith("http")) {
url
} else if (url.startsWith("//")) {
"https:$url"
} else {
try {
URL(URL(baseUrl), url).toString()
} catch (e: Exception) {
url
}
}
}
}
/**
* Données de métadonnées extraites
*/
data class LinkMetadata(
val url: String,
val title: String?,
val description: String?,
val thumbnailUrl: String?,
val siteName: String?,
val contentType: ContentType,
val readingTime: Int?,
val faviconUrl: String?
)
@@ -42,7 +42,7 @@ class LinkPagingSource(
searchTags = searchTags
)
val links = dtos.map { LinkMapper.toDomain(it) }
val links = dtos.mapNotNull { LinkMapper.toDomain(it) }
val nextKey =
if (links.isEmpty()) {
@@ -3,93 +3,221 @@ package com.shaarit.data.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.dto.CreateLinkDto
import com.shaarit.data.dto.LinkDto
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.TagDao
import com.shaarit.data.local.entity.ContentType
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.SyncStatus
import com.shaarit.data.local.entity.TagEntity
import com.shaarit.data.mapper.LinkMapper
import com.shaarit.data.mapper.TagMapper
import com.shaarit.data.paging.LinkPagingSource
import com.shaarit.data.sync.SyncManager
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.AddLinkResult
import com.shaarit.domain.repository.LinkRepository
import com.squareup.moshi.Moshi
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import retrofit2.HttpException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Implémentation du repository avec architecture offline-first
* Utilise Room pour le cache local et l'API Shaarli pour la synchronisation
*/
@Singleton
class LinkRepositoryImpl
@Inject
constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkRepository {
constructor(
private val api: ShaarliApi,
private val linkDao: LinkDao,
private val tagDao: TagDao,
private val syncManager: SyncManager,
private val moshi: Moshi
) : LinkRepository {
// ====== Lecture (Offline-First) ======
override fun getLinksStream(
searchTerm: String?,
searchTags: String?
searchTerm: String?,
searchTags: String?
): Flow<PagingData<ShaarliLink>> {
// Utiliser Room pour la pagination locale
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = { LinkPagingSource(api, searchTerm, searchTags) }
)
.flow
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = {
when {
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
!searchTags.isNullOrBlank() -> {
val tags = searchTags.split(" ").filter { it.isNotBlank() }
if (tags.size == 1) {
linkDao.getLinksByTag(tags.first())
} else {
// Pour plusieurs tags, on prend les liens qui ont au moins un des tags
linkDao.getLinksByTag(tags.first())
}
}
else -> linkDao.getAllLinksPaged()
}
}
).flow.map { pagingData ->
pagingData.map { it.toDomainModel() }
}
}
override suspend fun addLink(
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean
): Result<Unit> {
override suspend fun getLink(id: Int): Result<ShaarliLink> {
// Essayer d'abord le cache local
val localLink = linkDao.getLinkById(id)
if (localLink != null) {
return Result.success(localLink.toDomainModel())
}
// Fallback vers l'API
return try {
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
Result.success(Unit)
val link = api.getLink(id)
val entity = link.toEntity()
if (entity != null) {
linkDao.insertLink(entity)
Result.success(entity.toDomainModel())
} else {
Result.failure(HttpException(response))
Result.failure(Exception("Invalid link data from server"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override fun getLinkFlow(id: Int): Flow<ShaarliLink?> {
return linkDao.getLinkByIdFlow(id).map { it?.toDomainModel() }
}
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
// Lire depuis le cache local
return try {
// Si nous avons des données locales, les retourner
val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList()
if (localLinks.isNotEmpty()) {
val filtered = localLinks.filter { it.tags.contains(tag) }
if (filtered.isNotEmpty()) {
return Result.success(filtered.map { it.toDomainModel() })
}
}
// Fallback vers l'API
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
Result.success(links.mapNotNull { LinkMapper.toDomain(it) })
} catch (e: Exception) {
Result.failure(e)
}
}
// ====== Écriture (avec file d'attente de sync) ======
override suspend fun addLink(
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean
): Result<Unit> {
// Créer l'entité locale avec un ID temporaire négatif
val tempId = -(System.currentTimeMillis() % 10000000).toInt()
val entity = LinkEntity(
id = tempId,
url = url,
title = title ?: url,
description = description ?: "",
tags = tags ?: emptyList(),
isPrivate = isPrivate,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING_CREATE
)
// Sauvegarder localement
linkDao.insertLink(entity)
// Mettre à jour les compteurs de tags
tags?.forEach { tag ->
val existingTag = tagDao.getTagByName(tag)
if (existingTag != null) {
tagDao.incrementOccurrences(tag)
} else {
tagDao.insertTag(TagEntity(tag, 1))
}
}
// Déclencher une sync en arrière-plan
syncManager.syncNow()
return Result.success(Unit)
}
override suspend fun addOrUpdateLink(
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean,
forceUpdate: Boolean,
existingLinkId: Int?
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean,
forceUpdate: Boolean,
existingLinkId: Int?
): AddLinkResult {
return try {
if (forceUpdate && existingLinkId != null) {
// Force update existing link
val response =
api.updateLink(
existingLinkId,
CreateLinkDto(url, title, description, tags, isPrivate)
)
if (response.isSuccessful) {
// Mise à jour forcée
val existing = linkDao.getLinkById(existingLinkId)
if (existing != null) {
val updated = existing.copy(
title = title ?: existing.title,
description = description ?: existing.description,
tags = tags ?: existing.tags,
isPrivate = isPrivate,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING_UPDATE
)
linkDao.updateLink(updated)
syncManager.syncNow()
AddLinkResult.Success
} else {
AddLinkResult.Error("Update failed: ${response.code()}")
AddLinkResult.Error("Link not found")
}
} else {
// Try to add new link
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
AddLinkResult.Success
} else if (response.code() == 409) {
// Conflict - link already exists
// Try to parse the existing link from response body
val errorBody = response.errorBody()?.string()
val existingLink = parseExistingLink(errorBody)
// Vérifier si le lien existe déjà localement
val existingByUrl = linkDao.getLinkByUrl(url)
if (existingByUrl != null) {
AddLinkResult.Conflict(
existingLinkId = existingLink?.id ?: 0,
existingTitle = existingLink?.title
existingLinkId = existingByUrl.id,
existingTitle = existingByUrl.title
)
} else {
AddLinkResult.Error("Failed: ${response.code()} - ${response.message()}")
// Essayer l'API directement
val response = api.addLink(CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
response.body()?.let { serverLink ->
serverLink.toEntity()?.let { entity ->
linkDao.insertLink(entity)
}
}
AddLinkResult.Success
} else if (response.code() == 409) {
val errorBody = response.errorBody()?.string()
val existingLink = parseExistingLink(errorBody)
AddLinkResult.Conflict(
existingLinkId = existingLink?.id ?: 0,
existingTitle = existingLink?.title
)
} else {
// Fallback : créer localement
addLink(url, title, description, tags, isPrivate)
AddLinkResult.Success
}
}
}
} catch (e: HttpException) {
@@ -97,17 +225,124 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
val errorBody = e.response()?.errorBody()?.string()
val existingLink = parseExistingLink(errorBody)
AddLinkResult.Conflict(
existingLinkId = existingLink?.id ?: 0,
existingTitle = existingLink?.title
existingLinkId = existingLink?.id ?: 0,
existingTitle = existingLink?.title
)
} else {
AddLinkResult.Error(e.message ?: "HTTP Error ${e.code()}")
// Fallback offline
addLink(url, title, description, tags, isPrivate)
AddLinkResult.Success
}
} catch (e: Exception) {
AddLinkResult.Error(e.message ?: "Unknown error")
// Fallback offline
addLink(url, title, description, tags, isPrivate)
AddLinkResult.Success
}
}
override suspend fun updateLink(
id: Int,
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean
): Result<Unit> {
val existing = linkDao.getLinkById(id)
?: return Result.failure(Exception("Link not found"))
val updated = existing.copy(
url = url,
title = title ?: existing.title,
description = description ?: existing.description,
tags = tags ?: existing.tags,
isPrivate = isPrivate,
updatedAt = System.currentTimeMillis(),
syncStatus = if (existing.syncStatus == SyncStatus.PENDING_CREATE) {
SyncStatus.PENDING_CREATE
} else {
SyncStatus.PENDING_UPDATE
}
)
linkDao.updateLink(updated)
syncManager.syncNow()
return Result.success(Unit)
}
override suspend fun deleteLink(id: Int): Result<Unit> {
val existing = linkDao.getLinkById(id)
?: return Result.failure(Exception("Link not found"))
if (existing.syncStatus == SyncStatus.PENDING_CREATE) {
// Si jamais sync, supprimer directement
linkDao.deleteLink(id)
} else {
// Marquer pour suppression
linkDao.markForDeletion(id)
syncManager.syncNow()
}
// Décrémenter les compteurs de tags
existing.tags.forEach { tag ->
tagDao.decrementOccurrences(tag)
}
return Result.success(Unit)
}
// ====== Tags ======
override suspend fun getTags(): Result<List<ShaarliTag>> {
// Essayer d'abord le cache local
val localTags = tagDao.getAllTags().firstOrNull()
if (!localTags.isNullOrEmpty()) {
return Result.success(localTags.map { ShaarliTag(it.name, it.occurrences) })
}
// Fallback vers l'API
return try {
val tags = api.getTags(offset = 0, limit = 500)
val entities = tags.map { TagMapper.toDomain(it) }.map {
TagEntity(it.name, it.occurrences)
}
tagDao.insertTags(entities)
Result.success(tags.map { TagMapper.toDomain(it) })
} catch (e: Exception) {
Result.failure(e)
}
}
fun getTagsFlow(): Flow<List<ShaarliTag>> {
return tagDao.getAllTags().map { tags ->
tags.map { ShaarliTag(it.name, it.occurrences) }
}
}
// ====== Actions supplémentaires ======
suspend fun togglePin(id: Int): Result<Boolean> {
val link = linkDao.getLinkById(id)
?: return Result.failure(Exception("Link not found"))
val newPinState = !link.isPinned
linkDao.updatePinStatus(id, newPinState)
return Result.success(newPinState)
}
suspend fun refreshFromServer(): Result<Unit> {
return try {
syncManager.performFullSync()
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
// ====== Helpers ======
private fun parseExistingLink(errorBody: String?): LinkDto? {
if (errorBody.isNullOrBlank()) return null
return try {
@@ -117,65 +352,46 @@ constructor(private val api: ShaarliApi, private val moshi: Moshi) : LinkReposit
null
}
}
override suspend fun updateLink(
id: Int,
url: String,
title: String?,
description: String?,
tags: List<String>?,
isPrivate: Boolean
): Result<Unit> {
return try {
val response =
api.updateLink(id, CreateLinkDto(url, title, description, tags, isPrivate))
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Update failed: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
private fun LinkEntity.toDomainModel(): ShaarliLink {
return ShaarliLink(
id = id,
url = url,
title = title,
description = description,
tags = tags,
isPrivate = isPrivate,
date = java.time.Instant.ofEpochMilli(createdAt).toString(),
isPinned = isPinned,
thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes,
contentType = contentType.name,
siteName = siteName
)
}
override suspend fun deleteLink(id: Int): Result<Unit> {
return try {
val response = api.deleteLink(id)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Delete failed: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
private fun LinkDto.toEntity(): LinkEntity? {
val linkId = id ?: return null
val linkUrl = url ?: return null
return LinkEntity(
id = linkId,
url = linkUrl,
title = title ?: linkUrl,
description = description ?: "",
tags = tags ?: emptyList(),
isPrivate = isPrivate ?: false,
createdAt = parseDate(created),
updatedAt = parseDate(updated),
syncStatus = SyncStatus.SYNCED
)
}
override suspend fun getLink(id: Int): Result<ShaarliLink> {
private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
return try {
val link = api.getLink(id)
Result.success(LinkMapper.toDomain(link))
java.time.Instant.parse(dateString).toEpochMilli()
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getTags(): Result<List<ShaarliTag>> {
return try {
val tags = api.getTags(offset = 0, limit = 500)
Result.success(tags.map { TagMapper.toDomain(it) })
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
return try {
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
Result.success(links.map { LinkMapper.toDomain(it) })
} catch (e: Exception) {
Result.failure(e)
System.currentTimeMillis()
}
}
}
@@ -0,0 +1,127 @@
package com.shaarit.data.sync
import android.util.Log
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.SyncStatus
import javax.inject.Inject
import javax.inject.Singleton
/**
* Stratégies de résolution des conflits de synchronisation
*/
@Singleton
class ConflictResolver @Inject constructor() {
companion object {
private const val TAG = "ConflictResolver"
}
/**
* Stratégie de résolution de conflit
*/
enum class Strategy {
SERVER_WINS, // La version serveur écrase la locale
CLIENT_WINS, // La version locale écrase celle du serveur
MERGE, // Fusionner les deux versions (si possible)
MANUAL // Demander à l'utilisateur
}
data class Conflict(
val localLink: LinkEntity,
val serverLink: LinkEntity,
val type: ConflictType
)
enum class ConflictType {
BOTH_MODIFIED, // Les deux versions ont été modifiées
LOCAL_DELETED, // Supprimé localement, modifié sur le serveur
SERVER_DELETED // Supprimé sur le serveur, modifié localement
}
/**
* Résout un conflit selon la stratégie choisie
*/
fun resolve(conflict: Conflict, strategy: Strategy = Strategy.SERVER_WINS): Resolution {
Log.d(TAG, "Résolution conflit ${conflict.type} avec stratégie $strategy")
return when (strategy) {
Strategy.SERVER_WINS -> resolveServerWins(conflict)
Strategy.CLIENT_WINS -> resolveClientWins(conflict)
Strategy.MERGE -> resolveMerge(conflict)
Strategy.MANUAL -> Resolution.RequiresManualResolution(conflict)
}
}
/**
* Stratégie : le serveur a toujours raison
*/
private fun resolveServerWins(conflict: Conflict): Resolution {
return Resolution.UseServerVersion(conflict.serverLink.copy(
isPinned = conflict.localLink.isPinned, // Préserver les préférences locales
syncStatus = SyncStatus.SYNCED
))
}
/**
* Stratégie : le client a toujours raison
*/
private fun resolveClientWins(conflict: Conflict): Resolution {
return Resolution.UseLocalVersion(conflict.localLink.copy(
syncStatus = SyncStatus.PENDING_UPDATE
))
}
/**
* Stratégie : fusionner les deux versions
*/
private fun resolveMerge(conflict: Conflict): Resolution {
val local = conflict.localLink
val server = conflict.serverLink
// Fusion intelligente : prendre le titre et description les plus récents
// Combiner les tags (union des deux ensembles)
val mergedTags = (local.tags + server.tags).distinct()
val merged = local.copy(
title = if (local.updatedAt > server.updatedAt) local.title else server.title,
description = if (local.updatedAt > server.updatedAt) local.description else server.description,
tags = mergedTags,
isPrivate = server.isPrivate, // La visibilité est toujours celle du serveur
syncStatus = SyncStatus.PENDING_UPDATE
)
return Resolution.Merged(merged)
}
/**
* Détecte si un conflit existe entre une version locale et serveur
*/
fun detectConflict(localLink: LinkEntity?, serverLink: LinkEntity?): Conflict? {
if (localLink == null || serverLink == null) return null
// Si les timestamps sont identiques, pas de conflit
if (localLink.updatedAt == serverLink.updatedAt) return null
// Si le lien local est en conflit
if (localLink.syncStatus == SyncStatus.CONFLICT) {
return Conflict(
localLink = localLink,
serverLink = serverLink,
type = ConflictType.BOTH_MODIFIED
)
}
return null
}
}
/**
* Résultat d'une résolution de conflit
*/
sealed class Resolution {
data class UseServerVersion(val link: LinkEntity) : Resolution()
data class UseLocalVersion(val link: LinkEntity) : Resolution()
data class Merged(val link: LinkEntity) : Resolution()
data class RequiresManualResolution(val conflict: ConflictResolver.Conflict) : Resolution()
object DeleteLocal : Resolution()
}
@@ -0,0 +1,360 @@
package com.shaarit.data.sync
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.dto.CreateLinkDto
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.TagDao
import com.shaarit.data.local.entity.LinkEntity
import com.shaarit.data.local.entity.SyncStatus
import com.shaarit.data.local.entity.TagEntity
import com.shaarit.data.mapper.LinkMapper
import com.shaarit.data.mapper.TagMapper
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Gestionnaire de synchronisation entre le cache local et le serveur Shaarli
*/
@Singleton
class SyncManager @Inject constructor(
@ApplicationContext private val context: Context,
private val linkDao: LinkDao,
private val tagDao: TagDao,
private val api: ShaarliApi
) {
companion object {
private const val TAG = "SyncManager"
private const val SYNC_WORK_NAME = "shaarli_sync_work"
}
private val workManager = WorkManager.getInstance(context)
/**
* État actuel de la synchronisation
*/
val syncState: Flow<SyncState> = workManager.getWorkInfosForUniqueWorkFlow(SYNC_WORK_NAME)
.map { workInfos ->
when {
workInfos.isEmpty() -> SyncState.Idle
workInfos.any { it.state == WorkInfo.State.RUNNING } -> SyncState.Syncing
workInfos.any { it.state == WorkInfo.State.FAILED } -> SyncState.Error(
workInfos.firstOrNull { it.state == WorkInfo.State.FAILED }?.outputData?.getString("error")
?: "Unknown error"
)
workInfos.any { it.state == WorkInfo.State.SUCCEEDED } -> {
val info = workInfos.firstOrNull { it.state == WorkInfo.State.SUCCEEDED }
val completedAt = info?.outputData?.getLong("completedAt", 0L) ?: 0L
SyncState.Synced(if (completedAt > 0L) completedAt else System.currentTimeMillis())
}
workInfos.all { it.state.isFinished } -> SyncState.Idle
else -> SyncState.Idle
}
}
/**
* Nombre d'éléments en attente de synchronisation
*/
val pendingSyncCount: Flow<Int> = linkDao.getUnsyncedCount()
/**
* Déclenche une synchronisation manuelle
*/
fun syncNow() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.addTag("sync")
.build()
workManager.enqueueUniqueWork(
SYNC_WORK_NAME,
ExistingWorkPolicy.REPLACE,
syncWorkRequest
)
}
/**
* Annule toutes les synchronisations en cours
*/
fun cancelSync() {
workManager.cancelUniqueWork(SYNC_WORK_NAME)
}
/**
* Synchronise immédiatement (appel synchrone - utiliser avec précaution)
*/
suspend fun performFullSync(): SyncResult = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Démarrage de la synchronisation complète...")
// 1. Pousser les modifications locales vers le serveur
pushLocalChanges()
// 2. Récupérer les données depuis le serveur
pullFromServer()
Log.d(TAG, "Synchronisation terminée avec succès")
SyncResult.Success
} catch (e: HttpException) {
val code = e.code()
val details = try {
e.response()?.errorBody()?.string()?.take(500)
} catch (_: Exception) {
null
}
val message = buildString {
append("HTTP ")
append(code)
val base = e.message
if (!base.isNullOrBlank()) {
append(": ")
append(base)
}
if (!details.isNullOrBlank()) {
append(" | ")
append(details)
}
}
Log.e(TAG, "Erreur HTTP lors de la synchronisation", e)
SyncResult.Error(message)
} catch (e: IOException) {
Log.e(TAG, "Erreur réseau lors de la synchronisation", e)
SyncResult.NetworkError(e.message ?: "Network error")
} catch (e: Exception) {
Log.e(TAG, "Erreur lors de la synchronisation", e)
SyncResult.Error("${e::class.java.simpleName}: ${e.message}")
}
}
/**
* Pousse les modifications locales (créations, mises à jour, suppressions)
*/
private suspend fun pushLocalChanges() {
// Traiter les créations en attente
val pendingCreates = linkDao.getLinksBySyncStatus(SyncStatus.PENDING_CREATE)
Log.d(TAG, "${pendingCreates.size} créations en attente")
for (link in pendingCreates) {
try {
val response = api.addLink(
CreateLinkDto(
url = link.url,
title = link.title.takeIf { it.isNotBlank() },
description = link.description.takeIf { it.isNotBlank() },
tags = link.tags.ifEmpty { null },
isPrivate = link.isPrivate
)
)
if (response.isSuccessful) {
response.body()?.let { serverLink ->
// Mettre à jour l'ID local avec l'ID serveur
val serverId = serverLink.id
if (serverId != null) {
val updatedLink = link.copy(
id = serverId,
syncStatus = SyncStatus.SYNCED
)
linkDao.insertLink(updatedLink)
} else {
Log.w(TAG, "Serveur a retourné un lien sans ID pour ${link.url}")
}
}
} else {
Log.e(TAG, "Échec création lien ${link.id}: ${response.code()}")
}
} catch (e: Exception) {
Log.e(TAG, "Exception lors de la création du lien ${link.id}", e)
}
}
// Traiter les mises à jour en attente
val pendingUpdates = linkDao.getLinksBySyncStatus(SyncStatus.PENDING_UPDATE)
Log.d(TAG, "${pendingUpdates.size} mises à jour en attente")
for (link in pendingUpdates) {
try {
val response = api.updateLink(
link.id,
CreateLinkDto(
url = link.url,
title = link.title.takeIf { it.isNotBlank() },
description = link.description.takeIf { it.isNotBlank() },
tags = link.tags.ifEmpty { null },
isPrivate = link.isPrivate
)
)
if (response.isSuccessful) {
linkDao.markAsSynced(link.id)
} else {
Log.e(TAG, "Échec mise à jour lien ${link.id}: ${response.code()}")
}
} catch (e: Exception) {
Log.e(TAG, "Exception lors de la mise à jour du lien ${link.id}", e)
}
}
// Traiter les suppressions en attente
val pendingDeletes = linkDao.getLinksBySyncStatus(SyncStatus.PENDING_DELETE)
Log.d(TAG, "${pendingDeletes.size} suppressions en attente")
for (link in pendingDeletes) {
try {
val response = api.deleteLink(link.id)
if (response.isSuccessful) {
linkDao.deleteLink(link.id)
} else {
Log.e(TAG, "Échec suppression lien ${link.id}: ${response.code()}")
}
} catch (e: Exception) {
Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e)
}
}
}
/**
* Récupère les données depuis le serveur
*/
private suspend fun pullFromServer() {
var offset = 0
val limit = 100
var hasMore = true
while (hasMore) {
try {
val links = api.getLinks(offset = offset, limit = limit)
Log.d(TAG, "Reçu ${links.size} liens (offset=$offset)")
if (links.isEmpty()) {
hasMore = false
} else {
// Filtrer les liens invalides (sans ID ou URL) et convertir en entités
val validLinks = links.filter { dto ->
dto.id != null && !dto.url.isNullOrBlank()
}
Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
val entities = validLinks.mapNotNull { dto ->
try {
val existing = linkDao.getLinkById(dto.id!!)
LinkEntity(
id = dto.id,
url = dto.url!!,
title = dto.title ?: dto.url,
description = dto.description ?: "",
tags = dto.tags ?: emptyList(),
isPrivate = dto.isPrivate ?: false,
isPinned = existing?.isPinned ?: false,
createdAt = parseDate(dto.created),
updatedAt = parseDate(dto.updated),
syncStatus = SyncStatus.SYNCED,
thumbnailUrl = existing?.thumbnailUrl,
readingTimeMinutes = existing?.readingTimeMinutes,
contentType = existing?.contentType ?: com.shaarit.data.local.entity.ContentType.UNKNOWN,
siteName = existing?.siteName,
excerpt = existing?.excerpt
)
} catch (e: Exception) {
Log.w(TAG, "Lien ignoré (id=${dto.id}): ${e.message}")
null
}
}
if (entities.isNotEmpty()) {
linkDao.insertLinks(entities)
}
offset += links.size
}
} catch (e: Exception) {
Log.e(TAG, "Erreur lors de la récupération des liens", e)
throw e
}
}
// Synchroniser les tags
try {
val tags = api.getTags(limit = 1000)
val tagEntities = tags.map { TagMapper.toDomain(it) }.map { TagEntity(it.name, it.occurrences) }
tagDao.insertTags(tagEntities)
} catch (e: Exception) {
Log.e(TAG, "Erreur lors de la récupération des tags", e)
}
}
private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
return try {
java.time.Instant.parse(dateString).toEpochMilli()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
}
/**
* Worker pour exécuter la synchronisation en arrière-plan
*/
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val syncManager: SyncManager
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
return when (val result = syncManager.performFullSync()) {
is SyncResult.Success -> Result.success(
workDataOf("completedAt" to System.currentTimeMillis())
)
is SyncResult.NetworkError -> Result.retry()
is SyncResult.Error -> Result.failure(
workDataOf("error" to result.message)
)
}
}
}
/**
* États possibles de la synchronisation
*/
sealed class SyncState {
object Idle : SyncState()
object Syncing : SyncState()
data class Synced(val completedAt: Long) : SyncState()
data class Error(val message: String) : SyncState()
}
/**
* Résultats possibles d'une synchronisation
*/
sealed class SyncResult {
object Success : SyncResult()
data class NetworkError(val message: String) : SyncResult()
data class Error(val message: String) : SyncResult()
}
@@ -9,5 +9,10 @@ data class ShaarliLink(
val description: String,
val tags: List<String>,
val isPrivate: Boolean,
val date: String
val date: String,
val isPinned: Boolean = false,
val thumbnailUrl: String? = null,
val readingTime: Int? = null,
val contentType: String? = null,
val siteName: String? = null
)
@@ -17,6 +17,8 @@ interface LinkRepository {
searchTags: String? = null
): Flow<PagingData<ShaarliLink>>
fun getLinkFlow(id: Int): Flow<ShaarliLink?>
suspend fun addLink(
url: String,
title: String?,
@@ -6,30 +6,28 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.components.GradientButton
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.SectionHeader
import com.shaarit.ui.components.TagChip
import coil.compose.AsyncImage
import com.shaarit.ui.components.*
import com.shaarit.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddLinkScreen(
onNavigateBack: () -> Unit,
onNavigateBack: () -> Unit,
onShareSuccess: (() -> Unit)? = null,
viewModel: AddLinkViewModel = hiltViewModel()
) {
@@ -42,13 +40,16 @@ fun AddLinkScreen(
val availableTags by viewModel.availableTags.collectAsState()
val isPrivate by viewModel.isPrivate.collectAsState()
val tagSuggestions by viewModel.tagSuggestions.collectAsState()
val isExtractingMetadata by viewModel.isExtractingMetadata.collectAsState()
val extractedThumbnail by viewModel.extractedThumbnail.collectAsState()
val contentType by viewModel.contentType.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showMarkdownEditor by remember { mutableStateOf(false) }
LaunchedEffect(uiState) {
when (val state = uiState) {
is AddLinkUiState.Success -> {
// If this was a share intent, finish the activity to return to source app
if (onShareSuccess != null) {
onShareSuccess()
} else {
@@ -58,9 +59,7 @@ fun AddLinkScreen(
is AddLinkUiState.Error -> {
snackbarHostState.showSnackbar(state.message)
}
is AddLinkUiState.Conflict -> {
// Show conflict dialog - handled in AlertDialog below
}
is AddLinkUiState.Conflict -> {}
else -> {}
}
}
@@ -69,110 +68,161 @@ fun AddLinkScreen(
if (uiState is AddLinkUiState.Conflict) {
val conflict = uiState as AddLinkUiState.Conflict
AlertDialog(
onDismissRequest = { viewModel.dismissConflict() },
title = {
Text("Link Already Exists", fontWeight = FontWeight.Bold, color = TextPrimary)
},
text = {
Column {
Text("A link with this URL already exists:", color = TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
Text(
conflict.existingTitle ?: "Untitled",
color = CyanPrimary,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Would you like to update the existing link instead?",
color = TextSecondary
)
}
},
confirmButton = {
TextButton(onClick = { viewModel.forceUpdateExistingLink() }) {
Text("Update", color = CyanPrimary)
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissConflict() }) {
Text("Cancel", color = TextMuted)
}
},
containerColor = CardBackground,
titleContentColor = TextPrimary,
textContentColor = TextSecondary
onDismissRequest = { viewModel.dismissConflict() },
title = {
Text("Lien déjà existant", fontWeight = FontWeight.Bold, color = TextPrimary)
},
text = {
Column {
Text("Un lien avec cette URL existe déjà:", color = TextSecondary)
Spacer(modifier = Modifier.height(8.dp))
Text(
conflict.existingTitle ?: "Sans titre",
color = CyanPrimary,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Voulez-vous mettre à jour le lien existant?",
color = TextSecondary
)
}
},
confirmButton = {
TextButton(onClick = { viewModel.forceUpdateExistingLink() }) {
Text("Mettre à jour", color = CyanPrimary)
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissConflict() }) {
Text("Annuler", color = TextMuted)
}
},
containerColor = CardBackground,
titleContentColor = TextPrimary,
textContentColor = TextSecondary
)
}
Box(
modifier =
Modifier.fillMaxSize()
.background(
brush =
Brush.verticalGradient(
colors = listOf(DeepNavy, DarkNavy)
)
)
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
)
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
"Add Link",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Back",
tint = TextPrimary
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
)
)
},
containerColor =
android.graphics.Color.TRANSPARENT.let {
androidx.compose.ui.graphics.Color.Transparent
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
"Ajouter un lien",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Retour",
tint = TextPrimary
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
)
)
},
containerColor = androidx.compose.ui.graphics.Color.Transparent
) { paddingValues ->
Column(
modifier =
Modifier.padding(paddingValues)
.padding(16.dp)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp)
modifier = Modifier
.padding(paddingValues)
.padding(16.dp)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// URL Section
// URL Section avec extraction de métadonnées
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(title = "URL", subtitle = "Required")
SectionHeader(title = "URL", subtitle = "Requis")
Spacer(modifier = Modifier.height(12.dp))
PremiumTextField(
value = url,
onValueChange = { viewModel.url.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "https://example.com",
leadingIcon = {
Icon(
Icons.Default.Share,
contentDescription = null,
tint = CyanPrimary
value = url,
onValueChange = { viewModel.url.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "https://example.com",
leadingIcon = {
Icon(
Icons.Default.Link,
contentDescription = null,
tint = CyanPrimary
)
},
trailingIcon = {
if (isExtractingMetadata) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = CyanPrimary,
strokeWidth = 2.dp
)
}
}
)
// Aperçu des métadonnées extraites
AnimatedVisibility(
visible = extractedThumbnail != null || contentType != null,
enter = expandVertically() + fadeIn()
) {
Column(modifier = Modifier.padding(top = 16.dp)) {
// Type de contenu détecté
contentType?.let { type ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
imageVector = when (type) {
"VIDEO" -> Icons.Default.PlayCircle
"ARTICLE" -> Icons.Default.Article
"PODCAST" -> Icons.Default.Headphones
"REPOSITORY" -> Icons.Default.Code
else -> Icons.Default.Web
},
contentDescription = null,
tint = CyanPrimary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Type: $type",
style = MaterialTheme.typography.labelMedium,
color = TextSecondary
)
}
}
// Thumbnail extrait
extractedThumbnail?.let { thumbnail ->
AsyncImage(
model = thumbnail,
contentDescription = "Aperçu",
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
}
}
}
}
@@ -180,53 +230,87 @@ fun AddLinkScreen(
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(
title = "Title",
subtitle = "Optional - auto-fetched if empty"
title = "Titre",
subtitle = "Optionnel - auto-extrait si vide"
)
Spacer(modifier = Modifier.height(12.dp))
PremiumTextField(
value = title,
onValueChange = { viewModel.title.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "Page title"
value = title,
onValueChange = { viewModel.title.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "Titre de la page"
)
}
}
// Description Section
// Description Section avec MarkdownEditor
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(title = "Description", subtitle = "Optional")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SectionHeader(title = "Description", subtitle = "Markdown supporté")
// Toggle pour l'éditeur Markdown
TextButton(onClick = { showMarkdownEditor = !showMarkdownEditor }) {
Icon(
if (showMarkdownEditor) Icons.Default.Edit else Icons.Default.Preview,
contentDescription = null,
tint = CyanPrimary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
if (showMarkdownEditor) "Simple" else "Markdown",
color = CyanPrimary
)
}
}
Spacer(modifier = Modifier.height(12.dp))
PremiumTextField(
if (showMarkdownEditor) {
// Éditeur Markdown avancé
MarkdownEditor(
value = description,
onValueChange = { viewModel.description.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "Add a description...",
mode = EditorMode.SPLIT,
minHeight = 200.dp
)
} else {
// Champ texte simple
PremiumTextField(
value = description,
onValueChange = { viewModel.description.value = it },
modifier = Modifier.fillMaxWidth(),
placeholder = "Ajoutez une description...",
singleLine = false,
minLines = 3
)
)
}
}
}
// Tags Section
GlassCard(modifier = Modifier.fillMaxWidth()) {
Column {
SectionHeader(title = "Tags", subtitle = "Organize your links")
SectionHeader(title = "Tags", subtitle = "Organisez vos liens")
Spacer(modifier = Modifier.height(12.dp))
// Selected tags
if (selectedTags.isNotEmpty()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 12.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 12.dp)
) {
items(selectedTags) { tag ->
TagChip(
tag = tag,
isSelected = true,
onClick = { viewModel.removeTag(tag) }
tag = tag,
isSelected = true,
onClick = { viewModel.removeTag(tag) }
)
}
}
@@ -234,76 +318,74 @@ fun AddLinkScreen(
// New tag input
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
PremiumTextField(
value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) },
modifier = Modifier.weight(1f),
placeholder = "Add tag..."
value = newTagInput,
onValueChange = { viewModel.onNewTagInputChanged(it) },
modifier = Modifier.weight(1f),
placeholder = "Ajouter un tag..."
)
IconButton(
onClick = { viewModel.addNewTag() },
enabled = newTagInput.isNotBlank()
onClick = { viewModel.addNewTag() },
enabled = newTagInput.isNotBlank()
) {
Icon(
Icons.Default.Add,
contentDescription = "Add tag",
tint =
if (newTagInput.isNotBlank()) CyanPrimary
else TextMuted
Icons.Default.Add,
contentDescription = "Ajouter tag",
tint = if (newTagInput.isNotBlank()) CyanPrimary else TextMuted
)
}
}
// Tag suggestions
AnimatedVisibility(
visible = tagSuggestions.isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
visible = tagSuggestions.isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Suggestions",
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
"Suggestions",
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(tagSuggestions.take(10)) { tag ->
TagChip(
tag = tag.name,
isSelected = false,
onClick = { viewModel.addTag(tag.name) },
count = tag.occurrences
tag = tag.name,
isSelected = false,
onClick = { viewModel.addTag(tag.name) },
count = tag.occurrences
)
}
}
}
}
// Popular tags from existing
// Popular tags
if (tagSuggestions.isEmpty() && availableTags.isNotEmpty()) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
"Popular tags",
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
"Tags populaires",
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(
availableTags
.filter { it.name !in selectedTags }
.take(10)
availableTags
.filter { it.name !in selectedTags }
.take(10)
) { tag ->
TagChip(
tag = tag.name,
isSelected = false,
onClick = { viewModel.addTag(tag.name) },
count = tag.occurrences
tag = tag.name,
isSelected = false,
onClick = { viewModel.addTag(tag.name) },
count = tag.occurrences
)
}
}
@@ -315,33 +397,32 @@ fun AddLinkScreen(
// Privacy Section
GlassCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Private",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
"Privé",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
Text(
"Only you can see this link",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
"Seul vous pouvez voir ce lien",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
Switch(
checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it },
colors =
SwitchDefaults.colors(
checkedThumbColor = CyanPrimary,
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
uncheckedThumbColor = TextMuted,
uncheckedTrackColor = SurfaceVariant
)
checked = isPrivate,
onCheckedChange = { viewModel.isPrivate.value = it },
colors = SwitchDefaults.colors(
checkedThumbColor = CyanPrimary,
checkedTrackColor = CyanPrimary.copy(alpha = 0.3f),
uncheckedThumbColor = TextMuted,
uncheckedTrackColor = SurfaceVariant
)
)
}
}
@@ -350,17 +431,17 @@ fun AddLinkScreen(
// Save Button
GradientButton(
text = if (uiState is AddLinkUiState.Loading) "Saving..." else "Save Link",
onClick = { viewModel.addLink() },
modifier = Modifier.fillMaxWidth(),
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
text = if (uiState is AddLinkUiState.Loading) "Enregistrement..." else "Enregistrer le lien",
onClick = { viewModel.addLink() },
modifier = Modifier.fillMaxWidth(),
enabled = url.isNotBlank() && uiState !is AddLinkUiState.Loading
)
if (uiState is AddLinkUiState.Loading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = CyanPrimary,
trackColor = SurfaceVariant
modifier = Modifier.fillMaxWidth(),
color = CyanPrimary,
trackColor = SurfaceVariant
)
}
@@ -3,21 +3,29 @@ package com.shaarit.presentation.add
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.metadata.LinkMetadataExtractor
import com.shaarit.domain.model.ShaarliTag
import com.shaarit.domain.repository.AddLinkResult
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import java.net.URLDecoder
import javax.inject.Inject
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.net.URLDecoder
import javax.inject.Inject
@HiltViewModel
class AddLinkViewModel
@Inject
constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedStateHandle) :
ViewModel() {
constructor(
private val linkRepository: LinkRepository,
private val metadataExtractor: LinkMetadataExtractor,
savedStateHandle: SavedStateHandle
) : ViewModel() {
// Pre-fill from usage arguments (e.g. from Share Intent via NavGraph)
private val initialUrl: String? = savedStateHandle["url"]
@@ -30,6 +38,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
var title = MutableStateFlow(decodeUrlParam(initialTitle) ?: "")
var description = MutableStateFlow("")
var isPrivate = MutableStateFlow(false)
// Extraction state
private val _isExtractingMetadata = MutableStateFlow(false)
val isExtractingMetadata = _isExtractingMetadata.asStateFlow()
private val _extractedThumbnail = MutableStateFlow<String?>(null)
val extractedThumbnail = _extractedThumbnail.asStateFlow()
private val _contentType = MutableStateFlow<String?>(null)
val contentType = _contentType.asStateFlow()
// New tag management
private val _selectedTags = MutableStateFlow<List<String>>(emptyList())
@@ -49,17 +67,75 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
init {
loadAvailableTags()
setupUrlMetadataExtraction()
// Si une URL initiale est fournie, extraire les métadonnées
if (!initialUrl.isNullOrBlank()) {
extractMetadata(initialUrl)
}
}
@OptIn(FlowPreview::class)
private fun setupUrlMetadataExtraction() {
url
.debounce(1000) // Attendre 1s après la fin de la saisie
.onEach { urlString ->
if (urlString.isNotBlank() && urlString.startsWith("http")) {
extractMetadata(urlString)
}
}
.launchIn(viewModelScope)
}
private fun extractMetadata(urlString: String) {
viewModelScope.launch {
_isExtractingMetadata.value = true
try {
val metadata = metadataExtractor.extract(urlString)
// Auto-remplir si les champs sont vides
if (title.value.isBlank() && !metadata.title.isNullOrBlank()) {
title.value = metadata.title
}
if (description.value.isBlank() && !metadata.description.isNullOrBlank()) {
description.value = metadata.description
}
_extractedThumbnail.value = metadata.thumbnailUrl
_contentType.value = metadata.contentType.name
// Suggérer des tags basés sur le site
metadata.siteName?.let { site ->
suggestTagFromSite(site)
}
} catch (e: Exception) {
// Ignorer silencieusement les erreurs d'extraction
} finally {
_isExtractingMetadata.value = false
}
}
}
private fun suggestTagFromSite(siteName: String) {
val siteTag = siteName.lowercase().replace(" ", "-").replace(".", "")
if (siteTag !in _selectedTags.value) {
// Ajouter automatiquement certains tags connus
when (siteTag) {
"youtube", "vimeo" -> addTag("video")
"github", "gitlab" -> addTag("dev")
"twitter", "x" -> addTag("social")
"reddit" -> addTag("reddit")
"medium", "devto" -> addTag("article")
}
}
}
/** Decodes URL-encoded parameters, handling both + signs and %20 for spaces */
private fun decodeUrlParam(param: String?): String? {
if (param.isNullOrBlank()) return null
return try {
// First decode URL encoding, then replace + with spaces
// The + signs appear because URLEncoder uses + for spaces
URLDecoder.decode(param, "UTF-8").replace("+", " ").trim()
} catch (e: Exception) {
// If decoding fails, just replace + with spaces
param.replace("+", " ").trim()
}
}
@@ -67,15 +143,15 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
private fun loadAvailableTags() {
viewModelScope.launch {
linkRepository
.getTags()
.fold(
onSuccess = { tags ->
_availableTags.value = tags.sortedByDescending { it.occurrences }
},
onFailure = {
// Silently fail - tags are optional
}
)
.getTags()
.fold(
onSuccess = { tags ->
_availableTags.value = tags.sortedByDescending { it.occurrences }
},
onFailure = {
// Silently fail - tags are optional
}
)
}
}
@@ -92,13 +168,13 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
val queryLower = query.lowercase()
_tagSuggestions.value =
_availableTags
.value
.filter {
it.name.lowercase().contains(queryLower) &&
it.name !in _selectedTags.value
}
.take(10)
_availableTags
.value
.filter {
it.name.lowercase().contains(queryLower) &&
it.name !in _selectedTags.value
}
.take(10)
}
fun addTag(tag: String) {
@@ -122,7 +198,6 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
viewModelScope.launch {
_uiState.value = AddLinkUiState.Loading
// Basic validation
val currentUrl = url.value
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required")
@@ -130,15 +205,15 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
}
val result =
linkRepository.addOrUpdateLink(
url = currentUrl,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = false,
existingLinkId = null
)
linkRepository.addOrUpdateLink(
url = currentUrl,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = false,
existingLinkId = null
)
when (result) {
is AddLinkResult.Success -> {
@@ -147,10 +222,10 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
is AddLinkResult.Conflict -> {
conflictLinkId = result.existingLinkId
_uiState.value =
AddLinkUiState.Conflict(
existingLinkId = result.existingLinkId,
existingTitle = result.existingTitle
)
AddLinkUiState.Conflict(
result.existingLinkId,
result.existingTitle
)
}
is AddLinkResult.Error -> {
_uiState.value = AddLinkUiState.Error(result.message)
@@ -160,21 +235,25 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
}
fun forceUpdateExistingLink() {
val linkId = conflictLinkId ?: return
viewModelScope.launch {
val currentUrl = url.value
if (currentUrl.isBlank()) {
_uiState.value = AddLinkUiState.Error("URL is required")
return@launch
}
_uiState.value = AddLinkUiState.Loading
val result =
linkRepository.addOrUpdateLink(
url = url.value,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = true,
existingLinkId = linkId
)
linkRepository.addOrUpdateLink(
url = currentUrl,
title = title.value.ifBlank { null },
description = description.value.ifBlank { null },
tags = _selectedTags.value.ifEmpty { null },
isPrivate = isPrivate.value,
forceUpdate = true,
existingLinkId = conflictLinkId
)
when (result) {
is AddLinkResult.Success -> {
@@ -184,19 +263,16 @@ constructor(private val linkRepository: LinkRepository, savedStateHandle: SavedS
_uiState.value = AddLinkUiState.Error(result.message)
}
else -> {
_uiState.value = AddLinkUiState.Error("Unexpected error")
_uiState.value = AddLinkUiState.Error("Unexpected result")
}
}
}
}
fun dismissConflict() {
conflictLinkId = null
_uiState.value = AddLinkUiState.Idle
conflictLinkId = null
}
// Legacy compatibility for old comma-separated tags input
@Deprecated("Use selectedTags instead") var tags = MutableStateFlow("")
}
sealed class AddLinkUiState {
@@ -26,7 +26,7 @@ fun LoginScreen(onLoginSuccess: () -> Unit, viewModel: LoginViewModel = hiltView
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var url by remember { mutableStateOf("") }
var url by remember { mutableStateOf(viewModel.getInitialUrl()) }
var secret by remember { mutableStateOf("") }
var showSecret by remember { mutableStateOf(false) }
@@ -2,6 +2,7 @@ package com.shaarit.presentation.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.sync.SyncManager
import com.shaarit.domain.repository.AuthRepository
import com.shaarit.domain.usecase.LoginUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -15,7 +16,8 @@ class LoginViewModel
@Inject
constructor(
private val loginUseCase: LoginUseCase,
private val authRepository: AuthRepository // To check login state
private val authRepository: AuthRepository, // To check login state
private val syncManager: SyncManager
) : ViewModel() {
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
@@ -27,6 +29,7 @@ constructor(
private fun checkLoginStatus() {
if (authRepository.isLoggedIn()) {
syncManager.syncNow()
_uiState.value = LoginUiState.Success
} else {
// Pre-fill URL if available
@@ -42,13 +45,20 @@ constructor(
_uiState.value = LoginUiState.Loading
val result = loginUseCase(url, secret)
result.fold(
onSuccess = { _uiState.value = LoginUiState.Success },
onSuccess = {
syncManager.syncNow()
_uiState.value = LoginUiState.Success
},
onFailure = {
_uiState.value = LoginUiState.Error(it.message ?: "Unknown Error")
}
)
}
}
fun getInitialUrl(): String {
return authRepository.getBaseUrl() ?: "https://bm.dracodev.net"
}
}
sealed class LoginUiState {
@@ -0,0 +1,435 @@
package com.shaarit.presentation.collections
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.ui.components.GlassCard
import com.shaarit.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollectionsScreen(
onNavigateBack: () -> Unit,
onCollectionClick: (Long) -> Unit,
viewModel: CollectionsViewModel = hiltViewModel()
) {
val collections by viewModel.collections.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(colors = listOf(DeepNavy, DarkNavy))
)
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"Collections",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.Default.ArrowBack,
contentDescription = "Retour",
tint = TextPrimary
)
}
},
actions = {
IconButton(onClick = { showCreateDialog = true }) {
Icon(
Icons.Default.Add,
contentDescription = "Nouvelle collection",
tint = CyanPrimary
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = DeepNavy.copy(alpha = 0.9f),
titleContentColor = TextPrimary
)
)
},
containerColor = androidx.compose.ui.graphics.Color.Transparent,
floatingActionButton = {
FloatingActionButton(
onClick = { showCreateDialog = true },
containerColor = CyanPrimary,
contentColor = DeepNavy
) {
Icon(Icons.Default.Add, contentDescription = "Nouvelle collection")
}
}
) { paddingValues ->
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = CyanPrimary
)
} else if (collections.isEmpty()) {
EmptyCollectionsView(onCreateClick = { showCreateDialog = true })
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(collections) { collection ->
CollectionCard(
collection = collection,
onClick = { onCollectionClick(collection.id) }
)
}
}
}
}
}
}
// Dialog de création
if (showCreateDialog) {
CreateCollectionDialog(
onDismiss = { showCreateDialog = false },
onCreate = { name, description, icon, isSmart ->
viewModel.createCollection(name, description, icon, isSmart)
showCreateDialog = false
}
)
}
}
@Composable
private fun CollectionCard(
collection: CollectionUiModel,
onClick: () -> Unit
) {
GlassCard(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable(onClick = onClick)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
// Icône et type
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = collection.icon,
fontSize = MaterialTheme.typography.headlineMedium.fontSize
)
if (collection.isSmart) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = "Collection intelligente",
tint = CyanPrimary,
modifier = Modifier.size(20.dp)
)
}
}
// Nom et description
Column {
Text(
text = collection.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
collection.description?.let { desc ->
if (desc.isNotBlank()) {
Text(
text = desc,
style = MaterialTheme.typography.bodySmall,
color = TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp)
)
}
}
// Nombre de liens
Text(
text = "${collection.linkCount} lien${if (collection.linkCount > 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
color = CyanPrimary,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
@Composable
private fun EmptyCollectionsView(onCreateClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = null,
tint = TextMuted,
modifier = Modifier.size(80.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Aucune collection",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Créez des collections pour organiser vos liens par thème",
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onCreateClick,
colors = ButtonDefaults.buttonColors(
containerColor = CyanPrimary,
contentColor = DeepNavy
)
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Créer une collection")
}
}
}
@Composable
private fun CreateCollectionDialog(
onDismiss: () -> Unit,
onCreate: (name: String, description: String, icon: String, isSmart: Boolean) -> Unit
) {
var name by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var selectedIcon by remember { mutableStateOf("📁") }
var isSmart by remember { mutableStateOf(false) }
val icons = listOf("📁", "💼", "🏠", "📚", "⭐", "🔥", "💡", "🎯", "📰", "🎬", "🎮", "🛒")
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Nouvelle collection") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Nom
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nom") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (optionnel)") },
minLines = 2,
maxLines = 3,
modifier = Modifier.fillMaxWidth()
)
// Icône
Text("Icône", style = MaterialTheme.typography.labelMedium)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
icons.forEach { icon ->
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (icon == selectedIcon) CyanPrimary.copy(alpha = 0.2f)
else CardBackgroundElevated
)
.clickable { selectedIcon = icon },
contentAlignment = Alignment.Center
) {
Text(icon, fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
}
// Collection intelligente
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Collection intelligente",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Remplie automatiquement selon des critères",
style = MaterialTheme.typography.bodySmall,
color = TextSecondary
)
}
Switch(
checked = isSmart,
onCheckedChange = { isSmart = it }
)
}
}
},
confirmButton = {
TextButton(
onClick = { onCreate(name, description, selectedIcon, isSmart) },
enabled = name.isNotBlank()
) {
Text("Créer")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Annuler")
}
}
)
}
// Modèle de données UI
data class CollectionUiModel(
val id: Long,
val name: String,
val description: String?,
val icon: String,
val isSmart: Boolean,
val linkCount: Int
)
// Layout helper
@Composable
private fun FlowRow(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
val hGapPx = 8.dp.roundToPx()
val vGapPx = 8.dp.roundToPx()
val rows = mutableListOf<List<androidx.compose.ui.layout.Placeable>>()
val rowWidths = mutableListOf<Int>()
val rowHeights = mutableListOf<Int>()
var currentRow = mutableListOf<androidx.compose.ui.layout.Placeable>()
var currentRowWidth = 0
var currentRowHeight = 0
measurables.forEach { measurable ->
val placeable = measurable.measure(constraints)
if (currentRow.isNotEmpty() && currentRowWidth + hGapPx + placeable.width > constraints.maxWidth) {
rows.add(currentRow)
rowWidths.add(currentRowWidth)
rowHeights.add(currentRowHeight)
currentRow = mutableListOf()
currentRowWidth = 0
currentRowHeight = 0
}
currentRow.add(placeable)
currentRowWidth += if (currentRow.size == 1) placeable.width else hGapPx + placeable.width
currentRowHeight = maxOf(currentRowHeight, placeable.height)
}
if (currentRow.isNotEmpty()) {
rows.add(currentRow)
rowWidths.add(currentRowWidth)
rowHeights.add(currentRowHeight)
}
val totalHeight = rowHeights.sum() + (rowHeights.size - 1).coerceAtLeast(0) * vGapPx
layout(constraints.maxWidth, totalHeight) {
var y = 0
rows.forEachIndexed { rowIndex, row ->
var x = when (horizontalArrangement) {
Arrangement.Center -> (constraints.maxWidth - rowWidths[rowIndex]) / 2
Arrangement.End -> constraints.maxWidth - rowWidths[rowIndex]
else -> 0
}
row.forEach { placeable ->
placeable.placeRelative(x, y)
x += placeable.width + hGapPx
}
y += rowHeights[rowIndex] + vGapPx
}
}
}
}
@@ -0,0 +1,77 @@
package com.shaarit.presentation.collections
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.entity.CollectionEntity
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class CollectionsViewModel @Inject constructor(
private val collectionDao: CollectionDao
) : ViewModel() {
private val _collections = MutableStateFlow<List<CollectionUiModel>>(emptyList())
val collections: StateFlow<List<CollectionUiModel>> = _collections.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
loadCollections()
}
private fun loadCollections() {
viewModelScope.launch {
_isLoading.value = true
try {
collectionDao.getAllCollections()
.map { entities ->
entities.map { entity ->
// Compter les liens dans chaque collection
val count = 0 // TODO: Implémenter le comptage
entity.toUiModel(count)
}
}
.collect { uiModels ->
_collections.value = uiModels
_isLoading.value = false
}
} catch (e: Exception) {
_isLoading.value = false
}
}
}
fun createCollection(name: String, description: String?, icon: String, isSmart: Boolean) {
viewModelScope.launch {
val entity = CollectionEntity(
name = name,
description = description,
icon = icon,
isSmart = isSmart
)
collectionDao.insertCollection(entity)
}
}
fun deleteCollection(id: Long) {
viewModelScope.launch {
collectionDao.deleteCollection(id)
}
}
private fun CollectionEntity.toUiModel(linkCount: Int): CollectionUiModel {
return CollectionUiModel(
id = id,
name = name,
description = description,
icon = icon,
isSmart = isSmart,
linkCount = linkCount
)
}
}
@@ -0,0 +1,474 @@
package com.shaarit.presentation.dashboard
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.data.local.entity.ContentType
import java.text.NumberFormat
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
onNavigateBack: () -> Unit,
viewModel: DashboardViewModel = hiltViewModel()
) {
val stats by viewModel.stats.collectAsState()
val tagStats by viewModel.tagStats.collectAsState()
val contentTypeStats by viewModel.contentTypeStats.collectAsState()
val activityData by viewModel.activityData.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Tableau de bord") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Retour")
}
},
actions = {
IconButton(onClick = { viewModel.refreshStats() }) {
Icon(Icons.Default.Refresh, contentDescription = "Rafraîchir")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Stats Cards Row
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
StatCard(
title = "Total",
value = formatNumber(stats.totalLinks),
icon = Icons.Default.Bookmark,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
StatCard(
title = "Cette semaine",
value = formatNumber(stats.linksThisWeek),
icon = Icons.Default.Today,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.weight(1f)
)
StatCard(
title = "Ce mois",
value = formatNumber(stats.linksThisMonth),
icon = Icons.Default.DateRange,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f)
)
}
}
// Reading Time Stats
item {
ReadingTimeCard(
totalReadingTimeMinutes = stats.totalReadingTimeMinutes,
averageReadingTimeMinutes = stats.averageReadingTimeMinutes
)
}
// Content Type Distribution
item {
ContentTypeCard(contentTypeStats)
}
// Top Tags
item {
TopTagsCard(tagStats)
}
// Activity Overview
item {
ActivityCard(activityData)
}
}
}
}
@Composable
private fun StatCard(
title: String,
value: String,
icon: ImageVector,
color: Color,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = color.copy(alpha = 0.1f)
)
) {
Column(
modifier = Modifier
.padding(12.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ReadingTimeCard(
totalReadingTimeMinutes: Int,
averageReadingTimeMinutes: Int
) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Statistiques de lecture",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ReadingTimeItem(
label = "Temps total",
minutes = totalReadingTimeMinutes,
icon = Icons.Default.Schedule
)
ReadingTimeItem(
label = "Moyenne/article",
minutes = averageReadingTimeMinutes,
icon = Icons.Default.Timer
)
}
}
}
}
@Composable
private fun ReadingTimeItem(
label: String,
minutes: Int,
icon: ImageVector
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatDuration(minutes),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun ContentTypeCard(
contentTypeStats: Map<ContentType, Int>
) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Liens par type",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
val total = contentTypeStats.values.sum().coerceAtLeast(1)
for ((type, count) in contentTypeStats.toList().sortedByDescending { it.second }) {
val percentage = (count * 100) / total
ContentTypeBar(
type = type,
count = count,
percentage = percentage
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun ContentTypeBar(
type: ContentType,
count: Int,
percentage: Int
) {
val (icon, label, color) = when (type) {
ContentType.ARTICLE -> Triple(Icons.Default.Article, "Article", Color(0xFF4CAF50))
ContentType.VIDEO -> Triple(Icons.Default.PlayCircle, "Vidéo", Color(0xFFF44336))
ContentType.IMAGE -> Triple(Icons.Default.Image, "Image", Color(0xFF9C27B0))
ContentType.PODCAST -> Triple(Icons.Default.Audiotrack, "Podcast", Color(0xFFFF9800))
ContentType.PDF -> Triple(Icons.Default.PictureAsPdf, "PDF", Color(0xFFE91E63))
ContentType.REPOSITORY -> Triple(Icons.Default.Code, "Code", Color(0xFF607D8B))
ContentType.DOCUMENT -> Triple(Icons.Default.Description, "Document", Color(0xFF795548))
ContentType.SOCIAL -> Triple(Icons.Default.Share, "Social", Color(0xFF03A9F4))
ContentType.SHOPPING -> Triple(Icons.Default.ShoppingCart, "Shopping", Color(0xFF2196F3))
ContentType.NEWSLETTER -> Triple(Icons.Default.Email, "Newsletter", Color(0xFF9C27B0))
ContentType.UNKNOWN -> Triple(Icons.Default.Link, "Autre", Color(0xFF9E9E9E))
}
Column {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Text(
text = "$count ($percentage%)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = percentage / 100f,
modifier = Modifier.fillMaxWidth(),
color = color,
trackColor = color.copy(alpha = 0.2f)
)
}
}
@Composable
private fun TopTagsCard(
tagStats: List<TagStat>
) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Tags les plus utilisés",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(12.dp))
if (tagStats.isEmpty()) {
Text(
text = "Aucun tag pour le moment",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
tagStats.take(10).forEach { stat ->
TagStatRow(stat)
if (stat != tagStats.take(10).last()) {
Divider(modifier = Modifier.padding(vertical = 8.dp))
}
}
}
}
}
}
@Composable
private fun TagStatRow(stat: TagStat) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = stat.name,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${stat.count} lien${if (stat.count > 1) "s" else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun ActivityCard(
activityData: List<ActivityPoint>
) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Activité (30 derniers jours)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
if (activityData.isEmpty()) {
Text(
text = "Pas d'activité récente",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
ActivityChart(activityData)
}
}
}
}
@Composable
private fun ActivityChart(
data: List<ActivityPoint>
) {
val maxValue = data.maxOfOrNull { it.count }?.coerceAtLeast(1) ?: 1
Row(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
data.takeLast(14).forEach { point ->
val heightFraction = point.count.toFloat() / maxValue
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.width(8.dp)
.fillMaxHeight(heightFraction.coerceAtLeast(0.05f))
.background(
color = MaterialTheme.colorScheme.primary,
shape = MaterialTheme.shapes.extraSmall
)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = point.day.substring(0, 1),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Data classes
@Immutable
data class DashboardStats(
val totalLinks: Int = 0,
val linksThisWeek: Int = 0,
val linksThisMonth: Int = 0,
val totalReadingTimeMinutes: Int = 0,
val averageReadingTimeMinutes: Int = 0
)
@Immutable
data class TagStat(
val name: String,
val count: Int
)
@Immutable
data class ActivityPoint(
val day: String,
val count: Int
)
// Helper functions
private fun formatNumber(number: Int): String {
return NumberFormat.getInstance().format(number)
}
private fun formatDuration(minutes: Int): String {
return when {
minutes < 60 -> "${minutes}m"
minutes < 1440 -> {
val hours = minutes / 60
val mins = minutes % 60
if (mins > 0) "${hours}h ${mins}m" else "${hours}h"
}
else -> {
val days = minutes / 1440
"${days}j"
}
}
}
@@ -0,0 +1,139 @@
package com.shaarit.presentation.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.dao.TagDao
import com.shaarit.data.local.entity.ContentType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val linkDao: LinkDao,
private val tagDao: TagDao
) : ViewModel() {
private val _stats = MutableStateFlow(DashboardStats())
val stats: StateFlow<DashboardStats> = _stats.asStateFlow()
private val _tagStats = MutableStateFlow<List<TagStat>>(emptyList())
val tagStats: StateFlow<List<TagStat>> = _tagStats.asStateFlow()
private val _contentTypeStats = MutableStateFlow<Map<ContentType, Int>>(emptyMap())
val contentTypeStats: StateFlow<Map<ContentType, Int>> = _contentTypeStats.asStateFlow()
private val _activityData = MutableStateFlow<List<ActivityPoint>>(emptyList())
val activityData: StateFlow<List<ActivityPoint>> = _activityData.asStateFlow()
init {
refreshStats()
}
fun refreshStats() {
viewModelScope.launch {
loadStats()
loadTagStats()
loadContentTypeStats()
loadActivityData()
}
}
private suspend fun loadStats() {
val allLinks = linkDao.getAllLinksForStats()
val calendar = Calendar.getInstance()
val now = calendar.timeInMillis
// This week
calendar.add(Calendar.DAY_OF_YEAR, -7)
val weekAgo = calendar.timeInMillis
// This month
calendar.timeInMillis = now
calendar.add(Calendar.DAY_OF_YEAR, -30)
val monthAgo = calendar.timeInMillis
val linksThisWeek = allLinks.count { it.createdAt > weekAgo }
val linksThisMonth = allLinks.count { it.createdAt > monthAgo }
val totalReadingTime = allLinks.sumOf { it.readingTimeMinutes ?: 0 }
val averageReadingTime = if (allLinks.isNotEmpty()) {
totalReadingTime / allLinks.size
} else 0
_stats.value = DashboardStats(
totalLinks = allLinks.size,
linksThisWeek = linksThisWeek,
linksThisMonth = linksThisMonth,
totalReadingTimeMinutes = totalReadingTime,
averageReadingTimeMinutes = averageReadingTime
)
}
private suspend fun loadTagStats() {
val tags = tagDao.getAllTagsOnce()
_tagStats.value = tags
.sortedByDescending { it.occurrences }
.take(20)
.map { TagStat(it.name, it.occurrences) }
}
private suspend fun loadContentTypeStats() {
val links = linkDao.getAllLinksForStats()
val grouped = links.groupBy { it.contentType }
.mapValues { it.value.size }
.toMutableMap()
// Ensure all content types are represented
ContentType.values().forEach { type ->
if (!grouped.containsKey(type)) {
grouped[type] = 0
}
}
_contentTypeStats.value = grouped
}
private suspend fun loadActivityData() {
val links = linkDao.getAllLinksForStats()
val dateFormat = SimpleDateFormat("EEE", Locale.getDefault())
val dayFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
// Group by day for the last 30 days
val calendar = Calendar.getInstance()
val activityMap = mutableMapOf<String, Int>()
// Initialize all days with 0
for (i in 0 until 30) {
calendar.timeInMillis = System.currentTimeMillis()
calendar.add(Calendar.DAY_OF_YEAR, -i)
val dayKey = dayFormat.format(calendar.time)
activityMap[dayKey] = 0
}
// Count links per day
links.forEach { link ->
val dayKey = dayFormat.format(Date(link.createdAt))
if (activityMap.containsKey(dayKey)) {
activityMap[dayKey] = activityMap.getOrDefault(dayKey, 0) + 1
}
}
// Convert to sorted list
_activityData.value = activityMap
.toList()
.sortedBy { it.first }
.map { (day, count) ->
val date = dayFormat.parse(day)!!
ActivityPoint(
day = dateFormat.format(date),
count = count
)
}
}
}
@@ -37,6 +37,9 @@ fun FeedScreen(
onNavigateToAdd: () -> Unit,
onNavigateToEdit: (Int) -> Unit = {},
onNavigateToTags: () -> Unit = {},
onNavigateToCollections: () -> Unit = {},
onNavigateToSettings: () -> Unit = {},
onNavigateToRandom: () -> Unit = {},
initialTagFilter: String? = null,
viewModel: FeedViewModel = hiltViewModel()
) {
@@ -54,7 +57,10 @@ fun FeedScreen(
val pullRefreshState = rememberPullRefreshState(
refreshing = pagingItems.loadState.refresh is LoadState.Loading,
onRefresh = { pagingItems.refresh() }
onRefresh = {
viewModel.refresh()
pagingItems.refresh()
}
)
Box(
@@ -80,7 +86,10 @@ fun FeedScreen(
},
actions = {
// Refresh Button
IconButton(onClick = { pagingItems.refresh() }) {
IconButton(onClick = {
viewModel.refresh()
pagingItems.refresh()
}) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Refresh",
@@ -176,13 +185,39 @@ fun FeedScreen(
}
}
// Random button
IconButton(onClick = onNavigateToRandom) {
Icon(
imageVector = Icons.Default.Shuffle,
contentDescription = "Random link",
tint = CyanPrimary
)
}
// Collections button
IconButton(onClick = onNavigateToCollections) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = "Collections",
tint = CyanPrimary
)
}
// Tags button
TextButton(onClick = onNavigateToTags) {
Text(
text = "#",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TealSecondary
IconButton(onClick = onNavigateToTags) {
Icon(
imageVector = Icons.Default.Label,
contentDescription = "Tags",
tint = CyanPrimary
)
}
// Settings button
IconButton(onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
tint = CyanPrimary
)
}
},
@@ -299,8 +334,15 @@ fun FeedScreen(
color = ErrorRed
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { pagingItems.refresh() }) {
Text("Retry", color = CyanPrimary)
IconButton(onClick = {
viewModel.refresh()
pagingItems.refresh()
}) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh",
tint = CyanPrimary
)
}
}
}
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.shaarit.data.sync.SyncManager
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.model.ViewStyle
import com.shaarit.domain.repository.LinkRepository
@@ -20,7 +21,10 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@HiltViewModel
class FeedViewModel @Inject constructor(private val linkRepository: LinkRepository) : ViewModel() {
class FeedViewModel @Inject constructor(
private val linkRepository: LinkRepository,
private val syncManager: SyncManager
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery = _searchQuery.asStateFlow()
@@ -82,6 +86,7 @@ class FeedViewModel @Inject constructor(private val linkRepository: LinkReposito
}
fun refresh() {
syncManager.syncNow()
_refreshTrigger.value++
}
@@ -14,6 +14,7 @@ import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.window.DialogProperties
@@ -43,7 +44,8 @@ fun ListViewItem(
onLinkClick: (String) -> Unit,
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -85,6 +87,18 @@ fun ListViewItem(
}
Row {
// Pin button
IconButton(
onClick = { onTogglePin(link.id) },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = if (link.isPinned) "Désépingler" else "Épingler",
tint = if (link.isPinned) CyanPrimary else TextMuted,
modifier = Modifier.size(18.dp)
)
}
IconButton(onClick = onViewClick, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Default.Visibility,
@@ -176,7 +190,8 @@ fun GridViewItem(
onLinkClick: (String) -> Unit,
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -202,15 +217,31 @@ fun GridViewItem(
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
// Title
Text(
text = link.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = CyanPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Title with pin indicator
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = link.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = CyanPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (link.isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Épinglé",
tint = CyanPrimary,
modifier = Modifier.size(16.dp)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
@@ -277,6 +308,18 @@ fun GridViewItem(
}
Row {
// Pin button
IconButton(
onClick = { onTogglePin(link.id) },
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = if (link.isPinned) "Désépingler" else "Épingler",
tint = if (link.isPinned) CyanPrimary else TextMuted,
modifier = Modifier.size(14.dp)
)
}
IconButton(
onClick = onViewClick,
modifier = Modifier.size(24.dp)
@@ -326,7 +369,8 @@ fun CompactViewItem(
onLinkClick: (String) -> Unit,
onViewClick: () -> Unit,
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -360,6 +404,15 @@ fun CompactViewItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (link.isPinned) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = "Épinglé",
tint = CyanPrimary,
modifier = Modifier.size(14.dp)
)
}
if (link.isPrivate) {
Icon(
Icons.Default.Lock,
@@ -403,6 +456,17 @@ fun CompactViewItem(
}
Row {
IconButton(
onClick = { onTogglePin(link.id) },
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Default.PushPin,
contentDescription = if (link.isPinned) "Désépingler" else "Épingler",
tint = if (link.isPinned) CyanPrimary else TextMuted,
modifier = Modifier.size(16.dp)
)
}
IconButton(onClick = onViewClick, modifier = Modifier.size(28.dp)) {
Icon(
imageVector = Icons.Default.Visibility,
@@ -1,13 +1,17 @@
package com.shaarit.presentation.nav
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import java.net.URLEncoder
sealed class Screen(val route: String) {
@@ -22,13 +26,17 @@ sealed class Screen(val route: String) {
fun createRoute(linkId: Int): String = "edit/$linkId"
}
object Tags : Screen("tags")
object Collections : Screen("collections")
object Dashboard : Screen("dashboard")
object Settings : Screen("settings")
}
@Composable
fun AppNavGraph(
startDestination: String = Screen.Login.route,
shareUrl: String? = null,
shareTitle: String? = null
shareTitle: String? = null,
initialDeepLink: String? = null
) {
val navController = rememberNavController()
val context = LocalContext.current
@@ -47,6 +55,11 @@ fun AppNavGraph(
navController.navigate("add?url=$encodedUrl&title=$encodedTitle&isShare=true") {
popUpTo(Screen.Login.route) { inclusive = true }
}
} else if (initialDeepLink != null) {
// Handle deep link after login
navController.navigate(initialDeepLink) {
popUpTo(Screen.Login.route) { inclusive = true }
}
} else {
navController.navigate(Screen.Feed.createRoute()) {
popUpTo(Screen.Login.route) { inclusive = true }
@@ -64,6 +77,10 @@ fun AppNavGraph(
nullable = true
defaultValue = null
}
),
deepLinks = listOf(
navDeepLink { uriPattern = "shaarit://feed" },
navDeepLink { uriPattern = "shaarit://search" }
)
) { backStackEntry ->
val tag = backStackEntry.arguments?.getString("tag")
@@ -73,6 +90,9 @@ fun AppNavGraph(
navController.navigate(Screen.Edit.createRoute(linkId))
},
onNavigateToTags = { navController.navigate(Screen.Tags.route) },
onNavigateToCollections = { navController.navigate(Screen.Collections.route) },
onNavigateToSettings = { navController.navigate(Screen.Settings.route) },
onNavigateToRandom = { },
initialTagFilter = tag
)
}
@@ -94,6 +114,9 @@ fun AppNavGraph(
type = NavType.BoolType
defaultValue = false
}
),
deepLinks = listOf(
navDeepLink { uriPattern = "shaarit://add" }
)
) { backStackEntry ->
val isShare = backStackEntry.arguments?.getBoolean("isShare") ?: false
@@ -118,7 +141,12 @@ fun AppNavGraph(
)
}
composable(Screen.Tags.route) {
composable(
route = Screen.Tags.route,
deepLinks = listOf(
navDeepLink { uriPattern = "shaarit://tags" }
)
) {
com.shaarit.presentation.tags.TagsScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToFeedWithTag = { tag ->
@@ -128,5 +156,42 @@ fun AppNavGraph(
}
)
}
composable(
route = Screen.Collections.route,
deepLinks = listOf(
navDeepLink { uriPattern = "shaarit://collections" }
)
) {
com.shaarit.presentation.collections.CollectionsScreen(
onNavigateBack = { navController.popBackStack() },
onCollectionClick = { collectionId ->
// Naviguer vers le feed avec le filtre de collection
navController.navigate(Screen.Feed.createRoute()) {
popUpTo(Screen.Collections.route) { inclusive = true }
}
}
)
}
composable(
route = Screen.Dashboard.route,
deepLinks = listOf(
navDeepLink { uriPattern = "shaarit://dashboard" }
)
) {
com.shaarit.presentation.dashboard.DashboardScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(
route = Screen.Settings.route
) {
com.shaarit.presentation.settings.SettingsScreen(
onNavigateBack = { navController.popBackStack() },
onNavigateToDashboard = { navController.navigate(Screen.Dashboard.route) }
)
}
}
}
@@ -0,0 +1,394 @@
package com.shaarit.presentation.settings
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.data.export.BookmarkImporter
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onNavigateToDashboard: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
// Export JSON
val exportJsonLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { viewModel.exportToJson(it) }
}
// Export CSV
val exportCsvLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/csv")
) { uri ->
uri?.let { viewModel.exportToCsv(it) }
}
// Export HTML
val exportHtmlLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/html")
) { uri ->
uri?.let { viewModel.exportToHtml(it) }
}
// Import JSON
val importJsonLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.importFromJson(it) }
}
// Import HTML
val importHtmlLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.importFromHtml(it) }
}
// Show toast for results
LaunchedEffect(uiState.message) {
uiState.message?.let { message ->
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
viewModel.clearMessage()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Paramètres") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Retour")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Analytics Section
item {
SettingsSection(title = "Analytiques")
}
item {
SettingsItem(
icon = Icons.Default.Dashboard,
title = "Tableau de bord",
subtitle = "Voir les statistiques d'utilisation",
onClick = onNavigateToDashboard
)
}
// Export Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Export")
}
item {
SettingsItem(
icon = Icons.Default.Code,
title = "Exporter en JSON",
subtitle = "Format complet avec métadonnées",
onClick = {
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
exportJsonLauncher.launch("shaarit-export-$date.json")
}
)
}
item {
SettingsItem(
icon = Icons.Default.TableChart,
title = "Exporter en CSV",
subtitle = "Compatible avec Excel",
onClick = {
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
exportCsvLauncher.launch("shaarit-export-$date.csv")
}
)
}
item {
SettingsItem(
icon = Icons.Default.Language,
title = "Exporter en HTML",
subtitle = "Format Netscape/Chrome",
onClick = {
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
exportHtmlLauncher.launch("shaarit-export-$date.html")
}
)
}
// Import Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Import")
}
item {
SettingsItem(
icon = Icons.Default.UploadFile,
title = "Importer depuis JSON",
subtitle = "Fichier exporté par ShaarIt",
onClick = { importJsonLauncher.launch(arrayOf("application/json")) }
)
}
item {
SettingsItem(
icon = Icons.Default.BookmarkAdd,
title = "Importer depuis HTML",
subtitle = "Bookmarks Chrome/Firefox",
onClick = {
importHtmlLauncher.launch(arrayOf("text/html", "text/plain"))
}
)
}
// Sync Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Synchronisation")
}
item {
val syncStatus by viewModel.syncStatus.collectAsState()
SyncStatusItem(
status = syncStatus,
onSyncClick = { viewModel.triggerManualSync() }
)
}
// About Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "À propos")
}
item {
SettingsItem(
icon = Icons.Default.Info,
title = "Version",
subtitle = "ShaarIt v1.0",
onClick = {}
)
}
}
}
// Show import result dialog
if (uiState.importResult != null) {
ImportResultDialog(
result = uiState.importResult!!,
onDismiss = { viewModel.clearImportResult() }
)
}
// Loading indicator
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
@Composable
private fun SettingsSection(title: String) {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
private fun SettingsItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun SyncStatusItem(
status: SyncUiStatus,
onSyncClick: () -> Unit
) {
val (icon, title, subtitle) = when (status) {
is SyncUiStatus.Synced -> Triple(
Icons.Default.CloudDone,
"Synchronisé",
"Dernière sync: ${status.lastSync}"
)
is SyncUiStatus.Syncing -> Triple(
Icons.Default.Sync,
"Synchronisation...",
"En cours"
)
is SyncUiStatus.Error -> Triple(
Icons.Default.CloudOff,
"Erreur de sync",
status.message
)
is SyncUiStatus.Offline -> Triple(
Icons.Default.CloudOff,
"Mode hors-ligne",
"${status.pendingChanges} changements en attente"
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSyncClick, enabled = status !is SyncUiStatus.Syncing)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (status is SyncUiStatus.Syncing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = icon,
contentDescription = null,
tint = when (status) {
is SyncUiStatus.Synced -> MaterialTheme.colorScheme.primary
is SyncUiStatus.Error -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun ImportResultDialog(
result: BookmarkImporter.ImportResult,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Importation terminée") },
text = {
Column {
Text("${result.importedCount} liens importés")
if (result.skippedCount > 0) {
Text("${result.skippedCount} liens ignorés (déjà existants)")
}
if (result.errors.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text("Erreurs:", style = MaterialTheme.typography.labelSmall)
for (error in result.errors.take(5)) {
Text("• $error", style = MaterialTheme.typography.bodySmall)
}
if (result.errors.size > 5) {
Text("... et ${result.errors.size - 5} autres erreurs",
style = MaterialTheme.typography.bodySmall)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("OK")
}
}
)
}
sealed class SyncUiStatus {
data class Synced(val lastSync: String) : SyncUiStatus()
object Syncing : SyncUiStatus()
data class Error(val message: String) : SyncUiStatus()
data class Offline(val pendingChanges: Int) : SyncUiStatus()
}
@@ -0,0 +1,182 @@
package com.shaarit.presentation.settings
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.export.BookmarkExporter
import com.shaarit.data.export.BookmarkImporter
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.sync.SyncManager
import com.shaarit.data.sync.SyncState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val bookmarkExporter: BookmarkExporter,
private val bookmarkImporter: BookmarkImporter,
private val syncManager: SyncManager,
private val linkDao: LinkDao
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
private val _syncStatus = MutableStateFlow<SyncUiStatus>(SyncUiStatus.Synced("Jamais"))
val syncStatus: StateFlow<SyncUiStatus> = _syncStatus.asStateFlow()
init {
observeSyncStatus()
}
private fun observeSyncStatus() {
viewModelScope.launch {
val dateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
combine(syncManager.syncState, linkDao.getUnsyncedCount()) { state, unsyncedCount ->
Pair(state, unsyncedCount)
}.collect { (state, unsyncedCount) ->
_syncStatus.value = when (state) {
is SyncState.Syncing -> SyncUiStatus.Syncing
is SyncState.Error -> SyncUiStatus.Error(state.message)
is SyncState.Synced -> SyncUiStatus.Synced(dateFormat.format(Date(state.completedAt)))
is SyncState.Idle -> {
when {
unsyncedCount > 0 -> SyncUiStatus.Offline(unsyncedCount)
else -> SyncUiStatus.Synced("Jamais")
}
}
}
}
}
}
fun exportToJson(uri: Uri) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
bookmarkExporter.exportToJson(uri)
.onSuccess { count ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "$count liens exportés avec succès"
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Erreur d'export: ${error.message}"
)
}
}
}
fun exportToCsv(uri: Uri) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
bookmarkExporter.exportToCsv(uri)
.onSuccess { count ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "$count liens exportés avec succès"
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Erreur d'export: ${error.message}"
)
}
}
}
fun exportToHtml(uri: Uri) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
bookmarkExporter.exportToHtml(uri)
.onSuccess { count ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "$count liens exportés avec succès"
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Erreur d'export: ${error.message}"
)
}
}
}
fun importFromJson(uri: Uri) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
bookmarkImporter.importFromJson(uri)
.onSuccess { result ->
_uiState.value = _uiState.value.copy(
isLoading = false,
importResult = result,
message = "${result.importedCount} liens importés"
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Erreur d'import: ${error.message}"
)
}
}
}
fun importFromHtml(uri: Uri) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
bookmarkImporter.importFromHtml(uri)
.onSuccess { result ->
_uiState.value = _uiState.value.copy(
isLoading = false,
importResult = result,
message = "${result.importedCount} liens importés"
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
message = "Erreur d'import: ${error.message}"
)
}
}
}
fun triggerManualSync() {
viewModelScope.launch {
_syncStatus.value = SyncUiStatus.Syncing
syncManager.syncNow()
_uiState.value = _uiState.value.copy(message = "Synchronisation lancée")
}
}
fun clearMessage() {
_uiState.value = _uiState.value.copy(message = null)
}
fun clearImportResult() {
_uiState.value = _uiState.value.copy(importResult = null)
}
}
data class SettingsUiState(
val isLoading: Boolean = false,
val message: String? = null,
val importResult: BookmarkImporter.ImportResult? = null
)
@@ -0,0 +1,44 @@
package com.shaarit.service
import android.content.Intent
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
/**
* Quick Settings Tile for quickly adding a new link
*
* Swipe down from the top of the screen twice to access Quick Settings,
* then tap the ShaarIt tile to quickly add a bookmark.
*/
@RequiresApi(Build.VERSION_CODES.N)
class AddLinkTileService : TileService() {
override fun onClick() {
super.onClick()
// Launch MainActivity with the add link deep link
val intent = Intent(Intent.ACTION_VIEW).apply {
data = android.net.Uri.parse("shaarit://add")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
`package` = packageName
}
startActivityAndCollapse(intent)
}
override fun onStartListening() {
super.onStartListening()
updateTile()
}
private fun updateTile() {
qsTile?.apply {
state = Tile.STATE_ACTIVE
label = "Add Link"
contentDescription = "Quickly add a new bookmark to ShaarIt"
updateTile()
}
}
}
@@ -0,0 +1,383 @@
package com.shaarit.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.with
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Preview
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.shaarit.ui.theme.CardBackground
import com.shaarit.ui.theme.CardBackgroundElevated
import com.shaarit.ui.theme.CyanPrimary
import com.shaarit.ui.theme.TextPrimary
import com.shaarit.ui.theme.TextSecondary
import dev.jeziellago.compose.markdowntext.MarkdownText
/**
* Modes d'affichage de l'éditeur Markdown
*/
enum class EditorMode {
EDIT, // Mode édition uniquement
PREVIEW, // Mode aperçu uniquement
SPLIT // Mode édition + aperçu côte à côte
}
/**
* Éditeur Markdown complet avec preview temps réel
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun MarkdownEditor(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
mode: EditorMode = EditorMode.SPLIT,
onModeChange: ((EditorMode) -> Unit)? = null,
placeholder: String = "Commencez à écrire en Markdown...",
minHeight: androidx.compose.ui.unit.Dp = 200.dp,
readOnly: Boolean = false
) {
var currentMode by remember { mutableStateOf(mode) }
var textFieldValue by remember { mutableStateOf(TextFieldValue(value)) }
var isFullscreen by remember { mutableStateOf(false) }
// Synchroniser avec le value externe
LaunchedEffect(value) {
if (textFieldValue.text != value) {
textFieldValue = TextFieldValue(value)
}
}
Column(modifier = modifier) {
// Barre d'outils
if (!readOnly) {
EditorToolbar(
currentMode = currentMode,
isFullscreen = isFullscreen,
onModeChange = { newMode ->
currentMode = newMode
onModeChange?.invoke(newMode)
},
onFullscreenToggle = { isFullscreen = !isFullscreen },
onInsert = { insertion ->
val newText = textFieldValue.text + insertion
textFieldValue = TextFieldValue(newText)
onValueChange(newText)
}
)
Spacer(modifier = Modifier.height(8.dp))
}
// Contenu de l'éditeur
AnimatedContent(
targetState = currentMode,
transitionSpec = { fadeIn() with fadeOut() },
label = "editor_mode"
) { targetMode ->
when (targetMode) {
EditorMode.EDIT -> EditOnlyView(
value = textFieldValue,
onValueChange = {
textFieldValue = it
onValueChange(it.text)
},
placeholder = placeholder,
minHeight = minHeight,
readOnly = readOnly
)
EditorMode.PREVIEW -> PreviewOnlyView(
markdown = textFieldValue.text,
minHeight = minHeight
)
EditorMode.SPLIT -> SplitView(
value = textFieldValue,
onValueChange = {
textFieldValue = it
onValueChange(it.text)
},
placeholder = placeholder,
minHeight = minHeight,
readOnly = readOnly
)
}
}
}
}
/**
* Barre d'outils de l'éditeur
*/
@Composable
private fun EditorToolbar(
currentMode: EditorMode,
isFullscreen: Boolean,
onModeChange: (EditorMode) -> Unit,
onFullscreenToggle: () -> Unit,
onInsert: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(CardBackground, RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Sélecteur de mode
Row {
EditorModeButton(
icon = Icons.Default.Edit,
label = "Éditer",
isSelected = currentMode == EditorMode.EDIT,
onClick = { onModeChange(EditorMode.EDIT) }
)
EditorModeButton(
icon = Icons.Default.Preview,
label = "Aperçu",
isSelected = currentMode == EditorMode.PREVIEW,
onClick = { onModeChange(EditorMode.PREVIEW) }
)
EditorModeButton(
icon = null,
label = "Split",
isSelected = currentMode == EditorMode.SPLIT,
onClick = { onModeChange(EditorMode.SPLIT) }
)
}
// Raccourcis de formatage
Row {
TextButton(onClick = { onInsert("**texte en gras**") }) {
Text("B", fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)
}
TextButton(onClick = { onInsert("*texte en italique*") }) {
Text("I", fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)
}
TextButton(onClick = { onInsert("`code`") }) {
Text("<>", style = MaterialTheme.typography.bodySmall)
}
TextButton(onClick = { onInsert("\n- ") }) {
Text("•")
}
TextButton(onClick = { onInsert("\n> citation") }) {
Text("❝")
}
}
// Bouton plein écran
IconButton(onClick = onFullscreenToggle) {
Icon(
imageVector = if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
contentDescription = if (isFullscreen) "Quitter plein écran" else "Plein écran"
)
}
}
}
@Composable
private fun EditorModeButton(
icon: androidx.compose.ui.graphics.vector.ImageVector?,
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
TextButton(
onClick = onClick,
colors = ButtonDefaults.textButtonColors(
contentColor = if (isSelected) CyanPrimary else TextSecondary
)
) {
if (icon != null) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
}
Text(label, style = MaterialTheme.typography.labelMedium)
}
}
/**
* Vue édition uniquement
*/
@Composable
private fun EditOnlyView(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
placeholder: String,
minHeight: androidx.compose.ui.unit.Dp,
readOnly: Boolean
) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.background(CardBackgroundElevated, RoundedCornerShape(8.dp))
.border(1.dp, CyanPrimary.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
.padding(16.dp)
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
textStyle = TextStyle(
color = TextPrimary,
fontSize = MaterialTheme.typography.bodyLarge.fontSize
),
cursorBrush = SolidColor(CyanPrimary),
readOnly = readOnly,
decorationBox = { innerTextField ->
if (value.text.isEmpty()) {
Text(
text = placeholder,
color = TextSecondary,
style = MaterialTheme.typography.bodyLarge
)
}
innerTextField()
}
)
}
}
/**
* Vue aperçu uniquement
*/
@Composable
private fun PreviewOnlyView(
markdown: String,
minHeight: androidx.compose.ui.unit.Dp
) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.background(CardBackgroundElevated, RoundedCornerShape(8.dp))
.border(1.dp, CyanPrimary.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
if (markdown.isBlank()) {
Text(
text = "Rien à prévisualiser...",
color = TextSecondary,
style = MaterialTheme.typography.bodyLarge
)
} else {
MarkdownText(
markdown = markdown,
color = TextPrimary,
modifier = Modifier.fillMaxWidth()
)
}
}
}
/**
* Vue split édition + aperçu
*/
@Composable
private fun SplitView(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
placeholder: String,
minHeight: androidx.compose.ui.unit.Dp,
readOnly: Boolean
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Zone d'édition
Box(modifier = Modifier.weight(1f)) {
EditOnlyView(
value = value,
onValueChange = onValueChange,
placeholder = placeholder,
minHeight = minHeight,
readOnly = readOnly
)
}
// Zone de preview
Box(modifier = Modifier.weight(1f)) {
PreviewOnlyView(
markdown = value.text,
minHeight = minHeight
)
}
}
}
/**
* Mode lecture distraction-free pour les longues notes
*/
@Composable
fun MarkdownReader(
markdown: String,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxSize()
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.95f))
.padding(32.dp)
) {
Column {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.FullscreenExit,
contentDescription = "Fermer",
tint = androidx.compose.ui.graphics.Color.White
)
}
}
// Contenu
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
MarkdownText(
markdown = markdown,
color = androidx.compose.ui.graphics.Color.White,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
+42 -11
View File
@@ -2,8 +2,11 @@ package com.shaarit.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
@@ -84,25 +87,53 @@ private val LightColorScheme =
@Composable
fun ShaarItTheme(
darkTheme: Boolean = true, // Default to dark theme for premium look
dynamicColor: Boolean = false, // Disable dynamic color to maintain brand
content: @Composable () -> Unit
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, // Enable Material You by default
oledMode: Boolean = false, // Pure black for OLED screens
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme =
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) DarkColorScheme // Use custom dark even with dynamic
else lightColorScheme()
when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
// Material You (Monet) - dynamic colors from wallpaper
if (darkTheme) {
if (oledMode) {
// OLED pure black variant
dynamicDarkColorScheme(context).copy(
background = Color.Black,
surface = Color(0xFF0A0A0A),
surfaceVariant = Color(0xFF1A1A1A)
)
} else {
dynamicDarkColorScheme(context)
}
} else {
dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
darkTheme -> {
if (oledMode) {
// OLED pure black variant of custom theme
DarkColorScheme.copy(
background = Color.Black,
surface = Color(0xFF0A0A0A),
surfaceVariant = Color(0xFF1A1A1A)
)
} else {
DarkColorScheme
}
}
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = DeepNavy.toArgb()
window.navigationBarColor = DeepNavy.toArgb()
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
@@ -0,0 +1,135 @@
package com.shaarit.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.RemoteViews
import com.shaarit.MainActivity
import com.shaarit.R
/**
* Widget Provider pour afficher les liens Shaarli sur l'écran d'accueil
*/
class ShaarliWidgetProvider : AppWidgetProvider() {
companion object {
const val ACTION_ADD_LINK = "com.shaarit.widget.ACTION_ADD_LINK"
const val ACTION_REFRESH = "com.shaarit.widget.ACTION_REFRESH"
const val ACTION_RANDOM = "com.shaarit.widget.ACTION_RANDOM"
const val EXTRA_LINK_URL = "link_url"
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId)
}
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
when (intent.action) {
ACTION_ADD_LINK -> {
// Ouvrir l'app en mode ajout rapide
val mainIntent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(mainIntent)
}
ACTION_REFRESH -> {
// Rafraîchir le widget
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = android.content.ComponentName(context, ShaarliWidgetProvider::class.java)
val appWidgetIds = appWidgetManager.getAppWidgetIds(componentName)
onUpdate(context, appWidgetManager, appWidgetIds)
}
ACTION_RANDOM -> {
// Ouvrir un lien aléatoire
// TODO: Implémenter la sélection aléatoire
val mainIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(mainIntent)
}
}
}
private fun updateAppWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_shaarli)
// Configuration du titre
views.setTextViewText(R.id.widget_title, "ShaarIt")
// Bouton Ajouter
val addIntent = Intent(context, ShaarliWidgetProvider::class.java).apply {
action = ACTION_ADD_LINK
}
val addPendingIntent = PendingIntent.getBroadcast(
context,
0,
addIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_btn_add, addPendingIntent)
// Bouton Rafraîchir
val refreshIntent = Intent(context, ShaarliWidgetProvider::class.java).apply {
action = ACTION_REFRESH
}
val refreshPendingIntent = PendingIntent.getBroadcast(
context,
1,
refreshIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_btn_refresh, refreshPendingIntent)
// Bouton Aléatoire
val randomIntent = Intent(context, ShaarliWidgetProvider::class.java).apply {
action = ACTION_RANDOM
}
val randomPendingIntent = PendingIntent.getBroadcast(
context,
2,
randomIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_btn_random, randomPendingIntent)
// Configuration de la liste (utilise un RemoteViewsService)
val serviceIntent = Intent(context, ShaarliWidgetService::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
views.setRemoteAdapter(R.id.widget_list, serviceIntent)
// Intent pour les items de la liste
val clickIntent = Intent(context, MainActivity::class.java)
val clickPendingIntent = PendingIntent.getActivity(
context,
0,
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
views.setPendingIntentTemplate(R.id.widget_list, clickPendingIntent)
// Message vide
views.setEmptyView(R.id.widget_list, R.id.widget_empty)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
@@ -0,0 +1,101 @@
package com.shaarit.widget
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import com.shaarit.R
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.database.ShaarliDatabase
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
/**
* Service pour peupler la liste du widget avec les liens
*/
class ShaarliWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
return ShaarliWidgetItemFactory(applicationContext, intent)
}
}
class ShaarliWidgetItemFactory(
private val context: Context,
private val intent: Intent
) : RemoteViewsService.RemoteViewsFactory {
private var links: List<WidgetLinkItem> = emptyList()
private val linkDao: LinkDao by lazy {
ShaarliDatabase.getInstance(context).linkDao()
}
override fun onCreate() {
// Initialisation
}
override fun onDataSetChanged() {
// Charger les liens depuis la base de données
links = runBlocking {
try {
linkDao.getAllLinks()
.firstOrNull()
?.take(10) // Limiter à 10 liens
?.map { link ->
WidgetLinkItem(
id = link.id,
title = link.title,
url = link.url,
tags = link.tags.take(3).joinToString(", ") // Max 3 tags
)
} ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
}
override fun onDestroy() {
links = emptyList()
}
override fun getCount(): Int = links.size
override fun getViewAt(position: Int): RemoteViews {
val link = links[position]
return RemoteViews(context.packageName, R.layout.widget_list_item).apply {
setTextViewText(R.id.item_title, link.title)
setTextViewText(R.id.item_url, link.url)
if (link.tags.isNotBlank()) {
setTextViewText(R.id.item_tags, link.tags)
setViewVisibility(R.id.item_tags, android.view.View.VISIBLE)
} else {
setViewVisibility(R.id.item_tags, android.view.View.GONE)
}
// Intent pour ouvrir le lien
val fillInIntent = Intent().apply {
putExtra(ShaarliWidgetProvider.EXTRA_LINK_URL, link.url)
putExtra("link_id", link.id)
}
setOnClickFillInIntent(R.id.widget_item_container, fillInIntent)
}
}
override fun getLoadingView(): RemoteViews? = null
override fun getViewTypeCount(): Int = 1
override fun getItemId(position: Int): Long = links.getOrNull(position)?.id?.toLong() ?: position.toLong()
override fun hasStableIds(): Boolean = true
}
data class WidgetLinkItem(
val id: Int,
val title: String,
val url: String,
val tags: String
)
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1B2838" />
<corners android:radius="16dp" />
<stroke
android:width="1dp"
android:color="#00D4AA" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#33FFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="#243447" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_item_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/widget_item_background"
android:clickable="true"
android:focusable="true"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/item_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@android:color/white"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/item_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#94A3B8"
android:textSize="11sp" />
<TextView
android:id="@+id/item_tags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#00D4AA"
android:textSize="10sp"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
<!-- Header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/app_name"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold" />
<ImageButton
android:id="@+id/widget_btn_add"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/add_link"
android:src="@android:drawable/ic_input_add"
android:tint="@android:color/white" />
<ImageButton
android:id="@+id/widget_btn_refresh"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/refresh"
android:src="@android:drawable/ic_popup_sync"
android:tint="@android:color/white" />
<ImageButton
android:id="@+id/widget_btn_random"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/random"
android:src="@android:drawable/ic_menu_sort_by_size"
android:tint="@android:color/white" />
</LinearLayout>
<!-- List of links -->
<ListView
android:id="@+id/widget_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:divider="@android:color/transparent"
android:dividerHeight="4dp" />
<!-- Empty view -->
<TextView
android:id="@+id/widget_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/no_links"
android:textColor="@android:color/white"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>
+82 -1
View File
@@ -1,4 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ShaarIt</string>
</resources>
<!-- Widget -->
<string name="add_link">Ajouter un lien</string>
<string name="refresh">Rafraîchir</string>
<string name="random">Aléatoire</string>
<string name="no_links">Aucun lien</string>
<!-- Actions -->
<string name="pin">Épingler</string>
<string name="unpin">Désépingler</string>
<string name="delete">Supprimer</string>
<string name="edit">Modifier</string>
<string name="share">Partager</string>
<string name="copy_url">Copier l\'URL</string>
<string name="open_in_browser">Ouvrir dans le navigateur</string>
<!-- Editor Modes -->
<string name="mode_edit">Éditer</string>
<string name="mode_preview">Aperçu</string>
<string name="mode_split">Split</string>
<!-- Sync -->
<string name="syncing">Synchronisation…</string>
<string name="sync_complete">Synchronisé</string>
<string name="sync_error">Erreur de synchronisation</string>
<string name="offline_mode">Mode hors-ligne</string>
<!-- Collections -->
<string name="collections">Collections</string>
<string name="new_collection">Nouvelle collection</string>
<string name="smart_collection">Collection intelligente</string>
<!-- Search -->
<string name="search">Rechercher</string>
<string name="search_hint">Rechercher dans les liens…</string>
<string name="filter_today">Aujourd\'hui</string>
<string name="filter_week">Cette semaine</string>
<string name="filter_month">Ce mois</string>
<!-- App Shortcuts -->
<string name="shortcut_add_link_short">Ajouter</string>
<string name="shortcut_add_link_long">Ajouter un lien</string>
<string name="shortcut_add_link_disabled">Ajout de lien désactivé</string>
<string name="shortcut_random_short">Aléatoire</string>
<string name="shortcut_random_long">Lien aléatoire</string>
<string name="shortcut_random_disabled">Aléatoire désactivé</string>
<string name="shortcut_search_short">Rechercher</string>
<string name="shortcut_search_long">Rechercher des liens</string>
<string name="shortcut_search_disabled">Recherche désactivée</string>
<string name="shortcut_collections_short">Collections</string>
<string name="shortcut_collections_long">Voir les collections</string>
<string name="shortcut_collections_disabled">Collections désactivées</string>
<!-- Quick Settings Tile -->
<string name="tile_add_link">Ajouter un lien</string>
<string name="tile_add_link_desc">Ajouter rapidement un bookmark à ShaarIt</string>
<!-- Dashboard -->
<string name="dashboard">Tableau de bord</string>
<string name="total_links">Liens totaux</string>
<string name="links_this_week">Cette semaine</string>
<string name="links_this_month">Ce mois</string>
<string name="most_used_tags">Tags les plus utilisés</string>
<string name="reading_stats">Statistiques de lecture</string>
<string name="estimated_reading_time">Temps de lecture estimé</string>
<string name="links_by_type">Liens par type</string>
<string name="activity_overview">Aperçu d\'activité</string>
<!-- Export/Import -->
<string name="export">Exporter</string>
<string name="import_bookmarks">Importer</string>
<string name="export_json">Exporter en JSON</string>
<string name="export_csv">Exporter en CSV</string>
<string name="import_html">Importer depuis HTML</string>
<string name="import_success">Importation réussie</string>
<string name="import_error">Erreur d\'importation</string>
<string name="export_success">Exportation réussie</string>
<string name="export_error">Erreur d\'exportation</string>
<string name="select_file">Sélectionner un fichier</string>
</resources>
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add Link Shortcut -->
<shortcut
android:shortcutId="add_link"
android:enabled="true"
android:icon="@drawable/ic_launcher_foreground"
android:shortcutShortLabel="@string/shortcut_add_link_short"
android:shortcutLongLabel="@string/shortcut_add_link_long"
android:shortcutDisabledMessage="@string/shortcut_add_link_disabled">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="${applicationId}"
android:targetClass="com.shaarit.MainActivity"
android:data="shaarit://add" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<!-- Random Link Shortcut -->
<shortcut
android:shortcutId="random_link"
android:enabled="true"
android:icon="@drawable/ic_launcher_foreground"
android:shortcutShortLabel="@string/shortcut_random_short"
android:shortcutLongLabel="@string/shortcut_random_long"
android:shortcutDisabledMessage="@string/shortcut_random_disabled">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="${applicationId}"
android:targetClass="com.shaarit.MainActivity"
android:data="shaarit://random" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<!-- Search Shortcut -->
<shortcut
android:shortcutId="search"
android:enabled="true"
android:icon="@drawable/ic_launcher_foreground"
android:shortcutShortLabel="@string/shortcut_search_short"
android:shortcutLongLabel="@string/shortcut_search_long"
android:shortcutDisabledMessage="@string/shortcut_search_disabled">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="${applicationId}"
android:targetClass="com.shaarit.MainActivity"
android:data="shaarit://search" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<!-- Collections Shortcut -->
<shortcut
android:shortcutId="collections"
android:enabled="true"
android:icon="@drawable/ic_launcher_foreground"
android:shortcutShortLabel="@string/shortcut_collections_short"
android:shortcutLongLabel="@string/shortcut_collections_long"
android:shortcutDisabledMessage="@string/shortcut_collections_disabled">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="${applicationId}"
android:targetClass="com.shaarit.MainActivity"
android:data="shaarit://collections" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/widget_shaarli"
android:initialLayout="@layout/widget_shaarli"
android:minWidth="300dp"
android:minHeight="200dp"
android:previewImage="@drawable/ic_launcher_foreground"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen|keyguard" />