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:
@@ -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"
|
||||
|
||||
@@ -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/**;->**(**)**
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user