auth settings
This commit is contained in:
@@ -8,9 +8,6 @@ plugins {
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
val apiBaseUrl: String = providers.gradleProperty("VAULTDROP_API_BASE_URL")
|
||||
.getOrElse("http://10.0.2.2:8080/api/v1")
|
||||
|
||||
android {
|
||||
namespace = "com.vaultdrop.mobile"
|
||||
compileSdk = 35
|
||||
@@ -21,8 +18,6 @@ android {
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -80,5 +75,7 @@ dependencies {
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.timber)
|
||||
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
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.data.remote.ApiService
|
||||
import com.vaultdrop.mobile.data.remote.AuthInterceptor
|
||||
import com.vaultdrop.mobile.data.remote.ServerUrlInterceptor
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -14,6 +15,7 @@ import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -28,7 +30,10 @@ object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
|
||||
fun provideOkHttpClient(
|
||||
serverUrlInterceptor: ServerUrlInterceptor,
|
||||
authInterceptor: AuthInterceptor,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BASIC
|
||||
@@ -37,6 +42,7 @@ object NetworkModule {
|
||||
}
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(serverUrlInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
@@ -44,11 +50,27 @@ object NetworkModule {
|
||||
.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
|
||||
@Singleton
|
||||
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit =
|
||||
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)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
@@ -57,4 +79,6 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideApiService(retrofit: Retrofit): ApiService =
|
||||
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 SYNC_MODE = "sync_mode"
|
||||
const val THEME = "theme"
|
||||
const val SERVER_URL = "server_url"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vaultdrop.mobile.ui.auth
|
||||
|
||||
import com.vaultdrop.mobile.R
|
||||
import com.vaultdrop.mobile.data.remote.dto.UserDto
|
||||
|
||||
/** État global de connexion — équivalent de `AuthStatus` JS. */
|
||||
@@ -13,4 +14,11 @@ sealed interface AuthState {
|
||||
data class LoginUiState(
|
||||
val isSubmitting: Boolean = false,
|
||||
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
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
viewModelScope.launch {
|
||||
Timber.d("auth: session purgée (signOut)")
|
||||
|
||||
@@ -108,7 +108,7 @@ fun LoginScreen(
|
||||
loginUi.error?.let { error ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(errorResFor(error)),
|
||||
text = stringResource(authErrorResFor(error)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -126,10 +126,4 @@ fun LoginScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
@@ -23,6 +19,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -30,9 +28,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -41,7 +40,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vaultdrop.mobile.R
|
||||
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.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -49,9 +49,9 @@ import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Consultation d'un document : titre + métadonnées, navigation au swipe entre
|
||||
* les documents (même fil que l'écran d'accueil). Le lecteur sera intégré par
|
||||
* la suite dans la zone centrale.
|
||||
* Consultation d'un document : lecteur intégré (PDF / image / texte) ou fallback
|
||||
* externe, carte de métadonnées, navigation au swipe entre les documents (même
|
||||
* fil que l'écran d'accueil).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -77,7 +77,17 @@ fun DocumentViewerScreen(
|
||||
|
||||
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(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@@ -105,36 +115,31 @@ fun DocumentViewerScreen(
|
||||
.padding(padding),
|
||||
) { page ->
|
||||
val file = documents.getOrNull(page)
|
||||
if (file != null) DocumentViewerPage(file)
|
||||
if (file != null) DocumentViewerPage(file = file, onOpenExternalFailed = onOpenExternalFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentViewerPage(file: FileEntity, modifier: Modifier = Modifier) {
|
||||
private fun DocumentViewerPage(
|
||||
file: FileEntity,
|
||||
onOpenExternalFailed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
// Emplacement du futur lecteur de documents.
|
||||
Box(
|
||||
// Lecteur central : dispatch par catégorie (PDF / image / texte /
|
||||
// délégation externe), état dédié pour les fichiers cloud-only.
|
||||
DocumentContentViewer(
|
||||
file = file,
|
||||
onOpenExternalFailed = onOpenExternalFailed,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.folderdetail.FolderDetailScreen
|
||||
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
||||
@@ -43,7 +44,10 @@ private fun String?.toNavTab(): NavTab = when (this) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavGraph(syncViewModel: SyncViewModel) {
|
||||
fun NavGraph(
|
||||
authViewModel: AuthViewModel,
|
||||
syncViewModel: SyncViewModel,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
@@ -84,6 +88,7 @@ fun NavGraph(syncViewModel: SyncViewModel) {
|
||||
SettingsScreen(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = onTabSelected,
|
||||
authViewModel = authViewModel,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
||||
@@ -46,7 +46,7 @@ fun VaultDropApp(
|
||||
when (authState) {
|
||||
AuthState.Loading -> SplashScreen()
|
||||
AuthState.SignedOut -> LoginScreen()
|
||||
AuthState.Local -> NavGraph(syncViewModel = syncViewModel)
|
||||
is AuthState.SignedIn -> NavGraph(syncViewModel = syncViewModel)
|
||||
AuthState.Local -> NavGraph(authViewModel = authViewModel, 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.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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.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.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vaultdrop.mobile.R
|
||||
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.NavTab
|
||||
|
||||
@@ -34,9 +57,18 @@ import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||
fun SettingsScreen(
|
||||
selectedTab: NavTab,
|
||||
onTabSelected: (NavTab) -> Unit,
|
||||
authViewModel: AuthViewModel,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
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) {
|
||||
ThemePreference.DARK -> true
|
||||
ThemePreference.LIGHT -> false
|
||||
@@ -54,7 +86,8 @@ fun SettingsScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
HorizontalDivider()
|
||||
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,
|
||||
trailing: @Composable () -> Unit,
|
||||
) {
|
||||
androidx.compose.foundation.layout.Row(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
@@ -109,4 +266,15 @@ private fun SettingsRow(
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
+49
-2
@@ -2,25 +2,72 @@ package com.vaultdrop.mobile.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vaultdrop.mobile.domain.ServerConfigStore
|
||||
import com.vaultdrop.mobile.domain.ThemePreference
|
||||
import com.vaultdrop.mobile.domain.ThemePreferenceStore
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val themePreferenceStore: ThemePreferenceStore,
|
||||
private val serverConfigStore: ServerConfigStore,
|
||||
) : ViewModel() {
|
||||
|
||||
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 {
|
||||
viewModelScope.launch { themePreferenceStore.load() }
|
||||
viewModelScope.launch {
|
||||
themePreferenceStore.load()
|
||||
serverConfigStore.load()
|
||||
_serverUrl.value = serverConfigStore.current
|
||||
}
|
||||
}
|
||||
|
||||
fun setTheme(theme: ThemePreference) {
|
||||
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 -->
|
||||
<string name="document">Document</string>
|
||||
<string name="document_reader_coming">Document reader coming soon</string>
|
||||
<string name="document_size">Size</string>
|
||||
<string name="document_type">Type</string>
|
||||
<string name="document_added_at">Added 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 -->
|
||||
<string name="nav_files">Files</string>
|
||||
@@ -30,6 +38,14 @@
|
||||
<string name="settings_dark_mode">Dark mode</string>
|
||||
<string name="settings_dark_mode_on">Dark 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 -->
|
||||
<string name="search">Search</string>
|
||||
|
||||
@@ -17,11 +17,19 @@
|
||||
|
||||
<!-- Consultation de documents -->
|
||||
<string name="document">Document</string>
|
||||
<string name="document_reader_coming">Lecteur de documents à venir</string>
|
||||
<string name="document_size">Taille</string>
|
||||
<string name="document_type">Type</string>
|
||||
<string name="document_added_at">Ajouté 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 -->
|
||||
<string name="nav_files">Fichiers</string>
|
||||
@@ -31,6 +39,14 @@
|
||||
<string name="settings_dark_mode">Mode sombre</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_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 -->
|
||||
<string name="search">Recherche</string>
|
||||
|
||||
@@ -15,6 +15,7 @@ okhttp = "4.12.0"
|
||||
moshi = "1.15.1"
|
||||
securityCrypto = "1.0.0"
|
||||
timber = "5.0.1"
|
||||
coil = "2.7.0"
|
||||
|
||||
[libraries]
|
||||
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" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user