Добавил работу с сетью и сценарии авторизации

This commit is contained in:
2025-12-25 16:14:55 +03:00
parent b41de4aaf5
commit d8a0237e43
35 changed files with 869 additions and 271 deletions

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

2
.idea/gradle.xml generated
View File

@@ -4,6 +4,7 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
@@ -12,7 +13,6 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

View File

@@ -100,6 +100,9 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// DataStore
implementation(libs.datastore.preferences)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -14,6 +14,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.NewPlanet">
<activity
android:name=".MainActivity"

View File

@@ -21,7 +21,7 @@ import com.novayaplaneta.data.local.entity.UserEntity
RewardEntity::class,
ChatMessageEntity::class
],
version = 1,
version = 2,
exportSchema = false
)
abstract class NewPlanetDatabase : RoomDatabase() {

View File

@@ -0,0 +1,60 @@
package com.novayaplaneta.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "auth_preferences")
@Singleton
class TokenManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val accessTokenKey = stringPreferencesKey("access_token")
private val refreshTokenKey = stringPreferencesKey("refresh_token")
val accessToken: Flow<String?> = context.dataStore.data.map { preferences ->
preferences[accessTokenKey]
}
val refreshToken: Flow<String?> = context.dataStore.data.map { preferences ->
preferences[refreshTokenKey]
}
suspend fun getAccessToken(): String? {
return accessToken.first()
}
suspend fun getRefreshToken(): String? {
return refreshToken.first()
}
suspend fun saveTokens(accessToken: String, refreshToken: String) {
context.dataStore.edit { preferences ->
preferences[accessTokenKey] = accessToken
preferences[refreshTokenKey] = refreshToken
}
}
suspend fun clearTokens() {
context.dataStore.edit { preferences ->
preferences.remove(accessTokenKey)
preferences.remove(refreshTokenKey)
}
}
suspend fun isAuthenticated(): Boolean {
return getAccessToken() != null
}
}

View File

@@ -7,9 +7,10 @@ import androidx.room.PrimaryKey
data class UserEntity(
@PrimaryKey
val id: String,
val name: String,
val fullName: String,
val email: String,
val role: String,
val token: String?
val createdAt: String?,
val updatedAt: String?
)

View File

@@ -7,20 +7,22 @@ import com.novayaplaneta.domain.model.UserRole
fun UserEntity.toDomain(): User {
return User(
id = id,
name = name,
fullName = fullName,
email = email,
role = UserRole.valueOf(role),
token = token
createdAt = createdAt,
updatedAt = updatedAt
)
}
fun User.toEntity(): UserEntity {
return UserEntity(
id = id,
name = name,
fullName = fullName,
email = email,
role = role.name,
token = token
createdAt = createdAt,
updatedAt = updatedAt
)
}

View File

@@ -0,0 +1,42 @@
package com.novayaplaneta.data.remote
import com.novayaplaneta.data.remote.dto.MeResponse
import com.novayaplaneta.data.remote.dto.RefreshRequest
import com.novayaplaneta.data.remote.dto.RefreshResponse
import com.novayaplaneta.data.remote.dto.RegisterRequest
import com.novayaplaneta.data.remote.dto.RegisterResponse
import com.novayaplaneta.data.remote.dto.TokenResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.POST
interface AuthApi {
@POST("api/v1/auth/register")
suspend fun register(
@Body request: RegisterRequest
): Response<RegisterResponse>
@FormUrlEncoded
@POST("api/v1/auth/login")
suspend fun login(
@Field("grant_type") grantType: String = "password",
@Field("username") username: String,
@Field("password") password: String,
@Field("scope") scope: String = "",
@Field("client_id") clientId: String? = null,
@Field("client_secret") clientSecret: String? = null
): Response<TokenResponse>
@POST("api/v1/auth/refresh")
suspend fun refresh(
@Body request: RefreshRequest
): Response<RefreshResponse>
@GET("api/v1/auth/me")
suspend fun getMe(): Response<MeResponse>
}

View File

@@ -0,0 +1,110 @@
package com.novayaplaneta.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.novayaplaneta.data.local.TokenManager
import com.novayaplaneta.data.remote.dto.RefreshRequest
import com.novayaplaneta.data.remote.dto.RefreshResponse
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Response
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager
) : Interceptor {
// Создаем отдельный Retrofit для refresh запросов без interceptor
private val refreshRetrofit: Retrofit by lazy {
val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
}
val contentType = "application/json".toMediaType()
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
Retrofit.Builder()
.baseUrl("http://localhost:8000/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
}
private val refreshAuthApi: AuthApi by lazy {
refreshRetrofit.create(AuthApi::class.java)
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
// Не добавляем токен к auth эндпоинтам
val isAuthEndpoint = originalRequest.url.encodedPath.contains("/auth/login") ||
originalRequest.url.encodedPath.contains("/auth/register") ||
originalRequest.url.encodedPath.contains("/auth/refresh")
// Добавляем токен к запросу, если он есть и это не auth эндпоинт
val accessToken = runBlocking { tokenManager.getAccessToken() }
val requestWithToken = if (accessToken != null && !isAuthEndpoint) {
originalRequest.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
} else {
originalRequest
}
var response = chain.proceed(requestWithToken)
// Если получили 401, пытаемся обновить токен
if (response.code == 401 && !isAuthEndpoint && !originalRequest.url.encodedPath.contains("/auth/refresh")) {
val refreshToken = runBlocking { tokenManager.getRefreshToken() }
if (refreshToken != null) {
try {
val refreshResponse = runBlocking {
refreshAuthApi.refresh(RefreshRequest(refreshToken))
}
if (refreshResponse.isSuccessful && refreshResponse.body() != null) {
val tokenResponse = refreshResponse.body()!!
runBlocking {
tokenManager.saveTokens(
tokenResponse.access_token,
tokenResponse.refresh_token
)
}
// Повторяем исходный запрос с новым токеном
val newRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer ${tokenResponse.access_token}")
.build()
response.close()
response = chain.proceed(newRequest)
} else {
// Refresh не удался, очищаем токены
runBlocking { tokenManager.clearTokens() }
}
} catch (e: Exception) {
// Ошибка при refresh, очищаем токены
runBlocking { tokenManager.clearTokens() }
}
} else {
// Нет refresh токена, очищаем
runBlocking { tokenManager.clearTokens() }
}
}
return response
}
}

View File

@@ -0,0 +1,15 @@
package com.novayaplaneta.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class MeResponse(
val email: String,
val full_name: String,
val role: String,
val id: String,
val created_at: String,
val updated_at: String
)

View File

@@ -0,0 +1,10 @@
package com.novayaplaneta.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class RefreshRequest(
val refresh_token: String
)

View File

@@ -0,0 +1,12 @@
package com.novayaplaneta.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class RefreshResponse(
val access_token: String,
val token_type: String,
val refresh_token: String
)

View File

@@ -0,0 +1,14 @@
package com.novayaplaneta.data.remote.dto
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.Serializable
@Serializable
data class RegisterRequest(
val email: String,
val full_name: String,
@EncodeDefault
val role: String = "CHILD",
val password: String,
)

View File

@@ -0,0 +1,15 @@
package com.novayaplaneta.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class RegisterResponse(
val email: String,
val full_name: String,
val role: String,
val id: String,
val created_at: String,
val updated_at: String
)

View File

@@ -0,0 +1,12 @@
package com.novayaplaneta.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class TokenResponse(
val access_token: String,
val token_type: String,
val refresh_token: String
)

View File

@@ -1,10 +1,12 @@
package com.novayaplaneta.data.repository
import com.novayaplaneta.data.local.TokenManager
import com.novayaplaneta.data.local.dao.UserDao
import com.novayaplaneta.data.local.mapper.toDomain
import com.novayaplaneta.data.local.mapper.toEntity
import com.novayaplaneta.data.remote.BackendApi
import com.novayaplaneta.data.remote.dto.LoginRequest
import com.novayaplaneta.data.remote.AuthApi
import com.novayaplaneta.data.remote.dto.RefreshRequest
import com.novayaplaneta.data.remote.dto.RegisterRequest
import com.novayaplaneta.domain.model.User
import com.novayaplaneta.domain.model.UserRole
import com.novayaplaneta.domain.repository.AuthRepository
@@ -14,25 +16,113 @@ import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor(
private val userDao: UserDao,
private val api: BackendApi
private val authApi: AuthApi,
private val tokenManager: TokenManager
) : AuthRepository {
override suspend fun register(email: String, fullName: String, password: String, role: String): Result<User> {
return try {
val response = authApi.register(RegisterRequest(email, fullName, role, password))
if (response.isSuccessful && response.body() != null) {
val registerResponse = response.body()!!
val user = User(
id = registerResponse.id,
fullName = registerResponse.full_name,
email = registerResponse.email,
role = UserRole.valueOf(registerResponse.role),
createdAt = registerResponse.created_at,
updatedAt = registerResponse.updated_at
)
// После регистрации нужно залогиниться, чтобы получить токены
val loginResult = login(email, password)
if (loginResult.isSuccess) {
saveUser(user)
Result.success(user)
} else {
Result.failure(Exception("Registration successful but login failed"))
}
} else {
val errorBody = response.errorBody()?.string() ?: "Registration failed"
Result.failure(Exception(errorBody))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun login(email: String, password: String): Result<User> {
return try {
val response = api.login(LoginRequest(email, password))
val response = authApi.login(username = email, password = password)
if (response.isSuccessful && response.body() != null) {
val loginResponse = response.body()!!
val tokenResponse = response.body()!!
// Сохраняем токены
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
// Получаем информацию о пользователе
val meResponse = authApi.getMe()
if (meResponse.isSuccessful && meResponse.body() != null) {
val me = meResponse.body()!!
val user = User(
id = me.id,
fullName = me.full_name,
email = me.email,
role = UserRole.valueOf(me.role),
createdAt = me.created_at,
updatedAt = me.updated_at
)
saveUser(user)
Result.success(user)
} else {
Result.failure(Exception("Failed to get user info"))
}
} else {
val errorBody = response.errorBody()?.string() ?: "Login failed"
Result.failure(Exception(errorBody))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun refresh(): Result<Unit> {
return try {
val refreshToken = tokenManager.getRefreshToken()
if (refreshToken == null) {
return Result.failure(Exception("No refresh token"))
}
val response = authApi.refresh(RefreshRequest(refreshToken))
if (response.isSuccessful && response.body() != null) {
val tokenResponse = response.body()!!
tokenManager.saveTokens(tokenResponse.access_token, tokenResponse.refresh_token)
Result.success(Unit)
} else {
tokenManager.clearTokens()
Result.failure(Exception("Refresh failed"))
}
} catch (e: Exception) {
tokenManager.clearTokens()
Result.failure(e)
}
}
override suspend fun getMe(): Result<User> {
return try {
val response = authApi.getMe()
if (response.isSuccessful && response.body() != null) {
val me = response.body()!!
val user = User(
id = loginResponse.user.id,
name = loginResponse.user.name,
email = loginResponse.user.email,
role = UserRole.valueOf(loginResponse.user.role),
token = loginResponse.token
id = me.id,
fullName = me.full_name,
email = me.email,
role = UserRole.valueOf(me.role),
createdAt = me.created_at,
updatedAt = me.updated_at
)
saveUser(user)
Result.success(user)
} else {
Result.failure(Exception("Login failed"))
Result.failure(Exception("Failed to get user info"))
}
} catch (e: Exception) {
Result.failure(e)
@@ -41,6 +131,7 @@ class AuthRepositoryImpl @Inject constructor(
override suspend fun logout() {
userDao.deleteAllUsers()
tokenManager.clearTokens()
}
override fun getCurrentUser(): Flow<User?> {

View File

@@ -1,6 +1,8 @@
package com.novayaplaneta.di
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.novayaplaneta.data.remote.AuthApi
import com.novayaplaneta.data.remote.AuthInterceptor
import com.novayaplaneta.data.remote.BackendApi
import dagger.Module
import dagger.Provides
@@ -17,43 +19,57 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
}
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
fun provideOkHttpClient(
authInterceptor: AuthInterceptor
): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
fun provideRetrofit(
okHttpClient: OkHttpClient
): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl("https://api.novayaplaneta.ru/")
.baseUrl("http://10.0.2.2:8000/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
}
@Provides
@Singleton
fun provideBackendApi(retrofit: Retrofit): BackendApi {
fun provideAuthApi(
retrofit: Retrofit
): AuthApi {
return retrofit.create(AuthApi::class.java)
}
@Provides
@Singleton
fun provideBackendApi(
retrofit: Retrofit
): BackendApi {
return retrofit.create(BackendApi::class.java)
}
}

View File

@@ -2,10 +2,11 @@ package com.novayaplaneta.domain.model
data class User(
val id: String,
val name: String,
val fullName: String,
val email: String,
val role: UserRole,
val token: String? = null
val createdAt: String? = null,
val updatedAt: String? = null
)
enum class UserRole {

View File

@@ -4,7 +4,10 @@ import com.novayaplaneta.domain.model.User
import kotlinx.coroutines.flow.Flow
interface AuthRepository {
suspend fun register(email: String, fullName: String, password: String, role: String = "CHILD"): Result<User>
suspend fun login(email: String, password: String): Result<User>
suspend fun refresh(): Result<Unit>
suspend fun getMe(): Result<User>
suspend fun logout()
fun getCurrentUser(): Flow<User?>
suspend fun saveUser(user: User)

View File

@@ -0,0 +1,15 @@
package com.novayaplaneta.domain.usecase
import com.novayaplaneta.domain.model.User
import com.novayaplaneta.domain.repository.AuthRepository
import javax.inject.Inject
class GetMeUseCase @Inject constructor(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(): Result<User> {
return authRepository.getMe()
}
}

View File

@@ -0,0 +1,15 @@
package com.novayaplaneta.domain.usecase
import com.novayaplaneta.domain.model.User
import com.novayaplaneta.domain.repository.AuthRepository
import javax.inject.Inject
class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(email: String, password: String): Result<User> {
return authRepository.login(email, password)
}
}

View File

@@ -0,0 +1,14 @@
package com.novayaplaneta.domain.usecase
import com.novayaplaneta.domain.repository.AuthRepository
import javax.inject.Inject
class LogoutUseCase @Inject constructor(
private val authRepository: AuthRepository
) {
suspend operator fun invoke() {
authRepository.logout()
}
}

View File

@@ -0,0 +1,20 @@
package com.novayaplaneta.domain.usecase
import com.novayaplaneta.domain.model.User
import com.novayaplaneta.domain.repository.AuthRepository
import javax.inject.Inject
class RegisterUseCase @Inject constructor(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(
email: String,
fullName: String,
password: String,
role: String = "CHILD"
): Result<User> {
return authRepository.register(email, fullName, password, role)
}
}

View File

@@ -9,6 +9,7 @@ import com.novayaplaneta.ui.screens.ai.AIScreen
import com.novayaplaneta.ui.screens.auth.ForgotPasswordScreen
import com.novayaplaneta.ui.screens.auth.LoginScreen
import com.novayaplaneta.ui.screens.auth.RegistrationScreen
import com.novayaplaneta.ui.screens.auth.SplashScreen
import com.novayaplaneta.ui.screens.rewards.RewardsScreen
import com.novayaplaneta.ui.screens.schedule.ScheduleScreen
import com.novayaplaneta.ui.screens.settings.SettingsScreen
@@ -19,13 +20,16 @@ import com.novayaplaneta.ui.screens.timer.TimerScreen
fun NewPlanetNavigation(
navController: NavHostController,
modifier: Modifier = Modifier,
startDestination: String = "login"
startDestination: String = "splash"
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier
) {
composable("splash") {
SplashScreen(navController = navController)
}
composable("login") {
LoginScreen(navController = navController)
}

View File

@@ -122,44 +122,64 @@ fun LoginScreen(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Поле логина
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
// Поле email
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextField(
value = uiState.login,
onValueChange = { viewModel.onLoginChange(it) },
placeholder = {
Text(
text = "Введи логин",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
.height(height = inputHeight)
.background(
color = if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
Color(0xFFFFEBEE)
} else {
LoginInputLightBlue
},
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.email,
onValueChange = { viewModel.onEmailChange(it) },
placeholder = {
Text(
text = "Введи email",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
isError = uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)
)
}
// Сообщение об ошибке email
if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
Text(
text = "Введите корректный email",
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
// Поле пароля
@@ -208,12 +228,12 @@ fun LoginScreen(
Button(
onClick = {
if (uiState.isFormValid) {
// Переход на экран расписания при заполненных полях
navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
viewModel.login {
// Переход на экран расписания после успешного входа
navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
}
}
// Также вызываем логин для проверки через API (в фоне)
viewModel.login { }
}
},
modifier = Modifier

View File

@@ -2,7 +2,7 @@ package com.novayaplaneta.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.repository.AuthRepository
import com.novayaplaneta.domain.usecase.LoginUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -12,23 +12,25 @@ import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository
private val loginUseCase: LoginUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun onLoginChange(login: String) {
fun onEmailChange(email: String) {
_uiState.value = _uiState.value.copy(
login = login,
isFormValid = isFormValid(login, _uiState.value.password)
email = email,
isFormValid = isFormValid(email, _uiState.value.password),
errorMessage = null
)
}
fun onPasswordChange(password: String) {
_uiState.value = _uiState.value.copy(
password = password,
isFormValid = isFormValid(_uiState.value.login, password)
isFormValid = isFormValid(_uiState.value.email, password),
errorMessage = null
)
}
@@ -38,7 +40,7 @@ class LoginViewModel @Inject constructor(
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = authRepository.login(_uiState.value.login, _uiState.value.password)
val result = loginUseCase(_uiState.value.email, _uiState.value.password)
result.onSuccess { user ->
_uiState.value = _uiState.value.copy(isLoading = false, isLoggedIn = true)
@@ -52,13 +54,13 @@ class LoginViewModel @Inject constructor(
}
}
private fun isFormValid(login: String, password: String): Boolean {
return login.isNotBlank() && password.isNotBlank()
private fun isFormValid(email: String, password: String): Boolean {
return email.isNotBlank() && password.isNotBlank() && EmailValidator.isValid(email)
}
}
data class LoginUiState(
val login: String = "",
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val isFormValid: Boolean = false,

View File

@@ -127,7 +127,7 @@ fun RegistrationScreen(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Поле логина
// Поле полного имени
Box(
modifier = Modifier
.fillMaxWidth()
@@ -139,11 +139,11 @@ fun RegistrationScreen(
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.login,
onValueChange = { viewModel.onLoginChange(it) },
value = uiState.fullName,
onValueChange = { viewModel.onFullNameChange(it) },
placeholder = {
Text(
text = "Введи логин",
text = "Введи полное имя",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
@@ -168,85 +168,114 @@ fun RegistrationScreen(
}
// Поле пароля
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextField(
value = uiState.password,
onValueChange = { viewModel.onPasswordChange(it) },
placeholder = {
Text(
text = "Введи пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
.height(height = inputHeight)
.background(
color = if (uiState.passwordError != null) {
Color(0xFFFFEBEE)
} else {
LoginInputLightBlue
},
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.password,
onValueChange = { viewModel.onPasswordChange(it) },
placeholder = {
Text(
text = "Введи пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = uiState.passwordError != null
)
}
if (uiState.passwordError != null) {
Text(
text = uiState.passwordError!!,
fontSize = (inputTextSizeValue * 0.8f).toInt().sp,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
// Поле повторения пароля
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextField(
value = uiState.confirmPassword,
onValueChange = { viewModel.onConfirmPasswordChange(it) },
placeholder = {
Text(
text = "Повтори пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
.height(height = inputHeight)
.background(
color = if (uiState.passwordError != null) {
Color(0xFFFFEBEE)
} else {
LoginInputLightBlue
},
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.confirmPassword,
onValueChange = { viewModel.onConfirmPasswordChange(it) },
placeholder = {
Text(
text = "Повтори пароль",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
visualTransformation = PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = uiState.passwordError != null
)
}
}
// Поле email
@@ -309,76 +338,17 @@ fun RegistrationScreen(
}
}
// Поле кода (показывается после нажатия "Готово!" когда все 4 поля заполнены)
if (uiState.showCodeField) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = inputHeight)
.background(
color = LoginInputLightBlue,
shape = RoundedCornerShape(20.dp)
),
contentAlignment = Alignment.CenterStart
) {
TextField(
value = uiState.code,
onValueChange = { viewModel.onCodeChange(it) },
placeholder = {
Text(
text = "Введи код из письма на почте",
fontSize = inputTextSize,
color = Color.Gray.copy(alpha = 0.7f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedTextColor = Color.Black,
focusedTextColor = Color.Black
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontSize = inputTextSize
),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}
}
}
// Кнопка "Готово!" - адаптивный размер
// Активна когда заполнены все 4 поля (до показа кода) или когда заполнен код
val isButtonEnabled = if (!uiState.showCodeField) {
uiState.isBasicFormValid && !uiState.isLoading
} else {
uiState.isFormValid && !uiState.isLoading
}
val buttonColor = if (isButtonEnabled) {
ButtonDefaults.buttonColors(
containerColor = LoginGreenAccent,
contentColor = Color.White
)
} else {
ButtonDefaults.buttonColors(
containerColor = LoginInputLightBlue,
contentColor = Color.Gray,
disabledContainerColor = LoginInputLightBlue,
disabledContentColor = Color.Gray
)
}
// Кнопка "Зарегистрироваться" - адаптивный размер
Button(
onClick = {
viewModel.onReadyClick {
navController.navigate("login") {
popUpTo("login") { inclusive = false }
if (uiState.isFormValid) {
viewModel.register {
// Переход на экран расписания после успешной регистрации
navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
}
}
}
},
@@ -386,8 +356,20 @@ fun RegistrationScreen(
.fillMaxWidth()
.height(height = inputHeight),
shape = RoundedCornerShape(20.dp),
enabled = isButtonEnabled,
colors = buttonColor
enabled = uiState.isFormValid && !uiState.isLoading,
colors = if (uiState.isFormValid) {
ButtonDefaults.buttonColors(
containerColor = LoginGreenAccent,
contentColor = Color.White
)
} else {
ButtonDefaults.buttonColors(
containerColor = LoginInputLightBlue,
contentColor = Color.Gray,
disabledContainerColor = LoginInputLightBlue,
disabledContentColor = Color.Gray
)
}
) {
if (uiState.isLoading) {
CircularProgressIndicator(
@@ -397,7 +379,7 @@ fun RegistrationScreen(
} else {
val buttonTextSize = (screenHeightDp * 0.027f).toInt().coerceIn(22, 34).sp
Text(
text = "Готово!",
text = "Зарегистрироваться",
fontSize = buttonTextSize,
fontWeight = FontWeight.Bold
)

View File

@@ -2,8 +2,8 @@ package com.novayaplaneta.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.novayaplaneta.domain.usecase.RegisterUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -11,64 +11,74 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class RegistrationViewModel @Inject constructor() : ViewModel() {
class RegistrationViewModel @Inject constructor(
private val registerUseCase: RegisterUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(RegistrationUiState())
val uiState: StateFlow<RegistrationUiState> = _uiState.asStateFlow()
fun onLoginChange(login: String) {
_uiState.value = _uiState.value.copy(login = login)
fun onFullNameChange(fullName: String) {
_uiState.value = _uiState.value.copy(
fullName = fullName,
errorMessage = null
)
}
fun onPasswordChange(password: String) {
_uiState.value = _uiState.value.copy(password = password)
_uiState.value = _uiState.value.copy(
password = password,
errorMessage = null
)
}
fun onConfirmPasswordChange(password: String) {
_uiState.value = _uiState.value.copy(confirmPassword = password)
_uiState.value = _uiState.value.copy(
confirmPassword = password,
errorMessage = null
)
}
fun onEmailChange(email: String) {
_uiState.value = _uiState.value.copy(email = email)
_uiState.value = _uiState.value.copy(
email = email,
errorMessage = null
)
}
fun onCodeChange(code: String) {
_uiState.value = _uiState.value.copy(code = code)
}
fun onReadyClick(onSuccess: () -> Unit) {
val currentState = _uiState.value
fun register(onSuccess: () -> Unit) {
if (!_uiState.value.isFormValid) return
// Если поле кода еще не показано, но все 4 поля заполнены - показываем поле кода
if (!currentState.showCodeField && currentState.isBasicFormValid) {
_uiState.value = currentState.copy(showCodeField = true)
return
}
// Если форма полностью валидна (включая код), выполняем регистрацию
if (currentState.isFormValid) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
// TODO: Реализовать вызов API для регистрации
// Пока что просто эмулируем успешную регистрацию
delay(1000)
_uiState.value = _uiState.value.copy(isLoading = false)
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = registerUseCase(
email = _uiState.value.email,
fullName = _uiState.value.fullName,
password = _uiState.value.password,
role = "CHILD"
)
result.onSuccess { user ->
_uiState.value = _uiState.value.copy(isLoading = false, isRegistered = true)
onSuccess()
}.onFailure { exception ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = exception.message ?: "Ошибка регистрации"
)
}
}
}
}
data class RegistrationUiState(
val login: String = "",
val fullName: String = "",
val password: String = "",
val confirmPassword: String = "",
val email: String = "",
val code: String = "",
val showCodeField: Boolean = false,
val isLoading: Boolean = false,
val isRegistered: Boolean = false,
val errorMessage: String? = null
) {
// Валидация email
@@ -81,17 +91,19 @@ data class RegistrationUiState(
"Введите корректный email"
} else null
// Валидация первых 4 полей (без кода)
val isBasicFormValid: Boolean
get() = login.isNotBlank() &&
// Валидация паролей
val passwordError: String?
get() = if (confirmPassword.isNotBlank() && password != confirmPassword) {
"Пароли не совпадают"
} else null
// Валидация формы
val isFormValid: Boolean
get() = fullName.isNotBlank() &&
password.isNotBlank() &&
confirmPassword.isNotBlank() &&
isEmailValid &&
password == confirmPassword
// Валидация полной формы (включая код)
val isFormValid: Boolean
get() = isBasicFormValid &&
code.isNotBlank()
password == confirmPassword &&
password.length >= 6
}

View File

@@ -0,0 +1,45 @@
package com.novayaplaneta.ui.screens.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.novayaplaneta.ui.theme.LoginBackgroundTurquoise
@Composable
fun SplashScreen(
navController: NavController,
viewModel: SplashViewModel = hiltViewModel()
) {
LaunchedEffect(Unit) {
viewModel.checkAuth { isAuthenticated ->
if (isAuthenticated) {
navController.navigate("schedule") {
popUpTo(0) { inclusive = true }
}
} else {
navController.navigate("login") {
popUpTo(0) { inclusive = true }
}
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(color = LoginBackgroundTurquoise),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Color.White)
}
}

View File

@@ -0,0 +1,38 @@
package com.novayaplaneta.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.novayaplaneta.data.local.TokenManager
import com.novayaplaneta.domain.usecase.GetMeUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SplashViewModel @Inject constructor(
private val tokenManager: TokenManager,
private val getMeUseCase: GetMeUseCase
) : ViewModel() {
fun checkAuth(onResult: (Boolean) -> Unit) {
viewModelScope.launch {
val hasToken = tokenManager.isAuthenticated()
if (hasToken) {
// Пытаемся получить информацию о пользователе
val result = getMeUseCase()
if (result.isSuccess) {
onResult(true)
} else {
// Токен невалиден, очищаем и переходим на логин
tokenManager.clearTokens()
onResult(false)
}
} else {
onResult(false)
}
}
}
}

View File

@@ -210,12 +210,12 @@ fun SettingsScreen(
val textSize = (screenHeightDp * 0.025f).toInt().coerceIn(20, 28).sp
UserInfoRow(
label = "Имя:",
value = user.name,
value = user.fullName,
textSize = textSize
)
UserInfoRow(
label = "Логин:",
value = "${user.name}12", // Используем имя + число как логин
value = "${user.fullName}12", // Используем имя + число как логин
textSize = textSize
)
UserInfoRow(

View File

@@ -48,10 +48,9 @@ class SettingsViewModel @Inject constructor(
// Заглушка с тестовыми данными
return User(
id = "user_123",
name = "Коля",
fullName = "Коля",
email = "kolya12@mail.ru",
role = UserRole.CHILD,
token = null
role = UserRole.CHILD
)
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>

View File

@@ -21,6 +21,7 @@ coil = "2.7.0"
lottie = "6.1.0"
coroutines = "1.9.0"
ksp = "2.1.0-1.0.28"
datastore = "1.1.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -71,6 +72,9 @@ lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", versio
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }