From 45028dd70ab625893fb63483b25d86c1663f7e7d Mon Sep 17 00:00:00 2001 From: m Date: Sat, 12 Sep 2026 01:08:10 +0200 Subject: [PATCH] auth settings --- mobile-kotlin/app/build.gradle.kts | 7 +- .../com/vaultdrop/mobile/di/NetworkModule.kt | 28 ++- .../com/vaultdrop/mobile/domain/PrefKeys.kt | 1 + .../com/vaultdrop/mobile/ui/auth/AuthState.kt | 10 +- .../vaultdrop/mobile/ui/auth/AuthViewModel.kt | 11 ++ .../vaultdrop/mobile/ui/auth/LoginScreen.kt | 8 +- .../ui/document/DocumentViewerScreen.kt | 59 +++--- .../mobile/ui/navigation/NavGraph.kt | 7 +- .../mobile/ui/navigation/VaultDropApp.kt | 4 +- .../mobile/ui/settings/SettingsScreen.kt | 172 +++++++++++++++++- .../mobile/ui/settings/SettingsViewModel.kt | 51 +++++- .../app/src/main/res/values-en/strings.xml | 18 +- .../app/src/main/res/values/strings.xml | 18 +- mobile-kotlin/gradle/libs.versions.toml | 2 + 14 files changed, 345 insertions(+), 51 deletions(-) diff --git a/mobile-kotlin/app/build.gradle.kts b/mobile-kotlin/app/build.gradle.kts index e9135ad..3e8b747 100644 --- a/mobile-kotlin/app/build.gradle.kts +++ b/mobile-kotlin/app/build.gradle.kts @@ -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) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/NetworkModule.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/NetworkModule.kt index fa05949..3002f9d 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/NetworkModule.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/di/NetworkModule.kt @@ -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/" } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt index 795faea..031b37b 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/domain/PrefKeys.kt @@ -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" } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthState.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthState.kt index a344f9c..8f656f5 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthState.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthState.kt @@ -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, -) \ No newline at end of file +) + +/** 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 +} \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt index 079229b..2ead6c7 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/AuthViewModel.kt @@ -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)") diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/LoginScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/LoginScreen.kt index c563dcf..7171a64 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/LoginScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/auth/LoginScreen.kt @@ -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 } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt index be0a49c..2177825 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/document/DocumentViewerScreen.kt @@ -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)) diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt index e363810..cbb7089 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/NavGraph.kt @@ -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( diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt index 29eb675..f5215f7 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/navigation/VaultDropApp.kt @@ -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) } } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt index 472af8f..296c7e3 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsScreen.kt @@ -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), + ) } \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsViewModel.kt b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsViewModel.kt index 1a4a52c..f211ae6 100644 --- a/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsViewModel.kt +++ b/mobile-kotlin/app/src/main/java/com/vaultdrop/mobile/ui/settings/SettingsViewModel.kt @@ -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 = themePreferenceStore.theme + private val _serverUrl = MutableStateFlow(serverConfigStore.current) + val serverUrl: StateFlow = _serverUrl.asStateFlow() + + private val _testUiState = MutableStateFlow(ServerTestUiState()) + val testUiState: StateFlow = _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) } } -} \ No newline at end of file + + /** 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, +) \ No newline at end of file diff --git a/mobile-kotlin/app/src/main/res/values-en/strings.xml b/mobile-kotlin/app/src/main/res/values-en/strings.xml index fc96623..83c7334 100644 --- a/mobile-kotlin/app/src/main/res/values-en/strings.xml +++ b/mobile-kotlin/app/src/main/res/values-en/strings.xml @@ -16,11 +16,19 @@ Document - Document reader coming soon Size Type Added on Modified on + Open with + No built-in reader — open this file in an external app. + No app can open this file. + Cloud only + This file is not downloaded on this device. Sign in to your server to make it available. + Preview truncated — the full file is not displayed. + Reading document… + Could not read this document. + Unreadable page Files @@ -30,6 +38,14 @@ Dark mode Dark theme active Light theme active + Server + Server address + Test connection + Server reachable + Server unreachable — check the address and your connection. + Sign in + Sign out + Signed in as %1$s Search diff --git a/mobile-kotlin/app/src/main/res/values/strings.xml b/mobile-kotlin/app/src/main/res/values/strings.xml index f824cff..579b844 100644 --- a/mobile-kotlin/app/src/main/res/values/strings.xml +++ b/mobile-kotlin/app/src/main/res/values/strings.xml @@ -17,11 +17,19 @@ Document - Lecteur de documents à venir Taille Type Ajouté le Modifié le + Ouvrir avec + Aucun lecteur intégré — ouvre ce fichier dans une application externe. + Aucune application ne peut ouvrir ce fichier. + Disponible uniquement sur le cloud + Ce fichier n\'est pas téléchargé sur cet appareil. Connecte-toi à ton serveur pour le rendre disponible. + Aperçu tronqué — le fichier complet n\'est pas affiché. + Lecture du document… + Impossible de lire ce document. + Page illisible Fichiers @@ -31,6 +39,14 @@ Mode sombre Thème sombre actif Thème clair actif + Serveur + Adresse du serveur + Tester la connexion + Serveur joignable + Serveur injoignable — vérifie l\'adresse et ta connexion. + Se connecter + Se déconnecter + Connecté en tant que %1$s Recherche diff --git a/mobile-kotlin/gradle/libs.versions.toml b/mobile-kotlin/gradle/libs.versions.toml index 5244860..ff15aac 100644 --- a/mobile-kotlin/gradle/libs.versions.toml +++ b/mobile-kotlin/gradle/libs.versions.toml @@ -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" }