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 {
implementation("androidx.compose.material3:material3:1.4.0")
defaultComposeLibrary()
implementation("androidx.datastore:datastore-preferences:1.1.7")
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-serialization-kotlinx-json:$ktor")
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 CODE_INPUT = "auth_code_input"
}
object Main {
const val ERROR = "main_error"
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
import kotlinx.coroutines.flow.Flow
import ru.myitschool.work.data.source.LocalDataSource
import ru.myitschool.work.data.source.NetworkDataSource
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> {
return NetworkDataSource.checkAuth(text).onSuccess { 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
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
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
import ru.myitschool.work.data.models.BookBody
import ru.myitschool.work.data.models.BookingInfo
import ru.myitschool.work.data.models.UserInfo
object NetworkDataSource {
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"
}

View File

@@ -1,6 +1,7 @@
package ru.myitschool.work.domain.auth
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.App
class CheckAndSaveAuthCodeUseCase(
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.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.theme.WorkTheme
class RootActivity : ComponentActivity() {
class RootActivity() : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
actionBar?.hide()
setContent {
WorkTheme {
val codePresence by AuthRepository.isCodePresentFlow.collectAsState(initial = null)
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AppNavHost(
codePresence = codePresence,
modifier = Modifier
.fillMaxSize()
.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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
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 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.BookScreenDestination
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.book.BookScreen
import ru.myitschool.work.ui.screen.main.MainScreen
import ru.myitschool.work.ui.screen.splash.SplashScreen
@Composable
fun AppNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController()
navController: NavHostController = rememberNavController(),
codePresence: Boolean?
) {
val startDestination = if (codePresence == null) SplashScreenDestination
else if (codePresence) MainScreenDestination
else AuthScreenDestination
NavHost(
modifier = modifier,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
// enterTransition = { EnterTransition.None },
// exitTransition = { ExitTransition.None },
navController = navController,
startDestination = AuthScreenDestination,
startDestination = startDestination,
) {
composable<AuthScreenDestination> {
AuthScreen(navController = navController)
}
composable<MainScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
composable<SplashScreenDestination> {
SplashScreen()
}
composable<MainScreenDestination> {
MainScreen(navController = navController)
}
composable<BookScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
BookScreen(navController = navController)
}
}
}

View File

@@ -1,17 +1,22 @@
package ru.myitschool.work.ui.screen.auth
import androidx.compose.foundation.background
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.padding
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.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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
@@ -30,6 +35,7 @@ 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.components.conditionalImePadding
import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable
@@ -48,22 +54,18 @@ fun AuthScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 24.dp),
.padding(all = 32.dp)
.conditionalImePadding(),
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)
)
}
else -> Content(viewModel, currentState)
}
}
}
@@ -71,27 +73,53 @@ fun AuthScreen(
@Composable
private fun Content(
viewModel: AuthViewModel,
state: AuthState.Data
state: AuthState,
) {
var inputText by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp))
TextField(
val err by viewModel.errorFlow.collectAsState()
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(),
value = inputText,
onValueChange = {
inputText = 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))
}
Button(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
modifier = Modifier
.testTag(TestIds.Auth.SIGN_BUTTON)
.fillMaxWidth()
.height(56.dp),
onClick = {
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 {
object Loading: AuthState
object Data: AuthState
object Error: AuthState
}

View File

@@ -1,5 +1,6 @@
package ru.myitschool.work.ui.screen.auth
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -13,31 +14,45 @@ import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
class AuthViewModel : ViewModel() {
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 _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()
val actionFlow: SharedFlow<Unit> = _actionFlow
fun onIntent(intent: AuthIntent) {
when (intent) {
is AuthIntent.Send -> {
onIntent(AuthIntent.TextInput(""))
viewModelScope.launch(Dispatchers.Default) {
_uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = {
_actionFlow.emit(Unit)
},
onFailure = { error ->
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(
colorScheme = colorScheme,
typography = Typography,
content = content
content = content,
)
}

View File

@@ -1,7 +1,7 @@
<resources>
<string name="app_name">Work</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_sign_in">Войти</string>
</resources>