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

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ import com.novayaplaneta.data.local.entity.UserEntity
RewardEntity::class, RewardEntity::class,
ChatMessageEntity::class ChatMessageEntity::class
], ],
version = 1, version = 2,
exportSchema = false exportSchema = false
) )
abstract class NewPlanetDatabase : RoomDatabase() { 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( data class UserEntity(
@PrimaryKey @PrimaryKey
val id: String, val id: String,
val name: String, val fullName: String,
val email: String, val email: String,
val role: 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 { fun UserEntity.toDomain(): User {
return User( return User(
id = id, id = id,
name = name, fullName = fullName,
email = email, email = email,
role = UserRole.valueOf(role), role = UserRole.valueOf(role),
token = token createdAt = createdAt,
updatedAt = updatedAt
) )
} }
fun User.toEntity(): UserEntity { fun User.toEntity(): UserEntity {
return UserEntity( return UserEntity(
id = id, id = id,
name = name, fullName = fullName,
email = email, email = email,
role = role.name, 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 package com.novayaplaneta.data.repository
import com.novayaplaneta.data.local.TokenManager
import com.novayaplaneta.data.local.dao.UserDao import com.novayaplaneta.data.local.dao.UserDao
import com.novayaplaneta.data.local.mapper.toDomain import com.novayaplaneta.data.local.mapper.toDomain
import com.novayaplaneta.data.local.mapper.toEntity import com.novayaplaneta.data.local.mapper.toEntity
import com.novayaplaneta.data.remote.BackendApi import com.novayaplaneta.data.remote.AuthApi
import com.novayaplaneta.data.remote.dto.LoginRequest 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.User
import com.novayaplaneta.domain.model.UserRole import com.novayaplaneta.domain.model.UserRole
import com.novayaplaneta.domain.repository.AuthRepository import com.novayaplaneta.domain.repository.AuthRepository
@@ -14,25 +16,113 @@ import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor( class AuthRepositoryImpl @Inject constructor(
private val userDao: UserDao, private val userDao: UserDao,
private val api: BackendApi private val authApi: AuthApi,
private val tokenManager: TokenManager
) : AuthRepository { ) : 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> { override suspend fun login(email: String, password: String): Result<User> {
return try { return try {
val response = api.login(LoginRequest(email, password)) val response = authApi.login(username = email, password = password)
if (response.isSuccessful && response.body() != null) { 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( val user = User(
id = loginResponse.user.id, id = me.id,
name = loginResponse.user.name, fullName = me.full_name,
email = loginResponse.user.email, email = me.email,
role = UserRole.valueOf(loginResponse.user.role), role = UserRole.valueOf(me.role),
token = loginResponse.token createdAt = me.created_at,
updatedAt = me.updated_at
) )
saveUser(user) saveUser(user)
Result.success(user) Result.success(user)
} else { } else {
Result.failure(Exception("Login failed")) Result.failure(Exception("Failed to get user info"))
} }
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
@@ -41,6 +131,7 @@ class AuthRepositoryImpl @Inject constructor(
override suspend fun logout() { override suspend fun logout() {
userDao.deleteAllUsers() userDao.deleteAllUsers()
tokenManager.clearTokens()
} }
override fun getCurrentUser(): Flow<User?> { override fun getCurrentUser(): Flow<User?> {

View File

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

View File

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

View File

@@ -4,7 +4,10 @@ import com.novayaplaneta.domain.model.User
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AuthRepository { 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 login(email: String, password: String): Result<User>
suspend fun refresh(): Result<Unit>
suspend fun getMe(): Result<User>
suspend fun logout() suspend fun logout()
fun getCurrentUser(): Flow<User?> fun getCurrentUser(): Flow<User?>
suspend fun saveUser(user: 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.ForgotPasswordScreen
import com.novayaplaneta.ui.screens.auth.LoginScreen import com.novayaplaneta.ui.screens.auth.LoginScreen
import com.novayaplaneta.ui.screens.auth.RegistrationScreen 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.rewards.RewardsScreen
import com.novayaplaneta.ui.screens.schedule.ScheduleScreen import com.novayaplaneta.ui.screens.schedule.ScheduleScreen
import com.novayaplaneta.ui.screens.settings.SettingsScreen import com.novayaplaneta.ui.screens.settings.SettingsScreen
@@ -19,13 +20,16 @@ import com.novayaplaneta.ui.screens.timer.TimerScreen
fun NewPlanetNavigation( fun NewPlanetNavigation(
navController: NavHostController, navController: NavHostController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = "login" startDestination: String = "splash"
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
modifier = modifier modifier = modifier
) { ) {
composable("splash") {
SplashScreen(navController = navController)
}
composable("login") { composable("login") {
LoginScreen(navController = navController) LoginScreen(navController = navController)
} }

View File

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

View File

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

View File

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

View File

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

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

View File

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

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" lottie = "6.1.0"
coroutines = "1.9.0" coroutines = "1.9.0"
ksp = "2.1.0-1.0.28" ksp = "2.1.0-1.0.28"
datastore = "1.1.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -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-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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }