main #6

Closed
student-d-sherstnev wants to merge 19 commits from Minipigi-org/NTO-2026-Android-TeamTask-Template:main into main
18 changed files with 344 additions and 100 deletions

1
README.MD Normal file
View File

@ -0,0 +1 @@
penpot deck: https://pp.sicampus.ru/#/workspace?team-id=14a6b474-d5fa-807f-8007-9f0741c1b7ad&project-id=14a6b474-d5fa-807f-8007-9f0741c1b7ae&file-id=8e3f3321-7df1-807a-8007-9f6a7201af74&page-id=50b061ab-362d-8056-8007-9d94752aa99f&layout=layers&board-id=6fa8b836-315d-80ad-8007-9f6ddff01da2

View File

@ -18,7 +18,7 @@
<activity <activity
android:name=".ui.root.RootActivity" android:name=".ui.root.RootActivity"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustNothing"
android:label="@string/title_activity_root"> android:label="@string/title_activity_root">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@ -1,9 +1,10 @@
package ru.myitschool.work.core package ru.myitschool.work.core
object Constants { object Constants {
const val HOST = "http://localhost:8090" const val HOST = "http://10.0.0.12:49165"
const val AUTH_URL = "/auth" const val AUTH_URL = "/auth"
const val INFO_URL = "/info" const val INFO_URL = "/info"
const val BOOKING_URL = "/booking" const val BOOKING_URL = "/booking"
const val BOOK_URL = "/book" const val BOOK_URL = "/book"
const val AUTH_DELAY = 60
} }

View File

@ -0,0 +1,58 @@
package ru.myitschool.work.data
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
object AESEncyption {
const val secretKey = "tK5Ugskdkipokuodvknfdk3434weofnf="
const val salt = "QLlGNHNhYTJTQWZ2bGhpV3U="
const val iv = "bVQqNFNhRkQ1Njc4UUFaPA=="
fun encrypt(strToEncrypt: String): String? {
try {
val ivParameterSpec = IvParameterSpec(Base64.decode(iv, Base64.DEFAULT))
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec =
PBEKeySpec(secretKey.toCharArray(), Base64.decode(salt, Base64.DEFAULT), 10000, 256)
val tmp = factory.generateSecret(spec)
val secretKey = SecretKeySpec(tmp.encoded, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec)
return Base64.encodeToString(
cipher.doFinal(strToEncrypt.toByteArray(Charsets.UTF_8)),
Base64.DEFAULT
)
} catch (e: Exception) {
println("Error while encrypting: $e")
}
return null
}
fun decrypt(strToDecrypt: String?): String? {
try {
val ivParameterSpec = IvParameterSpec(Base64.decode(iv, Base64.DEFAULT))
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val spec =
PBEKeySpec(secretKey.toCharArray(), Base64.decode(salt, Base64.DEFAULT), 10000, 256)
val tmp = factory.generateSecret(spec)
val secretKey = SecretKeySpec(tmp.encoded, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding")
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec)
return String(cipher.doFinal(Base64.decode(strToDecrypt, Base64.DEFAULT)))
} catch (e: Exception) {
println("Error while decrypting: $e")
}
return null
}
}

View File

@ -0,0 +1,20 @@
package ru.myitschool.work.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AuthRequestDto(
@SerialName("login")
val login: String,
@SerialName("password")
val password: String,
)
@Serializable
data class AuthResponseDto(
@SerialName("token")
val token: String,
@SerialName("expired")
val expired: Int,
)

View File

@ -8,41 +8,49 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import ru.myitschool.work.App import ru.myitschool.work.App
import ru.myitschool.work.data.AESEncyption
import ru.myitschool.work.data.dto.AuthRequestDto
import ru.myitschool.work.data.dto.AuthResponseDto
import ru.myitschool.work.data.source.NetworkDataSource import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository { object AuthRepository {
private const val STORE = "AUTH-STORE" private const val STORE = "AUTH-STORE"
private const val CODE_KEY = "CODE" private const val TOKEN_KEY = "TOKEN"
private var codeCache: String? = null private var tokenCache: String? = null
suspend fun checkAndSave(text: String): Result<Boolean> { suspend fun checkAndSave(login: String, password: String): Result<AuthResponseDto> {
return NetworkDataSource.checkAuth(text).onSuccess { success -> val data = AuthRequestDto(login=login, password=password)
if (success) { return NetworkDataSource.checkAuth(data).onSuccess { success ->
codeCache = text val encryptedTokenCache = AESEncyption.encrypt(success.token)
tokenCache = encryptedTokenCache
if (encryptedTokenCache != null) {
App.context.userDataStore.edit { preferences -> App.context.userDataStore.edit { preferences ->
val prefKey = stringPreferencesKey(CODE_KEY) val prefKey = stringPreferencesKey(TOKEN_KEY)
preferences[prefKey] = text preferences[prefKey] = encryptedTokenCache
} }
} }
} }
} }
suspend fun getCode(): String? { suspend fun getToken(): String? {
if (codeCache == null) { if (tokenCache == null) {
codeCache = App.context.userDataStore.data tokenCache = App.context.userDataStore.data
.firstOrNull() .firstOrNull()
?.let { preferences -> ?.let { preferences ->
preferences[stringPreferencesKey(CODE_KEY)] preferences[stringPreferencesKey(TOKEN_KEY)]
} }
} }
return codeCache if (tokenCache != null) {
return AESEncyption.decrypt(tokenCache)
}
return null
} }
suspend fun logout() { suspend fun logout() {
codeCache = null tokenCache = null
App.context.userDataStore.edit { preferences -> App.context.userDataStore.edit { preferences ->
val prefKey = stringPreferencesKey(CODE_KEY) val prefKey = stringPreferencesKey(TOKEN_KEY)
preferences.remove(prefKey) preferences.remove(prefKey)
} }
} }

View File

@ -10,8 +10,8 @@ class BookRepository(
private val authRepository: AuthRepository private val authRepository: AuthRepository
) { ) {
suspend fun getInfo(): Result<MainInfoEntity> { suspend fun getInfo(): Result<MainInfoEntity> {
val code = authRepository.getCode() ?: return getNoAuthResult() val token = authRepository.getToken() ?: return getNoAuthResult()
return NetworkDataSource.getInfo(code).mapCatching { dto -> return NetworkDataSource.getInfo(token).mapCatching { dto ->
MainInfoEntity( MainInfoEntity(
name = dto.name ?: error("Name is null"), name = dto.name ?: error("Name is null"),
photoUrl = dto.photoUrl ?: error("Photo url is null"), photoUrl = dto.photoUrl ?: error("Photo url is null"),
@ -26,8 +26,8 @@ class BookRepository(
} }
suspend fun getBookingInfo(): Result<List<BookingData>> { suspend fun getBookingInfo(): Result<List<BookingData>> {
val code = authRepository.getCode() ?: return getNoAuthResult() val token = authRepository.getToken() ?: return getNoAuthResult()
return NetworkDataSource.getBooking(code).mapCatching { dto -> return NetworkDataSource.getBooking(token).mapCatching { dto ->
dto?.map { (date, places) -> dto?.map { (date, places) ->
BookingData( BookingData(
date = date, date = date,
@ -43,9 +43,9 @@ class BookRepository(
} }
suspend fun sendBook(data: BookRequestData): Result<Boolean> { suspend fun sendBook(data: BookRequestData): Result<Boolean> {
val code = authRepository.getCode() ?: return getNoAuthResult() val token = authRepository.getToken() ?: return getNoAuthResult()
val dto = BookRequestDto(data.date, data.placeId) val dto = BookRequestDto(data.date, data.placeId)
return NetworkDataSource.addBook(code, dto) return NetworkDataSource.addBook(token, dto)
} }
private fun <T> getNoAuthResult() = Result.failure<T>( private fun <T> getNoAuthResult() = Result.failure<T>(
IllegalStateException("No auth") IllegalStateException("No auth")

View File

@ -5,6 +5,7 @@ import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.client.request.post import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
@ -16,9 +17,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants import ru.myitschool.work.core.Constants
import ru.myitschool.work.data.dto.AuthRequestDto
import ru.myitschool.work.data.dto.AuthResponseDto
import ru.myitschool.work.data.dto.PlaceDto import ru.myitschool.work.data.dto.PlaceDto
import ru.myitschool.work.data.dto.BookRequestDto import ru.myitschool.work.data.dto.BookRequestDto
import ru.myitschool.work.data.dto.UserDto import ru.myitschool.work.data.dto.UserDto
import ru.myitschool.work.data.repo.AuthRepository
object NetworkDataSource { object NetworkDataSource {
private val client by lazy { private val client by lazy {
@ -36,54 +40,79 @@ object NetworkDataSource {
} }
} }
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) { suspend fun checkAuth(data: AuthRequestDto): Result<AuthResponseDto> = withContext(Dispatchers.IO) {
return@withContext runCatching { return@withContext runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL)) val response = client.post(getUrl(Constants.AUTH_URL)) {
contentType(ContentType.Application.Json)
setBody(data)
}
when (response.status) { when (response.status) {
HttpStatusCode.OK -> true HttpStatusCode.OK -> response.body<AuthResponseDto>()
else -> false else -> error(response.bodyAsText())
} }
} }
} }
suspend fun getInfo(code: String): Result<UserDto> = withContext(Dispatchers.IO) { suspend fun getInfo(token: String): Result<UserDto> = withContext(Dispatchers.IO) {
return@withContext runCatching { return@withContext runCatching {
println("!!!!!!!!!!!!!! getInfo $code") println("!!!!!!!!!!!!!! getInfo")
val response = client.get(getUrl(code, Constants.INFO_URL)) val response = client.get(getUrl(Constants.INFO_URL)) {
headers {
append("Authorization", "Bearer $token")
}
}
if (response.status == HttpStatusCode.OK) { if (response.status == HttpStatusCode.OK) {
println("!!!!!!!!!!!!!! getInfo OK ${response.bodyAsText()}") println("!!!!!!!!!!!!!! getInfo OK ${response.bodyAsText()}")
response.body<UserDto>() response.body<UserDto>()
} else { } else {
if (response.status == HttpStatusCode.Unauthorized) {
AuthRepository.logout()
}
println("!!!!!!!!!!!!!! getInfo ERROR ${response.bodyAsText()}") println("!!!!!!!!!!!!!! getInfo ERROR ${response.bodyAsText()}")
error(response.bodyAsText()) error(response.bodyAsText())
} }
} }
} }
suspend fun getBooking(code: String): Result<Map<String, List<PlaceDto>>?> = withContext(Dispatchers.IO) { suspend fun getBooking(token: String): Result<Map<String, List<PlaceDto>>?> = withContext(Dispatchers.IO) {
return@withContext runCatching { return@withContext runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL)) val response = client.get(getUrl(Constants.BOOKING_URL)) {
headers {
append("Authorization", "Bearer $token")
}
}
if (response.status == HttpStatusCode.OK) { if (response.status == HttpStatusCode.OK) {
response.body<Map<String, List<PlaceDto>>>() response.body<Map<String, List<PlaceDto>>>()
} else { } else {
if (response.status == HttpStatusCode.Unauthorized) {
AuthRepository.logout()
}
error(response.bodyAsText()) error(response.bodyAsText())
} }
} }
} }
suspend fun addBook(code: String, data: BookRequestDto): Result<Boolean> = withContext(Dispatchers.IO) { suspend fun addBook(token: String, data: BookRequestDto): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching { return@withContext runCatching {
val response = client.post(getUrl(code, Constants.BOOK_URL)) { val response = client.post(getUrl(Constants.BOOK_URL)) {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(data) setBody(data)
headers {
append("Authorization", "Bearer $token")
}
} }
when (response.status) { when (response.status) {
HttpStatusCode.Created -> true HttpStatusCode.Created -> true
HttpStatusCode.Conflict -> false HttpStatusCode.Conflict -> false
else -> error(response.bodyAsText()) else -> {
if (response.status == HttpStatusCode.Unauthorized) {
AuthRepository.logout()
}
error(response.bodyAsText())
}
} }
} }
} }
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" private fun getUrl(targetUrl: String) = "${Constants.HOST}/api$targetUrl"
} }

View File

@ -1,15 +1,15 @@
package ru.myitschool.work.domain.auth package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.dto.AuthResponseDto
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
class CheckAndSaveAuthCodeUseCase( class CheckAndSaveAuthCodeUseCase(
private val repository: AuthRepository private val repository: AuthRepository
) { ) {
suspend operator fun invoke( suspend operator fun invoke(
text: String login: String,
): Result<Unit> { password: String
return repository.checkAndSave(text).mapCatching { success -> ): Result<AuthResponseDto> {
if (!success) error("Code is incorrect") return repository.checkAndSave(login, password)
}
} }
} }

View File

@ -2,11 +2,18 @@ package ru.myitschool.work.domain.auth
class CheckCodeFormatUseCase { class CheckCodeFormatUseCase {
operator fun invoke( operator fun invoke(
text: String login: String, password: String
): Boolean { ): Boolean {
return text.length == 4 && text.all { char -> val passwordList = password.toList()
char.isLetterOrDigit() && var passwordCorrect = true
((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char.isDigit()) for (i in 1..(passwordList.size - 3)) {
val s = StringBuilder().append(passwordList[i]).append(passwordList[i+1]).append(passwordList[i+2]).toString()
if (login.contains(s)) {
passwordCorrect = false
} }
} }
return login.isNotEmpty() && password.length >= 8 && passwordCorrect && login.all { char ->
char.isLetterOrDigit() && ((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char.isDigit())
} && password.all({ char -> password.count { it == char } < 3 }) && password.count { it == '!' } + password.count { it == '@' } + password.count { it == '#' } + password.count { it == '"' } + password.count { it == '№' } + password.count { it == ';' } + password.count { it == '$' } + password.count { it == '%' } + password.count { it == '^' } + password.count { it == ':' } + password.count { it == '&' } + password.count { it == '?' } + password.count { it == '*' } >= 1
}
} }

View File

@ -6,6 +6,6 @@ class GetCodeUseCase(
private val repository: AuthRepository private val repository: AuthRepository
) { ) {
suspend operator fun invoke(): String? { suspend operator fun invoke(): String? {
return repository.getCode() return repository.getToken()
} }
} }

View File

@ -1,6 +1,7 @@
package ru.myitschool.work.ui.root package ru.myitschool.work.ui.root
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager.LayoutParams
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -15,6 +16,8 @@ class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
actionBar?.hide()
window.setFlags(LayoutParams.FLAG_SECURE, LayoutParams.FLAG_SECURE)
setContent { setContent {
WorkTheme { WorkTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->

View File

@ -13,7 +13,6 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.delay
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.GetCodeUseCase import ru.myitschool.work.domain.auth.GetCodeUseCase
import ru.myitschool.work.ui.nav.AppDestination import ru.myitschool.work.ui.nav.AppDestination

View File

@ -1,6 +1,6 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
sealed interface AuthIntent { sealed interface AuthIntent {
data class Send(val text: String): AuthIntent data class Send(val login: String, val password: String): AuthIntent
data class TextInput(val text: String): AuthIntent data class TextInput(val login: String, val password: String): AuthIntent
} }

View File

@ -1,17 +1,28 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imeNestedScroll
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -21,8 +32,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -31,6 +42,7 @@ import androidx.navigation.NavController
import ru.myitschool.work.R import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds import ru.myitschool.work.core.TestIds
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun AuthScreen( fun AuthScreen(
viewModel: AuthViewModel = viewModel(), viewModel: AuthViewModel = viewModel(),
@ -48,18 +60,17 @@ fun AuthScreen(
} }
} }
BoxWithConstraints {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(all = 24.dp), .padding(horizontal = if(maxWidth < 400.dp) 48.dp else 200.dp)
.verticalScroll(rememberScrollState())
.imePadding()
.imeNestedScroll(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text(
text = stringResource(R.string.auth_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
when (val currentState = state) { when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState) is AuthState.Data -> Content(viewModel, currentState)
is AuthState.Loading -> { is AuthState.Loading -> {
@ -69,6 +80,7 @@ fun AuthScreen(
} }
} }
} }
}
} }
@Composable @Composable
@ -76,33 +88,83 @@ private fun Content(
viewModel: AuthViewModel, viewModel: AuthViewModel,
state: AuthState.Data state: AuthState.Data
) { ) {
var inputText by remember { mutableStateOf("") } var login by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp)) var password by remember { mutableStateOf("") }
TextField(
Icon(
painter = painterResource(R.drawable.difference),
contentDescription = stringResource(R.string.icon_alter),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(184.dp)
)
Spacer(modifier = Modifier.size(48.dp))
Text(
text = stringResource(R.string.auth_title_1),
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.auth_title_2),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(48.dp))
OutlinedTextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText, shape = RoundedCornerShape(8.dp),
value = login,
onValueChange = { onValueChange = {
inputText = it login = it
viewModel.onIntent(AuthIntent.TextInput(it)) viewModel.onIntent(AuthIntent.TextInput(login, password))
}, },
label = { Text(stringResource(R.string.auth_label)) } placeholder = { Text(stringResource(R.string.auth_placeholder_login)) },
label = { Text(stringResource(R.string.auth_label_login)) }
) )
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( OutlinedTextField(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
onClick = { shape = RoundedCornerShape(8.dp),
viewModel.onIntent(AuthIntent.Send(inputText)) value = password,
onValueChange = {
password = it
viewModel.onIntent(AuthIntent.TextInput(login, password))
}, },
enabled = state.isEnabledSend placeholder = { Text(stringResource(R.string.auth_placeholder_password)) },
) { label = { Text(stringResource(R.string.auth_label_passord)) }
Text(stringResource(R.string.auth_sign_in)) )
} Spacer(modifier = Modifier.size(16.dp))
if (state.error != null) { if (state.error != null) {
Card(
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
modifier = Modifier.fillMaxWidth()
) {
Text( Text(
modifier = Modifier.testTag(TestIds.Auth.ERROR), modifier = Modifier.testTag(TestIds.Auth.ERROR).padding(16.dp),
text = state.error, text = state.error,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color.Red, color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
Spacer(modifier = Modifier.size(20.dp))
Button(
modifier = Modifier
.testTag(TestIds.Auth.SIGN_BUTTON)
.fillMaxWidth()
.height(64.dp),
onClick = {
viewModel.onIntent(AuthIntent.Send(login, password))
},
enabled = state.isEnabledSend,
) {
Text(
text = stringResource(R.string.auth_sign_in),
style = MaterialTheme.typography.titleMedium,
) )
} }
} }

View File

@ -1,5 +1,6 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
import android.os.CountDownTimer
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -9,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.myitschool.work.core.Constants
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
import ru.myitschool.work.domain.auth.CheckCodeFormatUseCase import ru.myitschool.work.domain.auth.CheckCodeFormatUseCase
@ -28,29 +30,68 @@ class AuthViewModel : ViewModel() {
private val _actionFlow: MutableSharedFlow<AuthAction> = MutableSharedFlow() private val _actionFlow: MutableSharedFlow<AuthAction> = MutableSharedFlow()
val actionFlow: SharedFlow<AuthAction> = _actionFlow val actionFlow: SharedFlow<AuthAction> = _actionFlow
private var authTries: Int = 0
private var timerSecs: Int = 0
private var login: String = ""
private var password: String = ""
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
if (authTries >= 5) {
timerSecs = Constants.AUTH_DELAY
val timer = object : CountDownTimer((timerSecs * 1000).toLong(), 1000) {
override fun onTick(millisUntilFinished: Long) {
timerSecs -= 1
updateStateIfData { oldState ->
oldState.copy(
isEnabledSend = false,
error = "Слишком много попыток входа, попробуйте через $timerSecs секунд"
)
}
}
override fun onFinish() {
authTries = 0
updateStateIfData { oldState ->
oldState.copy(
isEnabledSend = checkCodeFormatUseCase.invoke(
login = login,
password = password
),
error = null
)
}
}
}
timer.start()
} else {
viewModelScope.launch { viewModelScope.launch {
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold( _uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke(intent.login, intent.password).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(AuthAction.Open(MainScreenDestination)) _actionFlow.emit(AuthAction.Open(MainScreenDestination))
}, },
onFailure = { error -> onFailure = { error ->
updateStateIfData { oldState -> authTries += 1
oldState.copy( _uiState.update { AuthState.Data(isEnabledSend = false, error = error.message) }
error = error.message
)
}
} }
) )
} }
} }
}
is AuthIntent.TextInput -> { is AuthIntent.TextInput -> {
login = intent.login
password = intent.password
updateStateIfData { oldState -> updateStateIfData { oldState ->
oldState.copy( oldState.copy(
isEnabledSend = checkCodeFormatUseCase.invoke(intent.text), isEnabledSend = checkCodeFormatUseCase.invoke(
error = null login = intent.login,
password = intent.password
) && authTries <= 5,
error = if (authTries >= 5) oldState.error else null
) )
} }
} }

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="284.03dp"
android:height="345.01dp"
android:viewportWidth="284.03"
android:viewportHeight="345.01">
<path
android:pathData="M284.03,232.8C281.79,277.45 275.6,316.26 264.81,332.14C255.31,346.12 224.16,350.21 197.88,336.87C171.73,323.6 154,299.01 143,298.26C132,299.01 113.06,323.57 87.11,336.73C60.83,350.07 29.69,345.98 20.19,332C10.6,317.9 4.65,285.69 1.87,247.37C8.57,245.74 26.72,242.23 51,243.01C82,244.01 170,254.01 227,247.01C255.62,243.49 273.4,237.71 284.01,232.82M-0,191.11C0.02,181.74 0.19,172.34 0.5,163.06C16.31,172.82 46.19,184.27 101,187.01C201,192.01 187,216.01 169,224.01C151,232.01 118.71,236.27 78,230.01C47.59,225.33 17.17,210.92 0.04,191.05M7.06,86.76C10.85,62.13 16.09,43.3 22.58,35.36C52.8,-1.59 143,0.01 143,0.01C143,0.01 233.57,0.22 262.42,35.5C267.09,41.21 271.11,52.57 274.41,67.66C253.93,70.28 222.7,71.01 176,66.01C92.79,57.1 35.53,71.91 7.07,86.68M281.46,115.48C282.28,123.97 282.96,132.82 283.5,141.88C272.8,137.26 252.16,131 214,127.01C147,120.01 125.17,117.16 127,98.01C129,77.01 169,76.01 199,82.01C225.31,87.27 259.31,94.84 281.44,115.51"
android:fillColor="#6750A4"/>
</vector>

View File

@ -1,8 +1,12 @@
<resources> <resources>
<string name="app_name">Work</string> <string name="app_name">Work</string>
<string name="title_activity_root">RootActivity</string> <string name="title_activity_root">RootActivity</string>
<string name="auth_title">Привет! Введи код для авторизации</string> <string name="auth_title_1">Войдите</string>
<string name="auth_label">Код</string> <string name="auth_title_2">Используя аккаунт S-App</string>
<string name="auth_label_login">Логин</string>
<string name="auth_placeholder_login">Введите логин</string>
<string name="auth_label_passord">Пароль</string>
<string name="auth_placeholder_password">Введите пароль</string>
<string name="auth_sign_in">Войти</string> <string name="auth_sign_in">Войти</string>
<string name="main_refresh">Обновить</string> <string name="main_refresh">Обновить</string>
@ -12,4 +16,6 @@
<string name="book_add">Забронировать</string> <string name="book_add">Забронировать</string>
<string name="book_back">Назад</string> <string name="book_back">Назад</string>
<string name="book_empty">Всё забронировано</string> <string name="book_empty">Всё забронировано</string>
<string name="icon_alter">Иконка</string>
</resources> </resources>