NtoTeamTask
Some checks failed
Android Test / validate-and-test (pull_request) Has been cancelled

This commit is contained in:
2025-12-12 09:36:38 +07:00
parent 945b9d347d
commit e5f945ef4c
70 changed files with 2642 additions and 350 deletions

View File

@@ -47,5 +47,21 @@ dependencies {
implementation("io.ktor:ktor-client-cio:$ktor")
implementation("io.ktor:ktor-client-content-negotiation:$ktor")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
implementation("io.coil-kt.coil3:coil-compose-core:$coil")
implementation("io.ktor:ktor-client-okhttp:$ktor")
implementation("io.ktor:ktor-client-logging:$ktor")
implementation("io.ktor:ktor-client-auth:$ktor")
implementation("androidx.compose.material:material-icons-extended:1.6.3")
implementation(platform("io.insert-koin:koin-bom:4.0.0"))
implementation("io.insert-koin:koin-core")
implementation("io.insert-koin:koin-android")
implementation("io.insert-koin:koin-compose")
implementation("io.insert-koin:koin-androidx-compose")
implementation(platform("io.coil-kt.coil3:coil-bom:3.0.0"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
}

View File

@@ -5,7 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".App"
android:name=".Application"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@@ -16,10 +16,10 @@
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".ui.root.RootActivity"
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:label="@string/title_activity_root">
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -1,15 +0,0 @@
package ru.myitschool.work
import android.app.Application
import android.content.Context
class App: Application() {
override fun onCreate() {
super.onCreate()
context = this
}
companion object {
lateinit var context: Context
}
}

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work
import android.app.Application
import ru.myitschool.work.comp.di.appModule
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class Application : Application(){
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@Application)
modules(appModule)
}
}
}

View File

@@ -0,0 +1,19 @@
package ru.myitschool.work
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import ru.myitschool.work.ui.theme.WorkTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
WorkTheme {
AppNavHost()
}
}
}
}

View File

@@ -0,0 +1,83 @@
package ru.myitschool.work
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import ru.myitschool.work.comp.presentation.auth.AuthScreenRoot
import ru.myitschool.work.comp.presentation.bookingScreen.BookingScreenRoot
import ru.myitschool.work.comp.presentation.main.MainScreenRoot
import ru.myitschool.work.comp.domain.nav.AuthScreenDestination
import ru.myitschool.work.comp.domain.nav.BookScreenDestination
import ru.myitschool.work.comp.domain.nav.MainScreenDestination
import ru.myitschool.work.comp.domain.nav.SplashScreenDestination
import com.example.result.comp.presentation.splash.SplashScreen
@Composable
fun AppNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController()
) {
NavHost(
modifier = modifier,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
navController = navController,
startDestination = SplashScreenDestination,
) {
composable<SplashScreenDestination> {
SplashScreen(
onNavigateToMainScreen = {
navController.navigate(MainScreenDestination){
popUpTo(SplashScreenDestination){
inclusive = true
}
}
},
onNavigateToAuthScreen = {
navController.navigate(AuthScreenDestination){
popUpTo(SplashScreenDestination){
inclusive = true
}
}
}
)
}
composable<AuthScreenDestination> {
AuthScreenRoot(
onNavigateToMainScreen = {
navController.navigate(MainScreenDestination){
popUpTo(AuthScreenDestination){
inclusive = true
}
}
}
)
}
composable<MainScreenDestination> {
MainScreenRoot(
onNavigateToAuthScreen = {
navController.navigate(AuthScreenDestination){
popUpTo(MainScreenDestination){
inclusive = true
}
}
},
onNavigateToBookingScreen = {
navController.navigate(BookScreenDestination)
}
)
}
composable<BookScreenDestination> {
BookingScreenRoot(
onNavigateBack = {
navController.popBackStack()
}
)
}
}
}

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.comp.data.dtos
import kotlinx.serialization.Serializable
@Serializable
data class BookDto(
val date : String,
val placeId : Int
)

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.comp.data.dtos
import kotlinx.serialization.Serializable
@Serializable
data class PlaceDto(
val id: Int,
val place: String
)
typealias BookingsResponse = Map<String, List<PlaceDto>>

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.comp.data.dtos
import kotlinx.serialization.Serializable
@Serializable
data class GetInfoDto(
val photoUrl : String,
val booking : Map<String, BookingDto>,
val name : String,
)
@Serializable
data class BookingDto(
val id: Int,
val place: String
)

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.comp.data.mappers
import ru.myitschool.work.comp.domain.model.BookModel
import ru.myitschool.work.comp.data.dtos.BookDto
fun BookModel.toDto(): BookDto {
return BookDto(
date = date,
placeId = placeId
)
}

View File

@@ -0,0 +1,19 @@
package ru.myitschool.work.comp.data.mappers
import ru.myitschool.work.comp.domain.model.GetBookingsModel
import ru.myitschool.work.comp.domain.model.PlaceModel
import ru.myitschool.work.comp.data.dtos.PlaceDto
fun Map<String, List<PlaceDto>>.toModel(): GetBookingsModel {
return GetBookingsModel(
bookings = this.mapValues { (_, places) ->
places.map { placeDto ->
PlaceModel(
id = placeDto.id,
place = placeDto.place
)
}
}
)
}

View File

@@ -0,0 +1,24 @@
package ru.myitschool.work.comp.data.mappers
import ru.myitschool.work.comp.data.dtos.BookingDto
import ru.myitschool.work.comp.data.dtos.GetInfoDto
import ru.myitschool.work.comp.domain.model.BookingModel
import ru.myitschool.work.comp.domain.model.GetInfoModel
fun GetInfoDto.toModel(): GetInfoModel {
return GetInfoModel(
photoUrl = photoUrl,
booking = booking.map { (date, bookingItem) ->
bookingItem.toModel(date)
}.toList(),
name = name,
)
}
fun BookingDto.toModel(date: String): BookingModel {
return BookingModel(
id = id,
date = date,
place = place
)
}

View File

@@ -0,0 +1,79 @@
package ru.myitschool.work.comp.data.repository
import ru.myitschool.work.comp.data.dtos.BookDto
import ru.myitschool.work.comp.data.dtos.GetInfoDto
import ru.myitschool.work.comp.data.dtos.PlaceDto
import ru.myitschool.work.comp.domain.repository.AppRepository
import ru.myitschool.work.core.auth.CodeProvider
import ru.myitschool.work.core.data.safeCall
import ru.myitschool.work.core.domain.Constants
import ru.myitschool.work.core.domain.DataError
import ru.myitschool.work.core.domain.Result
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
class AppRepositoryImpl(
private val httpClient: HttpClient,
private val codeProvider: CodeProvider
) : AppRepository {
private val code: String?
get() = codeProvider.getCode()
override suspend fun auth(codeToSend: String): Result<Unit, DataError.Remote> {
return try {
val response: HttpResponse = httpClient.get(
urlString = "/api/" + codeToSend + Constants.AUTH_URL
)
when (response.status.value) {
200 -> {
Result.Success(Unit)
}
401 -> {
Result.Error(DataError.Remote.UNAUTHORIZED)
}
400 -> {
Result.Error(DataError.Remote.BAD_REQUEST)
}
else -> {
Result.Error(DataError.Remote.UNKNOWN)
}
}
} catch (e: Exception) {
e.printStackTrace()
Result.Error(DataError.Remote.NETWORK)
}
}
override suspend fun getInfo(): Result<GetInfoDto, DataError.Remote> {
return safeCall<GetInfoDto> {
httpClient.get(
urlString = code + Constants.INFO_URL
)
}
}
override suspend fun getBooking(): Result<Map<String, List<PlaceDto>>, DataError.Remote> {
return safeCall<Map<String, List<PlaceDto>>> {
httpClient.get(
urlString = code + Constants.BOOKING_URL
)
}
}
override suspend fun book(bookDto: BookDto): Result<Unit, DataError.Remote> {
return safeCall<Unit> {
httpClient.post(
urlString = code + Constants.BOOK_URL
) {
contentType(ContentType.Application.Json)
setBody(bookDto)
}
}
}
}

View File

@@ -0,0 +1,56 @@
package ru.myitschool.work.comp.di
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import ru.myitschool.work.comp.data.repository.AppRepositoryImpl
import ru.myitschool.work.comp.domain.repository.AppRepository
import ru.myitschool.work.core.auth.SharedPrefCodeProvider
import ru.myitschool.work.core.data.HttpClientFactory
import ru.myitschool.work.comp.domain.use_case.AppUseCases
import ru.myitschool.work.comp.domain.use_case.AuthUseCase
import ru.myitschool.work.comp.domain.use_case.BookUseCase
import ru.myitschool.work.comp.domain.use_case.GetBookingUseCase
import ru.myitschool.work.comp.domain.use_case.GetInfoUseCase
import ru.myitschool.work.comp.presentation.auth.AuthViewModel
import ru.myitschool.work.comp.presentation.bookingScreen.BookingViewModel
import ru.myitschool.work.comp.presentation.main.MainScreenViewModel
import ru.myitschool.work.comp.presentation.splash.SplashScreenViewModel
import ru.myitschool.work.core.auth.CodeProvider
import ru.myitschool.work.core.domain.validation.ValidateCode
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val appModule = module {
single<SharedPreferences> {
androidContext().getSharedPreferences("code", MODE_PRIVATE)
}
single<CodeProvider>{SharedPrefCodeProvider(get())}
single { HttpClientFactory.create() }
single { ValidateCode() }
single<AppRepository> { AppRepositoryImpl(get(), get()) }
single { AuthUseCase(get()) }
single { BookUseCase(get()) }
single { GetBookingUseCase(get()) }
single { GetInfoUseCase(get()) }
single {
AppUseCases(
authUseCase = AuthUseCase(get()),
bookUseCase = BookUseCase(get()),
getBookingUseCase = GetBookingUseCase(get()),
getInfoUseCase = GetInfoUseCase(get())
)
}
viewModelOf(::SplashScreenViewModel)
viewModelOf(::AuthViewModel)
viewModelOf(::MainScreenViewModel)
viewModelOf(::BookingViewModel)
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.comp.domain.model
data class BookModel(
val date : String,
val placeId : Int
)

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.comp.domain.model
data class GetBookingsModel(
val bookings: Map<String, List<PlaceModel>>
)
data class PlaceModel(
val id: Int,
val place: String
)

View File

@@ -0,0 +1,13 @@
package ru.myitschool.work.comp.domain.model
data class GetInfoModel(
val photoUrl: String,
val booking: List<BookingModel>,
val name: String,
)
data class BookingModel(
val id: Int,
val date: String,
val place: String
)

View File

@@ -0,0 +1,3 @@
package ru.myitschool.work.comp.domain.nav
sealed interface AppDestination

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.comp.domain.nav
import kotlinx.serialization.Serializable
@Serializable
data object AuthScreenDestination: AppDestination

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.comp.domain.nav
import kotlinx.serialization.Serializable
@Serializable
data object BookScreenDestination: AppDestination

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.comp.domain.nav
import kotlinx.serialization.Serializable
@Serializable
data object MainScreenDestination: AppDestination

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.comp.domain.nav
import kotlinx.serialization.Serializable
@Serializable
data object SplashScreenDestination : AppDestination

View File

@@ -0,0 +1,14 @@
package ru.myitschool.work.comp.domain.repository
import ru.myitschool.work.comp.data.dtos.BookDto
import ru.myitschool.work.comp.data.dtos.GetInfoDto
import ru.myitschool.work.comp.data.dtos.PlaceDto
import ru.myitschool.work.core.domain.DataError
import ru.myitschool.work.core.domain.Result
interface AppRepository {
suspend fun auth(codeToSend: String): Result<Unit, DataError.Remote>
suspend fun getInfo(): Result<GetInfoDto, DataError.Remote>
suspend fun getBooking(): Result<Map<String, List<PlaceDto>>, DataError.Remote>
suspend fun book(bookDto: BookDto): Result<Unit, DataError.Remote>
}

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.comp.domain.use_case
data class AppUseCases(
val authUseCase: AuthUseCase,
val bookUseCase: BookUseCase,
val getBookingUseCase: GetBookingUseCase,
val getInfoUseCase: GetInfoUseCase
)

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.comp.domain.use_case
import ru.myitschool.work.comp.domain.repository.AppRepository
import ru.myitschool.work.core.domain.DataError
import ru.myitschool.work.core.domain.Result
class AuthUseCase(
private val repository: AppRepository
) {
suspend operator fun invoke(codeToSend: String): Result<Unit, DataError.Remote> {
return repository.auth(codeToSend)
}
}

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.comp.domain.use_case
import ru.myitschool.work.comp.data.mappers.toDto
import ru.myitschool.work.comp.domain.model.BookModel
import ru.myitschool.work.comp.domain.repository.AppRepository
import ru.myitschool.work.core.domain.DataError
import ru.myitschool.work.core.domain.Result
class BookUseCase(
val repository: AppRepository,
) {
suspend operator fun invoke(bookModel: BookModel) : Result<Unit, DataError.Remote> {
return repository
.book(bookModel.toDto())
}
}

View File

@@ -0,0 +1,34 @@
package ru.myitschool.work.comp.domain.use_case
import ru.myitschool.work.comp.data.dtos.PlaceDto
import ru.myitschool.work.comp.domain.model.GetBookingsModel
import ru.myitschool.work.comp.domain.model.PlaceModel
import ru.myitschool.work.comp.domain.repository.AppRepository
import ru.myitschool.work.core.domain.DataError
import ru.myitschool.work.core.domain.Result
import ru.myitschool.work.core.domain.map
class GetBookingUseCase(
private val repository: AppRepository
) {
suspend operator fun invoke(): Result<GetBookingsModel, DataError.Remote> {
return repository
.getBooking()
.map { bookingsMap: Map<String, List<PlaceDto>> ->
convertToModel(bookingsMap)
}
}
private fun convertToModel(bookingsMap: Map<String, List<PlaceDto>>): GetBookingsModel {
return GetBookingsModel(
bookings = bookingsMap.mapValues { (_, places) ->
places.map { placeDto ->
PlaceModel(
id = placeDto.id,
place = placeDto.place
)
}
}
)
}
}

View File

@@ -0,0 +1,20 @@
package ru.myitschool.work.comp.domain.use_case
import ru.myitschool.work.comp.data.mappers.toModel
import ru.myitschool.work.comp.domain.model.GetInfoModel
import ru.myitschool.work.comp.domain.repository.AppRepository
import ru.myitschool.work.core.domain.DataError
import ru.myitschool.work.core.domain.Result
import ru.myitschool.work.core.domain.map
class GetInfoUseCase(
private val repository: AppRepository
) {
suspend operator fun invoke() : Result<GetInfoModel, DataError.Remote> {
return repository
.getInfo()
.map { response ->
response.toModel()
}
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.comp.presentation.auth
interface AuthEvent {
data class CodeChanged(val code : String) : AuthEvent
object Submit : AuthEvent
}

View File

@@ -0,0 +1,226 @@
package ru.myitschool.work.comp.presentation.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.myitschool.work.comp.presentation.auth.component.CustomTextField
import ru.myitschool.work.core.domain.TestIds
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.compose.koinViewModel
import ru.myitschool.work.ui.RememberWindowInfo
import ru.myitschool.work.ui.WindowInfo
@Composable
fun AuthScreenRoot(
viewModel: AuthViewModel = koinViewModel(),
onNavigateToMainScreen : () -> Unit
) {
val windowInfo = RememberWindowInfo()
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.uiEvent.collectLatest { event ->
when(event){
UiEvent.Success -> {
onNavigateToMainScreen()
}
}
}
}
when{
state.isLoading -> {
LoadingScreen()
}
else -> {
if (windowInfo.screenWidthInfo is WindowInfo.WindowType.Compact){
AuthScreen(
state = state,
onEvent = { event ->
viewModel.authEvent(event)
}
)
} else{
TabletAuthScreen(
state = state,
onEvent = { event ->
viewModel.authEvent(event)
}
)
}
}
}
}
@Composable
fun AuthScreen(
state : AuthState,
onEvent: (AuthEvent) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Привет! Введи код для авторизации",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
CustomTextField(
modifier = Modifier
.testTag(TestIds.Auth.CODE_INPUT)
.fillMaxWidth(),
label = "Код",
value = state.code,
onValueChange = {
onEvent(AuthEvent.CodeChanged(it))
},
keyboardType = KeyboardType.Text,
error = state.authError
)
Button(
onClick = {
onEvent(AuthEvent.Submit)
},
modifier = Modifier
.testTag(TestIds.Auth.SIGN_BUTTON)
.padding(top = 24.dp)
.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF007BFF),
disabledContainerColor = Color.Gray
),
shape = RoundedCornerShape(12.dp),
enabled = state.isCodeValid
) {
Text(
text = "Войти",
color = Color.White,
modifier = Modifier.padding(vertical = 6.dp)
)
}
}
}
@Composable
fun TabletAuthScreen(
state: AuthState,
onEvent: (AuthEvent) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.padding(horizontal = 32.dp, vertical = 16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(40.dp))
Text(
text = "Привет! Введи код для авторизации",
style = MaterialTheme.typography.headlineMedium.copy(fontSize = 28.sp),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 32.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.sizeIn(maxWidth = 500.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CustomTextField(
modifier = Modifier
.testTag(TestIds.Auth.CODE_INPUT)
.width(500.dp),
label = "Код",
value = state.code,
onValueChange = {
onEvent(AuthEvent.CodeChanged(it))
},
keyboardType = KeyboardType.Text,
error = state.authError,
)
Button(
onClick = {
onEvent(AuthEvent.Submit)
},
modifier = Modifier
.testTag(TestIds.Auth.SIGN_BUTTON)
.padding(top = 24.dp)
.width(200.dp)
.heightIn(min = 48.dp, max = 56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF007BFF),
disabledContainerColor = Color.Gray
),
shape = RoundedCornerShape(12.dp),
enabled = state.isCodeValid
) {
Text(
text = "Войти",
color = Color.White,
fontSize = 16.sp,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
Spacer(modifier = Modifier.height(40.dp))
}
}
@Composable
fun LoadingScreen () {
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
}
}

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.comp.presentation.auth
data class AuthState(
val code : String = "",
val isLoading : Boolean = false,
val authError : String? = null,
val isCodeValid : Boolean = false
)

View File

@@ -0,0 +1,81 @@
package ru.myitschool.work.comp.presentation.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import ru.myitschool.work.comp.domain.use_case.AppUseCases
import ru.myitschool.work.core.auth.CodeProvider
import ru.myitschool.work.core.domain.onError
import ru.myitschool.work.core.domain.onSuccess
import ru.myitschool.work.core.domain.validation.ValidateCode
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AuthViewModel(
private val codeProvider: CodeProvider,
private val appUseCases: AppUseCases,
private val validateCode: ValidateCode,
) : ViewModel() {
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
private val _uiEvent = MutableSharedFlow<UiEvent>()
val uiEvent = _uiEvent.asSharedFlow()
fun authEvent(event: AuthEvent){
when(event){
is AuthEvent.CodeChanged -> {
codeChanged(code = event.code)
}
is AuthEvent.Submit -> {
submit()
}
}
}
private fun codeChanged(code : String){
_state.update {
it.copy(code = code, authError = null)
}
val result = validateCode.execute(code)
if (!result.success){
_state.update {
it.copy(isCodeValid = false)
}
}else{
_state.update {
it.copy(isCodeValid = true)
}
}
}
private fun submit(){
viewModelScope.launch {
_state.update {
it.copy(isLoading = true)
}
appUseCases.authUseCase
.invoke(codeToSend = _state.value.code)
.onSuccess {
codeProvider.saveCode(code = _state.value.code)
_uiEvent.emit(UiEvent.Success)
_state.update {
it.copy(isLoading = false)
}
}
.onError {
_state.update {
it.copy(isLoading = false, authError = it.toString())
}
}
}
}
}
sealed interface UiEvent{
object Success : UiEvent
}

View File

@@ -0,0 +1,96 @@
package ru.myitschool.work.comp.presentation.auth.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.myitschool.work.core.domain.TestIds
@Composable
fun CustomTextField(
modifier: Modifier = Modifier,
label: String,
value: String,
onValueChange: (String) -> Unit,
isPassword: Boolean = false,
keyboardType: KeyboardType,
error: String?
){
var isPasswordVisible by remember { mutableStateOf(false) }
Column {
OutlinedTextField(
modifier = modifier
.padding(horizontal = 8.dp, vertical = 12.dp),
shape = shapes.large,
isError = error != null,
value = value,
onValueChange = onValueChange,
colors = OutlinedTextFieldDefaults.colors(
unfocusedLabelColor = Color(0xFF61758A),
unfocusedContainerColor = Color(0xFFF0F2F5),
unfocusedBorderColor = Color(0xFF61758A),
unfocusedTextColor = Color(0xFF61758A),
focusedLabelColor = Color(0xFF61758A),
focusedContainerColor = Color(0xFFF0F2F5),
focusedBorderColor = Color(0xFF61758A),
focusedTextColor = Color(0xFF61758A),
),
label = {
Text(
text = label
)
},
visualTransformation = if(isPassword && !isPasswordVisible) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType
),
trailingIcon = {
if(isPassword) {
IconButton(
onClick = {
isPasswordVisible = !isPasswordVisible
}
) {
Icon(
imageVector = if(isPasswordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null,
)
}
}
},
singleLine = true
)
error?.let {
Text(
modifier = Modifier
.testTag(TestIds.Auth.ERROR)
.padding(horizontal = 24.dp),
text = it,
fontSize = 15.sp,
color = Color(0xFFb73028)
)
}
}
}

View File

@@ -0,0 +1,451 @@
package ru.myitschool.work.comp.presentation.bookingScreen
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.myitschool.work.comp.domain.model.PlaceModel
import ru.myitschool.work.comp.presentation.auth.LoadingScreen
import ru.myitschool.work.core.domain.TestIds
import org.koin.androidx.compose.koinViewModel
import ru.myitschool.work.ui.RememberWindowInfo
import ru.myitschool.work.ui.WindowInfo
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun BookingScreenRoot(
viewModel: BookingViewModel = koinViewModel(),
onNavigateBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val windowInfo = RememberWindowInfo()
val selectedDate = state.selectedDateKey
when {
state.isLoading -> {
LoadingScreen()
}
state.isError -> {
ErrorState(
onRefresh = {
viewModel.onEvent(BookingScreenEvent.RefreshData)
}
)
}
state.isEmpty -> {
EmptyState()
}
else -> {
if (windowInfo.screenWidthInfo is WindowInfo.WindowType.Compact){
BookingScreen(
state = state,
selectedDateKey = selectedDate,
onDateSelected = { dateKey ->
viewModel.onEvent(BookingScreenEvent.SelectDate(dateKey))
},
onPlaceSelected = { place ->
viewModel.onEvent(BookingScreenEvent.SelectPlace(place))
},
onNavigateBack = onNavigateBack,
onEvent = { event ->
viewModel.onEvent(event)
}
)
} else{
BookingScreen(
state = state,
selectedDateKey = selectedDate,
onDateSelected = { dateKey ->
viewModel.onEvent(BookingScreenEvent.SelectDate(dateKey))
},
onPlaceSelected = { place ->
viewModel.onEvent(BookingScreenEvent.SelectPlace(place))
},
onNavigateBack = onNavigateBack,
onEvent = { event ->
viewModel.onEvent(event)
}
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BookingScreen(
state: BookingState,
selectedDateKey: String?,
onDateSelected: (String) -> Unit,
onPlaceSelected: (PlaceModel) -> Unit,
onNavigateBack: () -> Unit,
onEvent: (BookingScreenEvent) -> Unit
) {
val bookingsMap = state.bookings
val selectedPlaces = if (selectedDateKey != null) bookingsMap[selectedDateKey] else emptyList()
Scaffold(
topBar = {
TopAppBar(
title = {
Text("Бронирование",
color = Color.Black
) },
navigationIcon = {
IconButton(
modifier = Modifier
.testTag(TestIds.Book.BACK_BUTTON),
onClick = onNavigateBack,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.Black
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.White
)
)
},
bottomBar = {
if (!state.isEmpty && !state.isError && bookingsMap.isNotEmpty()) {
BookingBottomBar(
state = state,
onEvent = { onEvent(BookingScreenEvent.Book) },
onNavigateBack = onNavigateBack
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.background(Color.White),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (bookingsMap.isNotEmpty()) {
DateTabs(
dateKeys = bookingsMap.keys.toList(),
selectedDateKey = selectedDateKey,
onDateSelected = onDateSelected
)
Spacer(modifier = Modifier.height(24.dp))
if (selectedDateKey != null && selectedPlaces?.isNotEmpty() == true) {
PlacesList(
places = selectedPlaces,
selectedPlace = state.selectedPlace,
onPlaceSelected = onPlaceSelected
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TabletBookingScreen(
state: BookingState,
selectedDateKey: String?,
onDateSelected: (String) -> Unit,
onPlaceSelected: (PlaceModel) -> Unit,
onNavigateBack: () -> Unit,
onEvent: (BookingScreenEvent) -> Unit
) {
val bookingsMap = state.bookings
val selectedPlaces = if (selectedDateKey != null) bookingsMap[selectedDateKey] else emptyList()
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier
.background(Color.White),
title = { Text("Бронирование") },
navigationIcon = {
IconButton(
modifier = Modifier
.testTag(TestIds.Book.BACK_BUTTON),
onClick = onNavigateBack,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null
)
}
}
)
},
bottomBar = {
if (!state.isEmpty && !state.isError && bookingsMap.isNotEmpty()) {
BookingBottomBar(
state = state,
onEvent = { onEvent(BookingScreenEvent.Book) },
onNavigateBack = onNavigateBack
)
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.width(700.dp)
.height(600.dp)
.padding(paddingValues),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (bookingsMap.isNotEmpty()) {
DateTabs(
dateKeys = bookingsMap.keys.toList(),
selectedDateKey = selectedDateKey,
onDateSelected = onDateSelected
)
Spacer(modifier = Modifier.height(24.dp))
if (selectedDateKey != null && selectedPlaces?.isNotEmpty() == true) {
PlacesList(
places = selectedPlaces,
selectedPlace = state.selectedPlace,
onPlaceSelected = onPlaceSelected
)
}
}
}
}
}
}
@Composable
private fun DateTabs(
dateKeys: List<String>,
selectedDateKey: String?,
onDateSelected: (String) -> Unit
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
itemsIndexed(dateKeys.sorted()) { index, dateKey ->
val isSelected = selectedDateKey == dateKey
ElevatedAssistChip(
modifier = Modifier
.testTag(TestIds.Book.getIdDateItemByPosition(index)),
onClick = { onDateSelected(dateKey) },
label = {
Text(
modifier = Modifier
.testTag(TestIds.Book.ITEM_DATE),
text = formatDate(dateKey),
color = Color.Black
)
},
border = BorderStroke(
width = 2.dp,
color = if (isSelected) {
Color(0xFF007BFF)
} else {
Color.Transparent
}
),
colors = AssistChipDefaults.assistChipColors(
containerColor = Color.White
)
)
}
}
}
@Composable
private fun PlacesList(
places: List<PlaceModel>,
selectedPlace: PlaceModel?,
onPlaceSelected: (PlaceModel) -> Unit
) {
Column(
modifier = Modifier.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
places.forEach { place ->
val isSelected = selectedPlace?.id == place.id
Surface(
shape = MaterialTheme.shapes.medium,
color = Color.Transparent,
tonalElevation = if (isSelected) 4.dp else 1.dp,
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onPlaceSelected(place) },
role = Role.RadioButton
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
modifier = Modifier
.testTag(TestIds.Book.ITEM_PLACE_TEXT),
text = place.place,
style = MaterialTheme.typography.bodyLarge,
color = Color.Black
)
RadioButton(
selected = isSelected,
onClick = null,
colors = RadioButtonDefaults.colors(
selectedColor = Color(0xFF007BFF)
)
)
}
}
}
}
}
@Composable
private fun BookingBottomBar(
state: BookingState,
onEvent: (BookingScreenEvent) -> Unit,
onNavigateBack: () -> Unit
) {
Surface(
tonalElevation = 8.dp,
color = Color.White
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.Center
) {
Button(
modifier = Modifier
.testTag(TestIds.Book.BOOK_BUTTON),
onClick = {
onEvent(BookingScreenEvent.Book)
onNavigateBack()
},
enabled = state.selectedPlace != null && !state.isBookingLoading,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF007BFF)
)
) {
if (state.isBookingLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
} else {
Text("Забронировать",
color = Color.White)
}
}
}
}
}
@Composable
private fun ErrorState(onRefresh: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Что-то пошло не так",
color = Color.White,
modifier = Modifier
.testTag(TestIds.Main.ERROR)
.padding(vertical = 6.dp)
)
Button(
onClick = {
onRefresh()
},
modifier = Modifier
.testTag(TestIds.Main.REFRESH_BUTTON)
.fillMaxWidth()
.padding(top = 24.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF007BFF)),
shape = RoundedCornerShape(12.dp),
) {
Text(
text = "Попробовать заново",
color = Color.White,
modifier = Modifier.padding(vertical = 6.dp)
)
}
}
}
@Composable
private fun EmptyState() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier
.testTag(TestIds.Book.EMPTY),
text = "Всё забронировано",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
}
}
private fun formatDate(dateString: String): String {
return try {
val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val outputFormat = SimpleDateFormat("dd.MM", Locale.getDefault())
val date = inputFormat.parse(dateString)
outputFormat.format(date ?: return dateString)
} catch (e: Exception) {
dateString
}
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.comp.presentation.bookingScreen
import ru.myitschool.work.comp.domain.model.PlaceModel
sealed class BookingScreenEvent {
data object GetData : BookingScreenEvent()
data object RefreshData : BookingScreenEvent()
data class SelectDate(val date: String) : BookingScreenEvent()
data class SelectPlace(val place: PlaceModel) : BookingScreenEvent()
data object Book : BookingScreenEvent()
}

View File

@@ -0,0 +1,15 @@
package ru.myitschool.work.comp.presentation.bookingScreen
import ru.myitschool.work.comp.domain.model.PlaceModel
data class BookingState(
val bookings: Map<String, List<PlaceModel>> = emptyMap(),
val selectedDateKey: String? = null,
val selectedPlace: PlaceModel? = null,
val isLoading: Boolean = false,
val isBookingLoading: Boolean = false,
val isEmpty: Boolean = false,
val isBookingSuccess: Boolean = false,
val isError: Boolean = false
)

View File

@@ -0,0 +1,175 @@
package ru.myitschool.work.comp.presentation.bookingScreen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import ru.myitschool.work.comp.domain.model.BookModel
import ru.myitschool.work.comp.domain.use_case.BookUseCase
import ru.myitschool.work.comp.domain.use_case.GetBookingUseCase
import ru.myitschool.work.core.domain.Result
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookingViewModel(
private val getBookingUseCase: GetBookingUseCase,
private val bookUseCase: BookUseCase
) : ViewModel() {
private val _state = MutableStateFlow(BookingState())
val state = _state.asStateFlow()
init {
loadBookings()
}
fun onEvent(event: BookingScreenEvent) {
when (event) {
is BookingScreenEvent.GetData -> {
if (!_state.value.isError) {
loadBookings()
}
}
is BookingScreenEvent.RefreshData -> {
loadBookings()
}
is BookingScreenEvent.SelectDate -> {
selectDate(event.date)
}
is BookingScreenEvent.SelectPlace -> {
_state.update { currentState ->
currentState.copy(
selectedPlace = event.place,
isError = false
)
}
}
is BookingScreenEvent.Book -> {
bookSelected()
}
}
}
private fun selectDate(dateKey: String) {
_state.update { currentState ->
val placesForDate = currentState.bookings[dateKey]
val updatedSelectedPlace = if (currentState.selectedPlace != null &&
placesForDate?.contains(currentState.selectedPlace) != true
) {
placesForDate?.firstOrNull()
} else {
currentState.selectedPlace ?: placesForDate?.firstOrNull()
}
currentState.copy(
selectedDateKey = dateKey,
selectedPlace = updatedSelectedPlace,
isError = false
)
}
}
private fun loadBookings() {
_state.update { it.copy(isLoading = true, isError = false) }
viewModelScope.launch {
when (val result = getBookingUseCase()) {
is Result.Success -> {
val bookingsModel = result.data
val bookingsMap = bookingsModel.bookings
val hasAvailableBookings = bookingsMap.any { (_, places) ->
places.isNotEmpty()
}
if (!hasAvailableBookings) {
_state.update {
it.copy(
bookings = emptyMap(),
selectedDateKey = null,
selectedPlace = null,
isLoading = false,
isEmpty = true,
isError = false
)
}
return@launch
}
val sortedDates = bookingsMap.keys.sorted()
val selectedDateKey = _state.value.selectedDateKey ?: sortedDates.firstOrNull()
val selectedPlace = if (selectedDateKey != null) {
val currentPlace = _state.value.selectedPlace
val placesForDate = bookingsMap[selectedDateKey]
if (currentPlace != null && placesForDate?.contains(currentPlace) == true) {
currentPlace
} else {
placesForDate?.firstOrNull()
}
} else {
null
}
_state.update {
it.copy(
bookings = bookingsMap,
selectedDateKey = selectedDateKey,
selectedPlace = selectedPlace,
isLoading = false,
isEmpty = false,
isError = false,
isBookingSuccess = false
)
}
}
is Result.Error -> {
_state.update {
it.copy(
isLoading = false,
isError = true,
isEmpty = false
)
}
}
}
}
}
private fun bookSelected() {
val currentState = _state.value
val selectedDateKey = currentState.selectedDateKey
val selectedPlace = currentState.selectedPlace
if (selectedDateKey == null || selectedPlace == null) return
_state.update { it.copy(isBookingLoading = true, isError = false) }
viewModelScope.launch {
val bookModel = BookModel(
date = selectedDateKey,
placeId = selectedPlace.id
)
when (val result = bookUseCase(bookModel)) {
is Result.Success -> {
_state.update {
it.copy(
isBookingLoading = false,
isBookingSuccess = true,
)
}
loadBookings()
}
is Result.Error -> {
_state.update {
it.copy(
isBookingLoading = false,
isError = true
)
}
}
}
}
}
}

View File

@@ -0,0 +1,530 @@
package ru.myitschool.work.comp.presentation.main
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.rememberAsyncImagePainter
import ru.myitschool.work.comp.presentation.auth.LoadingScreen
import ru.myitschool.work.comp.presentation.main.component.BookingItem
import ru.myitschool.work.core.domain.TestIds
import org.koin.androidx.compose.koinViewModel
import ru.myitschool.work.ui.RememberWindowInfo
import ru.myitschool.work.ui.WindowInfo
@Composable
fun MainScreenRoot(
viewModel : MainScreenViewModel = koinViewModel(),
onNavigateToAuthScreen : () -> Unit,
onNavigateToBookingScreen : () -> Unit
) {
val state by viewModel.state.collectAsState()
val windowInfo = RememberWindowInfo()
when{
state.isLoading -> {
LoadingScreen()
}
state.isError ->{
if (windowInfo.screenWidthInfo is WindowInfo.WindowType.Compact){
ErrorScreen {
viewModel.onEvent(MainScreenEvent.Refresh)
}
} else{
TabletErrorScreen {
viewModel.onEvent(MainScreenEvent.Refresh)
}
}
}
state.shouldNavigateToAuth -> {
onNavigateToAuthScreen()
}
else -> {
if (windowInfo.screenWidthInfo is WindowInfo.WindowType.Compact){
MainScreen(
state = state,
onNavigateToAuthScreen = onNavigateToAuthScreen,
onNavigateToBookingScreen = onNavigateToBookingScreen,
onEvent = {event ->
viewModel.onEvent(event)
},
onRefresh = {viewModel.onEvent(MainScreenEvent.Refresh)}
)
} else{
MainScreen(
state = state,
onNavigateToAuthScreen = onNavigateToAuthScreen,
onNavigateToBookingScreen = onNavigateToBookingScreen,
onEvent = {event ->
viewModel.onEvent(event)
},
onRefresh = {viewModel.onEvent(MainScreenEvent.Refresh)}
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
state: MainScreenState,
onNavigateToAuthScreen : () -> Unit,
onNavigateToBookingScreen : () -> Unit,
onRefresh : () -> Unit,
onEvent : (MainScreenEvent) -> Unit
) {
val userInfo = state.data
Scaffold(
floatingActionButton = {
FloatingActionButton(
modifier = Modifier
.testTag(TestIds.Main.ADD_BUTTON),
onClick = onNavigateToBookingScreen,
containerColor = Color(0xFF007BFF),
contentColor = Color.White
) {
Icon(
Icons.Filled.Add,
contentDescription = null
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.background(Color.White)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.testTag(TestIds.Main.PROFILE_IMAGE)
.size(80.dp)
.clip(CircleShape)
.background(Color(0xFFcfe6ff)),
contentAlignment = Alignment.Center
) {
if (userInfo?.photoUrl?.isNotEmpty() == true) {
Image(painter = rememberAsyncImagePainter(userInfo.photoUrl),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.clip(CircleShape), contentScale = ContentScale.Crop)
} else {
Icon(
Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = Color.Black
)
}
}
Spacer(modifier = Modifier.size(16.dp))
Column {
Text(
text = userInfo?.name ?: "Пользователь",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color.Black,
modifier = Modifier
.testTag(TestIds.Main.PROFILE_NAME)
)
}
Spacer(modifier = Modifier.size(16.dp))
Column{
IconButton(
onClick = {
onEvent(MainScreenEvent.Logout)
onNavigateToAuthScreen()
},
modifier = Modifier
.testTag(TestIds.Main.LOGOUT_BUTTON)
.padding(end = 8.dp)
) {
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(24.dp)
)
}
}
}
}
Button(
onClick = {
onRefresh()
},
modifier = Modifier
.testTag(TestIds.Main.REFRESH_BUTTON)
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF007BFF)
)
) {
Icon(
Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.White
)
Spacer(modifier = Modifier.size(8.dp))
Text("Обновить Данные",
color = Color.White)
}
Text(
text = "Мои Бронирования",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.Black,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
)
if (userInfo?.booking?.isEmpty() == true) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "У вас пока нет бронирований",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray
)
}
} else {
val sortedBookings = userInfo?.booking?.sortedBy { it.date } ?: emptyList()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(sortedBookings) { index, booking ->
BookingItem(
booking = booking,
index = index,
modifier = Modifier
.testTag(TestIds.Main.getIdItemByPosition(index))
.fillMaxWidth()
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TabletMainScreen(
state: MainScreenState,
onNavigateToAuthScreen : () -> Unit,
onNavigateToBookingScreen : () -> Unit,
onRefresh : () -> Unit,
onEvent : (MainScreenEvent) -> Unit
) {
val userInfo = state.data
Scaffold(
floatingActionButton = {
FloatingActionButton(
modifier = Modifier
.testTag(TestIds.Main.ADD_BUTTON),
onClick = onNavigateToBookingScreen,
containerColor = Color(0xFF007BFF),
contentColor = Color.White
) {
Icon(
Icons.Filled.Add,
contentDescription = null
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.background(Color.White)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.testTag(TestIds.Main.PROFILE_IMAGE)
.size(80.dp)
.clip(CircleShape)
.background(Color(0xFFcfe6ff)),
contentAlignment = Alignment.Center
) {
if (userInfo?.photoUrl?.isNotEmpty() == true) {
Image(painter = rememberAsyncImagePainter(userInfo.photoUrl),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.clip(CircleShape), contentScale = ContentScale.Crop)
} else {
Icon(
Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = Color.Black
)
}
}
Spacer(modifier = Modifier.size(16.dp))
Column {
Text(
text = userInfo?.name ?: "Пользователь",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color.Black,
modifier = Modifier
.testTag(TestIds.Main.PROFILE_NAME)
)
}
Spacer(modifier = Modifier.weight(3f))
Column{
IconButton(
onClick = {
onEvent(MainScreenEvent.Logout)
onNavigateToAuthScreen()
},
modifier = Modifier
.testTag(TestIds.Main.LOGOUT_BUTTON)
.padding(end = 8.dp)
) {
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(24.dp)
)
}
}
}
}
Button(
onClick = {
onRefresh()
},
modifier = Modifier
.testTag(TestIds.Main.REFRESH_BUTTON)
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF007BFF)
)
) {
Icon(
Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text("Обновить данные")
}
Text(
text = "Мои Бронирования",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color.Black,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
)
if (userInfo?.booking?.isEmpty() == true) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "У вас пока нет бронирований",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray
)
}
} else {
val sortedBookings = userInfo?.booking?.sortedBy { it.date } ?: emptyList()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(sortedBookings) { index, booking ->
BookingItem(
booking = booking,
index = index,
modifier = Modifier
.testTag(TestIds.Main.getIdItemByPosition(index))
.fillMaxWidth()
)
}
}
}
}
}
}
@Composable
fun ErrorScreen(
onRetry : () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Что-то пошло не так",
color = Color.White,
modifier = Modifier
.testTag(TestIds.Main.ERROR)
.padding(vertical = 6.dp)
)
Button(
onClick = {
onRetry()
},
modifier = Modifier
.testTag(TestIds.Main.REFRESH_BUTTON)
.fillMaxWidth()
.padding(top = 24.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF007BFF)),
shape = RoundedCornerShape(12.dp),
) {
Text(
text = "Попробовать заново",
color = Color.White,
modifier = Modifier.padding(vertical = 6.dp)
)
}
}
}
@Composable
fun TabletErrorScreen(
onRetry : () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Что-то пошло не так",
color = Color.White,
modifier = Modifier
.testTag(TestIds.Main.ERROR)
.padding(vertical = 6.dp)
)
Button(
onClick = {
onRetry()
},
modifier = Modifier
.testTag(TestIds.Main.REFRESH_BUTTON)
.padding(top = 24.dp)
.width(400.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF007BFF)),
shape = RoundedCornerShape(12.dp),
) {
Text(
text = "Попробовать заново",
color = Color.White,
modifier = Modifier.padding(vertical = 6.dp)
)
}
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.comp.presentation.main
interface MainScreenEvent {
object Refresh : MainScreenEvent
object Logout : MainScreenEvent
}

View File

@@ -0,0 +1,10 @@
package ru.myitschool.work.comp.presentation.main
import ru.myitschool.work.comp.domain.model.GetInfoModel
data class MainScreenState(
val isLoading : Boolean = false,
val isError: Boolean = false,
val data : GetInfoModel? = null,
val shouldNavigateToAuth : Boolean = false
)

View File

@@ -0,0 +1,76 @@
package ru.myitschool.work.comp.presentation.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import ru.myitschool.work.comp.domain.use_case.AppUseCases
import ru.myitschool.work.core.auth.CodeProvider
import ru.myitschool.work.core.domain.onError
import ru.myitschool.work.core.domain.onSuccess
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class MainScreenViewModel(
private val appUseCases: AppUseCases,
private val codeProvider: CodeProvider
) : ViewModel() {
private val _state = MutableStateFlow(MainScreenState())
val state = _state.asStateFlow()
init {
viewModelScope.launch {
val code = codeProvider.getCode()
if (code != null) {
getInfo()
} else {
_state.update { it.copy(shouldNavigateToAuth = true) }
}
}
}
fun onEvent(event: MainScreenEvent){
when(event){
is MainScreenEvent.Refresh -> {
getInfo()
}
is MainScreenEvent.Logout -> {
logOut()
}
}
}
private fun getInfo(){
viewModelScope.launch {
_state.update {
it.copy(
isLoading = true,
)
}
appUseCases.getInfoUseCase
.invoke()
.onSuccess { info ->
_state.update {
it.copy(
isLoading = false,
data = info
)
}
}
.onError {
_state.update {
it.copy(
isLoading = false,
isError = true,
)
}
}
}
}
private fun logOut(){
codeProvider.clearCode()
}
}

View File

@@ -0,0 +1,60 @@
package ru.myitschool.work.comp.presentation.main.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ru.myitschool.work.comp.domain.model.BookingModel
import ru.myitschool.work.core.domain.TestIds
@Composable
fun BookingItem(
booking: BookingModel,
index: Int,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = booking.date,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = Color.Black,
modifier = Modifier
.testTag(TestIds.Main.ITEM_DATE)
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = booking.place,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
modifier = Modifier
.testTag(TestIds.Main.ITEM_PLACE)
.fillMaxWidth()
)
}
}
}

View File

@@ -0,0 +1,67 @@
package com.example.result.comp.presentation.splash
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import ru.myitschool.work.R
import ru.myitschool.work.comp.presentation.splash.SplashScreenViewModel
@Composable
fun SplashScreen(
onNavigateToAuthScreen : () -> Unit,
onNavigateToMainScreen : () -> Unit,
viewModel: SplashScreenViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val size = remember { Animatable(initialValue = 75.dp, Dp.VectorConverter) }
LaunchedEffect(key1 = Unit) {
launch {
while (true){
size.animateTo(
targetValue = 200.dp,
animationSpec = tween(durationMillis = 800, easing = LinearEasing)
)
size.animateTo(
targetValue = 150.dp,
animationSpec = tween(durationMillis = 800, easing = LinearEasing)
)
}
}
delay(1500)
if (state.isAuthenticated == true){
onNavigateToMainScreen()
}else{
onNavigateToAuthScreen()
}
}
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
){
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(size.value)
)
}
}

View File

@@ -0,0 +1,5 @@
package ru.myitschool.work.comp.presentation.splash
data class SplashScreenState(
val isAuthenticated : Boolean? = null
)

View File

@@ -0,0 +1,35 @@
package ru.myitschool.work.comp.presentation.splash
import androidx.lifecycle.ViewModel
import ru.myitschool.work.core.auth.CodeProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class SplashScreenViewModel(
private val codeProvider: CodeProvider
) : ViewModel() {
private val _state = MutableStateFlow(SplashScreenState())
val state = _state.asStateFlow()
init {
isAuthenticated()
}
private fun isAuthenticated(){
val code = codeProvider.getCode()
if(!code.isNullOrBlank()){
_state.update {
it.copy(
isAuthenticated = true
)
}
}else{
_state.update {
it.copy(
isAuthenticated = false
)
}
}
}
}

View File

@@ -0,0 +1,30 @@
package ru.myitschool.work.core.auth
import android.content.SharedPreferences
import androidx.core.content.edit
interface CodeProvider {
fun getCode() : String?
fun saveCode(code : String)
fun clearCode()
}
class SharedPrefCodeProvider(
private val sharedPreferences: SharedPreferences,
) : CodeProvider {
override fun getCode(): String? {
return sharedPreferences.getString("code", null)
}
override fun saveCode(code: String) {
sharedPreferences.edit {
putString("code", "/api/$code")
}
}
override fun clearCode() {
sharedPreferences.edit {
remove("code",)
}
}
}

View File

@@ -0,0 +1,45 @@
package ru.myitschool.work.core.data
import ru.myitschool.work.core.domain.DataError
import ru.myitschool.work.core.domain.Result
import io.ktor.client.call.NoTransformationFoundException
import io.ktor.client.call.body
import io.ktor.client.network.sockets.SocketTimeoutException
import io.ktor.client.statement.HttpResponse
import io.ktor.util.network.UnresolvedAddressException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
suspend inline fun <reified T> safeCall(
execute: () -> HttpResponse
) : Result<T, DataError.Remote>{
val response = try{
execute()
}catch (e: SocketTimeoutException){
return Result.Error(DataError.Remote.REQUEST_TIMEOUT)
}catch (e: UnresolvedAddressException){
return Result.Error(DataError.Remote.NO_INTERNET)
}catch (e: Exception){
currentCoroutineContext().ensureActive()
return Result.Error(DataError.Remote.UNKNOWN)
}
return responseToResult(response)
}
suspend inline fun <reified T> responseToResult(
response : HttpResponse
) : Result<T, DataError.Remote>{
return when(response.status.value){
in 200..290 -> {
try {
Result.Success(response.body())
}catch (e: NoTransformationFoundException){
Result.Error(DataError.Remote.SERIALIZATION)
}
}
400 -> Result.Error(DataError.Remote.REQUEST_TIMEOUT)
429 -> Result.Error(DataError.Remote.TOO_MANY_REQUESTS)
in 500..599 -> Result.Error(DataError.Remote.SERVER_ERROR)
else -> Result.Error(DataError.Remote.UNKNOWN)
}
}

View File

@@ -0,0 +1,45 @@
package ru.myitschool.work.core.data
import ru.myitschool.work.core.domain.Constants
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.accept
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
object HttpClientFactory {
fun create(): HttpClient {
val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = true
isLenient
}
return HttpClient(OkHttp) {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
println(message)
}
}
level = LogLevel.ALL
}
defaultRequest {
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
url(Constants.HOST)
}
}
}
}

View File

@@ -1,7 +1,7 @@
package ru.myitschool.work.core
package ru.myitschool.work.core.domain
object Constants {
const val HOST = "http://10.0.2.2:8080"
const val HOST = "http://192.168.1.106:8080"
const val AUTH_URL = "/auth"
const val INFO_URL = "/info"
const val BOOKING_URL = "/booking"

View File

@@ -0,0 +1,19 @@
package ru.myitschool.work.core.domain
interface DataError : Error {
enum class Remote : DataError{
REQUEST_TIMEOUT,
TOO_MANY_REQUESTS,
NO_INTERNET,
SERVER_ERROR,
SERIALIZATION,
UNKNOWN,
UNAUTHORIZED,
BAD_REQUEST,
NETWORK
}
enum class Local : DataError{
DISK_FULL,
UNKNOWN
}
}

View File

@@ -0,0 +1,3 @@
package ru.myitschool.work.core.domain
interface Error

View File

@@ -0,0 +1,31 @@
package ru.myitschool.work.core.domain
sealed interface Result<out D, out E>{
data class Success<out D>(val data : D) : Result<D, Nothing>
data class Error<out E : ru.myitschool.work.core.domain.Error>(val error : E) : Result<Nothing, E>
}
inline fun <T, E : Error, R> Result<T, E>.map(map : (T) -> (R)) : Result<R, E>{
return when(this){
is Result.Success -> Result.Success(map(data))
is Result.Error -> this
}
}
inline fun <T, E : Error> Result<T, E>.onSuccess(action : (T) -> Unit) : Result<T, E>{
return when(this){
is Result.Success -> {
action(data)
this
}
is Result.Error -> this
}
}
inline fun <T, E : Error> Result<T, E>.onError(action : (E) -> Unit) : Result<T, E>{
return when(this){
is Result.Success -> this
is Result.Error -> {
action(error)
this
}
}
}

View File

@@ -1,4 +1,4 @@
package ru.myitschool.work.core
package ru.myitschool.work.core.domain
object TestIds {
object Auth {

View File

@@ -0,0 +1,31 @@
package ru.myitschool.work.core.domain.validation
import java.util.regex.Pattern
class ValidateCode {
fun execute(code : String) : ValidationResult {
if(code.isBlank()){
return ValidationResult(
success = false,
errorMessage = "Поле не может быть пустым"
)
}
if(code.length != 4){
return ValidationResult(
success = false,
errorMessage = "Длина кода должна быть рава 4-ем"
)
}
val pattern = Pattern.compile("^[A-Za-z0-9]+\$")
if(!pattern.matcher(code).matches()){
return ValidationResult(
success = false,
errorMessage = "Код может содержать только латинские буквы, цифры и специальные символы"
)
}
return ValidationResult(
success = true
)
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.core.domain.validation
data class ValidationResult(
val success: Boolean,
val errorMessage : String? = null
)

View File

@@ -1,16 +0,0 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository {
private var codeCache: String? = null
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) {
codeCache = text
}
}
}
}

View File

@@ -1,42 +0,0 @@
package ru.myitschool.work.data.source
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants
object NetworkDataSource {
private val client by lazy {
HttpClient(CIO) {
install(ContentNegotiation) {
json(
Json {
isLenient = true
ignoreUnknownKeys = true
explicitNulls = true
encodeDefaults = true
}
)
}
}
}
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) {
HttpStatusCode.OK -> true
else -> error(response.bodyAsText())
}
}
}
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$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,38 @@
package ru.myitschool.work.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun RememberWindowInfo() : WindowInfo {
val configuration = LocalConfiguration.current
return WindowInfo(
screenWidthInfo = when{
configuration.screenWidthDp < 600 -> WindowInfo.WindowType.Compact
configuration.screenWidthDp < 840 -> WindowInfo.WindowType.Medium
else -> WindowInfo.WindowType.Expanded
},
screenHeightInfo = when{
configuration.screenHeightDp < 480 -> WindowInfo.WindowType.Compact
configuration.screenHeightDp < 900 -> WindowInfo.WindowType.Medium
else -> WindowInfo.WindowType.Expanded
},
screenWidth = configuration.screenWidthDp.dp,
screenHeight = configuration.screenHeightDp.dp
)
}
data class WindowInfo(
val screenWidthInfo : WindowType,
val screenHeightInfo : WindowType,
val screenWidth : Dp,
val screenHeight: Dp
){
sealed class WindowType{
object Compact : WindowType()
object Medium : WindowType()
object Expanded : WindowType()
}
}

View File

@@ -1,3 +0,0 @@
package ru.myitschool.work.ui.nav
sealed interface AppDestination

View File

@@ -1,6 +0,0 @@
package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object AuthScreenDestination: AppDestination

View File

@@ -1,6 +0,0 @@
package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object BookScreenDestination: AppDestination

View File

@@ -1,6 +0,0 @@
package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable
@Serializable
data object MainScreenDestination: AppDestination

View File

@@ -1,30 +0,0 @@
package ru.myitschool.work.ui.root
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import ru.myitschool.work.ui.screen.AppNavHost
import ru.myitschool.work.ui.theme.WorkTheme
class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
WorkTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AppNavHost(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
}
}
}
}
}

View File

@@ -1,49 +0,0 @@
package ru.myitschool.work.ui.screen
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
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.screen.auth.AuthScreen
@Composable
fun AppNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController()
) {
NavHost(
modifier = modifier,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
navController = navController,
startDestination = AuthScreenDestination,
) {
composable<AuthScreenDestination> {
AuthScreen(navController = navController)
}
composable<MainScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
}
composable<BookScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
}
}
}

View File

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

View File

@@ -1,97 +0,0 @@
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.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.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable
fun AuthScreen(
viewModel: AuthViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect {
navController.navigate(MainScreenDestination)
}
}
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)
)
}
}
}
}
@Composable
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)) }
)
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = {
viewModel.onIntent(AuthIntent.Send(inputText))
},
enabled = true
) {
Text(stringResource(R.string.auth_sign_in))
}
}

View File

@@ -1,6 +0,0 @@
package ru.myitschool.work.ui.screen.auth
sealed interface AuthState {
object Loading: AuthState
object Data: AuthState
}

View File

@@ -1,43 +0,0 @@
package ru.myitschool.work.ui.screen.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
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.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
fun onIntent(intent: AuthIntent) {
when (intent) {
is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) {
_uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
onSuccess = {
_actionFlow.emit(Unit)
},
onFailure = { error ->
error.printStackTrace()
_actionFlow.emit(Unit)
}
)
}
}
is AuthIntent.TextInput -> Unit
}
}
}

View File

@@ -1,7 +1,12 @@
<resources>
<string name="app_name">Work</string>
<string name="title_activity_root">RootActivity</string>
<string name="app_name">Result</string>
<string name="enter">Войти</string>
<string name="code">Код</string>
<string name="auth_title">Привет! Введи код для авторизации</string>
<string name="auth_label">Код</string>
<string name="auth_sign_in">Войти</string>
<string name="user">Пользователь</string>
<string name="updateData">Обновить данные</string>
<string name="myBookings">Мои бронирования</string>
<string name="noBookings">У вас пока нет бронирований</string>
<string name="SomethingGoneWrong">Что-то пошло не так</string>
<string name="tryAgain">Попробовать заново</string>
</resources>