feat(auth): add user profile, phone login, and account deletion

This commit is contained in:
iu
2026-10-12 14:26:32 +03:00
parent 1cb9aaf198
commit 86aff695df
46 changed files with 2893 additions and 134 deletions
@@ -18,6 +18,7 @@ import com.galo.android.data.prefs.SettingsState
import com.galo.android.ui.album.AlbumScreen
import com.galo.android.ui.albums.AlbumsScreen
import com.galo.android.ui.auth.AuthScreen
import com.galo.android.ui.profile.ProfileScreen
import com.galo.android.ui.setup.SetupScreen
import com.galo.android.ui.upload.UploadScreen
import com.galo.android.ui.viewer.ViewerScreen
@@ -27,6 +28,7 @@ object Routes {
const val AUTH = "auth"
const val ALBUMS = "albums"
const val UPLOADS = "uploads"
const val PROFILE = "profile"
const val ALBUM = "album/{albumId}"
const val VIEWER = "album/{albumId}/viewer?index={index}"
@@ -82,6 +84,7 @@ fun GaloNavHost(app: GaloApplication) {
factory = factory,
onOpenAlbum = { id -> navController.navigate(Routes.album(id)) },
onOpenUploads = { navController.navigate(Routes.UPLOADS) },
onOpenProfile = { navController.navigate(Routes.PROFILE) },
onLoggedOut = {
navController.navigate(Routes.AUTH) {
popUpTo(Routes.ALBUMS) { inclusive = true }
@@ -89,6 +92,17 @@ fun GaloNavHost(app: GaloApplication) {
},
)
}
composable(Routes.PROFILE) {
ProfileScreen(
factory = factory,
onLoggedOut = {
navController.navigate(Routes.AUTH) {
popUpTo(Routes.ALBUMS) { inclusive = true }
}
},
onBack = { navController.popBackStack() },
)
}
composable(Routes.UPLOADS) {
UploadScreen(
factory = factory,
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
import com.galo.android.GaloApplication
import com.galo.android.ui.albums.AlbumsViewModel
import com.galo.android.ui.auth.AuthViewModel
import com.galo.android.ui.profile.ProfileViewModel
import com.galo.android.ui.setup.SetupViewModel
import com.galo.android.ui.upload.UploadViewModel
@@ -19,6 +20,8 @@ class GaloViewModelFactory(private val app: GaloApplication) : ViewModelProvider
modelClass.isAssignableFrom(AuthViewModel::class.java) -> AuthViewModel(container)
modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container)
modelClass.isAssignableFrom(UploadViewModel::class.java) -> UploadViewModel(container)
modelClass.isAssignableFrom(ProfileViewModel::class.java) ->
ProfileViewModel(container.api, container.tokenStore)
else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
}
return viewModel as T
@@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
@@ -50,6 +51,7 @@ fun AlbumsScreen(
factory: GaloViewModelFactory,
onOpenAlbum: (String) -> Unit,
onOpenUploads: () -> Unit,
onOpenProfile: () -> Unit,
onLoggedOut: () -> Unit,
) {
val viewModel: AlbumsViewModel = viewModel(factory = factory)
@@ -58,6 +60,7 @@ fun AlbumsScreen(
state = state,
onOpenAlbum = onOpenAlbum,
onOpenUploads = onOpenUploads,
onOpenProfile = onOpenProfile,
onRefresh = viewModel::refresh,
onLogout = { viewModel.logout(onLoggedOut) },
)
@@ -69,6 +72,7 @@ fun AlbumsContent(
state: AlbumsViewModel.UiState,
onOpenAlbum: (String) -> Unit,
onOpenUploads: () -> Unit,
onOpenProfile: () -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit,
) {
@@ -77,6 +81,9 @@ fun AlbumsContent(
TopAppBar(
title = { Text("Альбомы") },
actions = {
IconButton(onClick = onOpenProfile) {
Icon(Icons.Filled.Person, contentDescription = "Профиль")
}
IconButton(onClick = onOpenUploads) {
Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки")
}
@@ -22,10 +22,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.galo.android.ui.GaloViewModelFactory
const val TAG_AUTH_IDENTIFIER = "auth_identifier"
const val TAG_AUTH_EMAIL = "auth_email"
const val TAG_AUTH_PHONE = "auth_phone"
const val TAG_AUTH_PASSWORD = "auth_password"
const val TAG_AUTH_NAME = "auth_name"
const val TAG_AUTH_SUBMIT = "auth_submit"
const val TAG_AUTH_TOGGLE = "auth_toggle"
@Composable
fun AuthScreen(
@@ -39,6 +42,37 @@ fun AuthScreen(
viewModel.authenticated.collect { onAuthenticated() }
}
AuthContent(
state = state,
onIdentifierChange = viewModel::onIdentifierChange,
onEmailChange = viewModel::onEmailChange,
onPhoneChange = viewModel::onPhoneChange,
onPasswordChange = viewModel::onPasswordChange,
onNameChange = viewModel::onNameChange,
onSubmit = viewModel::submit,
onToggleMode = {
viewModel.setMode(
if (state.mode == AuthViewModel.Mode.LOGIN) {
AuthViewModel.Mode.REGISTER
} else {
AuthViewModel.Mode.LOGIN
},
)
},
)
}
@Composable
fun AuthContent(
state: AuthViewModel.UiState,
onIdentifierChange: (String) -> Unit,
onEmailChange: (String) -> Unit,
onPhoneChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onNameChange: (String) -> Unit,
onSubmit: () -> Unit,
onToggleMode: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -52,23 +86,44 @@ fun AuthScreen(
if (state.mode == AuthViewModel.Mode.REGISTER) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
onValueChange = onNameChange,
label = { Text("Имя") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_NAME),
)
OutlinedTextField(
value = state.email,
onValueChange = onEmailChange,
label = { Text("Email (необязательно)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_EMAIL),
)
OutlinedTextField(
value = state.phone,
onValueChange = onPhoneChange,
label = { Text("Телефон (необязательно)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_PHONE),
)
Text(
"Нужен хотя бы один из email или телефона",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
OutlinedTextField(
value = state.identifier,
onValueChange = onIdentifierChange,
label = { Text("Email или телефон") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_IDENTIFIER),
)
}
OutlinedTextField(
value = state.email,
onValueChange = viewModel::onEmailChange,
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_EMAIL),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
onValueChange = onPasswordChange,
label = { Text("Пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
@@ -79,7 +134,7 @@ fun AuthScreen(
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
Button(
onClick = viewModel::submit,
onClick = onSubmit,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_SUBMIT),
) {
@@ -93,15 +148,8 @@ fun AuthScreen(
}
Row {
TextButton(
onClick = {
viewModel.setMode(
if (state.mode == AuthViewModel.Mode.LOGIN) {
AuthViewModel.Mode.REGISTER
} else {
AuthViewModel.Mode.LOGIN
},
)
},
onClick = onToggleMode,
modifier = Modifier.testTag(TAG_AUTH_TOGGLE),
) {
Text(
if (state.mode == AuthViewModel.Mode.LOGIN) {
@@ -18,7 +18,9 @@ class AuthViewModel(private val container: AppContainer) : ViewModel() {
data class UiState(
val mode: Mode = Mode.LOGIN,
val identifier: String = "",
val email: String = "",
val phone: String = "",
val password: String = "",
val name: String = "",
val loading: Boolean = false,
@@ -31,8 +33,13 @@ class AuthViewModel(private val container: AppContainer) : ViewModel() {
private val _authenticated = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val authenticated = _authenticated.asSharedFlow()
fun onIdentifierChange(value: String) =
_state.update { it.copy(identifier = value, error = null) }
fun onEmailChange(value: String) = _state.update { it.copy(email = value, error = null) }
fun onPhoneChange(value: String) = _state.update { it.copy(phone = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null) }
@@ -41,25 +48,30 @@ class AuthViewModel(private val container: AppContainer) : ViewModel() {
fun submit() {
val current = _state.value
val identifier = current.identifier.trim()
val email = current.email.trim()
when {
email.isEmpty() || current.password.isEmpty() -> {
_state.update { it.copy(error = "Заполните email и пароль") }
return
}
val phone = current.phone.trim()
current.mode == Mode.REGISTER && current.name.trim().isEmpty() -> {
_state.update { it.copy(error = "Укажите имя") }
return
}
val validationError = when {
current.password.isEmpty() -> "Введите пароль"
current.mode == Mode.LOGIN && identifier.isEmpty() -> "Введите email или телефон"
current.mode == Mode.REGISTER && email.isEmpty() && phone.isEmpty() ->
"Укажите email или телефон"
current.mode == Mode.REGISTER && current.name.trim().isEmpty() -> "Укажите имя"
else -> null
}
if (validationError != null) {
_state.update { it.copy(error = validationError) }
return
}
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
try {
val tokens = when (current.mode) {
Mode.LOGIN -> container.api.login(email, current.password)
Mode.REGISTER -> container.api.register(email, current.password, current.name.trim())
Mode.LOGIN -> container.api.login(identifier, current.password)
Mode.REGISTER ->
container.api.register(email, phone, current.password, current.name.trim())
}
container.tokenStore.save(tokens.accessToken, tokens.refreshToken)
_state.update { it.copy(loading = false) }
@@ -0,0 +1,246 @@
package com.galo.android.ui.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.galo.android.ui.GaloViewModelFactory
const val TAG_PROFILE_EMAIL = "profile_email"
const val TAG_PROFILE_PHONE = "profile_phone"
const val TAG_PROFILE_NAME = "profile_name"
const val TAG_PROFILE_SAVE_NAME = "profile_save_name"
const val TAG_PROFILE_NEW_EMAIL = "profile_new_email"
const val TAG_PROFILE_NEW_PHONE = "profile_new_phone"
const val TAG_PROFILE_ADD_ID = "profile_add_identifier"
const val TAG_PROFILE_CURRENT_PASSWORD = "profile_current_password"
const val TAG_PROFILE_NEW_PASSWORD = "profile_new_password"
const val TAG_PROFILE_CHANGE_PASSWORD = "profile_change_password"
const val TAG_PROFILE_DELETE_PASSWORD = "profile_delete_password"
const val TAG_PROFILE_DELETE = "profile_delete"
@Composable
fun ProfileScreen(
factory: GaloViewModelFactory,
onLoggedOut: () -> Unit,
onBack: () -> Unit,
) {
val viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loggedOut.collect { onLoggedOut() }
}
ProfileContent(
state = state,
onNameChange = viewModel::onNameChange,
onSaveName = viewModel::saveName,
onNewEmailChange = viewModel::onNewEmailChange,
onNewPhoneChange = viewModel::onNewPhoneChange,
onAddIdentifier = viewModel::addIdentifier,
onCurrentPasswordChange = viewModel::onCurrentPasswordChange,
onNewPasswordChange = viewModel::onNewPasswordChange,
onChangePassword = viewModel::changePassword,
onDeletePasswordChange = viewModel::onDeletePasswordChange,
onDeleteAccount = viewModel::deleteAccount,
onBack = onBack,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileContent(
state: ProfileViewModel.UiState,
onNameChange: (String) -> Unit,
onSaveName: () -> Unit,
onNewEmailChange: (String) -> Unit,
onNewPhoneChange: (String) -> Unit,
onAddIdentifier: () -> Unit,
onCurrentPasswordChange: (String) -> Unit,
onNewPasswordChange: (String) -> Unit,
onChangePassword: () -> Unit,
onDeletePasswordChange: (String) -> Unit,
onDeleteAccount: () -> Unit,
onBack: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Профиль") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
)
},
) { padding ->
if (state.loading) {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Идентификаторы", style = MaterialTheme.typography.titleMedium)
Text(
"Email: ${state.email ?: "—"}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag(TAG_PROFILE_EMAIL),
)
Text(
"Телефон: ${state.phone ?: "—"}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag(TAG_PROFILE_PHONE),
)
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Имя", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.name,
onValueChange = onNameChange,
label = { Text("Отображаемое имя") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_NAME),
)
Button(
onClick = onSaveName,
modifier = Modifier.testTag(TAG_PROFILE_SAVE_NAME),
) {
Text("Сохранить")
}
}
if (state.email.isNullOrBlank() || state.phone.isNullOrBlank()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Добавить идентификатор", style = MaterialTheme.typography.titleMedium)
if (state.email.isNullOrBlank()) {
OutlinedTextField(
value = state.newEmail,
onValueChange = onNewEmailChange,
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_NEW_EMAIL),
)
}
if (state.phone.isNullOrBlank()) {
OutlinedTextField(
value = state.newPhone,
onValueChange = onNewPhoneChange,
label = { Text("Телефон (E.164)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_NEW_PHONE),
)
}
Button(
onClick = onAddIdentifier,
modifier = Modifier.testTag(TAG_PROFILE_ADD_ID),
) {
Text("Добавить")
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Смена пароля", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.currentPassword,
onValueChange = onCurrentPasswordChange,
label = { Text("Текущий пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_CURRENT_PASSWORD),
)
OutlinedTextField(
value = state.newPassword,
onValueChange = onNewPasswordChange,
label = { Text("Новый пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_NEW_PASSWORD),
)
Button(
onClick = onChangePassword,
modifier = Modifier.testTag(TAG_PROFILE_CHANGE_PASSWORD),
) {
Text("Сменить пароль")
}
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Удаление аккаунта",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
OutlinedTextField(
value = state.deletePassword,
onValueChange = onDeletePasswordChange,
label = { Text("Подтвердите пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().testTag(TAG_PROFILE_DELETE_PASSWORD),
)
OutlinedButton(
onClick = onDeleteAccount,
modifier = Modifier.testTag(TAG_PROFILE_DELETE),
) {
Text("Удалить аккаунт")
}
}
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
state.notice?.let {
Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@@ -0,0 +1,170 @@
package com.galo.android.ui.profile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.auth.TokenStore
import com.galo.android.data.network.ErrorMapper
import com.galo.android.data.network.ProfileApi
import com.galo.android.domain.model.ApiException
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Drives the profile screen. Password change and account deletion clear the
* local session and signal [loggedOut] so the caller returns to the auth screen.
*/
class ProfileViewModel(
private val api: ProfileApi,
private val tokenStore: TokenStore,
) : ViewModel() {
data class UiState(
val loading: Boolean = true,
val name: String = "",
val email: String? = null,
val phone: String? = null,
val newEmail: String = "",
val newPhone: String = "",
val currentPassword: String = "",
val newPassword: String = "",
val deletePassword: String = "",
val error: String? = null,
val notice: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()
private val _loggedOut = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val loggedOut = _loggedOut.asSharedFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
try {
val user = api.me()
_state.update {
it.copy(loading = false, name = user.name, email = user.email, phone = user.phone)
}
} catch (e: ApiException) {
_state.update { it.copy(loading = false, error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(loading = false, error = e.message ?: "Не удалось загрузить профиль") }
}
}
}
fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null, notice = null) }
fun onNewEmailChange(value: String) =
_state.update { it.copy(newEmail = value, error = null, notice = null) }
fun onNewPhoneChange(value: String) =
_state.update { it.copy(newPhone = value, error = null, notice = null) }
fun onCurrentPasswordChange(value: String) =
_state.update { it.copy(currentPassword = value, error = null) }
fun onNewPasswordChange(value: String) =
_state.update { it.copy(newPassword = value, error = null) }
fun onDeletePasswordChange(value: String) =
_state.update { it.copy(deletePassword = value, error = null) }
fun saveName() {
val name = _state.value.name.trim()
if (name.isEmpty()) {
_state.update { it.copy(error = "Укажите имя") }
return
}
mutate(notice = "Имя сохранено") { api.updateProfile(name) }
}
fun addIdentifier() {
val current = _state.value
val email = current.newEmail.trim()
val phone = current.newPhone.trim()
if (email.isEmpty() && phone.isEmpty()) {
_state.update { it.copy(error = "Укажите email или телефон") }
return
}
viewModelScope.launch {
_state.update { it.copy(error = null, notice = null) }
try {
val user = api.addIdentifier(email, phone)
_state.update {
it.copy(email = user.email, phone = user.phone, newEmail = "", newPhone = "", notice = "Идентификатор добавлен")
}
} catch (e: ApiException) {
_state.update { it.copy(error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "Не удалось добавить идентификатор") }
}
}
}
fun changePassword() {
val current = _state.value
if (current.newPassword.length < 8) {
_state.update { it.copy(error = "Пароль должен быть не короче 8 символов") }
return
}
viewModelScope.launch {
_state.update { it.copy(error = null) }
try {
api.changePassword(current.currentPassword, current.newPassword)
logout()
} catch (e: ApiException) {
_state.update { it.copy(error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "Не удалось сменить пароль") }
}
}
}
fun deleteAccount() {
val password = _state.value.deletePassword
if (password.isEmpty()) {
_state.update { it.copy(error = "Введите пароль") }
return
}
viewModelScope.launch {
_state.update { it.copy(error = null) }
try {
api.deleteAccount(password)
logout()
} catch (e: ApiException) {
_state.update { it.copy(error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "Не удалось удалить аккаунт") }
}
}
}
private fun logout() {
tokenStore.clear()
_loggedOut.tryEmit(Unit)
}
private inline fun mutate(notice: String, crossinline block: suspend () -> com.galo.android.data.network.dto.UserDto) {
viewModelScope.launch {
_state.update { it.copy(error = null, notice = null) }
try {
val user = block()
_state.update { it.copy(name = user.name, email = user.email, phone = user.phone, notice = notice) }
} catch (e: ApiException) {
_state.update { it.copy(error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "Ошибка сохранения") }
}
}
}
}
@@ -40,6 +40,7 @@ class AlbumsContentTest {
state = AlbumsViewModel.UiState(albums = listOf(album("a1", "Summer"))),
onOpenAlbum = {},
onOpenUploads = {},
onOpenProfile = {},
onRefresh = {},
onLogout = {},
)
@@ -63,6 +64,7 @@ class AlbumsContentTest {
),
onOpenAlbum = {},
onOpenUploads = {},
onOpenProfile = {},
onRefresh = {},
onLogout = {},
)
@@ -79,6 +81,7 @@ class AlbumsContentTest {
state = AlbumsViewModel.UiState(),
onOpenAlbum = {},
onOpenUploads = {},
onOpenProfile = {},
onRefresh = {},
onLogout = {},
)
@@ -0,0 +1,52 @@
package com.galo.android.ui.auth
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import com.galo.android.ui.theme.GaloTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], qualifiers = "w411dp-h2200dp")
class AuthContentTest {
@get:Rule
val compose = createComposeRule()
private fun render(state: AuthViewModel.UiState) {
compose.setContent {
GaloTheme(dynamicColor = false) {
AuthContent(
state = state,
onIdentifierChange = {},
onEmailChange = {},
onPhoneChange = {},
onPasswordChange = {},
onNameChange = {},
onSubmit = {},
onToggleMode = {},
)
}
}
}
@Test
fun `login shows a single identifier field`() {
render(AuthViewModel.UiState(mode = AuthViewModel.Mode.LOGIN))
compose.onNodeWithTag(TAG_AUTH_IDENTIFIER).assertIsDisplayed()
compose.onNodeWithTag(TAG_AUTH_PASSWORD).assertIsDisplayed()
}
@Test
fun `register shows optional email and phone fields`() {
render(AuthViewModel.UiState(mode = AuthViewModel.Mode.REGISTER))
compose.onNodeWithTag(TAG_AUTH_NAME).assertIsDisplayed()
compose.onNodeWithTag(TAG_AUTH_EMAIL).assertIsDisplayed()
compose.onNodeWithTag(TAG_AUTH_PHONE).assertIsDisplayed()
compose.onNodeWithTag(TAG_AUTH_SUBMIT).assertIsDisplayed()
}
}
@@ -0,0 +1,94 @@
package com.galo.android.ui.profile
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.galo.android.ui.theme.GaloTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], qualifiers = "w411dp-h2400dp")
class ProfileContentTest {
@get:Rule
val compose = createComposeRule()
private fun render(
state: ProfileViewModel.UiState,
onAddIdentifier: () -> Unit = {},
onDeleteAccount: () -> Unit = {},
onChangePassword: () -> Unit = {},
) {
compose.setContent {
GaloTheme(dynamicColor = false) {
ProfileContent(
state = state,
onNameChange = {},
onSaveName = {},
onNewEmailChange = {},
onNewPhoneChange = {},
onAddIdentifier = onAddIdentifier,
onCurrentPasswordChange = {},
onNewPasswordChange = {},
onChangePassword = onChangePassword,
onDeletePasswordChange = {},
onDeleteAccount = onDeleteAccount,
onBack = {},
)
}
}
}
@Test
fun `identifiers and forms are shown`() {
render(
ProfileViewModel.UiState(
loading = false,
name = "Tester",
email = "t@test.local",
phone = "+15550000001",
),
)
compose.onNodeWithText("Email: t@test.local").assertIsDisplayed()
compose.onNodeWithText("Телефон: +15550000001").assertIsDisplayed()
compose.onNodeWithTag(TAG_PROFILE_NAME).assertIsDisplayed()
compose.onNodeWithTag(TAG_PROFILE_CHANGE_PASSWORD).assertIsDisplayed()
compose.onNodeWithTag(TAG_PROFILE_DELETE).assertIsDisplayed()
}
@Test
fun `missing identifier exposes add form`() {
var added = false
render(
ProfileViewModel.UiState(
loading = false,
name = "Tester",
email = "t@test.local",
phone = null,
),
onAddIdentifier = { added = true },
)
compose.onNodeWithTag(TAG_PROFILE_NEW_PHONE).assertIsDisplayed()
compose.onNodeWithTag(TAG_PROFILE_ADD_ID).performClick()
assertTrue(added)
}
@Test
fun `delete button triggers confirmation flow`() {
var deleted = false
render(
ProfileViewModel.UiState(loading = false, email = "t@test.local"),
onDeleteAccount = { deleted = true },
)
compose.onNodeWithTag(TAG_PROFILE_DELETE_PASSWORD).assertIsDisplayed()
compose.onNodeWithTag(TAG_PROFILE_DELETE).performClick()
assertTrue(deleted)
}
}
@@ -0,0 +1,115 @@
package com.galo.android.ui.profile
import com.galo.android.data.auth.InMemoryTokenStore
import com.galo.android.data.network.ProfileApi
import com.galo.android.data.network.dto.UserDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class ProfileViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private class FakeApi : ProfileApi {
var changed: Pair<String, String>? = null
var deletedPassword: String? = null
override suspend fun me(): UserDto =
UserDto(id = "u1", email = "a@test.local", phone = "+15550000001", name = "Tester")
override suspend fun updateProfile(name: String): UserDto =
UserDto(id = "u1", email = "a@test.local", phone = "+15550000001", name = name)
override suspend fun changePassword(currentPassword: String, newPassword: String) {
changed = currentPassword to newPassword
}
override suspend fun addIdentifier(email: String, phone: String): UserDto =
UserDto(
id = "u1",
email = email.ifBlank { null },
phone = phone.ifBlank { null },
name = "Tester",
)
override suspend fun deleteAccount(password: String) {
deletedPassword = password
}
}
@Test
fun `successful password change clears tokens`() {
val store = InMemoryTokenStore().apply { save("access", "refresh") }
val api = FakeApi()
val vm = ProfileViewModel(api, store)
vm.onCurrentPasswordChange("oldpass12")
vm.onNewPasswordChange("newpass12")
vm.changePassword()
assertEquals("oldpass12" to "newpass12", api.changed)
assertNull(store.accessToken())
assertNull(store.refreshToken())
}
@Test
fun `successful deletion clears tokens`() {
val store = InMemoryTokenStore().apply { save("access", "refresh") }
val api = FakeApi()
val vm = ProfileViewModel(api, store)
vm.onDeletePasswordChange("secret12")
vm.deleteAccount()
assertEquals("secret12", api.deletedPassword)
assertNull(store.accessToken())
}
@Test
fun `weak password is rejected without clearing the session`() {
val store = InMemoryTokenStore().apply { save("access", "refresh") }
val vm = ProfileViewModel(FakeApi(), store)
vm.onNewPasswordChange("short")
vm.changePassword()
assertNotNull(store.accessToken())
assertEquals("Пароль должен быть не короче 8 символов", vm.state.value.error)
}
@Test
fun `name change updates the loaded profile`() {
val store = InMemoryTokenStore()
val vm = ProfileViewModel(FakeApi(), store)
vm.onNameChange("New Name")
vm.saveName()
assertEquals("New Name", vm.state.value.name)
assertTrue(vm.state.value.notice != null)
}
}