new_select_screen #9

Open
student-i-nikolaevskiy wants to merge 42 commits from Minipigi-org/NTO-2026-Android-TeamTask-Template:new_select_screen into main
39 changed files with 897 additions and 134 deletions

3
README.MD Normal file
View File

@ -0,0 +1,3 @@
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
login: anna
password: qwerty1!

BIN
S-APP-MINIPIGS.penpot Normal file

Binary file not shown.

View File

@ -17,8 +17,9 @@
tools:targetApi="31">
<activity
android:name=".ui.root.RootActivity"
android:screenOrientation="portrait"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:windowSoftInputMode="adjustNothing"
android:label="@string/title_activity_root">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -1,9 +1,11 @@
package ru.myitschool.work.core
object Constants {
const val HOST = "http://localhost:8090"
const val AUTH_URL = "/auth"
const val HOST = "http://10.0.0.12:49165"
const val AUTH_URL = "/login"
const val INFO_URL = "/info"
const val BOOKING_URL = "/booking"
const val BOOK_URL = "/book"
const val ROOM_BOOKING_URL = "/room/booking"
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,22 @@
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,
@SerialName("role")
val role: String
)

View File

@ -8,5 +8,5 @@ data class BookRequestDto(
@SerialName("date")
val date: String,
@SerialName("placeId")
val placeId: String,
val placeId: Int,
)

View File

@ -0,0 +1,12 @@
package ru.myitschool.work.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class RoomBookingRequestDto(
@SerialName("roomId")
val roomId: Int,
@SerialName("date")
val date: String,
)

View File

@ -0,0 +1,12 @@
package ru.myitschool.work.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class RoomBookingsDto(
@SerialName("name")
val name: String,
@SerialName("data")
val data: Map<String, String?>
)

View File

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

View File

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

View File

@ -0,0 +1,32 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.dto.BookRequestDto
import ru.myitschool.work.data.dto.RoomBookingRequestDto
import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.domain.room.entities.RoomEntity
class RoomBookingRepository(
private val authRepository: AuthRepository
) {
suspend fun getRoomBookings(roomId: Int): Result<RoomEntity> {
val token = authRepository.getToken() ?: return getNoAuthResult()
return NetworkDataSource.getRoomBookings(token, roomId = roomId).mapCatching { dto ->
RoomEntity(name=dto.name, data=dto.data)
}
}
suspend fun deleteRoomBooking(roomId: Int, date: String): Result<Boolean> {
val token = authRepository.getToken() ?: return getNoAuthResult()
val data = RoomBookingRequestDto(roomId = roomId, date = date)
return NetworkDataSource.deleteRoomBooking(token, data)
}
suspend fun sendRoomBooking(roomId: Int, date: String): Result<Boolean> {
val token = authRepository.getToken() ?: return getNoAuthResult()
val dto = BookRequestDto(date, roomId)
return NetworkDataSource.addBook(token, dto)
}
private fun <T> getNoAuthResult() = Result.failure<T>(
IllegalStateException("No auth")
)
}

View File

@ -4,7 +4,9 @@ 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.delete
import io.ktor.client.request.get
import io.ktor.client.request.headers
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
@ -16,9 +18,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
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.BookRequestDto
import ru.myitschool.work.data.dto.RoomBookingRequestDto
import ru.myitschool.work.data.dto.RoomBookingsDto
import ru.myitschool.work.data.dto.UserDto
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.room.entities.RoomEntity
object NetworkDataSource {
private val client by lazy {
@ -36,54 +44,116 @@ 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 {
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) {
HttpStatusCode.OK -> true
else -> false
HttpStatusCode.OK -> response.body<AuthResponseDto>()
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 {
println("!!!!!!!!!!!!!! getInfo $code")
val response = client.get(getUrl(code, Constants.INFO_URL))
println("!!!!!!!!!!!!!! getInfo")
val response = client.get(getUrl(Constants.INFO_URL)) {
headers {
append("Authorization", "Bearer $token")
}
}
if (response.status == HttpStatusCode.OK) {
println("!!!!!!!!!!!!!! getInfo OK ${response.bodyAsText()}")
response.body<UserDto>()
} else {
if (response.status == HttpStatusCode.Unauthorized) {
AuthRepository.logout()
}
println("!!!!!!!!!!!!!! getInfo 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 {
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) {
response.body<Map<String, List<PlaceDto>>>()
} else {
if (response.status == HttpStatusCode.Unauthorized) {
AuthRepository.logout()
}
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 {
val response = client.post(getUrl(code, Constants.BOOK_URL)) {
val response = client.post(getUrl(Constants.BOOK_URL)) {
contentType(ContentType.Application.Json)
setBody(data)
headers {
append("Authorization", "Bearer $token")
}
}
when (response.status) {
HttpStatusCode.Created -> true
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"
suspend fun getRoomBookings(token: String, roomId: Int): Result<RoomBookingsDto> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl("$Constants.ROOM_BOOKING_URL/$roomId")) {
headers {
append("Authorization", "Bearer $token")
}
}
if (response.status == HttpStatusCode.OK) {
response.body<RoomBookingsDto>()
} else {
if (response.status == HttpStatusCode.Unauthorized) {
AuthRepository.logout()
}
error(response.bodyAsText())
}
}
}
suspend fun deleteRoomBooking(token: String, data: RoomBookingRequestDto): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.delete(getUrl(Constants.ROOM_BOOKING_URL)) {
headers {
append("Authorization", "Bearer $token")
}
setBody(data)
}
if (response.status == HttpStatusCode.OK) {
true
} else {
if (response.status == HttpStatusCode.Unauthorized) {
AuthRepository.logout()
}
error(response.bodyAsText())
}
}
}
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,15 @@
package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.dto.AuthResponseDto
import ru.myitschool.work.data.repo.AuthRepository
class CheckAndSaveAuthTokenUseCase(
private val repository: AuthRepository
) {
suspend operator fun invoke(
login: String,
password: String
): Result<AuthResponseDto> {
return repository.checkAndSave(login, password)
}
}

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,19 @@
package ru.myitschool.work.domain.auth
class CheckCredsFormatUseCase {
operator fun invoke(
login: String, password: String
): Boolean {
val passwordList = password.toList()
var passwordCorrect = true
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

@ -2,10 +2,10 @@ package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.repo.AuthRepository
class GetCodeUseCase(
class GetTokenUseCase(
private val repository: AuthRepository
) {
suspend operator fun invoke(): String? {
return repository.getCode()
return repository.getToken()
}
}

View File

@ -2,5 +2,5 @@ package ru.myitschool.work.domain.book.entities
data class BookRequestData(
val date: String,
val placeId: String
val placeId: Int
)

View File

@ -0,0 +1,11 @@
package ru.myitschool.work.domain.room
import ru.myitschool.work.data.repo.RoomBookingRepository
class DeleteRoomBookingsUseCase(
private val repository: RoomBookingRepository
) {
suspend operator fun invoke(roomId: Int, date: String): Result<Boolean> {
return repository.deleteRoomBooking(roomId = roomId, date = date)
}
}

View File

@ -0,0 +1,11 @@
package ru.myitschool.work.domain.room
import ru.myitschool.work.data.repo.AuthRepository
class GetRoleUseCase(
private val repository: AuthRepository
) {
suspend operator fun invoke(): String? {
return repository.getRole()
}
}

View File

@ -0,0 +1,12 @@
package ru.myitschool.work.domain.room
import ru.myitschool.work.data.repo.RoomBookingRepository
import ru.myitschool.work.domain.room.entities.RoomEntity
class GetRoomBookingsUseCase(
private val repository: RoomBookingRepository
) {
suspend operator fun invoke(roomId: Int): Result<RoomEntity> {
return repository.getRoomBookings(roomId = roomId)
}
}

View File

@ -0,0 +1,13 @@
package ru.myitschool.work.domain.room
import ru.myitschool.work.data.repo.RoomBookingRepository
class SendRoomBookingRequestUseCase(
private val repository: RoomBookingRepository
) {
suspend operator fun invoke(roomId: Int, date: String): Result<Unit> {
return repository.sendRoomBooking(roomId, date).mapCatching { success ->
if (!success) error("Book error")
}
}
}

View File

@ -0,0 +1,7 @@
package ru.myitschool.work.domain.room.entities
class RoomEntity (
val name: String,
val data: Map<String, String?>
)

View File

@ -0,0 +1,6 @@
package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data class RoomScreenDestination(val roomId: Int): AppDestination

View File

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

View File

@ -10,19 +10,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.delay
import androidx.navigation.navArgument
import androidx.navigation.toRoute
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.GetCodeUseCase
import ru.myitschool.work.domain.auth.GetTokenUseCase
import ru.myitschool.work.domain.room.GetRoleUseCase
import ru.myitschool.work.ui.nav.AppDestination
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.nav.RoomScreenDestination
import ru.myitschool.work.ui.screen.auth.AuthScreen
import ru.myitschool.work.ui.screen.book.BookScreen
import ru.myitschool.work.ui.screen.main.MainScreen
import ru.myitschool.work.ui.screen.room.RoomScreen
@Composable
fun AppNavHost(
@ -31,12 +36,15 @@ fun AppNavHost(
) {
var destination by remember { mutableStateOf<AppDestination?>(null) }
LaunchedEffect(Unit) {
val code = GetCodeUseCase(AuthRepository).invoke()
destination = if (code == null) {
val code = GetTokenUseCase(AuthRepository).invoke()
val role = GetRoleUseCase(AuthRepository).invoke()
destination = (if (code == null) {
AuthScreenDestination
} else if (role == "ROOM") {
RoomScreenDestination
} else {
MainScreenDestination
}
}) as AppDestination?
}
if (destination != null) {
NavHost(
@ -55,6 +63,13 @@ fun AppNavHost(
composable<BookScreenDestination> {
BookScreen(navController = navController)
}
composable<RoomScreenDestination> { backStackEntry ->
val room: RoomScreenDestination = backStackEntry.toRoute()
RoomScreen(
roomId = room.roomId,
navController = navController
)
}
}
}
}

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 login: String, val password: String): AuthIntent
data class TextInput(val login: String, val password: String): AuthIntent
}

View File

@ -1,17 +1,33 @@
package ru.myitschool.work.ui.screen.auth
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imeNestedScroll
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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
@ -21,8 +37,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -31,12 +47,14 @@ import androidx.navigation.NavController
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthScreen(
viewModel: AuthViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
val isIme = WindowInsets.isImeVisible
LaunchedEffect(Unit) {
viewModel.actionFlow.collect { action ->
@ -48,24 +66,24 @@ fun AuthScreen(
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 24.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 -> {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
BoxWithConstraints {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = if(maxWidth < 400.dp) 48.dp else 200.dp)
.verticalScroll(rememberScrollState())
.imePadding()
.imeNestedScroll(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState, isIme)
is AuthState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
}
}
}
}
@ -74,35 +92,90 @@ fun AuthScreen(
@Composable
private fun Content(
viewModel: AuthViewModel,
state: AuthState.Data
state: AuthState.Data,
isIme: Boolean
) {
var inputText by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp))
TextField(
var login by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
AnimatedVisibility(!isIme ) {
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(),
value = inputText,
shape = RoundedCornerShape(8.dp),
value = login,
onValueChange = {
inputText = it
viewModel.onIntent(AuthIntent.TextInput(it))
login = 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))
Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = {
viewModel.onIntent(AuthIntent.Send(inputText))
OutlinedTextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
value = password,
onValueChange = {
password = it
viewModel.onIntent(AuthIntent.TextInput(login, password))
},
enabled = state.isEnabledSend
) {
Text(stringResource(R.string.auth_sign_in))
}
placeholder = { Text(stringResource(R.string.auth_placeholder_password)) },
label = { Text(stringResource(R.string.auth_label_passord)) }
)
Spacer(modifier = Modifier.size(16.dp))
if (state.error != null) {
Card(
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
modifier = Modifier.fillMaxWidth()
) {
Text(
modifier = Modifier.testTag(TestIds.Auth.ERROR).padding(16.dp),
text = state.error,
style = MaterialTheme.typography.bodyMedium,
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(
modifier = Modifier.testTag(TestIds.Auth.ERROR),
text = state.error,
style = MaterialTheme.typography.bodyMedium,
color = Color.Red,
text = stringResource(R.string.auth_sign_in),
style = MaterialTheme.typography.titleMedium,
)
}
}

View File

@ -1,5 +1,6 @@
package ru.myitschool.work.ui.screen.auth
import android.os.CountDownTimer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
@ -9,14 +10,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.core.Constants
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.CheckAndSaveAuthTokenUseCase
import ru.myitschool.work.domain.auth.CheckCredsFormatUseCase
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 checkCodeFormatUseCase by lazy { CheckCredsFormatUseCase() }
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthTokenUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(
AuthState.Data(
isEnabledSend = false,
@ -28,29 +30,68 @@ class AuthViewModel : ViewModel() {
private val _actionFlow: MutableSharedFlow<AuthAction> = MutableSharedFlow()
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) {
when (intent) {
is AuthIntent.Send -> {
viewModelScope.launch {
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = {
_actionFlow.emit(AuthAction.Open(MainScreenDestination))
},
onFailure = { error ->
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(
error = error.message
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 {
_uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke(intent.login, intent.password).fold(
onSuccess = {
_actionFlow.emit(AuthAction.Open(MainScreenDestination))
},
onFailure = { error ->
authTries += 1
_uiState.update { AuthState.Data(isEnabledSend = false, error = error.message) }
}
)
}
}
}
is AuthIntent.TextInput -> {
login = intent.login
password = intent.password
updateStateIfData { oldState ->
oldState.copy(
isEnabledSend = checkCodeFormatUseCase.invoke(intent.text),
error = null
isEnabledSend = checkCodeFormatUseCase.invoke(
login = intent.login,
password = intent.password
) && authTries <= 5,
error = if (authTries >= 5) oldState.error else null
)
}
}

View File

@ -4,6 +4,6 @@ sealed interface BookIntent {
data object Refresh: BookIntent
data class Add(
val date: String,
val placeId: String
val placeId: Int
): BookIntent
}

View File

@ -170,7 +170,7 @@ private fun ContentState(
mutableIntStateOf(startDestination.index)
}
var selectedPlaceId by rememberSaveable {
mutableStateOf<String?>(null)
mutableStateOf<Int?>(null)
}
Box {
Column {
@ -213,7 +213,7 @@ private fun ContentState(
startDestination = startDestination,
state = state,
onPlaceSelected = { id ->
selectedPlaceId = id
selectedPlaceId = id.toInt()
}
)
}

View File

@ -0,0 +1,7 @@
package ru.myitschool.work.ui.screen.room
sealed interface CardState {
data object Booked: CardState
data object Open: CardState
data object BookedByMe: CardState
}

View File

@ -0,0 +1,7 @@
package ru.myitschool.work.ui.screen.room
sealed interface RoomIntent {
data class Refresh(val placeId: Int): RoomIntent
data class Booking(val placeId: Int, val date: String): RoomIntent
data class UnBook(val placeId: Int, val date: String): RoomIntent
}

View File

@ -0,0 +1,153 @@
package ru.myitschool.work.ui.screen.room
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.screen.auth.AuthViewModel
@Composable
fun RoomScreen(
viewModel: RoomViewModel = viewModel(),
roomId: Int,
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
when(val currentState = state) {
is RoomState.Data -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
) {
Text(
text = "TEST_LOCATOIN",
style = MaterialTheme.typography.displayMedium
)
Spacer(modifier = Modifier.size(12.dp))
LazyColumn {
itemsIndexed(currentState.data.entries.toList()) { index, item ->
DayCard(item.key, item.value.toString(), index)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
) {
Button(
onClick = { viewModel.onIntent(RoomIntent.Booking(roomId, currentState.data.keys.toList()[0])) },
shape = RoundedCornerShape(64, 0, 0, 64)
) {
Text("Book")
}
Button(
onClick = { viewModel.onIntent(RoomIntent.UnBook(roomId, currentState.data.keys.toList()[0])) },
shape = RoundedCornerShape(0, 64, 64, 0)
) {
Text("Cancel")
}
}
}
is RoomState.Error -> {
Card(
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Text(
modifier = Modifier.testTag(TestIds.Auth.ERROR).padding(16.dp),
text = currentState.error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
Button(
onClick = { viewModel.onIntent(RoomIntent.Refresh(roomId)) }
) {
Text("Refresh")
}
}
is RoomState.Loading -> {
CircularProgressIndicator()
}
}
}
}
@Composable
fun DayCard(name: String?, day: String, index: Int) {
Card(
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = if (name == null) {
MaterialTheme.colorScheme.surfaceContainerLowest
} else if (name == "hitler") {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.tertiaryContainer
}, //REMAKE TO STATE
),
modifier = Modifier.fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(24.dp)
) {
Column(
verticalArrangement = Arrangement.Center,
) {
Text(
text = "${name}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "${day}",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
Text("${index}")
}
}
}

View File

@ -0,0 +1,15 @@
package ru.myitschool.work.ui.screen.room
import ru.myitschool.work.domain.room.entities.RoomEntity
import ru.myitschool.work.ui.screen.main.MainState
sealed interface RoomState {
data object Loading: RoomState
data class Error(
val error: String
): RoomState
data class Data(
val data: Map<String, String?>,
val name: String
): RoomState
}

View File

@ -0,0 +1,124 @@
package ru.myitschool.work.ui.screen.room
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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.data.repo.RoomBookingRepository
import ru.myitschool.work.domain.room.DeleteRoomBookingsUseCase
import ru.myitschool.work.domain.room.GetRoomBookingsUseCase
import ru.myitschool.work.domain.room.SendRoomBookingRequestUseCase
import ru.myitschool.work.ui.nav.RoomScreenDestination
class RoomViewModel(
savedStateHandle: SavedStateHandle,
): ViewModel() {
private val roomScreen = savedStateHandle.toRoute<RoomScreenDestination>()
private val roomBookingRepository = RoomBookingRepository(
AuthRepository
)
private val getRoomBookingsDataUseCase by lazy {
GetRoomBookingsUseCase(
RoomBookingRepository(
AuthRepository
)
)
}
private val deleteRoomBookingRequestUseCase by lazy {
DeleteRoomBookingsUseCase(
roomBookingRepository
)
}
private val sendRoomBookingRequestUseCase by lazy {
SendRoomBookingRequestUseCase(
roomBookingRepository
)
}
private val _uiState = MutableStateFlow<RoomState>(RoomState.Loading)
val uiState: StateFlow<RoomState> = _uiState.asStateFlow()
init {
refresh(roomScreen.roomId)
// Timer().schedule(object : TimerTask() {
// override fun run() {
// Log.d("mytimer", "A Kiss every 5 seconds")
// }
// }, 0, 5000)
}
fun onIntent(intent: RoomIntent) {
when (intent) {
is RoomIntent.Refresh -> {
refresh(intent.placeId)
}
is RoomIntent.Booking -> {
viewModelScope.launch {
sendRoomBookingRequestUseCase.invoke(
intent.placeId,
intent.date
).fold(
onSuccess = {
refresh(intent.placeId)
},
onFailure = { error ->
RoomState.Error(
error = error.message.orEmpty()
)
}
)
}
}
is RoomIntent.UnBook -> {
viewModelScope.launch {
deleteRoomBookingRequestUseCase.invoke(
intent.placeId,
intent.date
).fold(
onSuccess = {
refresh(intent.placeId)
},
onFailure = { error ->
RoomState.Error(
error = error.message.orEmpty()
)
}
)
}
}
}
}
private fun refresh(roomId: Int) {
viewModelScope.launch {
_uiState.update { RoomState.Loading }
_uiState.update {
getRoomBookingsDataUseCase.invoke(roomId = roomId).fold(
onSuccess = { data ->
RoomState.Data(
data = data.data,
name = data.name
)
},
onFailure = { error ->
RoomState.Error(
error = error.message.orEmpty()
)
}
)
}
}
}
}

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>
<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="auth_title_1">Войдите</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="main_refresh">Обновить</string>
@ -12,4 +16,6 @@
<string name="book_add">Забронировать</string>
<string name="book_back">Назад</string>
<string name="book_empty">Всё забронировано</string>
<string name="icon_alter">Иконка</string>
</resources>