auth settings

This commit is contained in:
m
2026-09-12 01:08:10 +02:00
parent ea11f28420
commit 45028dd70a
14 changed files with 345 additions and 51 deletions
+2 -5
View File
@@ -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
}
@@ -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)
} }
} }
@@ -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),
)
}
@@ -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>
+2
View File
@@ -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" }