First request #5

Closed
student-21892 wants to merge 14 commits from student-21892/NTO-2025-Android-minipigs:main into main
35 changed files with 1012 additions and 42 deletions

View File

@@ -35,6 +35,7 @@ android {
} }
dependencies { dependencies {
implementation("androidx.compose.material3:material3:1.4.0")
defaultComposeLibrary() defaultComposeLibrary()
implementation("androidx.datastore:datastore-preferences:1.1.7") implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
@@ -48,4 +49,6 @@ dependencies {
implementation("io.ktor:ktor-client-content-negotiation:$ktor") implementation("io.ktor:ktor-client-content-negotiation:$ktor")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("androidx.datastore:datastore-preferences:1.2.0")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
} }

View File

@@ -6,6 +6,7 @@ object TestIds {
const val SIGN_BUTTON = "auth_sign_button" const val SIGN_BUTTON = "auth_sign_button"
const val CODE_INPUT = "auth_code_input" const val CODE_INPUT = "auth_code_input"
} }
object Main { object Main {
const val ERROR = "main_error" const val ERROR = "main_error"
const val ADD_BUTTON = "main_add_button" const val ADD_BUTTON = "main_add_button"

View File

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

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.data.models
import kotlinx.serialization.Serializable
@Serializable
data class Booking(
val id: Int,
val place: String
)

View File

@@ -0,0 +1,3 @@
package ru.myitschool.work.data.models
typealias BookingInfo = Map<String, List<Booking>>

View File

@@ -0,0 +1,10 @@
package ru.myitschool.work.data.models
import kotlinx.serialization.Serializable
@Serializable
data class UserInfo(
val name: String,
val photoUrl: String,
val booking: Map<String, Booking>
)

View File

@@ -1,15 +1,21 @@
package ru.myitschool.work.data.repo package ru.myitschool.work.data.repo
import kotlinx.coroutines.flow.Flow
import ru.myitschool.work.data.source.LocalDataSource
import ru.myitschool.work.data.source.NetworkDataSource import ru.myitschool.work.data.source.NetworkDataSource
object AuthRepository { object AuthRepository {
suspend fun clearCode() {
LocalDataSource.setCode("")
}
suspend fun getCode(): String {
return LocalDataSource.getCode()
}
private var codeCache: String? = null val isCodePresentFlow: Flow<Boolean> = LocalDataSource.isCodePresentFlow
suspend fun checkAndSave(text: String): Result<Boolean> { suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success -> return NetworkDataSource.checkAuth(text).onSuccess { success ->
if (success) { if (success) {
codeCache = text LocalDataSource.setCode(text)
} }
} }
} }

View File

@@ -0,0 +1,15 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.models.BookingInfo
import ru.myitschool.work.data.repo.AuthRepository.getCode
import ru.myitschool.work.data.source.NetworkDataSource
object BookRepository {
suspend fun fetch(): Result<BookingInfo> {
return NetworkDataSource.bookInfo(getCode()).onSuccess { data -> data }
}
suspend fun book(date: String, placeId: Int): Result<Boolean> {
return NetworkDataSource.book(getCode(), date, placeId).onSuccess { success -> success }
}
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.models.UserInfo
import ru.myitschool.work.data.repo.AuthRepository.getCode
import ru.myitschool.work.data.source.NetworkDataSource
object MainRepository {
suspend fun fetch(): Result<UserInfo> {
return NetworkDataSource.info(getCode()).onSuccess { data -> data }
}
}

View File

@@ -0,0 +1,31 @@
package ru.myitschool.work.data.source
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import ru.myitschool.work.App
object LocalDataSource {
private val Context.dataStore by preferencesDataStore("user_data")
object Keys {
val CODE = stringPreferencesKey("Username")
}
private val appContext get() = App.context
suspend fun getCode(): String {
return appContext.dataStore.data.map { it[Keys.CODE] ?: "" }.first()
}
suspend fun setCode(code: String) {
appContext.dataStore.edit { it[Keys.CODE] = code }
}
val isCodePresentFlow: Flow<Boolean> = appContext.dataStore.data.map { it[Keys.CODE] != "" && it[Keys.CODE] != null }
}

View File

@@ -1,16 +1,24 @@
package ru.myitschool.work.data.source package ru.myitschool.work.data.source
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants import ru.myitschool.work.core.Constants
import ru.myitschool.work.data.models.BookBody
import ru.myitschool.work.data.models.BookingInfo
import ru.myitschool.work.data.models.UserInfo
object NetworkDataSource { object NetworkDataSource {
private val client by lazy { private val client by lazy {
@@ -38,5 +46,39 @@ object NetworkDataSource {
} }
} }
suspend fun info(code: String): Result<UserInfo> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.INFO_URL))
when (response.status) {
HttpStatusCode.OK -> Json.decodeFromString<UserInfo>(response.body())
else -> error(response.bodyAsText())
}
}
}
suspend fun bookInfo(code: String): Result<BookingInfo> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) {
HttpStatusCode.OK -> Json.decodeFromString<BookingInfo>(response.body())
else -> error(response.bodyAsText())
}
}
}
suspend fun book(code: String, date: String, placeId: Int): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
// val requestBodyString = Json.encodeToString(BookBody(date, placeId))
val response = client.post((getUrl(code, Constants.BOOK_URL))) {
contentType(ContentType.Application.Json)
setBody(BookBody(date, placeId))
}
when(response.status) {
HttpStatusCode.Created -> true
else -> error(response.bodyAsText())
}
}
}
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl" private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
} }

View File

@@ -1,6 +1,7 @@
package ru.myitschool.work.domain.auth package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.App
class CheckAndSaveAuthCodeUseCase( class CheckAndSaveAuthCodeUseCase(
private val repository: AuthRepository private val repository: AuthRepository

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.domain.book
import ru.myitschool.work.data.repo.BookRepository
class Book (
private val repository: BookRepository
) {
suspend operator fun invoke(
date: String,
placeId: Int
): Result<Unit> {
return repository.book(date, placeId).mapCatching { success ->
if (!success) error("Code is incorrect")
}
}
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.book
import ru.myitschool.work.data.models.BookingInfo
import ru.myitschool.work.data.repo.BookRepository
class Fetch (
private val repository: BookRepository
) {
suspend operator fun invoke(): Result<BookingInfo> {
return repository.fetch().mapCatching { success -> success.filter { it.value.isNotEmpty() } as BookingInfo }
}
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.main
import ru.myitschool.work.data.models.UserInfo
import ru.myitschool.work.data.repo.MainRepository
class Fetch(
private val repository: MainRepository
) {
suspend operator fun invoke(): Result<UserInfo> {
return repository.fetch().mapCatching { success -> success }
}
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.domain.main
import ru.myitschool.work.data.repo.AuthRepository
import kotlin.mapCatching
class Logout (
private val repository: AuthRepository
) {
suspend operator fun invoke(): Unit {
return repository.clearCode()
}
}

View File

@@ -0,0 +1,15 @@
package ru.myitschool.work.ui.components
import android.os.Build
import androidx.compose.foundation.layout.imePadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun Modifier.conditionalImePadding(): Modifier {
return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
this.then(Modifier.imePadding())
} else {
this
}
}

View File

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

View File

@@ -7,18 +7,28 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.screen.AppNavHost import ru.myitschool.work.ui.screen.AppNavHost
import ru.myitschool.work.ui.theme.WorkTheme import ru.myitschool.work.ui.theme.WorkTheme
class RootActivity : ComponentActivity() { class RootActivity() : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
actionBar?.hide()
setContent { setContent {
WorkTheme { WorkTheme {
val codePresence by AuthRepository.isCodePresentFlow.collectAsState(initial = null)
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AppNavHost( AppNavHost(
codePresence = codePresence,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.ui.root
sealed interface RootState {
object Loading: RootState
object CodePresent: RootState
object CodeAbsent: RootState
}

View File

@@ -5,45 +5,56 @@ import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.ui.nav.AuthScreenDestination import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.nav.SplashScreenDestination
import ru.myitschool.work.ui.root.RootState
import ru.myitschool.work.ui.screen.auth.AuthIntent
import ru.myitschool.work.ui.screen.auth.AuthScreen 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.splash.SplashScreen
@Composable @Composable
fun AppNavHost( fun AppNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController() navController: NavHostController = rememberNavController(),
codePresence: Boolean?
) { ) {
val startDestination = if (codePresence == null) SplashScreenDestination
else if (codePresence) MainScreenDestination
else AuthScreenDestination
NavHost( NavHost(
modifier = modifier, modifier = modifier,
enterTransition = { EnterTransition.None }, // enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, // exitTransition = { ExitTransition.None },
navController = navController, navController = navController,
startDestination = AuthScreenDestination, startDestination = startDestination,
) { ) {
composable<AuthScreenDestination> { composable<AuthScreenDestination> {
AuthScreen(navController = navController) AuthScreen(navController = navController)
} }
composable<MainScreenDestination> { composable<SplashScreenDestination> {
Box( SplashScreen()
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
} }
composable<MainScreenDestination> {
MainScreen(navController = navController)
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
Box( BookScreen(navController = navController)
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
} }
} }
} }

View File

@@ -1,17 +1,22 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -30,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import ru.myitschool.work.R import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.components.conditionalImePadding
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable @Composable
@@ -48,22 +54,18 @@ fun AuthScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(all = 24.dp), .padding(all = 32.dp)
.conditionalImePadding(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text(
text = stringResource(R.string.auth_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
when (val currentState = state) { when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState)
is AuthState.Loading -> { is AuthState.Loading -> {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(64.dp) modifier = Modifier.size(64.dp)
) )
} }
else -> Content(viewModel, currentState)
} }
} }
} }
@@ -71,27 +73,53 @@ fun AuthScreen(
@Composable @Composable
private fun Content( private fun Content(
viewModel: AuthViewModel, viewModel: AuthViewModel,
state: AuthState.Data state: AuthState,
) { ) {
var inputText by remember { mutableStateOf("") } var inputText by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp)) val err by viewModel.errorFlow.collectAsState()
TextField( val isButtonEnabled by viewModel.isButtonEnabled.collectAsState()
Text(
text = stringResource(R.string.auth_title),
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(48.dp))
OutlinedTextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText, value = inputText,
onValueChange = { onValueChange = {
inputText = it inputText = it
viewModel.onIntent(AuthIntent.TextInput(it)) viewModel.onIntent(AuthIntent.TextInput(it))
}, },
label = { Text(stringResource(R.string.auth_label)) } shape = MaterialTheme.shapes.medium,
label = { Text(stringResource(R.string.auth_label)) },
placeholder = { Text(stringResource(R.string.auth_label)) }
)
Spacer(modifier = Modifier.size(24.dp))
if (state == AuthState.Error) {
Text(
text = err,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestIds.Auth.ERROR)
) )
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
}
Button( Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(), modifier = Modifier
.testTag(TestIds.Auth.SIGN_BUTTON)
.fillMaxWidth()
.height(56.dp),
onClick = { onClick = {
viewModel.onIntent(AuthIntent.Send(inputText)) viewModel.onIntent(AuthIntent.Send(inputText))
}, },
enabled = true shape = MaterialTheme.shapes.large,
enabled = isButtonEnabled
) { ) {
Text(stringResource(R.string.auth_sign_in)) Text(
text = stringResource(R.string.auth_sign_in),
style = MaterialTheme.typography.titleLarge
)
} }
} }

View File

@@ -3,4 +3,5 @@ package ru.myitschool.work.ui.screen.auth
sealed interface AuthState { sealed interface AuthState {
object Loading: AuthState object Loading: AuthState
object Data: AuthState object Data: AuthState
object Error: AuthState
} }

View File

@@ -1,5 +1,6 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -13,31 +14,45 @@ import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() { class AuthViewModel() : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) } private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data) private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
val uiState: StateFlow<AuthState> = _uiState.asStateFlow() val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _errorFlow = MutableStateFlow<String>("")
val errorFlow: StateFlow<String> = _errorFlow.asStateFlow()
private val _isButtonEnabled = MutableStateFlow<Boolean>(false)
val isButtonEnabled: StateFlow<Boolean> = _isButtonEnabled.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow() private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow val actionFlow: SharedFlow<Unit> = _actionFlow
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
onIntent(AuthIntent.TextInput(""))
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
_uiState.update { AuthState.Loading } _uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold( checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = { onSuccess = {
_actionFlow.emit(Unit) _actionFlow.emit(Unit)
}, },
onFailure = { error -> onFailure = { error ->
error.printStackTrace() error.printStackTrace()
_actionFlow.emit(Unit) _errorFlow.update { error.message.toString() }
_uiState.update { AuthState.Error }
} }
) )
} }
} }
is AuthIntent.TextInput -> Unit
is AuthIntent.TextInput -> {
if (_uiState.value == AuthState.Error) _uiState.update { AuthState.Data }
if (intent.text.matches("[a-zA-Z0-9]{4}".toRegex())) {
_isButtonEnabled.update { true }
} else _isButtonEnabled.update { false }
}
} }
} }
} }

View File

@@ -0,0 +1,7 @@
package ru.myitschool.work.ui.screen.book
sealed interface BookIntent {
data object Fetch: BookIntent
data class Book(val date: String, val placeId: Int): BookIntent
data object GoBack: BookIntent
}

View File

@@ -0,0 +1,227 @@
package ru.myitschool.work.ui.screen.book
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBackIosNew
import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Tab
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.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.text.font.FontWeight
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.data.models.Booking
import ru.myitschool.work.ui.nav.MainScreenDestination
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
fun BookScreen(
navController: NavController,
viewModel: BookViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsState()
val info by viewModel.infoFlow.collectAsState()
val err by viewModel.errorFlow.collectAsState()
var selectedTabIndex by remember { mutableStateOf(0) }
var selectedPlaceIndex by remember { mutableStateOf(0) }
Box(
modifier = Modifier
.fillMaxSize()
){
when(val currentState = state) {
is BookState.Loading -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
}
}
is BookState.Error -> {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = err,
modifier = Modifier.testTag(TestIds.Book.ERROR),
color = MaterialTheme.colorScheme.error
)
}
FloatingActionButton(
onClick = { viewModel.onIntent(BookIntent.Fetch) },
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = -16.dp, y = -16.dp)
.testTag(TestIds.Book.REFRESH_BUTTON)
) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
}
is BookState.DataPresent -> {
val entriesList = info!!.entries.toList()
Column(modifier = Modifier.fillMaxSize()) {
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary,
) {
entriesList.forEachIndexed { index, entry ->
Tab(
selected = selectedTabIndex == index,
onClick = {
selectedTabIndex = index
selectedPlaceIndex = 0
},
text = {
Text(
text = LocalDate.parse(entry.key).format(DateTimeFormatter.ofPattern("dd.MM")),
modifier = Modifier.testTag(TestIds.Book.ITEM_DATE)
)
},
modifier = Modifier.testTag(TestIds.Book.getIdDateItemByPosition(index))
)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(entriesList[selectedTabIndex].value) { index, booking ->
Booking(
booking = booking,
index = index,
selected = selectedPlaceIndex,
onRadioChange = { selectedPlaceIndex = index }
)
}
}
}
ExtendedFloatingActionButton(
onClick = { viewModel.onIntent(BookIntent.Book(
date = entriesList[selectedTabIndex].key,
placeId = entriesList[selectedTabIndex].value[selectedPlaceIndex].id
)) },
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
text = {
Text("Бронировать")
},
icon = {
Icon(Icons.Default.BookmarkAdd, contentDescription = "Бронировать")
},
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = -16.dp, y = -16.dp)
.testTag(TestIds.Book.BOOK_BUTTON)
)
}
is BookState.DataAbsent -> {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text(text = "Всё забронировано", modifier = Modifier.testTag(TestIds.Book.EMPTY))
}
}
}
FloatingActionButton(
onClick = { viewModel.onIntent(BookIntent.GoBack) },
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.align(Alignment.BottomStart)
.offset(x = 16.dp, y = -16.dp)
.testTag(TestIds.Book.BACK_BUTTON)
) {
Icon(Icons.Default.ArrowBackIosNew, contentDescription = "Назад")
}
}
LaunchedEffect(Unit) {
viewModel.onIntent(BookIntent.Fetch)
viewModel.actionFlow.collect {
navController.popBackStack()
}
}
}
@Composable
private fun Booking(booking: Booking, index: Int, selected: Int, onRadioChange: Function0<Unit>) {
Row(
modifier = Modifier
.fillMaxWidth()
// .clickable { }
.padding(horizontal = 16.dp, vertical = 8.dp)
.testTag(TestIds.Book.getIdPlaceItemByPosition(index)),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = selected == index,
onClick = onRadioChange,
modifier = Modifier
.testTag(TestIds.Book.ITEM_PLACE_SELECTOR)
// .selectable(
// selected = false,
// onClick = {}
// )
)
Text(
text = booking.place,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT)
)
}
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 1.dp,
modifier = Modifier.padding(start = 16.dp)
)
}

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.ui.screen.book
sealed interface BookState {
data object Loading: BookState
data object DataPresent: BookState
data object DataAbsent: BookState
data object Error: BookState
}

View File

@@ -0,0 +1,68 @@
package ru.myitschool.work.ui.screen.book
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.models.BookingInfo
import ru.myitschool.work.data.repo.BookRepository
import ru.myitschool.work.domain.book.Book
import ru.myitschool.work.domain.book.Fetch
class BookViewModel(): ViewModel() {
private val fetch by lazy { Fetch(BookRepository) }
private val book by lazy { Book(BookRepository) }
private val _uiState = MutableStateFlow<BookState>(BookState.Loading)
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
private val _infoFlow: MutableStateFlow<BookingInfo?> = MutableStateFlow(null)
val infoFlow: StateFlow<BookingInfo?> = _infoFlow.asStateFlow()
private val _errorFlow = MutableStateFlow<String>("")
val errorFlow: StateFlow<String> = _errorFlow.asStateFlow()
fun onIntent(intent: BookIntent) {
when(intent) {
is BookIntent.Fetch -> viewModelScope.launch {
_uiState.update { BookState.Loading }
fetch.invoke().fold(
onSuccess = { success ->
if (success.isEmpty()) {
_uiState.update { BookState.DataAbsent }
} else {
_infoFlow.update { success }
_uiState.update { BookState.DataPresent }
}
},
onFailure = { failure ->
Log.d(failure.message, "failure")
_uiState.update { BookState.Error }
_errorFlow.update { failure.message.toString() }
}
)
}
is BookIntent.Book -> viewModelScope.launch {
_uiState.update { BookState.Loading }
book.invoke(intent.date, intent.placeId).fold(
onSuccess = { success ->
_actionFlow.emit(Unit)
},
onFailure = { failure ->
Log.d(failure.message, "failure")
_uiState.update { BookState.Error }
_errorFlow.update { failure.message.toString() }
}
)
}
is BookIntent.GoBack -> viewModelScope.launch {
_actionFlow.emit(Unit)
}
}
}
}

View File

@@ -0,0 +1,7 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainIntent {
data object Fetch: MainIntent
data object Logout: MainIntent
data object NewBooking: MainIntent
}

View File

@@ -0,0 +1,287 @@
package ru.myitschool.work.ui.screen.main
import android.graphics.drawable.Icon
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Logout
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
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.draw.clip
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 androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil3.compose.rememberAsyncImagePainter
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.data.models.Booking
import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
fun MainScreen(
viewModel: MainViewModel = viewModel(),
navController: NavController
) {
val state by viewModel.uiState.collectAsState()
val err by viewModel.errorFlow.collectAsState()
val info by viewModel.infoFlow.collectAsState()
when (val currentState = state) {
is MainState.Error -> {
Column (
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = err,
modifier = Modifier.testTag(TestIds.Main.ERROR),
color = MaterialTheme.colorScheme.error
)
IconButton(
onClick = { viewModel.onIntent(MainIntent.Fetch) },
modifier = Modifier
.size(32.dp)
.aspectRatio(1f)
.testTag(TestIds.Main.REFRESH_BUTTON),
enabled = true,
) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
)
}
}
}
is MainState.Loading -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
}
}
is MainState.Data -> {
info?.let {
Column (
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
) {
FilledTonalIconButton(
onClick = { viewModel.onIntent(MainIntent.Logout) },
modifier = Modifier
.align(Alignment.TopEnd)
.size(40.dp)
.aspectRatio(1f)
.offset(x = -16.dp)
.testTag(TestIds.Main.LOGOUT_BUTTON),
enabled = true,
shape = MaterialTheme.shapes.extraLarge,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Icon(
Icons.AutoMirrored.Outlined.Logout,
contentDescription = null,
modifier = Modifier
.size(20.dp)
)
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.size(120.dp)
.aspectRatio(1f)
.background(MaterialTheme.colorScheme.inverseOnSurface, CircleShape)
) {
Image(
painter = rememberAsyncImagePainter(info!!.photoUrl),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.size(105.dp)
.clip(CircleShape)
.testTag(TestIds.Main.PROFILE_IMAGE)
)
}
Spacer(modifier = Modifier.size(12.dp))
Text(
text = info!!.name,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.testTag(TestIds.Main.PROFILE_NAME)
)
Spacer(modifier = Modifier.size(16.dp))
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainerLow, RoundedCornerShape(topEnd = 24.dp , topStart = 24.dp))
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.padding(vertical = 16.dp, horizontal = 16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.SpaceBetween
) {
Icon(
imageVector = Icons.Default.BookmarkBorder,
contentDescription = null,
)
Text(
text = "Бронирования",
style = MaterialTheme.typography.titleMedium,
)
IconButton(
onClick = { viewModel.onIntent(MainIntent.Fetch) },
modifier = Modifier
.size(24.dp)
.aspectRatio(1f)
.testTag(TestIds.Main.REFRESH_BUTTON),
enabled = true,
) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
)
}
}
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 1.dp,
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
) {
itemsIndexed(info!!.booking.entries.toList()) { index, booking ->
Booking(booking = booking.value, date = booking.key, index = index)
}
}
}
FloatingActionButton(
onClick = { viewModel.onIntent(MainIntent.NewBooking) },
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = -16.dp, y = -16.dp)
.testTag(TestIds.Main.ADD_BUTTON)
) {
Icon(Icons.Default.Add, contentDescription = "Добавить")
}
}
}
}
}
}
LaunchedEffect(Unit) {
viewModel.onIntent(MainIntent.Fetch)
viewModel.actionFlow.collect {
navController.navigate(BookScreenDestination)
}
}
}
@Composable
private fun Booking(booking: Booking, date: String, index: Int){
Row(
modifier = Modifier
.fillMaxWidth()
// .clickable { }
.padding(horizontal = 16.dp, vertical = 12.dp)
.testTag(TestIds.Main.getIdItemByPosition(index)),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = LocalDate.parse(date).format(DateTimeFormatter.ofPattern("dd.MM.yyyy")),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE)
)
Text(
text = booking.place,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE)
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 1.dp,
modifier = Modifier.padding(start = 16.dp)
)
}

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainState {
object Loading: MainState
object Data: MainState
object Error: MainState
}

View File

@@ -0,0 +1,61 @@
package ru.myitschool.work.ui.screen.main
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.models.UserInfo
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.domain.main.Fetch
import ru.myitschool.work.domain.main.Logout
class MainViewModel(): ViewModel() {
private val fetch by lazy { Fetch(MainRepository) }
private val logout by lazy { Logout(AuthRepository) }
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
private val _infoFlow: MutableStateFlow<UserInfo?> = MutableStateFlow(null)
val infoFlow: StateFlow<UserInfo?> = _infoFlow.asStateFlow()
private val _errorFlow = MutableStateFlow<String>("")
val errorFlow: StateFlow<String> = _errorFlow.asStateFlow()
fun onIntent(intent: MainIntent) {
when (intent) {
is MainIntent.Fetch -> viewModelScope.launch {
_uiState.update { MainState.Loading }
fetch.invoke().fold(
onSuccess = { success ->
_infoFlow.update { success }
_uiState.update { MainState.Data }
},
onFailure = { failure ->
Log.d(failure.message, "failure")
_uiState.update { MainState.Error }
_errorFlow.update { failure.message.toString() }
}
)
}
is MainIntent.Logout -> {
viewModelScope.launch {
logout.invoke()
}
}
is MainIntent.NewBooking -> {
viewModelScope.launch {
_actionFlow.emit(Unit)
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.ui.screen.splash
import androidx.compose.runtime.Composable
@Composable
fun SplashScreen() {
}

View File

@@ -52,6 +52,6 @@ fun WorkTheme(
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
content = content content = content,
) )
} }

View File

@@ -1,7 +1,7 @@
<resources> <resources>
<string name="app_name">Work</string> <string name="app_name">Work</string>
<string name="title_activity_root">RootActivity</string> <string name="title_activity_root">RootActivity</string>
<string name="auth_title">Привет! Введи код для авторизации</string> <string name="auth_title">Авторизируйтесь при помощи кода</string>
<string name="auth_label">Код</string> <string name="auth_label">Код</string>
<string name="auth_sign_in">Войти</string> <string name="auth_sign_in">Войти</string>
</resources> </resources>