main #8

Open
student-m-kuchergin wants to merge 17 commits from KHBGPU-org/NTO-2026-Android-TeamTask-Template:main into main
22 changed files with 236 additions and 77 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/.idea
/.gradle
/build
/local.properties

BIN
Client_v1.apk Normal file

Binary file not shown.

BIN
Design_v0.penpot Normal file

Binary file not shown.

BIN
Design_v1.penpot Normal file

Binary file not shown.

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# Penpot Доска
https://pp.sicampus.ru/#/workspace?team-id=14a6b474-d5fa-807f-8007-9f077cdd8786&file-id=14a6b474-d5fa-807f-8007-9f541769571d&page-id=14a6b474-d5fa-807f-8007-9f541769571e

View File

@ -34,6 +34,7 @@ android {
}
dependencies {
implementation("androidx.compose.foundation:foundation:1.10.3")
defaultComposeLibrary()
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
@ -47,4 +48,8 @@ dependencies {
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("androidx.compose.ui:ui-tooling-preview:1.10.3")
implementation("androidx.compose.runtime:runtime-tracing:1.10.3")
implementation("androidx.compose.runtime:runtime-tracing:1.10.3")
debugImplementation ("androidx.compose.ui:ui-tooling:1.10.3")
}

View File

@ -1,8 +1,10 @@
package ru.myitschool.work.core
object Constants {
const val HOST = "http://localhost:8090"
const val HOST = "http://10.0.0.14:49183"
const val AUTH_URL = "/auth"
//TODO заменить на /auth
const val INFO_URL = "/info"
const val BOOKING_URL = "/booking"
const val BOOK_URL = "/book"

View File

@ -4,7 +4,8 @@ object TestIds {
object Auth {
const val ERROR = "auth_error"
const val SIGN_BUTTON = "auth_sign_button"
const val CODE_INPUT = "auth_code_input"
const val LOGIN_INPUT = "auth_login_input"
const val PASSWORD_INPUT = "auth_password_input"
}
object Main {
const val ERROR = "main_error"

View File

@ -1,14 +1,19 @@
package ru.myitschool.work.data.source
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.basicAuth
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpHeaders.Authorization
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
@ -19,6 +24,7 @@ import ru.myitschool.work.core.Constants
import ru.myitschool.work.data.dto.PlaceDto
import ru.myitschool.work.data.dto.BookRequestDto
import ru.myitschool.work.data.dto.UserDto
import kotlin.io.encoding.Base64
object NetworkDataSource {
private val client by lazy {
@ -36,9 +42,25 @@ object NetworkDataSource {
}
}
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
suspend fun checkAuth(text: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL))
/*
val response = client.post(
getUrl(Constants.AUTH_URL))
*/
val response = client.post(
urlString = "http://10.0.0.177:49183/api/auth" //getUrl(Constants.AUTH_URL)
) {
header(key = Authorization, value = basicAuth(text.split(Regex(":"))[0], text.split(Regex(":"))[1]))
}
// "Basic ${Base64.encode(text.toByteArray(), )}" "Basic ${Base64.encode(text.toByteArray())}"
//val response = client.get(getUrl(Constants.AUTH_URL)) {}
Log.d("RESPONSE", response.toString())
Log.d("RESPONSE_STATUS", response.status.toString())
when (response.status) {
HttpStatusCode.OK -> true
else -> false
@ -49,7 +71,7 @@ object NetworkDataSource {
suspend fun getInfo(code: String): Result<UserDto> = withContext(Dispatchers.IO) {
return@withContext runCatching {
println("!!!!!!!!!!!!!! getInfo $code")
val response = client.get(getUrl(code, Constants.INFO_URL))
val response = client.get(getUrl(Constants.INFO_URL))
if (response.status == HttpStatusCode.OK) {
println("!!!!!!!!!!!!!! getInfo OK ${response.bodyAsText()}")
response.body<UserDto>()
@ -62,7 +84,7 @@ object NetworkDataSource {
suspend fun getBooking(code: String): Result<Map<String, List<PlaceDto>>?> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL))
val response = client.get(getUrl(Constants.BOOKING_URL))
if (response.status == HttpStatusCode.OK) {
response.body<Map<String, List<PlaceDto>>>()
} else {
@ -73,7 +95,7 @@ object NetworkDataSource {
suspend fun addBook(code: String, data: BookRequestDto): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
val response = client.post(getUrl(Constants.BOOK_URL)) {
contentType(ContentType.Application.Json)
setBody(data)
}
@ -85,5 +107,5 @@ object NetworkDataSource {
}
}
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 +0,0 @@
package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.repo.AuthRepository
class CheckAndSaveAuthCodeUseCase(
private val repository: AuthRepository
) {
suspend operator fun invoke(
text: String
): Result<Unit> {
return repository.checkAndSave(text).mapCatching { success ->
if (!success) error("Code is incorrect")
}
}
}

View File

@ -0,0 +1,19 @@
package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.repo.AuthRepository
class CheckAndSaveAuthDataUseCase(
private val repository: AuthRepository
) {
suspend operator fun invoke(
textLogin: String,
textPassword: String
): Result<Unit> {
return repository.checkAndSave("$textLogin:$textPassword").mapCatching { success ->
if (!success) {
error("Login or password is incorrect")
}
}
}
}

View File

@ -1,12 +0,0 @@
package ru.myitschool.work.domain.auth
class CheckCodeFormatUseCase {
operator fun invoke(
text: String
): Boolean {
return text.length == 4 && text.all { char ->
char.isLetterOrDigit() &&
((char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char.isDigit())
}
}
}

View File

@ -0,0 +1,14 @@
package ru.myitschool.work.domain.auth
class CheckLoginFormatUseCase {
operator fun invoke(
text: String
): Boolean {
return text.all { char ->
(char.isLetterOrDigit() &&
((char in ('A'..'Z')) || (char in ('a'..'z')) || char.isDigit()))
|| char == '.'
}
}
}

View File

@ -0,0 +1,20 @@
package ru.myitschool.work.domain.auth
import java.util.Locale
import java.util.Locale.getDefault
class CheckPasswordFormatUseCase {
operator fun invoke(
textLogin: String,
textPassword: String
):
Boolean {
val lowerCasePassword = textPassword.lowercase(getDefault())
val lowerCaseLogin = textLogin.lowercase(getDefault())
val intersect = lowerCasePassword.toList().intersect(lowerCaseLogin.toList());
return (textPassword.length >= 8) && (textPassword.all { char ->
textPassword.count { it == char } < 3
&& intersect.size < 3
}) && !("[A-Za-z0-9]+".toRegex().matches(textPassword))
}
}

View File

@ -1,6 +1,7 @@
package ru.myitschool.work.ui.root
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@ -10,11 +11,17 @@ import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import ru.myitschool.work.ui.screen.AppNavHost
import ru.myitschool.work.ui.theme.WorkTheme
import android.view.View
class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
// Hide the status bar.
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
actionBar?.hide()
setContent {
WorkTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->

View File

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

View File

@ -3,21 +3,23 @@ package ru.myitschool.work.ui.screen.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -28,8 +30,18 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.material3.OutlinedTextField
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.sp
@Composable
fun AuthScreen(
@ -38,6 +50,7 @@ fun AuthScreen(
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect { action ->
when (action) {
@ -51,15 +64,11 @@ fun AuthScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 24.dp),
.padding(all = 44.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.auth_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState)
is AuthState.Loading -> {
@ -72,37 +81,108 @@ fun AuthScreen(
}
@Composable
private fun Content(
private fun Content
(
viewModel: AuthViewModel,
state: AuthState.Data
) {
var inputText by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp))
TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText,
onValueChange = {
inputText = it
viewModel.onIntent(AuthIntent.TextInput(it))
},
label = { Text(stringResource(R.string.auth_label)) }
var inputLogin by rememberSaveable { mutableStateOf("") }
val googleSans = FontFamily(
Font(R.font.googlesans_regular, FontWeight.Medium, FontStyle.Normal)
)
Spacer(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(5.dp))
Text(
text = stringResource(R.string.auth_title),
fontFamily = googleSans,
color = Color(0xFF284777),
fontSize = 35.sp,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium,
fontStyle = FontStyle.Normal,
modifier = Modifier.fillMaxWidth(1f)
)
Spacer(modifier = Modifier.height(5.dp))
Text(
text = stringResource(R.string.auth_label_login),
fontFamily = googleSans,
color = Color(0xFF74777f),
textAlign = TextAlign.Start,
fontWeight = FontWeight.Medium,
fontStyle = FontStyle.Normal,
modifier = Modifier.fillMaxWidth(1f)
)
Spacer(modifier = Modifier.size(10.dp))
OutlinedTextField(
modifier = Modifier.testTag(TestIds.Auth.LOGIN_INPUT).fillMaxWidth(),
value = inputLogin,
shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(unfocusedContainerColor = Color(0xFFededf4)),
onValueChange = {
inputLogin = it
viewModel.onIntent(AuthIntent.TextInput(
it,
textPassword = ""
))
}
)
var inputPassword by rememberSaveable { mutableStateOf("") }
Spacer(modifier = Modifier.size(20.dp))
Text(
text = stringResource(R.string.auth_label_password),
fontFamily = googleSans,
color = Color(0xFF74777f),
textAlign = TextAlign.Start,
fontWeight = FontWeight.Medium,
fontStyle = FontStyle.Normal,
modifier = Modifier.fillMaxWidth(1f)
)
Spacer(modifier = Modifier.size(10.dp))
OutlinedTextField(
modifier = Modifier.testTag(TestIds.Auth.PASSWORD_INPUT).fillMaxWidth(),
value = inputPassword,
shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(unfocusedContainerColor = Color(0xFFededf4)),
onValueChange = {
inputPassword = it
viewModel.onIntent(AuthIntent.TextInput(
inputLogin,
textPassword = it
))
}
)
Spacer(modifier = Modifier.size(35.dp))
Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = {
viewModel.onIntent(AuthIntent.Send(inputText))
viewModel.onIntent(AuthIntent.Send(inputLogin, inputPassword))
},
enabled = state.isEnabledSend
) {
Text(stringResource(R.string.auth_sign_in))
Text(
text = "Войти",
modifier = Modifier.padding(10.dp),
fontFamily = googleSans,
fontWeight = FontWeight.Medium,
fontStyle = FontStyle.Normal,
fontSize = 18.sp
)
}
if (state.error != null) {
Text(
modifier = Modifier.testTag(TestIds.Auth.ERROR),
text = state.error,
text = if (viewModel.incorrectAttemptNum >= 5) {
"Превышен лимит попыток входа"
} else {
state.error
},
style = MaterialTheme.typography.bodyMedium,
color = Color.Red,
)
}
}
@Preview
@Composable
fun AuthView() {
AuthScreen(navController = rememberNavController())
}

View File

@ -10,19 +10,25 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
import ru.myitschool.work.domain.auth.CheckCodeFormatUseCase
import ru.myitschool.work.domain.auth.CheckAndSaveAuthDataUseCase
import ru.myitschool.work.domain.auth.CheckLoginFormatUseCase
import ru.myitschool.work.domain.auth.CheckPasswordFormatUseCase
import ru.myitschool.work.ui.nav.MainScreenDestination
class AuthViewModel : ViewModel() {
private val checkCodeFormatUseCase by lazy { CheckCodeFormatUseCase() }
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val checkLoginFormatUseCase by lazy { CheckLoginFormatUseCase() }
private val checkAndSaveAuthDataUseCase by lazy { CheckAndSaveAuthDataUseCase(AuthRepository) }
private val checkPasswordFormatUseCase by lazy { CheckPasswordFormatUseCase() }
private val _uiState = MutableStateFlow<AuthState>(
AuthState.Data(
isEnabledSend = false,
error = null
)
)
var incorrectAttemptNum = 0
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<AuthAction> = MutableSharedFlow()
@ -32,7 +38,7 @@ class AuthViewModel : ViewModel() {
when (intent) {
is AuthIntent.Send -> {
viewModelScope.launch {
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
checkAndSaveAuthDataUseCase.invoke(intent.textLogin, intent.textPassword).fold(
onSuccess = {
_actionFlow.emit(AuthAction.Open(MainScreenDestination))
},
@ -42,14 +48,20 @@ class AuthViewModel : ViewModel() {
error = error.message
)
}
if (error.message == "Login or password is incorrect") {
incorrectAttemptNum += 1;
}
}
)
}
}
is AuthIntent.TextInput -> {
updateStateIfData { oldState ->
oldState.copy(
isEnabledSend = checkCodeFormatUseCase.invoke(intent.text),
isEnabledSend = (checkPasswordFormatUseCase.invoke(
intent.textLogin, intent.textPassword
) && checkLoginFormatUseCase.invoke(intent.textLogin) && incorrectAttemptNum <= 4),
error = null
)
}

Binary file not shown.

View File

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="purple_200">#415f91</color>
<color name="purple_500">#565F71</color>
<color name="purple_700">#33618D</color>
<color name="teal_200">#D6E3FF</color>
<color name="teal_700">#DAE2F9</color>
<color name="black">#44474E</color>
<color name="white">#F3F3FA</color>
</resources>

View File

@ -1,8 +1,9 @@
<resources>
<string name="app_name">Work</string>
<string name="title_activity_root">RootActivity</string>
<string name="auth_title">Привет! Введи код для авторизации</string>
<string name="auth_label">Код</string>
<string name="title_activity_root">Приложение</string>
<string name="auth_title">Авторизация</string>
<string name="auth_label_login">Логин</string>
<string name="auth_label_password">Пароль</string>
<string name="auth_sign_in">Войти</string>
<string name="main_refresh">Обновить</string>

BIN
khBGPU.apk Normal file

Binary file not shown.