Добавил работу с сетью и сценарии авторизации
This commit is contained in:
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal 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
2
.idea/gradle.xml
generated
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
app/src/main/java/com/novayaplaneta/data/remote/AuthApi.kt
Normal file
42
app/src/main/java/com/novayaplaneta/data/remote/AuthApi.kt
Normal 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.novayaplaneta.data.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RefreshRequest(
|
||||||
|
val refresh_token: String
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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(
|
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"))
|
||||||
|
}
|
||||||
|
} 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 = 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"))
|
||||||
}
|
}
|
||||||
} 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?> {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -26,26 +28,30 @@ object NetworkModule {
|
|||||||
|
|
||||||
@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()
|
||||||
@@ -53,7 +59,17 @@ object NetworkModule {
|
|||||||
|
|
||||||
@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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,23 +122,31 @@ fun LoginScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Поле логина
|
// Поле email
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height = inputHeight)
|
.height(height = inputHeight)
|
||||||
.background(
|
.background(
|
||||||
color = LoginInputLightBlue,
|
color = if (uiState.errorMessage != null && uiState.email.isNotBlank() && !EmailValidator.isValid(uiState.email)) {
|
||||||
|
Color(0xFFFFEBEE)
|
||||||
|
} else {
|
||||||
|
LoginInputLightBlue
|
||||||
|
},
|
||||||
shape = RoundedCornerShape(20.dp)
|
shape = RoundedCornerShape(20.dp)
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
TextField(
|
TextField(
|
||||||
value = uiState.login,
|
value = uiState.email,
|
||||||
onValueChange = { viewModel.onLoginChange(it) },
|
onValueChange = { viewModel.onEmailChange(it) },
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = "Введи логин",
|
text = "Введи email",
|
||||||
fontSize = inputTextSize,
|
fontSize = inputTextSize,
|
||||||
color = Color.Gray.copy(alpha = 0.7f)
|
color = Color.Gray.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
@@ -158,10 +166,22 @@ fun LoginScreen(
|
|||||||
fontSize = inputTextSize
|
fontSize = inputTextSize
|
||||||
),
|
),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Поле пароля
|
// Поле пароля
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -208,12 +228,12 @@ fun LoginScreen(
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (uiState.isFormValid) {
|
if (uiState.isFormValid) {
|
||||||
// Переход на экран расписания при заполненных полях
|
viewModel.login {
|
||||||
|
// Переход на экран расписания после успешного входа
|
||||||
navController.navigate("schedule") {
|
navController.navigate("schedule") {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
// Также вызываем логин для проверки через API (в фоне)
|
}
|
||||||
viewModel.login { }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,12 +168,20 @@ fun RegistrationScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Поле пароля
|
// Поле пароля
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height = inputHeight)
|
.height(height = inputHeight)
|
||||||
.background(
|
.background(
|
||||||
color = LoginInputLightBlue,
|
color = if (uiState.passwordError != null) {
|
||||||
|
Color(0xFFFFEBEE)
|
||||||
|
} else {
|
||||||
|
LoginInputLightBlue
|
||||||
|
},
|
||||||
shape = RoundedCornerShape(20.dp)
|
shape = RoundedCornerShape(20.dp)
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
@@ -204,17 +212,36 @@ fun RegistrationScreen(
|
|||||||
fontSize = inputTextSize
|
fontSize = inputTextSize
|
||||||
),
|
),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Поле повторения пароля
|
// Поле повторения пароля
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height = inputHeight)
|
.height(height = inputHeight)
|
||||||
.background(
|
.background(
|
||||||
color = LoginInputLightBlue,
|
color = if (uiState.passwordError != null) {
|
||||||
|
Color(0xFFFFEBEE)
|
||||||
|
} else {
|
||||||
|
LoginInputLightBlue
|
||||||
|
},
|
||||||
shape = RoundedCornerShape(20.dp)
|
shape = RoundedCornerShape(20.dp)
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
@@ -245,9 +272,11 @@ fun RegistrationScreen(
|
|||||||
fontSize = inputTextSize
|
fontSize = inputTextSize
|
||||||
),
|
),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
isError = uiState.passwordError != null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Поле email
|
// Поле email
|
||||||
Column(
|
Column(
|
||||||
@@ -309,58 +338,26 @@ fun RegistrationScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поле кода (показывается после нажатия "Готово!" когда все 4 поля заполнены)
|
}
|
||||||
if (uiState.showCodeField) {
|
|
||||||
Box(
|
// Кнопка "Зарегистрироваться" - адаптивный размер
|
||||||
modifier = Modifier
|
Button(
|
||||||
.fillMaxWidth()
|
onClick = {
|
||||||
.height(height = inputHeight)
|
if (uiState.isFormValid) {
|
||||||
.background(
|
viewModel.register {
|
||||||
color = LoginInputLightBlue,
|
// Переход на экран расписания после успешной регистрации
|
||||||
shape = RoundedCornerShape(20.dp)
|
navController.navigate("schedule") {
|
||||||
),
|
popUpTo(0) { inclusive = true }
|
||||||
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.height(height = inputHeight),
|
||||||
colors = TextFieldDefaults.colors(
|
shape = RoundedCornerShape(20.dp),
|
||||||
unfocusedContainerColor = Color.Transparent,
|
enabled = uiState.isFormValid && !uiState.isLoading,
|
||||||
focusedContainerColor = Color.Transparent,
|
colors = if (uiState.isFormValid) {
|
||||||
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(
|
ButtonDefaults.buttonColors(
|
||||||
containerColor = LoginGreenAccent,
|
containerColor = LoginGreenAccent,
|
||||||
contentColor = Color.White
|
contentColor = Color.White
|
||||||
@@ -373,21 +370,6 @@ fun RegistrationScreen(
|
|||||||
disabledContentColor = Color.Gray
|
disabledContentColor = Color.Gray
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
viewModel.onReadyClick {
|
|
||||||
navController.navigate("login") {
|
|
||||||
popUpTo("login") { inclusive = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(height = inputHeight),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
enabled = isButtonEnabled,
|
|
||||||
colors = buttonColor
|
|
||||||
) {
|
) {
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 поля заполнены - показываем поле кода
|
|
||||||
if (!currentState.showCodeField && currentState.isBasicFormValid) {
|
|
||||||
_uiState.value = currentState.copy(showCodeField = true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если форма полностью валидна (включая код), выполняем регистрацию
|
|
||||||
if (currentState.isFormValid) {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
|
|
||||||
// TODO: Реализовать вызов API для регистрации
|
val result = registerUseCase(
|
||||||
// Пока что просто эмулируем успешную регистрацию
|
email = _uiState.value.email,
|
||||||
delay(1000)
|
fullName = _uiState.value.fullName,
|
||||||
|
password = _uiState.value.password,
|
||||||
|
role = "CHILD"
|
||||||
|
)
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
result.onSuccess { user ->
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = false, isRegistered = true)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
app/src/main/res/xml/network_security_config.xml
Normal file
7
app/src/main/res/xml/network_security_config.xml
Normal 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>
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user