docs: Update comprehensive documentation with AI features, health checks, and architecture improvements

- Document automatic token verification on startup and note:// prefix for Markdown notes
- Add extensive AI capabilities section (Gemini integration, auto-tagging, content classification, multi-model fallback)
- Document link health monitoring system with dead link detection and exclusion features
- Add file sharing support (Markdown/text files) and deep links documentation
- Update technology
This commit is contained in:
2026-02-10 21:15:30 -05:00
parent c8a9e6653b
commit ec0931134c
39 changed files with 4717 additions and 308 deletions
+1
View File
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".ShaarItApp"
+15
View File
@@ -0,0 +1,15 @@
HSPLandroidx/compose/runtime/ComposerImpl;->startRestartGroup(I)Landroidx/compose/runtime/Composer;
HSPLandroidx/compose/runtime/ComposerImpl;->endRestartGroup()Landroidx/compose/runtime/ScopeUpdateScope;
HSPLandroidx/compose/material3/**;->**(**)**
HSPLandroidx/compose/foundation/**;->**(**)**
HSPLandroidx/compose/ui/**;->**(**)**
HSPLandroidx/compose/animation/**;->**(**)**
HSPLandroidx/navigation/**;->**(**)**
HSPLandroidx/paging/**;->**(**)**
HSPLandroidx/room/**;->**(**)**
HSPLcoil/**;->**(**)**
HSPLcom/shaarit/presentation/**;->**(**)**
HSPLcom/shaarit/ui/**;->**(**)**
HSPLcom/shaarit/core/**;->**(**)**
HSPLcom/shaarit/data/**;->**(**)**
HSPLcom/shaarit/domain/**;->**(**)**
+125 -51
View File
@@ -2,18 +2,23 @@ package com.shaarit
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.FragmentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.presentation.auth.LockScreen
import com.shaarit.presentation.nav.AppNavGraph
import com.shaarit.ui.theme.ShaarItTheme
import com.shaarit.ui.theme.ThemePreferences
@@ -23,9 +28,17 @@ import java.io.InputStreamReader
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
@Inject lateinit var themePreferences: ThemePreferences
@Inject lateinit var tokenManager: com.shaarit.core.storage.TokenManager
@Inject lateinit var securityPreferences: SecurityPreferences
@Inject lateinit var biometricAuthManager: BiometricAuthManager
// Start as authenticated — lock only triggers after app goes to background
private var isAuthenticated by mutableStateOf(true)
private var lastBackgroundTime: Long = 0L
private var hasBeenBackgrounded = false
override fun onCreate(savedInstanceState: Bundle?) {
// Install splash screen before super.onCreate
@@ -36,67 +49,128 @@ class MainActivity : ComponentActivity() {
// Enable edge-to-edge mode for proper keyboard (IME) insets detection
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
// On cold start, require auth if biometric + requireOnStartup are enabled
if (securityPreferences.isBiometricEnabled.value && securityPreferences.requireOnStartup.value) {
isAuthenticated = false
}
// Parse intent ONCE, before composition (avoids re-execution on recomposition)
val shareData = parseShareIntent(intent)
// Check if user is already logged in to skip login screen for share intents
val hasValidToken = tokenManager.getToken() != null && tokenManager.getBaseUrl() != null
val startDestination = if (hasValidToken) {
com.shaarit.presentation.nav.Screen.Feed.createRoute()
} else {
com.shaarit.presentation.nav.Screen.Login.route
}
setContent {
val currentTheme by themePreferences.currentTheme.collectAsState()
ShaarItTheme(appTheme = currentTheme) {
// A surface container using the 'background' color from the theme
val context = LocalContext.current
var shareUrl: String? = null
var shareTitle: String? = null
var shareDescription: String? = null
var shareTags: List<String>? = null
var deepLink: String? = null
var isFileShare = false
val currentThemeMode by themePreferences.themeMode.collectAsState()
val biometricEnabled by securityPreferences.isBiometricEnabled.collectAsState()
val activity = context as? androidx.activity.ComponentActivity
val intent = activity?.intent
// Handle share intent
if (intent?.action == android.content.Intent.ACTION_SEND) {
val mimeType = intent.type ?: ""
// Check if this is a file share (markdown or text file)
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
// Handle file sharing - use filename as title, content as description
isFileShare = true
val fileInfo = readFileContent(fileUri)
shareTitle = fileInfo.first // filename without extension
shareDescription = fileInfo.second // file content
shareTags = listOf("note", "fichier")
shareUrl = null // No URL for file shares
} else if (mimeType == "text/plain") {
// Regular text share (URL)
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
}
}
// Handle deep links from App Shortcuts
intent?.data?.let { uri ->
if (uri.scheme == "shaarit") {
deepLink = uri.toString()
}
}
val needsAuth = biometricEnabled && !isAuthenticated
ShaarItTheme(appTheme = currentTheme, themeMode = currentThemeMode) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavGraph(
shareUrl = shareUrl,
shareTitle = shareTitle,
shareDescription = shareDescription,
shareTags = shareTags,
isFileShare = isFileShare,
initialDeepLink = deepLink
)
) {
if (needsAuth) {
LockScreen(
activity = this@MainActivity,
biometricAuthManager = biometricAuthManager,
onAuthenticated = { isAuthenticated = true }
)
} else {
AppNavGraph(
startDestination = startDestination,
shareUrl = shareData.url,
shareTitle = shareData.title,
shareDescription = shareData.description,
shareTags = shareData.tags,
isFileShare = shareData.isFileShare,
initialDeepLink = shareData.deepLink
)
}
}
}
}
}
override fun onStop() {
super.onStop()
lastBackgroundTime = System.currentTimeMillis()
hasBeenBackgrounded = true
}
override fun onResume() {
super.onResume()
if (!hasBeenBackgrounded) return
if (securityPreferences.isBiometricEnabled.value) {
val elapsed = System.currentTimeMillis() - lastBackgroundTime
val timeout = securityPreferences.lockTimeout.value.delayMs
val shouldLock = if (securityPreferences.requireOnResume.value) {
lastBackgroundTime > 0 && elapsed > timeout
} else {
false
}
if (shouldLock) {
isAuthenticated = false
}
}
}
private data class ShareData(
val url: String? = null,
val title: String? = null,
val description: String? = null,
val tags: List<String>? = null,
val isFileShare: Boolean = false,
val deepLink: String? = null
)
private fun parseShareIntent(intent: android.content.Intent?): ShareData {
if (intent == null) return ShareData()
var shareUrl: String? = null
var shareTitle: String? = null
var shareDescription: String? = null
var shareTags: List<String>? = null
var isFileShare = false
var deepLink: String? = null
// Handle share intent
if (intent.action == android.content.Intent.ACTION_SEND) {
val mimeType = intent.type ?: ""
// Check if this is a file share (markdown or text file)
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
isFileShare = true
val fileInfo = readFileContent(fileUri)
shareTitle = fileInfo.first
shareDescription = fileInfo.second
shareTags = listOf("note", "fichier")
shareUrl = null
} else if (mimeType == "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()
}
}
return ShareData(shareUrl, shareTitle, shareDescription, shareTags, isFileShare, deepLink)
}
/**
* Checks if the shared content is a text or markdown file
@@ -1,5 +1,6 @@
package com.shaarit.core.di
import android.content.Context
import com.shaarit.core.network.AuthInterceptor
import com.shaarit.core.network.HostSelectionInterceptor
import com.shaarit.data.api.ShaarliApi
@@ -7,8 +8,12 @@ import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
@@ -23,11 +28,24 @@ object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
@ApplicationContext context: Context,
authInterceptor: AuthInterceptor,
hostSelectionInterceptor: HostSelectionInterceptor
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
val logging = HttpLoggingInterceptor().apply {
level = if (com.shaarit.BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
val cacheSize = 10L * 1024 * 1024 // 10 MB
val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)
return OkHttpClient.Builder()
.cache(cache)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.addInterceptor(hostSelectionInterceptor) // Host selection first
.addInterceptor(authInterceptor) // Auth header second
.addInterceptor(logging)
@@ -0,0 +1,115 @@
package com.shaarit.core.storage
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
enum class BiometricAvailability {
AVAILABLE,
NO_HARDWARE,
NOT_ENROLLED,
UNAVAILABLE
}
@Singleton
class BiometricAuthManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val biometricManager = BiometricManager.from(context)
/**
* Returns the best available authenticator flags, trying from strongest to weakest:
* 1. BIOMETRIC_STRONG | DEVICE_CREDENTIAL
* 2. BIOMETRIC_WEAK | DEVICE_CREDENTIAL
* 3. DEVICE_CREDENTIAL alone
* 4. BIOMETRIC_STRONG alone
* 5. BIOMETRIC_WEAK alone
* Returns 0 if nothing is available.
*/
private fun resolveAuthenticators(): Int {
val candidates = listOf(
BIOMETRIC_STRONG or DEVICE_CREDENTIAL,
BIOMETRIC_WEAK or DEVICE_CREDENTIAL,
DEVICE_CREDENTIAL,
BIOMETRIC_STRONG,
BIOMETRIC_WEAK
)
for (auth in candidates) {
if (biometricManager.canAuthenticate(auth) == BiometricManager.BIOMETRIC_SUCCESS) {
return auth
}
}
return 0
}
fun canAuthenticate(): BiometricAvailability {
if (resolveAuthenticators() != 0) return BiometricAvailability.AVAILABLE
val result = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
return when (result) {
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricAvailability.NO_HARDWARE
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricAvailability.NOT_ENROLLED
else -> BiometricAvailability.UNAVAILABLE
}
}
fun authenticate(
activity: FragmentActivity,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
val authenticators = resolveAuthenticators()
if (authenticators == 0) {
onError("Aucune méthode d'authentification disponible")
return
}
val executor = ContextCompat.getMainExecutor(activity)
val builder = BiometricPrompt.PromptInfo.Builder()
.setTitle("Déverrouiller ShaarIt")
.setSubtitle("Utilisez votre empreinte, visage ou code PIN")
.setAllowedAuthenticators(authenticators)
// setNegativeButtonText is required when DEVICE_CREDENTIAL is NOT included
if (authenticators and DEVICE_CREDENTIAL == 0) {
builder.setNegativeButtonText("Annuler")
}
val promptInfo = builder.build()
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
errorCode != BiometricPrompt.ERROR_CANCELED
) {
onError(errString.toString())
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
// Individual attempt failed, prompt stays open for retry
}
}
)
biometricPrompt.authenticate(promptInfo)
}
}
@@ -0,0 +1,79 @@
package com.shaarit.core.storage
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
enum class LockTimeout(val displayName: String, val delayMs: Long) {
IMMEDIATE("Immédiat", 0),
AFTER_1_MIN("Après 1 minute", 60_000),
AFTER_5_MIN("Après 5 minutes", 300_000),
AFTER_15_MIN("Après 15 minutes", 900_000)
}
@Singleton
class SecurityPreferences @Inject constructor(
@ApplicationContext private val context: Context
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("security_prefs", Context.MODE_PRIVATE)
private val _isBiometricEnabled = MutableStateFlow(loadBiometricEnabled())
val isBiometricEnabled: StateFlow<Boolean> = _isBiometricEnabled.asStateFlow()
private val _lockTimeout = MutableStateFlow(loadLockTimeout())
val lockTimeout: StateFlow<LockTimeout> = _lockTimeout.asStateFlow()
private val _requireOnStartup = MutableStateFlow(loadRequireOnStartup())
val requireOnStartup: StateFlow<Boolean> = _requireOnStartup.asStateFlow()
private val _requireOnResume = MutableStateFlow(loadRequireOnResume())
val requireOnResume: StateFlow<Boolean> = _requireOnResume.asStateFlow()
private fun loadBiometricEnabled(): Boolean =
prefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
private fun loadLockTimeout(): LockTimeout {
val name = prefs.getString(KEY_LOCK_TIMEOUT, LockTimeout.IMMEDIATE.name)
?: LockTimeout.IMMEDIATE.name
return try { LockTimeout.valueOf(name) } catch (_: Exception) { LockTimeout.IMMEDIATE }
}
private fun loadRequireOnStartup(): Boolean =
prefs.getBoolean(KEY_REQUIRE_ON_STARTUP, true)
private fun loadRequireOnResume(): Boolean =
prefs.getBoolean(KEY_REQUIRE_ON_RESUME, false)
fun setBiometricEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, enabled).apply()
_isBiometricEnabled.value = enabled
}
fun setLockTimeout(timeout: LockTimeout) {
prefs.edit().putString(KEY_LOCK_TIMEOUT, timeout.name).apply()
_lockTimeout.value = timeout
}
fun setRequireOnStartup(require: Boolean) {
prefs.edit().putBoolean(KEY_REQUIRE_ON_STARTUP, require).apply()
_requireOnStartup.value = require
}
fun setRequireOnResume(require: Boolean) {
prefs.edit().putBoolean(KEY_REQUIRE_ON_RESUME, require).apply()
_requireOnResume.value = require
}
companion object {
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_LOCK_TIMEOUT = "lock_timeout"
private const val KEY_REQUIRE_ON_STARTUP = "require_on_startup"
private const val KEY_REQUIRE_ON_RESUME = "require_on_resume"
}
}
@@ -28,6 +28,9 @@ interface TokenManager {
fun saveGeminiApiKey(apiKey: String)
fun getGeminiApiKey(): String?
fun clearGeminiApiKey()
fun saveLastSyncTimestamp(timestamp: Long)
fun getLastSyncTimestamp(): Long
}
@Singleton
@@ -128,6 +131,14 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
sharedPreferences.edit().remove(KEY_GEMINI_API_KEY).apply()
}
override fun saveLastSyncTimestamp(timestamp: Long) {
sharedPreferences.edit().putLong(KEY_LAST_SYNC_TIMESTAMP, timestamp).apply()
}
override fun getLastSyncTimestamp(): Long {
return sharedPreferences.getLong(KEY_LAST_SYNC_TIMESTAMP, 0L)
}
companion object {
private const val KEY_TOKEN = "jwt_token"
private const val KEY_BASE_URL = "base_url"
@@ -136,5 +147,6 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
private const val KEY_COLLECTIONS_DIRTY = "collections_config_dirty"
private const val KEY_COLLECTIONS_BOOKMARK_ID = "collections_config_bookmark_id"
private const val KEY_GEMINI_API_KEY = "gemini_api_key"
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
}
}
@@ -73,6 +73,9 @@ interface LinkDao {
@RawQuery(observedEntities = [LinkEntity::class])
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
@RawQuery(observedEntities = [LinkEntity::class])
suspend fun getLinksRawQuery(query: SupportSQLiteQuery): List<LinkEntity>
@Query("""
SELECT links.* FROM links
INNER JOIN collection_links ON links.id = collection_links.link_id
@@ -5,6 +5,8 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.shaarit.data.local.converter.Converters
import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.LinkDao
@@ -28,8 +30,8 @@ import com.shaarit.data.local.entity.TagEntity
CollectionEntity::class,
CollectionLinkCrossRef::class
],
version = 4,
exportSchema = false
version = 5,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class ShaarliDatabase : RoomDatabase() {
@@ -41,6 +43,22 @@ abstract class ShaarliDatabase : RoomDatabase() {
companion object {
private const val DATABASE_NAME = "shaarli.db"
/**
* Migration v4 → v5 : Ajout des index manquants pour les colonnes fréquemment filtrées
* - content_type : utilisé par getContentTypeDistribution()
* - site_name : utilisé par getTopSites() et getAllSites()
* - link_check_status : utilisé par getDeadLinks(), getDeadLinksCount(), getPendingLinksCount()
* - last_health_check : utilisé par getLinksForHealthCheck()
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `links` (`content_type`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `links` (`site_name`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `links` (`link_check_status`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `links` (`last_health_check`)")
}
}
@Volatile
private var instance: ShaarliDatabase? = null
@@ -56,7 +74,8 @@ abstract class ShaarliDatabase : RoomDatabase() {
ShaarliDatabase::class.java,
DATABASE_NAME
)
.fallbackToDestructiveMigration()
.addMigrations(MIGRATION_4_5)
.fallbackToDestructiveMigrationFrom(1, 2, 3)
.build()
}
}
@@ -17,7 +17,11 @@ import androidx.room.PrimaryKey
Index(value = ["is_private"]),
Index(value = ["created_at"]),
Index(value = ["is_pinned"]),
Index(value = ["url"], unique = true)
Index(value = ["url"], unique = true),
Index(value = ["content_type"]),
Index(value = ["site_name"]),
Index(value = ["link_check_status"]),
Index(value = ["last_health_check"])
]
)
data class LinkEntity(
@@ -21,6 +21,16 @@ class LinkMetadataExtractor @Inject constructor() {
private const val TAG = "LinkMetadataExtractor"
private const val TIMEOUT_MS = 10000
private const val MAX_DESCRIPTION_LENGTH = 300
private val VIDEO_PATTERN = Regex("youtube\\.com|vimeo\\.com|dailymotion|youtu\\.be")
private val REPO_PATTERN = Regex("github\\.com|gitlab\\.com|gitlab|bitbucket")
private val DOCUMENT_PATTERN = Regex("docs\\.google\\.com|docs\\.google|notion\\.so|confluence")
private val SOCIAL_PATTERN = Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")
private val SHOPPING_PATTERN = Regex("amazon|ebay|shopify")
private val NEWSLETTER_PATTERN = Regex("substack|revue|mailchimp")
private val MUSIC_PATTERN = Regex("spotify|deezer|soundcloud|bandcamp")
private val NEWS_PATTERN = Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")
private val PODCAST_PATTERN = Regex("podcast|anchor")
}
/**
@@ -172,16 +182,16 @@ class LinkMetadataExtractor @Inject constructor() {
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 == "video" || url.contains(VIDEO_PATTERN) -> 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("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER
url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC
url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS
doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor")) -> ContentType.PODCAST
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
url.contains(DOCUMENT_PATTERN) -> ContentType.DOCUMENT
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
url.contains(NEWS_PATTERN) -> ContentType.NEWS
doc.select("audio").isNotEmpty() || url.contains(PODCAST_PATTERN) -> ContentType.PODCAST
url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN
}
@@ -192,14 +202,14 @@ class LinkMetadataExtractor @Inject constructor() {
*/
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|bitbucket")) -> ContentType.REPOSITORY
url.contains(Regex("docs\\.google|notion\\.so|confluence")) -> ContentType.DOCUMENT
url.contains(Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER
url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC
url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS
url.contains(VIDEO_PATTERN) -> ContentType.VIDEO
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
url.contains(DOCUMENT_PATTERN) -> ContentType.DOCUMENT
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
url.contains(NEWS_PATTERN) -> ContentType.NEWS
url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN
}
@@ -1,68 +0,0 @@
package com.shaarit.data.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.mapper.LinkMapper
import com.shaarit.domain.model.ShaarliLink
import java.io.IOException
import retrofit2.HttpException
class LinkPagingSource(
private val api: ShaarliApi,
private val searchTerm: String? = null,
private val searchTags: String? = null
) : PagingSource<Int, ShaarliLink>() {
override fun getRefreshKey(state: PagingState<Int, ShaarliLink>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ShaarliLink> {
val position = params.key ?: 0
// Shaarli V1 API uses offset and limit
// Assuming we pass page index as key? No, offset is better for generic APIs, but here we
// can manage offset.
// Wait, params.key is usually page index if we set it so.
// Or we can use offset as the key.
// Let's use Offset as Key. Initial key = 0.
val offset = position
val limit = params.loadSize
return try {
val dtos =
api.getLinks(
offset = offset,
limit = limit,
searchTerm = searchTerm,
searchTags = searchTags
)
val links = dtos.mapNotNull { LinkMapper.toDomain(it) }
val nextKey =
if (links.isEmpty()) {
null
} else {
// If we got less than requested, we are at the end?
// Shaarli doesn't return total count easily in v1 (maybe headers).
// If detailed list is empty or < limit, next is null.
if (links.size < limit) null else offset + limit
}
LoadResult.Page(
data = links,
prevKey = if (offset == 0) null else offset - limit,
nextKey = nextKey
)
} catch (exception: IOException) {
LoadResult.Error(exception)
} catch (exception: HttpException) {
LoadResult.Error(exception)
}
}
}
@@ -13,6 +13,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import android.util.LruCache
import javax.inject.Inject
import javax.inject.Singleton
@@ -22,11 +23,11 @@ class GeminiRepositoryImpl @Inject constructor(
private val okHttpClient: OkHttpClient
) : GeminiRepository {
// Cache pour les tags
private val tagsCache = mutableMapOf<String, List<String>>()
// Cache pour les tags (borné à 50 entrées max)
private val tagsCache = LruCache<String, List<String>>(50)
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session (borné à 50 entrées max)
private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
override fun isApiKeyConfigured(): Boolean {
return !tokenManager.getGeminiApiKey().isNullOrBlank()
@@ -34,8 +35,8 @@ class GeminiRepositoryImpl @Inject constructor(
override suspend fun generateTags(title: String, description: String): Result<List<String>> = withContext(Dispatchers.IO) {
val cacheKey = "$title|$description"
if (tagsCache.containsKey(cacheKey)) {
return@withContext Result.success(tagsCache[cacheKey]!!)
tagsCache.get(cacheKey)?.let {
return@withContext Result.success(it)
}
try {
@@ -56,7 +57,7 @@ class GeminiRepositoryImpl @Inject constructor(
for (modelName in modelsToTry) {
try {
val tags = generateTagsWithModel(apiKey, modelName, title, description)
tagsCache[cacheKey] = tags
tagsCache.put(cacheKey, tags)
return@withContext Result.success(tags)
} catch (e: Exception) {
lastException = e
@@ -114,8 +115,8 @@ class GeminiRepositoryImpl @Inject constructor(
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
// Vérifier le cache d'abord
if (analysisCache.containsKey(url)) {
return@withContext Result.success(analysisCache[url]!!)
analysisCache.get(url)?.let {
return@withContext Result.success(it)
}
try {
@@ -146,7 +147,7 @@ class GeminiRepositoryImpl @Inject constructor(
try {
val result = generateWithModel(apiKey, modelName, url)
// Mettre en cache le résultat réussi
analysisCache[url] = result
analysisCache.put(url, result)
return@withContext Result.success(result)
} catch (e: Exception) {
lastException = e
@@ -55,11 +55,16 @@ constructor(
): Flow<PagingData<ShaarliLink>> {
// Utiliser Room pour la pagination locale
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = {
when {
collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId)
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
!searchTerm.isNullOrBlank() -> linkDao.searchLinksFullText(searchTerm)
!searchTags.isNullOrBlank() -> {
val tags =
searchTags
@@ -114,18 +119,15 @@ constructor(
}
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()
// Requête SQL directe via Room au lieu de charger toute la base en mémoire
val sql = "SELECT * FROM links WHERE tags LIKE ? AND sync_status != 'PENDING_DELETE' ORDER BY is_pinned DESC, created_at DESC LIMIT 100"
val localLinks = linkDao.getLinksRawQuery(SimpleSQLiteQuery(sql, arrayOf("%\"$tag\"%")))
if (localLinks.isNotEmpty()) {
val filtered = localLinks.filter { it.tags.contains(tag) }
if (filtered.isNotEmpty()) {
return Result.success(filtered.map { it.toDomainModel() })
}
return Result.success(localLinks.map { it.toDomainModel() })
}
// Fallback vers l'API
// Fallback vers l'API si aucun résultat local
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
Result.success(links.mapNotNull { LinkMapper.toDomain(it) })
} catch (e: Exception) {
@@ -507,7 +509,12 @@ constructor(
override fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = { linkDao.getDeadLinks() }
).flow.map { pagingData ->
pagingData.map { it.toDomainModel() }
@@ -516,7 +523,12 @@ constructor(
override fun getPinnedLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = { linkDao.getPinnedLinksPaged() }
).flow.map { pagingData ->
pagingData.map { it.toDomainModel() }
@@ -533,6 +545,10 @@ constructor(
}
}
companion object {
private val DATE_FORMAT = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US)
}
private fun LinkEntity.toDomainModel(): ShaarliLink {
return ShaarliLink(
id = id,
@@ -541,7 +557,7 @@ constructor(
description = description,
tags = tags,
isPrivate = isPrivate,
date = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault()).format(java.util.Date(createdAt)),
date = DATE_FORMAT.format(java.util.Date(createdAt)),
isPinned = isPinned,
thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes,
@@ -580,8 +596,7 @@ constructor(
private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
return try {
val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault())
format.parse(dateString)?.time ?: System.currentTimeMillis()
DATE_FORMAT.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}
@@ -370,12 +370,20 @@ class SyncManager @Inject constructor(
}
/**
* Récupère les données depuis le serveur
* Récupère les données depuis le serveur (sync incrémentale)
* S'arrête quand on rencontre des liens déjà synchronisés (non modifiés depuis la dernière sync)
*/
private suspend fun pullFromServer() {
var offset = 0
val limit = 100
var hasMore = true
val lastSyncTimestamp = tokenManager.getLastSyncTimestamp()
val syncStartTime = System.currentTimeMillis()
val isFirstSync = lastSyncTimestamp == 0L
var unchangedStreakCount = 0
val unchangedStreakThreshold = 2 // Stop after 2 consecutive pages of unchanged links
Log.d(TAG, "Sync incrémentale: lastSync=${if (isFirstSync) "jamais" else java.time.Instant.ofEpochMilli(lastSyncTimestamp)}")
while (hasMore) {
try {
@@ -395,9 +403,21 @@ class SyncManager @Inject constructor(
}
Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
var newOrUpdatedCount = 0
val entities = validLinks.mapNotNull { dto ->
try {
val existing = linkDao.getLinkById(dto.id!!)
val serverUpdatedAt = parseDate(dto.updated)
// Check if this link has been modified since last sync
if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) {
// Link unchanged since last sync — skip
return@mapNotNull null
}
newOrUpdatedCount++
LinkEntity(
id = dto.id,
url = dto.url!!,
@@ -407,7 +427,7 @@ class SyncManager @Inject constructor(
isPrivate = dto.isPrivate ?: false,
isPinned = existing?.isPinned ?: false,
createdAt = parseDate(dto.created),
updatedAt = parseDate(dto.updated),
updatedAt = serverUpdatedAt,
syncStatus = SyncStatus.SYNCED,
thumbnailUrl = existing?.thumbnailUrl,
readingTimeMinutes = existing?.readingTimeMinutes,
@@ -428,6 +448,20 @@ class SyncManager @Inject constructor(
if (entities.isNotEmpty()) {
linkDao.insertLinks(entities)
}
Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides")
// Incremental sync: stop early if we encounter pages with no changes
if (!isFirstSync && newOrUpdatedCount == 0) {
unchangedStreakCount++
if (unchangedStreakCount >= unchangedStreakThreshold) {
Log.d(TAG, "Sync incrémentale: $unchangedStreakThreshold pages consécutives sans changement, arrêt anticipé")
hasMore = false
}
} else {
unchangedStreakCount = 0
}
offset += links.size
}
} catch (e: Exception) {
@@ -436,6 +470,9 @@ class SyncManager @Inject constructor(
}
}
// Save sync timestamp on success
tokenManager.saveLastSyncTimestamp(syncStartTime)
// Synchroniser les tags
try {
val tags = api.getTags(limit = 1000)
@@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.shaarit.ui.components.*
import com.shaarit.ui.components.VoiceInputButton
import com.shaarit.ui.theme.Typography
import kotlinx.coroutines.launch
@@ -60,6 +61,7 @@ fun AddLinkScreen(
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
var showMarkdownPreview by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
@@ -75,6 +77,7 @@ fun AddLinkScreen(
LaunchedEffect(uiState) {
when (val state = uiState) {
is AddLinkUiState.Success -> {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (onShareSuccess != null) {
onShareSuccess()
} else {
@@ -335,7 +338,13 @@ fun AddLinkScreen(
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium
textStyle = MaterialTheme.typography.bodyMedium,
trailingIcon = {
VoiceInputButton(
onResult = { viewModel.title.value = it },
contentDescription = "Dicter le titre"
)
}
)
}
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.net.URLDecoder
import javax.inject.Inject
@@ -122,29 +123,47 @@ constructor(
private fun extractMetadata(urlString: String) {
viewModelScope.launch {
_isExtractingMetadata.value = true
try {
val metadata = metadataExtractor.extract(urlString)
// Auto-remplir si les champs sont vides
// Launch JSoup metadata extraction and AI analysis in parallel
val metadataDeferred = async {
try {
metadataExtractor.extract(urlString)
} catch (e: Exception) {
null
}
}
val aiDeferred = if (analyzeUrlWithAiUseCase.isApiKeyConfigured() && _aiEnrichmentState.value != AiEnrichmentState.Loading) {
_aiEnrichmentState.value = AiEnrichmentState.Loading
async {
analyzeUrlWithAiUseCase(urlString)
}
} else null
// Apply JSoup metadata as soon as it arrives
val metadata = metadataDeferred.await()
if (metadata != null) {
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
metadata.siteName?.let { site -> suggestTagFromSite(site) }
}
_isExtractingMetadata.value = false
// Complete with AI enrichment
aiDeferred?.await()
?.onSuccess { result ->
applyAiEnrichment(result)
_aiEnrichmentState.value = AiEnrichmentState.Success
}
?.onFailure {
_aiEnrichmentState.value = AiEnrichmentState.Idle
}
}
}
@@ -0,0 +1,155 @@
package com.shaarit.presentation.auth
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.BiometricAvailability
@Composable
fun LockScreen(
activity: FragmentActivity,
biometricAuthManager: BiometricAuthManager,
onAuthenticated: () -> Unit
) {
var errorMessage by remember { mutableStateOf<String?>(null) }
var hasTriedOnce by remember { mutableStateOf(false) }
// Auto-trigger biometric prompt only when activity lifecycle reaches RESUMED
DisposableEffect(activity) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && !hasTriedOnce) {
hasTriedOnce = true
if (biometricAuthManager.canAuthenticate() == BiometricAvailability.AVAILABLE) {
try {
biometricAuthManager.authenticate(
activity = activity,
onSuccess = onAuthenticated,
onError = { errorMessage = it }
)
} catch (e: Exception) {
errorMessage = e.message ?: "Erreur d'authentification"
}
}
}
}
activity.lifecycle.addObserver(observer)
onDispose { activity.lifecycle.removeObserver(observer) }
}
// Pulsing animation for the lock icon
val infiniteTransition = rememberInfiniteTransition(label = "lock_pulse")
val pulseAlpha by infiniteTransition.animateFloat(
initialValue = 0.6f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
),
label = "pulse_alpha"
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surface
)
)
),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(72.dp)
.alpha(pulseAlpha)
)
Text(
text = "ShaarIt est verrouillé",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Text(
text = "Authentifiez-vous pour accéder à vos favoris",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
errorMessage?.let { msg ->
Text(
text = msg,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
errorMessage = null
if (biometricAuthManager.canAuthenticate() == BiometricAvailability.AVAILABLE) {
biometricAuthManager.authenticate(
activity = activity,
onSuccess = onAuthenticated,
onError = { errorMessage = it }
)
} else {
errorMessage = "Authentification biométrique non disponible"
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = Icons.Default.Fingerprint,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Déverrouiller",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
@@ -43,6 +43,7 @@ import com.shaarit.domain.model.TagFilter
import com.shaarit.domain.model.ViewStyle
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.components.VoiceInputButton
import com.shaarit.ui.theme.Typography
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -1314,16 +1315,21 @@ fun FeedScreen(
)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(
onClick = { viewModel.onSearchQueryChanged("") }
) {
Icon(
Icons.Default.Close,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.outline
)
Row {
if (searchQuery.isNotEmpty()) {
IconButton(
onClick = { viewModel.onSearchQueryChanged("") }
) {
Icon(
Icons.Default.Close,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.outline
)
}
}
VoiceInputButton(
onResult = { viewModel.onSearchQueryChanged(it) }
)
}
}
)
@@ -1444,7 +1450,8 @@ fun FeedScreen(
},
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) }
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
)
}
}
@@ -1504,7 +1511,8 @@ fun FeedScreen(
},
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) }
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
)
}
}
@@ -1565,7 +1573,8 @@ fun FeedScreen(
},
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) }
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
)
}
}
@@ -16,6 +16,7 @@ import com.shaarit.domain.model.TimeFilter
import com.shaarit.domain.model.VisibilityFilter
import com.shaarit.domain.model.TagFilter
import com.shaarit.domain.model.ViewStyle
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@@ -44,7 +45,8 @@ class FeedViewModel @Inject constructor(
private val syncManager: SyncManager,
private val collectionDao: CollectionDao,
private val tagDao: TagDao,
private val tokenManager: TokenManager
private val tokenManager: TokenManager,
private val linkDao: LinkDao
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
@@ -75,20 +77,24 @@ class FeedViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val pagedLinks: Flow<PagingData<ShaarliLink>> =
combine(_searchQuery, _searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { query, tags, collectionId, bookmarkFilter, _ ->
Quadruple(query, tags, collectionId, bookmarkFilter)
}
.debounce(300) // Debounce for 300ms
.flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
linkRepository.getLinksStream(
searchTerm = if (query.isBlank()) null else query,
searchTags = tags,
collectionId = collectionId,
bookmarkFilter = bookmarkFilter
)
}
.cachedIn(viewModelScope)
val pagedLinks: Flow<PagingData<ShaarliLink>> = run {
val debouncedSearch = _searchQuery.debounce(300)
val instantFilters = combine(_searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { tags, collectionId, bookmarkFilter, _ ->
Triple(tags, collectionId, bookmarkFilter)
}
combine(debouncedSearch, instantFilters) { query, (tags, collectionId, bookmarkFilter) ->
Quadruple(query, tags, collectionId, bookmarkFilter)
}
.flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
linkRepository.getLinksStream(
searchTerm = if (query.isBlank()) null else query,
searchTags = tags,
collectionId = collectionId,
bookmarkFilter = bookmarkFilter
)
}
.cachedIn(viewModelScope)
}
fun setTagFilter(tags: String?) {
_collectionId.value = null
@@ -185,8 +191,19 @@ class FeedViewModel @Inject constructor(
_bookmarkFilter.value = filter
}
fun togglePin(id: Int) {
viewModelScope.launch {
linkDao.getLinkById(id)?.let { link ->
linkDao.updatePinStatus(id, !link.isPinned)
_refreshTrigger.value++
}
}
}
fun refresh() {
syncManager.syncNow()
_refreshTrigger.value++
viewModelScope.launch {
syncManager.performFullSync()
_refreshTrigger.value++
}
}
}
@@ -29,7 +29,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -40,6 +42,7 @@ import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.Typography
import dev.jeziellago.compose.markdowntext.MarkdownText
import coil.compose.AsyncImage
import coil.request.ImageRequest
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
@@ -61,12 +64,14 @@ fun ListViewItem(
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.displayTitle,
onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick()
showDeleteDialog = false
},
@@ -89,7 +94,12 @@ fun ListViewItem(
// Thumbnail (List View)
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(200)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -137,7 +147,10 @@ fun ListViewItem(
}
// Pin button
IconButton(
onClick = { onTogglePin(link.id) },
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(32.dp)
) {
Icon(
@@ -176,10 +189,12 @@ fun ListViewItem(
if (link.description.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
MarkdownText(
markdown = link.description,
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
Text(
text = link.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
@@ -245,12 +260,14 @@ fun GridViewItem(
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.displayTitle,
onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick()
showDeleteDialog = false
},
@@ -274,7 +291,12 @@ fun GridViewItem(
// Thumbnail (Grid View)
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(400, 280)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -322,12 +344,14 @@ fun GridViewItem(
Spacer(modifier = Modifier.height(4.dp))
// Description with Markdown
// Description (plain text for scroll performance)
if (link.description.isNotBlank()) {
MarkdownText(
markdown = link.description,
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
Text(
text = link.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
@@ -393,7 +417,10 @@ fun GridViewItem(
Row {
// Pin button
IconButton(
onClick = { onTogglePin(link.id) },
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(24.dp)
) {
Icon(
@@ -460,12 +487,14 @@ fun CompactViewItem(
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.displayTitle,
onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick()
showDeleteDialog = false
},
@@ -565,7 +594,10 @@ fun CompactViewItem(
Row {
IconButton(
onClick = { onTogglePin(link.id) },
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(28.dp)
) {
Icon(
@@ -713,7 +745,12 @@ fun LinkDetailsView(
// Hero Image in Details
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(800, 400)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -58,6 +58,29 @@ fun AppNavGraph(
) {
val navController = rememberNavController()
val context = LocalContext.current
// If user is already logged in and has share data, navigate directly to Add screen
val hasShareData = shareUrl != null || (isFileShare && shareTitle != null)
androidx.compose.runtime.LaunchedEffect(hasShareData) {
if (hasShareData && startDestination != Screen.Login.route) {
val route = if (isFileShare && shareTitle != null) {
val encodedTitle = URLEncoder.encode(shareTitle, "UTF-8")
val encodedDesc = if (shareDescription != null) URLEncoder.encode(shareDescription, "UTF-8") else ""
val encodedTags = shareTags?.joinToString(",") { URLEncoder.encode(it, "UTF-8") } ?: ""
"add?url=&title=$encodedTitle&isShare=true&isFileShare=true&description=$encodedDesc&tags=$encodedTags"
} else if (shareUrl != null) {
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
val encodedTitle = if (shareTitle != null) URLEncoder.encode(shareTitle, "UTF-8") else ""
"add?url=$encodedUrl&title=$encodedTitle&isShare=true&isFileShare=false&description=&tags="
} else null
route?.let {
navController.navigate(it) {
popUpTo(startDestination) { inclusive = true }
}
}
}
}
NavHost(navController = navController, startDestination = startDestination) {
composable(Screen.Login.route) {
@@ -8,7 +8,10 @@ import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -18,14 +21,19 @@ class PinnedViewModel @Inject constructor(
private val linkDao: LinkDao
) : ViewModel() {
private val _refreshTrigger = MutableStateFlow(0)
@OptIn(ExperimentalCoroutinesApi::class)
val pagedPinnedLinks: Flow<PagingData<ShaarliLink>> =
linkRepository.getPinnedLinksStream()
.cachedIn(viewModelScope)
_refreshTrigger.flatMapLatest {
linkRepository.getPinnedLinksStream()
}.cachedIn(viewModelScope)
fun togglePin(id: Int) {
viewModelScope.launch {
linkDao.getLinkById(id)?.let { link ->
linkDao.updatePinStatus(id, !link.isPinned)
_refreshTrigger.value++
}
}
}
@@ -33,10 +33,16 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.BiometricAvailability
import com.shaarit.core.storage.LockTimeout
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.data.export.BookmarkImporter
import com.shaarit.ui.theme.AppTheme
import com.shaarit.ui.theme.ThemeMode
import com.shaarit.ui.theme.ThemePreferences
import com.shaarit.ui.theme.getColorSchemeForTheme
import com.shaarit.ui.theme.getLightColorSchemeForTheme
import java.text.SimpleDateFormat
import java.util.*
@@ -51,6 +57,7 @@ fun SettingsScreen(
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val currentTheme by themePreferences.currentTheme.collectAsState()
val currentThemeMode by themePreferences.themeMode.collectAsState()
// Export JSON
val exportJsonLauncher = rememberLauncherForActivityResult(
@@ -122,7 +129,22 @@ fun SettingsScreen(
item {
ThemePickerItem(
currentTheme = currentTheme,
onThemeSelected = { themePreferences.setTheme(it) }
currentThemeMode = currentThemeMode,
onThemeSelected = { themePreferences.setTheme(it) },
onThemeModeSelected = { themePreferences.setThemeMode(it) }
)
}
// Security Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Sécurité")
}
item {
SecuritySettingsItem(
securityPreferences = viewModel.securityPreferences,
biometricAuthManager = viewModel.biometricAuthManager
)
}
@@ -695,11 +717,19 @@ private fun HealthCheckStatusItem(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThemePickerItem(
currentTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit
currentThemeMode: ThemeMode,
onThemeSelected: (AppTheme) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit
) {
val isDynamic = currentThemeMode in listOf(
ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM
)
val isDynamicAvailable = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
Card(
modifier = Modifier.fillMaxWidth()
) {
@@ -724,7 +754,7 @@ private fun ThemePickerItem(
style = MaterialTheme.typography.bodyLarge
)
Text(
text = currentTheme.displayName,
text = "${currentTheme.displayName} \u2022 ${currentThemeMode.displayName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -733,17 +763,129 @@ private fun ThemePickerItem(
Spacer(modifier = Modifier.height(16.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp)
// Theme Mode selector (Dark / Light / System)
Text(
text = "Mode",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(AppTheme.entries.toList()) { theme ->
ThemePreviewCard(
theme = theme,
isSelected = theme == currentTheme,
onClick = { onThemeSelected(theme) }
val baseMode = if (isDynamic) {
when (currentThemeMode) {
ThemeMode.DYNAMIC_DARK -> ThemeMode.DARK
ThemeMode.DYNAMIC_LIGHT -> ThemeMode.LIGHT
ThemeMode.DYNAMIC_SYSTEM -> ThemeMode.SYSTEM
else -> currentThemeMode
}
} else currentThemeMode
listOf(
ThemeMode.DARK to Icons.Default.DarkMode,
ThemeMode.LIGHT to Icons.Default.LightMode,
ThemeMode.SYSTEM to Icons.Default.BrightnessAuto
).forEach { (mode, icon) ->
val isSelected = baseMode == mode
FilterChip(
selected = isSelected,
onClick = {
val newMode = if (isDynamic) {
when (mode) {
ThemeMode.DARK -> ThemeMode.DYNAMIC_DARK
ThemeMode.LIGHT -> ThemeMode.DYNAMIC_LIGHT
ThemeMode.SYSTEM -> ThemeMode.DYNAMIC_SYSTEM
else -> mode
}
} else mode
onThemeModeSelected(newMode)
},
label = { Text(mode.displayName, style = MaterialTheme.typography.labelSmall) },
leadingIcon = {
Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp))
},
modifier = Modifier.weight(1f)
)
}
}
// Material You toggle (Android 12+)
if (isDynamicAvailable) {
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = null,
tint = if (isDynamic) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Material You",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Couleurs extraites du fond d'\u00e9cran",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isDynamic,
onCheckedChange = { enabled ->
val baseMode2 = when (currentThemeMode) {
ThemeMode.DYNAMIC_DARK -> ThemeMode.DARK
ThemeMode.DYNAMIC_LIGHT -> ThemeMode.LIGHT
ThemeMode.DYNAMIC_SYSTEM -> ThemeMode.SYSTEM
ThemeMode.DARK -> ThemeMode.DARK
ThemeMode.LIGHT -> ThemeMode.LIGHT
ThemeMode.SYSTEM -> ThemeMode.SYSTEM
}
val newMode = if (enabled) {
when (baseMode2) {
ThemeMode.DARK -> ThemeMode.DYNAMIC_DARK
ThemeMode.LIGHT -> ThemeMode.DYNAMIC_LIGHT
ThemeMode.SYSTEM -> ThemeMode.DYNAMIC_SYSTEM
else -> ThemeMode.DYNAMIC_SYSTEM
}
} else baseMode2
onThemeModeSelected(newMode)
}
)
}
}
// Theme color picker (hidden when Material You is active)
if (!isDynamic) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Palette de couleurs",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(AppTheme.entries.toList()) { theme ->
ThemePreviewCard(
theme = theme,
isSelected = theme == currentTheme,
isDarkPreview = currentThemeMode != ThemeMode.LIGHT,
onClick = { onThemeSelected(theme) }
)
}
}
}
}
}
}
@@ -752,9 +894,10 @@ private fun ThemePickerItem(
private fun ThemePreviewCard(
theme: AppTheme,
isSelected: Boolean,
isDarkPreview: Boolean = true,
onClick: () -> Unit
) {
val colors = getColorSchemeForTheme(theme)
val colors = if (isDarkPreview) getColorSchemeForTheme(theme) else getLightColorSchemeForTheme(theme)
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else colors.outline
Column(
@@ -844,3 +987,170 @@ private fun ThemePreviewCard(
)
}
}
@Composable
private fun SecuritySettingsItem(
securityPreferences: SecurityPreferences,
biometricAuthManager: BiometricAuthManager
) {
val isBiometricEnabled by securityPreferences.isBiometricEnabled.collectAsState()
val lockTimeout by securityPreferences.lockTimeout.collectAsState()
val requireOnStartup by securityPreferences.requireOnStartup.collectAsState()
val requireOnResume by securityPreferences.requireOnResume.collectAsState()
val biometricAvailability = remember { biometricAuthManager.canAuthenticate() }
var showTimeoutMenu by remember { mutableStateOf(false) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Fingerprint,
contentDescription = null,
tint = if (isBiometricEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Verrouillage biométrique",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = when (biometricAvailability) {
BiometricAvailability.AVAILABLE -> if (isBiometricEnabled) "Activé" else "Désactivé"
BiometricAvailability.NO_HARDWARE -> "Matériel non disponible"
BiometricAvailability.NOT_ENROLLED -> "Aucune empreinte enregistrée"
BiometricAvailability.UNAVAILABLE -> "Non disponible"
},
style = MaterialTheme.typography.bodySmall,
color = if (isBiometricEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isBiometricEnabled,
onCheckedChange = { securityPreferences.setBiometricEnabled(it) },
enabled = biometricAvailability == BiometricAvailability.AVAILABLE
)
}
if (isBiometricEnabled) {
Spacer(modifier = Modifier.height(16.dp))
Divider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp))
// Lock timeout
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { showTimeoutMenu = true },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Délai de verrouillage",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = lockTimeout.displayName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Box {
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
DropdownMenu(
expanded = showTimeoutMenu,
onDismissRequest = { showTimeoutMenu = false }
) {
LockTimeout.entries.forEach { timeout ->
DropdownMenuItem(
text = { Text(timeout.displayName) },
onClick = {
securityPreferences.setLockTimeout(timeout)
showTimeoutMenu = false
},
leadingIcon = {
if (timeout == lockTimeout) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}
}
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Require on startup
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PowerSettingsNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "Verrouiller au démarrage",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = requireOnStartup,
onCheckedChange = { securityPreferences.setRequireOnStartup(it) }
)
}
// Require on resume
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PhonelinkLock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "Verrouiller en arrière-plan",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = requireOnResume,
onCheckedChange = { securityPreferences.setRequireOnResume(it) }
)
}
}
}
}
}
@@ -3,6 +3,8 @@ package com.shaarit.presentation.settings
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.core.storage.TokenManager
import com.shaarit.data.export.BookmarkExporter
import com.shaarit.data.export.BookmarkImporter
@@ -31,7 +33,9 @@ class SettingsViewModel @Inject constructor(
private val classifyBookmarksUseCase: ClassifyBookmarksUseCase,
private val tokenManager: TokenManager,
private val workManager: WorkManager,
val themePreferences: ThemePreferences
val themePreferences: ThemePreferences,
val securityPreferences: SecurityPreferences,
val biometricAuthManager: BiometricAuthManager
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@@ -0,0 +1,87 @@
package com.shaarit.ui.components
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
@Composable
fun VoiceInputButton(
onResult: (String) -> Unit,
modifier: Modifier = Modifier,
language: String = "fr-FR",
contentDescription: String = "Recherche vocale"
) {
val context = LocalContext.current
val isAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) }
val speechLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val matches = result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
matches?.firstOrNull()?.let { onResult(it) }
}
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
speechLauncher.launch(createRecognitionIntent(language))
} else {
Toast.makeText(context, "Permission micro requise pour la saisie vocale", Toast.LENGTH_SHORT).show()
}
}
IconButton(
onClick = {
if (!isAvailable) {
Toast.makeText(context, "Reconnaissance vocale non disponible", Toast.LENGTH_SHORT).show()
return@IconButton
}
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
speechLauncher.launch(createRecognitionIntent(language))
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
modifier = modifier,
enabled = isAvailable
) {
Icon(
imageVector = if (isAvailable) Icons.Default.Mic else Icons.Default.MicOff,
contentDescription = contentDescription,
tint = if (isAvailable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
}
}
private fun createRecognitionIntent(language: String): Intent {
return Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, language)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Parlez maintenant...")
}
}
@@ -36,6 +36,21 @@ enum class AppTheme(
}
}
enum class ThemeMode(val displayName: String) {
DARK("Sombre"),
LIGHT("Clair"),
SYSTEM("Système"),
DYNAMIC_DARK("Material You Sombre"),
DYNAMIC_LIGHT("Material You Clair"),
DYNAMIC_SYSTEM("Material You Auto");
companion object {
fun fromName(name: String): ThemeMode {
return entries.find { it.name == name } ?: DARK
}
}
}
@Singleton
class ThemePreferences @Inject constructor(
@ApplicationContext private val context: Context
@@ -46,17 +61,31 @@ class ThemePreferences @Inject constructor(
private val _currentTheme = MutableStateFlow(loadTheme())
val currentTheme: StateFlow<AppTheme> = _currentTheme.asStateFlow()
private val _themeMode = MutableStateFlow(loadThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
private fun loadTheme(): AppTheme {
val name = prefs.getString(KEY_THEME, AppTheme.DEFAULT.name) ?: AppTheme.DEFAULT.name
return AppTheme.fromName(name)
}
private fun loadThemeMode(): ThemeMode {
val name = prefs.getString(KEY_THEME_MODE, ThemeMode.DARK.name) ?: ThemeMode.DARK.name
return ThemeMode.fromName(name)
}
fun setTheme(theme: AppTheme) {
prefs.edit().putString(KEY_THEME, theme.name).apply()
_currentTheme.value = theme
}
fun setThemeMode(mode: ThemeMode) {
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
_themeMode.value = mode
}
companion object {
private const val KEY_THEME = "selected_theme"
private const val KEY_THEME_MODE = "theme_mode"
}
}
+426 -7
View File
@@ -1,12 +1,18 @@
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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@@ -466,24 +472,437 @@ fun getColorSchemeForTheme(appTheme: AppTheme): androidx.compose.material3.Color
}
}
// ══════════════════════════════════════════════════════════
// ══════════════════ LIGHT COLOR SCHEMES ══════════════════
// ══════════════════════════════════════════════════════════
private val DefaultLightColorScheme = lightColorScheme(
primary = Color(0xFF006B5A),
onPrimary = Color.White,
primaryContainer = Color(0xFFB2F5E6),
onPrimaryContainer = Color(0xFF00201A),
secondary = Color(0xFF0077B6),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD0E8FF),
onSecondaryContainer = Color(0xFF001E36),
tertiary = Color(0xFF00897B),
onTertiary = Color.White,
background = Color(0xFFF8FAFA),
onBackground = Color(0xFF1A1C1E),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1C1E),
surfaceVariant = Color(0xFFE7F0EE),
onSurfaceVariant = Color(0xFF404944),
outline = Color(0xFF707974),
outlineVariant = Color(0xFFC0C9C4),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002)
)
private val GitHubLightColorScheme = lightColorScheme(
primary = Color(0xFF0969DA),
onPrimary = Color.White,
primaryContainer = Color(0xFFDDF4FF),
onPrimaryContainer = Color(0xFF0A3069),
secondary = Color(0xFF1A7F37),
onSecondary = Color.White,
secondaryContainer = Color(0xFFDAFBE1),
onSecondaryContainer = Color(0xFF0E4F1F),
tertiary = Color(0xFF8250DF),
onTertiary = Color.White,
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF1F2328),
surface = Color(0xFFF6F8FA),
onSurface = Color(0xFF1F2328),
surfaceVariant = Color(0xFFEAEEF2),
onSurfaceVariant = Color(0xFF656D76),
outline = Color(0xFFD0D7DE),
outlineVariant = Color(0xFFE1E4E8),
error = Color(0xFFCF222E),
onError = Color.White,
errorContainer = Color(0xFFFFEBE9),
onErrorContainer = Color(0xFF82071E)
)
private val LinearLightColorScheme = lightColorScheme(
primary = Color(0xFF5E6AD2),
onPrimary = Color.White,
primaryContainer = Color(0xFFE0E1FF),
onPrimaryContainer = Color(0xFF1B1F5E),
secondary = Color(0xFF2B6CB0),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD0E4FF),
onSecondaryContainer = Color(0xFF0A2E52),
tertiary = Color(0xFFBF7B3F),
onTertiary = Color.White,
background = Color(0xFFFCFCFD),
onBackground = Color(0xFF1B1C24),
surface = Color(0xFFF5F5F7),
onSurface = Color(0xFF1B1C24),
surfaceVariant = Color(0xFFECECF0),
onSurfaceVariant = Color(0xFF5A5B66),
outline = Color(0xFFCCCDD4),
outlineVariant = Color(0xFFDDDDE2),
error = Color(0xFFD32F2F),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val SpotifyLightColorScheme = lightColorScheme(
primary = Color(0xFF1DB954),
onPrimary = Color.White,
primaryContainer = Color(0xFFD4F5E0),
onPrimaryContainer = Color(0xFF0B3D1E),
secondary = Color(0xFF1DB954),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD4F5E0),
onSecondaryContainer = Color(0xFF0B3D1E),
tertiary = Color(0xFF535353),
onTertiary = Color.White,
background = Color(0xFFF8F8F8),
onBackground = Color(0xFF191414),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF191414),
surfaceVariant = Color(0xFFF0F0F0),
onSurfaceVariant = Color(0xFF535353),
outline = Color(0xFFB3B3B3),
outlineVariant = Color(0xFFD9D9D9),
error = Color(0xFFE22134),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val NotionLightColorScheme = lightColorScheme(
primary = Color(0xFF2F7ABA),
onPrimary = Color.White,
primaryContainer = Color(0xFFD6EAFF),
onPrimaryContainer = Color(0xFF0A2E52),
secondary = Color(0xFFCB5A3C),
onSecondary = Color.White,
secondaryContainer = Color(0xFFFFDDD3),
onSecondaryContainer = Color(0xFF4D1A0D),
tertiary = Color(0xFF4D8B6F),
onTertiary = Color.White,
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF37352F),
surface = Color(0xFFF7F6F3),
onSurface = Color(0xFF37352F),
surfaceVariant = Color(0xFFEFEEEB),
onSurfaceVariant = Color(0xFF787774),
outline = Color(0xFFD3D1CB),
outlineVariant = Color(0xFFE3E2DE),
error = Color(0xFFEB5757),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val DiscordLightColorScheme = lightColorScheme(
primary = Color(0xFF5865F2),
onPrimary = Color.White,
primaryContainer = Color(0xFFE0E3FF),
onPrimaryContainer = Color(0xFF1E2175),
secondary = Color(0xFF248046),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD4F5E0),
onSecondaryContainer = Color(0xFF0B3D1E),
tertiary = Color(0xFFC4960C),
onTertiary = Color.White,
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF313338),
surface = Color(0xFFF2F3F5),
onSurface = Color(0xFF313338),
surfaceVariant = Color(0xFFEBEDEF),
onSurfaceVariant = Color(0xFF5C5E66),
outline = Color(0xFFD1D3D8),
outlineVariant = Color(0xFFE1E3E8),
error = Color(0xFFED4245),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val DraculaLightColorScheme = lightColorScheme(
primary = Color(0xFF7C3AED),
onPrimary = Color.White,
primaryContainer = Color(0xFFEDE0FF),
onPrimaryContainer = Color(0xFF2D0A6E),
secondary = Color(0xFF16A34A),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD4F5E0),
onSecondaryContainer = Color(0xFF0B3D1E),
tertiary = Color(0xFFDB2777),
onTertiary = Color.White,
background = Color(0xFFFCFAFF),
onBackground = Color(0xFF1E1E2E),
surface = Color(0xFFF5F3FA),
onSurface = Color(0xFF1E1E2E),
surfaceVariant = Color(0xFFEDE9F5),
onSurfaceVariant = Color(0xFF5C5670),
outline = Color(0xFFCBC4D8),
outlineVariant = Color(0xFFDDD8E8),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val OneDarkProLightColorScheme = lightColorScheme(
primary = Color(0xFF2563EB),
onPrimary = Color.White,
primaryContainer = Color(0xFFDBEAFE),
onPrimaryContainer = Color(0xFF0A2E6E),
secondary = Color(0xFF4D7C0F),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE2F5C5),
onSecondaryContainer = Color(0xFF1A3305),
tertiary = Color(0xFFCA8A04),
onTertiary = Color.White,
background = Color(0xFFFAFAFB),
onBackground = Color(0xFF282C34),
surface = Color(0xFFF3F4F6),
onSurface = Color(0xFF282C34),
surfaceVariant = Color(0xFFECEDF0),
onSurfaceVariant = Color(0xFF5A5D66),
outline = Color(0xFFCCCED4),
outlineVariant = Color(0xFFDDDEE2),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val TokyoNightLightColorScheme = lightColorScheme(
primary = Color(0xFF3B5FD9),
onPrimary = Color.White,
primaryContainer = Color(0xFFDDE3FF),
onPrimaryContainer = Color(0xFF0F1E5E),
secondary = Color(0xFF5A8A1E),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE2F5C5),
onSecondaryContainer = Color(0xFF1A3305),
tertiary = Color(0xFF7C3AED),
onTertiary = Color.White,
background = Color(0xFFF8F9FC),
onBackground = Color(0xFF1A1B26),
surface = Color(0xFFF0F2F8),
onSurface = Color(0xFF1A1B26),
surfaceVariant = Color(0xFFE8EAF2),
onSurfaceVariant = Color(0xFF545770),
outline = Color(0xFFC4C7D8),
outlineVariant = Color(0xFFD8DAE8),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val NordLightColorScheme = lightColorScheme(
primary = Color(0xFF5E81AC),
onPrimary = Color.White,
primaryContainer = Color(0xFFD8E6F3),
onPrimaryContainer = Color(0xFF1E3450),
secondary = Color(0xFF6B8F71),
onSecondary = Color.White,
secondaryContainer = Color(0xFFDAEEDC),
onSecondaryContainer = Color(0xFF233828),
tertiary = Color(0xFFBF8B2E),
onTertiary = Color.White,
background = Color(0xFFF5F7FA),
onBackground = Color(0xFF2E3440),
surface = Color(0xFFECEFF4),
onSurface = Color(0xFF2E3440),
surfaceVariant = Color(0xFFE5E9F0),
onSurfaceVariant = Color(0xFF4C566A),
outline = Color(0xFFBCC3CE),
outlineVariant = Color(0xFFD8DEE9),
error = Color(0xFFBF616A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val NightOwlLightColorScheme = lightColorScheme(
primary = Color(0xFF0C7B93),
onPrimary = Color.White,
primaryContainer = Color(0xFFCCEFF5),
onPrimaryContainer = Color(0xFF003540),
secondary = Color(0xFF4D7C0F),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE2F5C5),
onSecondaryContainer = Color(0xFF1A3305),
tertiary = Color(0xFF9333EA),
onTertiary = Color.White,
background = Color(0xFFFBFCFD),
onBackground = Color(0xFF011627),
surface = Color(0xFFF0F4F8),
onSurface = Color(0xFF011627),
surfaceVariant = Color(0xFFE5ECF2),
onSurfaceVariant = Color(0xFF4A5568),
outline = Color(0xFFBCC8D6),
outlineVariant = Color(0xFFD4DEE8),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val AnthraciteLightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF00897B),
onSecondary = Color.White,
secondaryContainer = Color(0xFFB2F5E6),
onSecondaryContainer = Color(0xFF00201A),
tertiary = Color(0xFFC2185B),
onTertiary = Color.White,
background = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFF4F0F5),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE7E0EC),
onSurfaceVariant = Color(0xFF49454F),
outline = Color(0xFF79747E),
outlineVariant = Color(0xFFCAC4D0),
error = Color(0xFFB3261E),
onError = Color.White,
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B)
)
private val CyberpunkLightColorScheme = lightColorScheme(
primary = Color(0xFF0097A7),
onPrimary = Color.White,
primaryContainer = Color(0xFFCCF2F6),
onPrimaryContainer = Color(0xFF003840),
secondary = Color(0xFFC2185B),
onSecondary = Color.White,
secondaryContainer = Color(0xFFFFD9E3),
onSecondaryContainer = Color(0xFF4D0620),
tertiary = Color(0xFFC6A700),
onTertiary = Color.White,
background = Color(0xFFF8F8FC),
onBackground = Color(0xFF0A0A14),
surface = Color(0xFFF0F0F8),
onSurface = Color(0xFF0A0A14),
surfaceVariant = Color(0xFFE8E8F2),
onSurfaceVariant = Color(0xFF4A4A60),
outline = Color(0xFFB8B8CC),
outlineVariant = Color(0xFFD4D4E2),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val NavyEleganceLightColorScheme = lightColorScheme(
primary = Color(0xFFB8860B),
onPrimary = Color.White,
primaryContainer = Color(0xFFFFF0C8),
onPrimaryContainer = Color(0xFF3D2E00),
secondary = Color(0xFF6B7280),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE5E7EB),
onSecondaryContainer = Color(0xFF1F2937),
tertiary = Color(0xFF3B82F6),
onTertiary = Color.White,
background = Color(0xFFFAFBFD),
onBackground = Color(0xFF0B1929),
surface = Color(0xFFF0F3F8),
onSurface = Color(0xFF0B1929),
surfaceVariant = Color(0xFFE8ECF2),
onSurfaceVariant = Color(0xFF4A5568),
outline = Color(0xFFBCC5D2),
outlineVariant = Color(0xFFD4DAE4),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val EarthyLightColorScheme = lightColorScheme(
primary = Color(0xFF2E7D32),
onPrimary = Color.White,
primaryContainer = Color(0xFFD4EDDA),
onPrimaryContainer = Color(0xFF0B3D0E),
secondary = Color(0xFF8D6E4C),
onSecondary = Color.White,
secondaryContainer = Color(0xFFF5E6D3),
onSecondaryContainer = Color(0xFF3D2B18),
tertiary = Color(0xFF558B2F),
onTertiary = Color.White,
background = Color(0xFFFAF8F5),
onBackground = Color(0xFF1A1510),
surface = Color(0xFFF5F2ED),
onSurface = Color(0xFF1A1510),
surfaceVariant = Color(0xFFEDE8E0),
onSurfaceVariant = Color(0xFF5A5248),
outline = Color(0xFFC4BAA8),
outlineVariant = Color(0xFFD8D0C2),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
fun getLightColorSchemeForTheme(appTheme: AppTheme): androidx.compose.material3.ColorScheme {
return when (appTheme) {
AppTheme.DEFAULT -> DefaultLightColorScheme
AppTheme.GITHUB -> GitHubLightColorScheme
AppTheme.LINEAR -> LinearLightColorScheme
AppTheme.SPOTIFY -> SpotifyLightColorScheme
AppTheme.NOTION -> NotionLightColorScheme
AppTheme.DISCORD -> DiscordLightColorScheme
AppTheme.DRACULA -> DraculaLightColorScheme
AppTheme.ONE_DARK_PRO -> OneDarkProLightColorScheme
AppTheme.TOKYO_NIGHT -> TokyoNightLightColorScheme
AppTheme.NORD -> NordLightColorScheme
AppTheme.NIGHT_OWL -> NightOwlLightColorScheme
AppTheme.ANTHRACITE -> AnthraciteLightColorScheme
AppTheme.CYBERPUNK -> CyberpunkLightColorScheme
AppTheme.NAVY_ELEGANCE -> NavyEleganceLightColorScheme
AppTheme.EARTHY -> EarthyLightColorScheme
}
}
@Composable
fun ShaarItTheme(
appTheme: AppTheme = AppTheme.DEFAULT,
themeMode: ThemeMode = ThemeMode.DARK,
content: @Composable () -> Unit
) {
// Always use the explicit color scheme for the selected theme
val colorScheme = getColorSchemeForTheme(appTheme)
// All custom themes are dark
val isEffectivelyDark = true
val isDarkTheme = when (themeMode) {
ThemeMode.DARK, ThemeMode.DYNAMIC_DARK -> true
ThemeMode.LIGHT, ThemeMode.DYNAMIC_LIGHT -> false
ThemeMode.SYSTEM, ThemeMode.DYNAMIC_SYSTEM -> isSystemInDarkTheme()
}
val useDynamicColors = themeMode in listOf(
ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM
)
val colorScheme = when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
isDarkTheme -> getColorSchemeForTheme(appTheme)
else -> getLightColorSchemeForTheme(appTheme)
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isEffectivelyDark
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isDarkTheme
}
}