auth settings
This commit is contained in:
@@ -8,9 +8,6 @@ plugins {
|
|||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
}
|
}
|
||||||
|
|
||||||
val apiBaseUrl: String = providers.gradleProperty("VAULTDROP_API_BASE_URL")
|
|
||||||
.getOrElse("http://10.0.2.2:8080/api/v1")
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.vaultdrop.mobile"
|
namespace = "com.vaultdrop.mobile"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -21,8 +18,6 @@ android {
|
|||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
|
||||||
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -80,5 +75,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
|
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
|||||||
import com.vaultdrop.mobile.BuildConfig
|
import com.vaultdrop.mobile.BuildConfig
|
||||||
import com.vaultdrop.mobile.data.remote.ApiService
|
import com.vaultdrop.mobile.data.remote.ApiService
|
||||||
import com.vaultdrop.mobile.data.remote.AuthInterceptor
|
import com.vaultdrop.mobile.data.remote.AuthInterceptor
|
||||||
|
import com.vaultdrop.mobile.data.remote.ServerUrlInterceptor
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -14,6 +15,7 @@ import okhttp3.logging.HttpLoggingInterceptor
|
|||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -28,7 +30,10 @@ object NetworkModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
fun provideOkHttpClient(
|
||||||
|
serverUrlInterceptor: ServerUrlInterceptor,
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
): OkHttpClient {
|
||||||
val logging = HttpLoggingInterceptor().apply {
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
level = if (BuildConfig.DEBUG) {
|
level = if (BuildConfig.DEBUG) {
|
||||||
HttpLoggingInterceptor.Level.BASIC
|
HttpLoggingInterceptor.Level.BASIC
|
||||||
@@ -37,6 +42,7 @@ object NetworkModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(serverUrlInterceptor)
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
@@ -44,11 +50,27 @@ object NetworkModule {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client léger sans intercepteurs pour le test de connexion directe :
|
||||||
|
* il pinger l'URL saisie (peut-être non sauvegardée) sans la faire
|
||||||
|
* réécrire par [ServerUrlInterceptor].
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@Named("health")
|
||||||
|
fun provideHealthOkHttpClient(): OkHttpClient =
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit =
|
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit =
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL.trimEnd('/') + "/")
|
// Placeholder : chaque requête est réécrite vers la base configurée
|
||||||
|
// (Réglages) par ServerUrlInterceptor.
|
||||||
|
.baseUrl(PLACEHOLDER_BASE_URL)
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||||
.build()
|
.build()
|
||||||
@@ -57,4 +79,6 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideApiService(retrofit: Retrofit): ApiService =
|
fun provideApiService(retrofit: Retrofit): ApiService =
|
||||||
retrofit.create(ApiService::class.java)
|
retrofit.create(ApiService::class.java)
|
||||||
|
|
||||||
|
private const val PLACEHOLDER_BASE_URL = "http://vaultdrop.invalid/"
|
||||||
}
|
}
|
||||||
@@ -6,4 +6,5 @@ object PrefKeys {
|
|||||||
const val ACTIVE_USER_ID = "active_user_id"
|
const val ACTIVE_USER_ID = "active_user_id"
|
||||||
const val SYNC_MODE = "sync_mode"
|
const val SYNC_MODE = "sync_mode"
|
||||||
const val THEME = "theme"
|
const val THEME = "theme"
|
||||||
|
const val SERVER_URL = "server_url"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.vaultdrop.mobile.ui.auth
|
package com.vaultdrop.mobile.ui.auth
|
||||||
|
|
||||||
|
import com.vaultdrop.mobile.R
|
||||||
import com.vaultdrop.mobile.data.remote.dto.UserDto
|
import com.vaultdrop.mobile.data.remote.dto.UserDto
|
||||||
|
|
||||||
/** État global de connexion — équivalent de `AuthStatus` JS. */
|
/** État global de connexion — équivalent de `AuthStatus` JS. */
|
||||||
@@ -14,3 +15,10 @@ data class LoginUiState(
|
|||||||
val isSubmitting: Boolean = false,
|
val isSubmitting: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Mappe un code d'erreur contractuel vers une ressource de libellé (login). */
|
||||||
|
internal fun authErrorResFor(code: String): Int = when (code) {
|
||||||
|
"REQUIRED" -> R.string.login_error_required
|
||||||
|
"UNAUTHORIZED" -> R.string.login_error_unauthorized
|
||||||
|
else -> R.string.login_error_generic
|
||||||
|
}
|
||||||
@@ -70,6 +70,17 @@ class AuthViewModel @Inject constructor(
|
|||||||
_authState.value = AuthState.Local
|
_authState.value = AuthState.Local
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Purge la session et revient en mode local (sans repasser par le login). */
|
||||||
|
fun signOutToLocal() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
Timber.d("auth: session purgée (mode local)")
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
sessionManager.clear()
|
||||||
|
}
|
||||||
|
_authState.value = AuthState.Local
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun signOut() {
|
fun signOut() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
Timber.d("auth: session purgée (signOut)")
|
Timber.d("auth: session purgée (signOut)")
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ fun LoginScreen(
|
|||||||
loginUi.error?.let { error ->
|
loginUi.error?.let { error ->
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(errorResFor(error)),
|
text = stringResource(authErrorResFor(error)),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -127,9 +127,3 @@ fun LoginScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun errorResFor(code: String): Int = when (code) {
|
|
||||||
"REQUIRED" -> R.string.login_error_required
|
|
||||||
"UNAUTHORIZED" -> R.string.login_error_unauthorized
|
|
||||||
else -> R.string.login_error_generic
|
|
||||||
}
|
|
||||||
+32
-27
@@ -1,9 +1,6 @@
|
|||||||
package com.vaultdrop.mobile.ui.document
|
package com.vaultdrop.mobile.ui.document
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -13,7 +10,6 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -23,6 +19,8 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -30,9 +28,10 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -41,7 +40,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vaultdrop.mobile.R
|
import com.vaultdrop.mobile.R
|
||||||
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
import com.vaultdrop.mobile.ui.document.content.DocumentContentViewer
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -49,9 +49,9 @@ import java.time.format.FormatStyle
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consultation d'un document : titre + métadonnées, navigation au swipe entre
|
* Consultation d'un document : lecteur intégré (PDF / image / texte) ou fallback
|
||||||
* les documents (même fil que l'écran d'accueil). Le lecteur sera intégré par
|
* externe, carte de métadonnées, navigation au swipe entre les documents (même
|
||||||
* la suite dans la zone centrale.
|
* fil que l'écran d'accueil).
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -77,7 +77,17 @@ fun DocumentViewerScreen(
|
|||||||
|
|
||||||
val currentFile = documents.getOrNull(pagerState.currentPage)
|
val currentFile = documents.getOrNull(pagerState.currentPage)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val onOpenExternalFailed: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(context.getString(R.string.document_open_error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -105,36 +115,31 @@ fun DocumentViewerScreen(
|
|||||||
.padding(padding),
|
.padding(padding),
|
||||||
) { page ->
|
) { page ->
|
||||||
val file = documents.getOrNull(page)
|
val file = documents.getOrNull(page)
|
||||||
if (file != null) DocumentViewerPage(file)
|
if (file != null) DocumentViewerPage(file = file, onOpenExternalFailed = onOpenExternalFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DocumentViewerPage(file: FileEntity, modifier: Modifier = Modifier) {
|
private fun DocumentViewerPage(
|
||||||
|
file: FileEntity,
|
||||||
|
onOpenExternalFailed: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
// Emplacement du futur lecteur de documents.
|
// Lecteur central : dispatch par catégorie (PDF / image / texte /
|
||||||
Box(
|
// délégation externe), état dédié pour les fichiers cloud-only.
|
||||||
|
DocumentContentViewer(
|
||||||
|
file = file,
|
||||||
|
onOpenExternalFailed = onOpenExternalFailed,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f),
|
||||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)),
|
)
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
FileCategoryIcon(file = file, size = 64.dp)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.document_reader_coming),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vaultdrop.mobile.features.sync.SyncViewModel
|
import com.vaultdrop.mobile.features.sync.SyncViewModel
|
||||||
|
import com.vaultdrop.mobile.ui.auth.AuthViewModel
|
||||||
import com.vaultdrop.mobile.ui.document.DocumentViewerScreen
|
import com.vaultdrop.mobile.ui.document.DocumentViewerScreen
|
||||||
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
|
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
|
||||||
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
||||||
@@ -43,7 +44,10 @@ private fun String?.toNavTab(): NavTab = when (this) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph(syncViewModel: SyncViewModel) {
|
fun NavGraph(
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
syncViewModel: SyncViewModel,
|
||||||
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
@@ -84,6 +88,7 @@ fun NavGraph(syncViewModel: SyncViewModel) {
|
|||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = onTabSelected,
|
||||||
|
authViewModel = authViewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fun VaultDropApp(
|
|||||||
when (authState) {
|
when (authState) {
|
||||||
AuthState.Loading -> SplashScreen()
|
AuthState.Loading -> SplashScreen()
|
||||||
AuthState.SignedOut -> LoginScreen()
|
AuthState.SignedOut -> LoginScreen()
|
||||||
AuthState.Local -> NavGraph(syncViewModel = syncViewModel)
|
AuthState.Local -> NavGraph(authViewModel = authViewModel, syncViewModel = syncViewModel)
|
||||||
is AuthState.SignedIn -> NavGraph(syncViewModel = syncViewModel)
|
is AuthState.SignedIn -> NavGraph(authViewModel = authViewModel, syncViewModel = syncViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+170
-2
@@ -2,30 +2,53 @@ package com.vaultdrop.mobile.ui.settings
|
|||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.DarkMode
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vaultdrop.mobile.R
|
import com.vaultdrop.mobile.R
|
||||||
import com.vaultdrop.mobile.domain.ThemePreference
|
import com.vaultdrop.mobile.domain.ThemePreference
|
||||||
|
import com.vaultdrop.mobile.ui.auth.AuthState
|
||||||
|
import com.vaultdrop.mobile.ui.auth.AuthViewModel
|
||||||
|
import com.vaultdrop.mobile.ui.auth.authErrorResFor
|
||||||
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
||||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||||
|
|
||||||
@@ -34,9 +57,18 @@ import com.vaultdrop.mobile.ui.navigation.NavTab
|
|||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
selectedTab: NavTab,
|
selectedTab: NavTab,
|
||||||
onTabSelected: (NavTab) -> Unit,
|
onTabSelected: (NavTab) -> Unit,
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val theme by viewModel.theme.collectAsStateWithLifecycle()
|
val theme by viewModel.theme.collectAsStateWithLifecycle()
|
||||||
|
val serverUrl by viewModel.serverUrl.collectAsStateWithLifecycle()
|
||||||
|
val testUiState by viewModel.testUiState.collectAsStateWithLifecycle()
|
||||||
|
val authState by authViewModel.authState.collectAsStateWithLifecycle()
|
||||||
|
val loginUi by authViewModel.loginUiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var username by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
val darkNow = when (theme) {
|
val darkNow = when (theme) {
|
||||||
ThemePreference.DARK -> true
|
ThemePreference.DARK -> true
|
||||||
ThemePreference.LIGHT -> false
|
ThemePreference.LIGHT -> false
|
||||||
@@ -54,7 +86,8 @@ fun SettingsScreen(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding),
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
@@ -73,6 +106,130 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
SettingsSectionTitle(stringResource(R.string.settings_server))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = serverUrl,
|
||||||
|
onValueChange = viewModel::onServerUrlChange,
|
||||||
|
label = { Text(stringResource(R.string.settings_server_url)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = viewModel::testConnection,
|
||||||
|
enabled = !testUiState.isTesting,
|
||||||
|
) {
|
||||||
|
if (testUiState.isTesting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.settings_test_connection))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testUiState.success?.let { success ->
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (success) R.string.settings_test_success
|
||||||
|
else R.string.settings_test_failure,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (success) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
when (val state = authState) {
|
||||||
|
is AuthState.SignedIn -> {
|
||||||
|
SettingsRow(
|
||||||
|
icon = { Icon(Icons.Filled.Person, contentDescription = null) },
|
||||||
|
title = stringResource(R.string.settings_connected_as, state.user.username),
|
||||||
|
subtitle = stringResource(R.string.settings),
|
||||||
|
trailing = {
|
||||||
|
TextButton(onClick = authViewModel::signOutToLocal) {
|
||||||
|
Text(stringResource(R.string.settings_disconnect))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AuthState.Loading -> Unit
|
||||||
|
else -> {
|
||||||
|
SettingsSectionTitle(stringResource(R.string.settings_connect))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text(stringResource(R.string.login_username)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text(stringResource(R.string.login_password)) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { authViewModel.signIn(username, password) },
|
||||||
|
enabled = !loginUi.isSubmitting,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
if (loginUi.isSubmitting) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.settings_connect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loginUi.error?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(authErrorResFor(error)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +241,7 @@ private fun SettingsRow(
|
|||||||
subtitle: String,
|
subtitle: String,
|
||||||
trailing: @Composable () -> Unit,
|
trailing: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
androidx.compose.foundation.layout.Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
@@ -110,3 +267,14 @@ private fun SettingsRow(
|
|||||||
trailing()
|
trailing()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSectionTitle(title: String) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
+48
-1
@@ -2,25 +2,72 @@ package com.vaultdrop.mobile.ui.settings
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vaultdrop.mobile.domain.ServerConfigStore
|
||||||
import com.vaultdrop.mobile.domain.ThemePreference
|
import com.vaultdrop.mobile.domain.ThemePreference
|
||||||
import com.vaultdrop.mobile.domain.ThemePreferenceStore
|
import com.vaultdrop.mobile.domain.ThemePreferenceStore
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val themePreferenceStore: ThemePreferenceStore,
|
private val themePreferenceStore: ThemePreferenceStore,
|
||||||
|
private val serverConfigStore: ServerConfigStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val theme: StateFlow<ThemePreference> = themePreferenceStore.theme
|
val theme: StateFlow<ThemePreference> = themePreferenceStore.theme
|
||||||
|
|
||||||
|
private val _serverUrl = MutableStateFlow(serverConfigStore.current)
|
||||||
|
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||||
|
|
||||||
|
private val _testUiState = MutableStateFlow(ServerTestUiState())
|
||||||
|
val testUiState: StateFlow<ServerTestUiState> = _testUiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch { themePreferenceStore.load() }
|
viewModelScope.launch {
|
||||||
|
themePreferenceStore.load()
|
||||||
|
serverConfigStore.load()
|
||||||
|
_serverUrl.value = serverConfigStore.current
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTheme(theme: ThemePreference) {
|
fun setTheme(theme: ThemePreference) {
|
||||||
viewModelScope.launch { themePreferenceStore.set(theme) }
|
viewModelScope.launch { themePreferenceStore.set(theme) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persiste la base serveur au fil de la frappe (affichage = saisie brute). */
|
||||||
|
fun onServerUrlChange(url: String) {
|
||||||
|
_serverUrl.value = url
|
||||||
|
viewModelScope.launch { serverConfigStore.set(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ping `/health` sur l'URL saisie (même non sauvegardée). */
|
||||||
|
fun testConnection() {
|
||||||
|
val url = _serverUrl.value
|
||||||
|
if (url.isBlank()) {
|
||||||
|
Timber.w("settings: test HTTP sur URL vide")
|
||||||
|
_testUiState.value = ServerTestUiState(success = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_testUiState.value = ServerTestUiState(isTesting = true)
|
||||||
|
_testUiState.value = serverConfigStore.checkHealth(url).fold(
|
||||||
|
onSuccess = { ServerTestUiState(success = true) },
|
||||||
|
onFailure = { e ->
|
||||||
|
Timber.d(e, "settings: test serveur échoué")
|
||||||
|
ServerTestUiState(success = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ServerTestUiState(
|
||||||
|
val isTesting: Boolean = false,
|
||||||
|
/** null = pas encore testé ; true/false = résultat du dernier test. */
|
||||||
|
val success: Boolean? = null,
|
||||||
|
)
|
||||||
@@ -16,11 +16,19 @@
|
|||||||
|
|
||||||
<!-- Document viewer -->
|
<!-- Document viewer -->
|
||||||
<string name="document">Document</string>
|
<string name="document">Document</string>
|
||||||
<string name="document_reader_coming">Document reader coming soon</string>
|
|
||||||
<string name="document_size">Size</string>
|
<string name="document_size">Size</string>
|
||||||
<string name="document_type">Type</string>
|
<string name="document_type">Type</string>
|
||||||
<string name="document_added_at">Added on</string>
|
<string name="document_added_at">Added on</string>
|
||||||
<string name="document_modified_at">Modified on</string>
|
<string name="document_modified_at">Modified on</string>
|
||||||
|
<string name="document_open_with">Open with</string>
|
||||||
|
<string name="document_open_with_hint">No built-in reader — open this file in an external app.</string>
|
||||||
|
<string name="document_open_error">No app can open this file.</string>
|
||||||
|
<string name="document_cloud_only">Cloud only</string>
|
||||||
|
<string name="document_cloud_only_hint">This file is not downloaded on this device. Sign in to your server to make it available.</string>
|
||||||
|
<string name="document_truncated_notice">Preview truncated — the full file is not displayed.</string>
|
||||||
|
<string name="document_loading">Reading document…</string>
|
||||||
|
<string name="document_cannot_read">Could not read this document.</string>
|
||||||
|
<string name="document_page_unreadable">Unreadable page</string>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<string name="nav_files">Files</string>
|
<string name="nav_files">Files</string>
|
||||||
@@ -30,6 +38,14 @@
|
|||||||
<string name="settings_dark_mode">Dark mode</string>
|
<string name="settings_dark_mode">Dark mode</string>
|
||||||
<string name="settings_dark_mode_on">Dark theme active</string>
|
<string name="settings_dark_mode_on">Dark theme active</string>
|
||||||
<string name="settings_dark_mode_off">Light theme active</string>
|
<string name="settings_dark_mode_off">Light theme active</string>
|
||||||
|
<string name="settings_server">Server</string>
|
||||||
|
<string name="settings_server_url">Server address</string>
|
||||||
|
<string name="settings_test_connection">Test connection</string>
|
||||||
|
<string name="settings_test_success">Server reachable</string>
|
||||||
|
<string name="settings_test_failure">Server unreachable — check the address and your connection.</string>
|
||||||
|
<string name="settings_connect">Sign in</string>
|
||||||
|
<string name="settings_disconnect">Sign out</string>
|
||||||
|
<string name="settings_connected_as">Signed in as %1$s</string>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<string name="search">Search</string>
|
<string name="search">Search</string>
|
||||||
|
|||||||
@@ -17,11 +17,19 @@
|
|||||||
|
|
||||||
<!-- Consultation de documents -->
|
<!-- Consultation de documents -->
|
||||||
<string name="document">Document</string>
|
<string name="document">Document</string>
|
||||||
<string name="document_reader_coming">Lecteur de documents à venir</string>
|
|
||||||
<string name="document_size">Taille</string>
|
<string name="document_size">Taille</string>
|
||||||
<string name="document_type">Type</string>
|
<string name="document_type">Type</string>
|
||||||
<string name="document_added_at">Ajouté le</string>
|
<string name="document_added_at">Ajouté le</string>
|
||||||
<string name="document_modified_at">Modifié le</string>
|
<string name="document_modified_at">Modifié le</string>
|
||||||
|
<string name="document_open_with">Ouvrir avec</string>
|
||||||
|
<string name="document_open_with_hint">Aucun lecteur intégré — ouvre ce fichier dans une application externe.</string>
|
||||||
|
<string name="document_open_error">Aucune application ne peut ouvrir ce fichier.</string>
|
||||||
|
<string name="document_cloud_only">Disponible uniquement sur le cloud</string>
|
||||||
|
<string name="document_cloud_only_hint">Ce fichier n\'est pas téléchargé sur cet appareil. Connecte-toi à ton serveur pour le rendre disponible.</string>
|
||||||
|
<string name="document_truncated_notice">Aperçu tronqué — le fichier complet n\'est pas affiché.</string>
|
||||||
|
<string name="document_loading">Lecture du document…</string>
|
||||||
|
<string name="document_cannot_read">Impossible de lire ce document.</string>
|
||||||
|
<string name="document_page_unreadable">Page illisible</string>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<string name="nav_files">Fichiers</string>
|
<string name="nav_files">Fichiers</string>
|
||||||
@@ -31,6 +39,14 @@
|
|||||||
<string name="settings_dark_mode">Mode sombre</string>
|
<string name="settings_dark_mode">Mode sombre</string>
|
||||||
<string name="settings_dark_mode_on">Thème sombre actif</string>
|
<string name="settings_dark_mode_on">Thème sombre actif</string>
|
||||||
<string name="settings_dark_mode_off">Thème clair actif</string>
|
<string name="settings_dark_mode_off">Thème clair actif</string>
|
||||||
|
<string name="settings_server">Serveur</string>
|
||||||
|
<string name="settings_server_url">Adresse du serveur</string>
|
||||||
|
<string name="settings_test_connection">Tester la connexion</string>
|
||||||
|
<string name="settings_test_success">Serveur joignable</string>
|
||||||
|
<string name="settings_test_failure">Serveur injoignable — vérifie l\'adresse et ta connexion.</string>
|
||||||
|
<string name="settings_connect">Se connecter</string>
|
||||||
|
<string name="settings_disconnect">Se déconnecter</string>
|
||||||
|
<string name="settings_connected_as">Connecté en tant que %1$s</string>
|
||||||
|
|
||||||
<!-- Recherche -->
|
<!-- Recherche -->
|
||||||
<string name="search">Recherche</string>
|
<string name="search">Recherche</string>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ okhttp = "4.12.0"
|
|||||||
moshi = "1.15.1"
|
moshi = "1.15.1"
|
||||||
securityCrypto = "1.0.0"
|
securityCrypto = "1.0.0"
|
||||||
timber = "5.0.1"
|
timber = "5.0.1"
|
||||||
|
coil = "2.7.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -44,6 +45,7 @@ moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
|
|||||||
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
|
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
|
||||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||||
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
|
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
|
||||||
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user