main #6
1
README.MD
Normal file
1
README.MD
Normal 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
|
||||||
@ -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" />
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
58
app/src/main/java/ru/myitschool/work/data/AESEncryption.kt
Normal file
58
app/src/main/java/ru/myitschool/work/data/AESEncryption.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 ->
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/src/main/res/drawable/difference.xml
Normal file
9
app/src/main/res/drawable/difference.xml
Normal 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>
|
||||||
@ -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>
|
||||||
Loading…
x
Reference in New Issue
Block a user