main #4

Closed
student-32996 wants to merge 9 commits from (deleted):main into main
43 changed files with 1850 additions and 82 deletions

View File

@@ -36,7 +36,7 @@ android {
dependencies { dependencies {
defaultComposeLibrary() defaultComposeLibrary()
implementation("androidx.datastore:datastore-preferences:1.1.7") implementation("androidx.datastore:datastore-preferences:1.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
implementation("androidx.navigation:navigation-compose:2.9.6") implementation("androidx.navigation:navigation-compose:2.9.6")
val coil = "3.3.0" val coil = "3.3.0"

View File

@@ -2,11 +2,21 @@ package ru.myitschool.work
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import ru.myitschool.work.data.datastore.DataStoreManager
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "datastore")
class App: Application() { class App: Application() {
lateinit var dataStoreManager: DataStoreManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
context = this context = this
dataStoreManager = DataStoreManager(dataStore)
} }
companion object { companion object {

View File

@@ -0,0 +1,35 @@
package ru.myitschool.work.data.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class DataStoreManager(
private val dataStore: DataStore<Preferences>
) {
companion object {
private val USER_CODE_KEY = stringPreferencesKey("user_code")
}
suspend fun clearUserCode() {
dataStore.edit { preferences ->
preferences.remove(USER_CODE_KEY)
}
}
suspend fun saveUserCode(userCode: UserCode) {
dataStore.edit { preferences ->
preferences[USER_CODE_KEY] = userCode.code
}
}
fun getUserCode(): Flow<UserCode> = dataStore.data.map { preferences ->
UserCode(
code = preferences[USER_CODE_KEY] ?: ""
)
}
}

View File

@@ -0,0 +1,5 @@
package ru.myitschool.work.data.datastore
data class UserCode(
val code: String
)

View File

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

View File

@@ -0,0 +1,21 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.domain.book.entities.BookingEntity
import ru.myitschool.work.domain.main.entities.UserEntity
object BookRepository {
suspend fun loadBooking(text: String): Result<BookingEntity> {
return NetworkDataSource.loadBooking(text)
}
suspend fun bookPlace(
userCode: String,
date: String,
placeId: Int,
placeName: String
): Result<Unit> {
return NetworkDataSource.bookPlace(userCode, date, placeId, placeName)
}
}

View File

@@ -0,0 +1,11 @@
package ru.myitschool.work.data.repo
import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.domain.main.entities.UserEntity
object MainRepository {
suspend fun loadData(text: String): Result<UserEntity> {
return NetworkDataSource.loadData(text)
}
}

View File

@@ -1,9 +1,13 @@
package ru.myitschool.work.data.source package ru.myitschool.work.data.source
import android.util.Log
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.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
@@ -11,6 +15,30 @@ 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.domain.book.entities.BookingEntity
import ru.myitschool.work.domain.book.entities.PlaceInfo
import ru.myitschool.work.domain.main.entities.UserEntity
private const val testJson = """
{
"name": "Иванов Петр Федорович",
"photoUrl": "https://funnyducks.ru/upload/iblock/0cd/0cdeb7ec3ed6fddda0f90fccee05557d.jpg",
"booking": {
"2025-01-05": {"id":1,"place":"102"},
"2025-01-06": {"id":2,"place":"209.13"},
"2025-01-09": {"id":3,"place":"Зона 51. 50"}
}
}
"""
private const val testBookingJson = """
{
"2025-01-05": [{"id": 1, "place": "102"},{"id": 2, "place": "209.13"}],
"2025-01-06": [{"id": 3, "place": "Зона 51. 50"}],
"2025-01-07": [{"id": 1, "place": "102"},{"id": 2, "place": "209.13"}],
"2025-01-08": [{"id": 2, "place": "209.13"}]
}
"""
object NetworkDataSource { object NetworkDataSource {
private val client by lazy { private val client by lazy {
@@ -28,9 +56,38 @@ object NetworkDataSource {
} }
} }
suspend fun bookPlace(
userCode: String,
date: String,
placeId: Int,
placeName: String
): Result<Unit> = withContext(Dispatchers.IO) {
return@withContext runCatching {
// Log.i("aaa", "Booking: userCode=$userCode, date=$date, placeId=$placeId, placeName=$placeName")
// println("Booking: userCode=$userCode, date=$date, placeId=$placeId, placeName=$placeName")
val response = client.post(getUrl(userCode, Constants.BOOK_URL)) {
setBody(mapOf(
"date" to date,
"placeId" to placeId,
"placeName" to placeName
))
}
when (response.status) {
HttpStatusCode.OK -> Unit
else -> error(response.bodyAsText())
}
}
}
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) { suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching { return@withContext runCatching {
// true // удалить при проверке
val response = client.get(getUrl(code, Constants.AUTH_URL)) val response = client.get(getUrl(code, Constants.AUTH_URL))
response.status
when (response.status) { when (response.status) {
HttpStatusCode.OK -> true HttpStatusCode.OK -> true
else -> error(response.bodyAsText()) else -> error(response.bodyAsText())
@@ -38,5 +95,35 @@ object NetworkDataSource {
} }
} }
suspend fun loadData(code: String): Result<UserEntity> = withContext(Dispatchers.IO) {
return@withContext runCatching {
// Json.decodeFromString<UserEntity>(testJson) // удалить при проверке
val response = client.get(getUrl(code, Constants.INFO_URL))
when (response.status) {
HttpStatusCode.OK -> {
response.body<UserEntity>()
}
else -> error(response.bodyAsText())
}
}
}
suspend fun loadBooking(code: String): Result<BookingEntity> = withContext(Dispatchers.IO) {
return@withContext runCatching {
// BookingEntity(Json.decodeFromString<Map<String, List<PlaceInfo>>>(testBookingJson)) // удалить при проверке
val response = client.get(getUrl(code, Constants.BOOKING_URL))
when (response.status) {
HttpStatusCode.OK -> {
BookingEntity(response.body<Map<String, List<PlaceInfo>>>())
}
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

@@ -0,0 +1,16 @@
package ru.myitschool.work.domain.book
import ru.myitschool.work.data.repo.BookRepository
class BookingUseCase(
private val repository: BookRepository
) {
suspend operator fun invoke(
userCode: String,
date: String,
placeId: Int,
placeName: String
): Result<Unit> {
return repository.bookPlace(userCode, date, placeId, placeName)
}
}

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.domain.book
import ru.myitschool.work.data.repo.BookRepository
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.domain.book.entities.BookingEntity
import ru.myitschool.work.domain.main.entities.UserEntity
class LoadBookingUseCase(
private val repository: BookRepository
) {
suspend operator fun invoke(
text: String
): Result<BookingEntity> {
return repository.loadBooking(text)
}
}

View File

@@ -0,0 +1,8 @@
package ru.myitschool.work.domain.book.entities
import kotlinx.serialization.Serializable
@Serializable
data class BookingEntity(
val bookings: Map<String, List<PlaceInfo>>
)

View File

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

View File

@@ -0,0 +1,14 @@
package ru.myitschool.work.domain.main
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.domain.main.entities.UserEntity
class LoadDataUseCase(
private val repository: MainRepository
) {
suspend operator fun invoke(
text: String
): Result<UserEntity> {
return repository.loadData(text)
}
}

View File

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

View File

@@ -0,0 +1,26 @@
package ru.myitschool.work.domain.main.entities
import kotlinx.serialization.Serializable
import ru.myitschool.work.formatDate
@Serializable
data class UserEntity(
val name: String,
val photoUrl: String,
val booking: Map<String, BookingInfo>? = null
) {
fun getSortedBookings(): List<Pair<String, BookingInfo>> {
return booking?.entries
?.sortedBy { (date, _) -> date }
?.map { it.toPair() }
?: emptyList()
}
fun getSortedBookingsWithFormattedDate(): List<Triple<String, String, BookingInfo>> {
return getSortedBookings().map { (date, bookingInfo) ->
Triple(date, date.formatDate(), bookingInfo)
}
}
fun hasBookings(): Boolean = !booking.isNullOrEmpty()
}

View File

@@ -0,0 +1,198 @@
package ru.myitschool.work.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.myitschool.work.R
import ru.myitschool.work.ui.theme.Black
import ru.myitschool.work.ui.theme.Gray
import ru.myitschool.work.ui.theme.LightBlue
import ru.myitschool.work.ui.theme.LightGray
import ru.myitschool.work.ui.theme.Typography
import ru.myitschool.work.ui.theme.White
@Composable
fun BaseText24(
text: String,
modifier: Modifier = Modifier,
color: Color = Black,
textAlign: TextAlign = TextAlign.Left
) {
Text(
text = text,
fontSize = 24.sp,
style = Typography.bodyLarge,
modifier = modifier,
color = color,
textAlign = textAlign
)
}
@Composable
fun BaseNoBackgroundButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = White,
disabledContainerColor = Color.Transparent,
disabledContentColor = White
)
) {
BaseText16(text = text, color = White)
}
}
@Composable
fun Logo() {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = "Logo",
modifier = Modifier.padding(top = 40.dp, bottom = 60.dp)
)
}
@Composable
fun BaseText16(
text: String,
modifier: Modifier = Modifier,
color: Color = Black,
) {
Text(
text = text,
style = Typography.bodySmall,
fontSize = 16.sp,
color = color,
modifier = modifier
)
}
@Composable
fun BaseText12(
modifier: Modifier = Modifier,
text: String,
color: Color = Black,
) {
Text(
text = text,
style = Typography.bodySmall,
fontSize = 12.sp,
color = color,
modifier = modifier,
)
}
@Composable
fun BaseText14(
modifier: Modifier = Modifier,
text: String,
color: Color = Black,
) {
Text(
text = text,
style = Typography.bodySmall,
fontSize = 14.sp,
color = color,
modifier = modifier,
)
}
@Composable
fun BaseInputText(
placeholder: String= "",
modifier: Modifier = Modifier,
onValueChange: (String) -> Unit,
value: String
) {
TextField(
value = value,
onValueChange = onValueChange,
shape = RoundedCornerShape(16.dp),
placeholder = {
BaseText16(
text = placeholder,
color = Gray
)
},
textStyle = Typography.bodySmall.copy(fontSize = 16.sp),
colors = TextFieldDefaults.colors(
focusedContainerColor = LightBlue,
unfocusedContainerColor = LightBlue,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent
),
singleLine = true,
modifier = modifier
)
}
@Composable
fun BaseText20(
text: String,
color: Color = Color.Unspecified,
style: TextStyle = Typography.bodySmall,
modifier: Modifier = Modifier.padding(7.dp),
textAlign: TextAlign = TextAlign.Unspecified
) {
Text(
text = text,
style = style,
fontSize = 20.sp,
modifier = modifier,
color = color,
textAlign = textAlign
)
}
@Composable
fun BaseButton(
border: BorderStroke? = null,
enable: Boolean = true,
text: String,
btnColor: Color,
btnContentColor: Color,
onClick: () -> Unit,
icon: @Composable RowScope.() -> Unit = {},
modifier: Modifier = Modifier.fillMaxWidth()
) {
Button(
border = border,
enabled = enable,
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = btnColor,
contentColor = btnContentColor,
disabledContainerColor = LightGray,
disabledContentColor = Gray
),
modifier = modifier,
shape = RoundedCornerShape(16.dp),
) {
icon()
BaseText20(text = text)
}
}

View File

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

View File

@@ -14,7 +14,11 @@ import androidx.navigation.compose.rememberNavController
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.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(
@@ -26,24 +30,19 @@ fun AppNavHost(
enterTransition = { EnterTransition.None }, enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
navController = navController, navController = navController,
startDestination = AuthScreenDestination, startDestination = SplashScreenDestination,
) { ) {
composable<SplashScreenDestination> {
SplashScreen(navController = navController)
}
composable<AuthScreenDestination> { composable<AuthScreenDestination> {
AuthScreen(navController = navController) AuthScreen(navController = navController)
} }
composable<MainScreenDestination> { composable<MainScreenDestination> {
Box( MainScreen(navController = navController)
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
} }
composable<BookScreenDestination> { composable<BookScreenDestination> {
Box( BookScreen(navController = navController)
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
}
} }
} }
} }

View File

@@ -1,27 +1,20 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
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.material3.Button import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -30,12 +23,20 @@ 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.BaseButton
import ru.myitschool.work.ui.BaseInputText
import ru.myitschool.work.ui.BaseText12
import ru.myitschool.work.ui.BaseText24
import ru.myitschool.work.ui.Logo
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.theme.Blue
import ru.myitschool.work.ui.theme.Red
import ru.myitschool.work.ui.theme.White
@Composable @Composable
fun AuthScreen( fun AuthScreen(
viewModel: AuthViewModel = viewModel(), viewModel: AuthViewModel = viewModel(),
navController: NavController navController: NavController,
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
@@ -45,24 +46,34 @@ fun AuthScreen(
} }
} }
Column( Box(
modifier = Modifier contentAlignment = Alignment.Center,
.fillMaxSize() modifier = Modifier.fillMaxSize()
.padding(all = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
Text( Column(
text = stringResource(R.string.auth_title), modifier = Modifier
style = MaterialTheme.typography.headlineSmall, .width(400.dp)
textAlign = TextAlign.Center .fillMaxHeight()
) .padding(20.dp),
when (val currentState = state) { horizontalAlignment = Alignment.CenterHorizontally
is AuthState.Data -> Content(viewModel, currentState) ) {
is AuthState.Loading -> {
CircularProgressIndicator( Logo()
modifier = Modifier.size(64.dp)
) BaseText24(
text = stringResource(R.string.auth_title),
textAlign = TextAlign.Center
)
when (state) {
is AuthState.Data -> Content(viewModel)
is AuthState.Loading -> {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 40.dp)
.size(64.dp)
)
}
} }
} }
} }
@@ -70,28 +81,50 @@ fun AuthScreen(
@Composable @Composable
private fun Content( private fun Content(
viewModel: AuthViewModel, viewModel: AuthViewModel
state: AuthState.Data
) { ) {
var inputText by remember { mutableStateOf("") }
Spacer(modifier = Modifier.size(16.dp)) val isButtonEnabled by viewModel.isButtonEnabled.collectAsState()
TextField( val errorStateValue by viewModel.errorStateValue.collectAsState()
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(), val textState by viewModel.textState.collectAsState()
value = inputText,
onValueChange = { Column(
inputText = it modifier = Modifier.padding(vertical = 20.dp)
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))
BaseInputText(
value = textState,
placeholder = stringResource(R.string.auth_label),
modifier = Modifier
.testTag(TestIds.Auth.CODE_INPUT)
.fillMaxWidth(),
onValueChange = { viewModel.onIntent(AuthIntent.TextInput(it)) }
)
if (errorStateValue != "") {
BaseText12(
text = errorStateValue,
color = Red,
modifier = Modifier
.testTag(TestIds.Auth.ERROR)
.padding(
start = 10.dp,
top = 5.dp,
bottom = 0.dp
)
.fillMaxWidth()
)
}
} }
BaseButton(
text = stringResource(R.string.auth_sign_in),
onClick = { viewModel.onIntent(AuthIntent.Send(textState)) },
btnColor = Blue,
enable = isButtonEnabled,
btnContentColor = White,
modifier = Modifier
.testTag(TestIds.Auth.SIGN_BUTTON)
.fillMaxWidth()
)
} }

View File

@@ -1,6 +1,7 @@
package ru.myitschool.work.ui.screen.auth package ru.myitschool.work.ui.screen.auth
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -10,34 +11,52 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.myitschool.work.App
import ru.myitschool.work.data.datastore.UserCode
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(application: Application) : AndroidViewModel(application) {
private val dataStoreManager by lazy {
(getApplication() as App).dataStoreManager
}
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 _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow() private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow val actionFlow: SharedFlow<Unit> = _actionFlow
private val _errorStateValue = MutableStateFlow("")
val errorStateValue: StateFlow<String> = _errorStateValue.asStateFlow()
private val _isButtonEnabled = MutableStateFlow(false)
val isButtonEnabled: StateFlow<Boolean> = _isButtonEnabled.asStateFlow()
private val _textState = MutableStateFlow("")
val textState: StateFlow<String> = _textState.asStateFlow()
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.IO) {
_uiState.update { AuthState.Loading } _uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold( checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = { onSuccess = {
dataStoreManager.saveUserCode(UserCode(code = intent.text))
_actionFlow.emit(Unit) _actionFlow.emit(Unit)
}, },
onFailure = { error -> onFailure = { error ->
error.printStackTrace() error.printStackTrace()
_actionFlow.emit(Unit) _uiState.update { AuthState.Data }
_errorStateValue.value = error.message.toString() ?: "Неизвестная ошибка"
} }
) )
} }
} }
is AuthIntent.TextInput -> Unit is AuthIntent.TextInput -> {
_textState.value = intent.text
_errorStateValue.value = ""
_isButtonEnabled.value = if (intent.text.length == 4 && intent.text.matches(Regex("^[a-zA-Z0-9]*\$")))
true else false
}
} }
} }
} }

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.screen.book
sealed interface BookAction {
object Auth: BookAction
object Main: BookAction
}

View File

@@ -0,0 +1,12 @@
package ru.myitschool.work.ui.screen.book
sealed interface BookIntent {
object Back: BookIntent
object LoadBooking: BookIntent
object Book : BookIntent
data class SelectDate(val date: String) : BookIntent
data class SelectPlace(
val placeId: Int,
val placeName: String
) : BookIntent
}

View File

@@ -0,0 +1,424 @@
package ru.myitschool.work.ui.screen.book
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.CircularProgressIndicator
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.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.core.TestIds.Book
import ru.myitschool.work.core.TestIds.Main
import ru.myitschool.work.domain.book.entities.BookingEntity
import ru.myitschool.work.domain.book.entities.PlaceInfo
import ru.myitschool.work.formatBookingDate
import ru.myitschool.work.formatDate
import ru.myitschool.work.ui.BaseButton
import ru.myitschool.work.ui.BaseNoBackgroundButton
import ru.myitschool.work.ui.BaseText16
import ru.myitschool.work.ui.BaseText24
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.main.MainIntent
import ru.myitschool.work.ui.theme.Black
import ru.myitschool.work.ui.theme.Blue
import ru.myitschool.work.ui.theme.Typography
import ru.myitschool.work.ui.theme.White
@Composable
fun BookScreen(
navController: NavController,
viewModel: BookViewModel = viewModel(),
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect { action ->
when(action) {
is BookAction.Auth -> navController.navigate(AuthScreenDestination)
is BookAction.Main -> navController.navigate(MainScreenDestination)
}
}
}
when(state) {
is BookState.Loading -> {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
}
}
is BookState.Data -> {
val dataState = state as BookState.Data
DataContent(
viewModel = viewModel,
bookingData = dataState.userBooking,
selectedDate = dataState.selectedDate,
selectedPlaceId = dataState.selectedPlaceId
)
}
is BookState.Error -> ErrorContent(viewModel)
is BookState.Empty -> EmptyContent(viewModel)
}
}
@Composable
fun EmptyContent(
viewModel: BookViewModel
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(15.dp)
.fillMaxHeight()
.width(320.dp)
) {
Spacer(modifier = Modifier.height(80.dp))
BaseText24(
text = stringResource(R.string.book_all_booked),
modifier = Modifier.testTag(Book.EMPTY),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
BaseButton(
text = stringResource(R.string.book_back),
modifier = Modifier
.fillMaxWidth()
.testTag(Book.BACK_BUTTON),
onClick = { viewModel.onIntent(BookIntent.Back) },
btnContentColor = White,
btnColor = Blue
)
}
}
}
@Composable
fun ErrorContent(
viewModel: BookViewModel
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(15.dp)
.fillMaxHeight()
.width(320.dp)
) {
Spacer(modifier = Modifier.height(80.dp))
BaseText24(
text = stringResource(R.string.book_error),
modifier = Modifier.testTag(Book.ERROR),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
BaseButton(
border = BorderStroke(1.dp, Blue),
text = stringResource(R.string.book_back),
modifier = Modifier
.fillMaxWidth()
.testTag(Book.BACK_BUTTON),
onClick = { viewModel.onIntent(BookIntent.Back) },
btnContentColor = Blue,
btnColor = Color.Transparent
)
Spacer(modifier = Modifier.height(15.dp))
BaseButton(
text = stringResource(R.string.main_update),
modifier = Modifier
.fillMaxWidth()
.testTag(Book.REFRESH_BUTTON),
onClick = { viewModel.onIntent(BookIntent.LoadBooking) },
btnContentColor = White,
btnColor = Blue
)
}
}
}
@Composable
fun DataContent(
viewModel: BookViewModel,
bookingData: BookingEntity,
selectedDate: String,
selectedPlaceId: Int
) {
val availableDates = bookingData.bookings
.filter { it.value.isNotEmpty() }
.keys
.sorted()
val placesForSelectedDate = bookingData.bookings[selectedDate] ?: emptyList()
Column {
Row(
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp))
.background(Blue)
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 15.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
BaseText24(
text = stringResource(R.string.book_new_book),
color = White,
modifier = Modifier.padding(start = 15.dp)
)
BaseNoBackgroundButton(
text = stringResource(R.string.book_back),
modifier = Modifier.testTag(Book.BACK_BUTTON),
onClick = { viewModel.onIntent(BookIntent.Back) }
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 20.dp, horizontal = 10.dp)
.clip(RoundedCornerShape(16.dp))
.background(White)
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(13.dp)
) {
Column {
Text(
text = stringResource(R.string.book_available_date),
style = Typography.bodyMedium,
fontSize = 16.sp,
)
BookDateList(
dates = availableDates,
selectedDate = selectedDate,
onDateSelected = { date ->
viewModel.onIntent(BookIntent.SelectDate(date))
}
)
Text(
text = stringResource(R.string.book_choose_place),
style = Typography.bodyMedium,
fontSize = 16.sp,
)
BookPlaceList(
places = placesForSelectedDate,
selectedPlaceId = selectedPlaceId,
onPlaceSelected = { placeId, placeName ->
viewModel.onIntent(BookIntent.SelectPlace(placeId, placeName))
}
)
}
BaseButton(
enable = selectedPlaceId != -1,
text = stringResource(R.string.booking_button),
btnColor = Blue,
btnContentColor = White,
onClick = { viewModel.onIntent(BookIntent.Book) },
modifier = Modifier
.testTag(Book.BOOK_BUTTON)
.padding(horizontal = 10.dp)
.fillMaxWidth(),
icon = { Image(
painter = painterResource(R.drawable.add_icon),
contentDescription = stringResource(R.string.add_icon_description)
) }
)
}
}
}
}
@Composable
fun BookPlaceList(
places: List<PlaceInfo>,
selectedPlaceId: Int,
onPlaceSelected: (Int, String) -> Unit
) {
Column(
modifier = Modifier.padding(vertical = 15.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (places.isEmpty()) {
Text(
text = "Нет доступных мест для выбранной даты",
color = Color.Gray,
style = Typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
places.forEachIndexed { index, placeInfo ->
BookPlaceListElement(
placeInfo = placeInfo,
isSelected = placeInfo.id == selectedPlaceId,
onPlaceSelected = { onPlaceSelected(placeInfo.id, placeInfo.place) },
index = index
)
}
}
}
}
@Composable
fun BookPlaceListElement(
placeInfo: PlaceInfo,
isSelected: Boolean,
onPlaceSelected: () -> Unit,
index: Int
) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = onPlaceSelected
)
.testTag(Book.getIdPlaceItemByPosition(index))
.padding(vertical = 12.dp, horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
BaseText16(
text = placeInfo.place,
modifier = Modifier.testTag(Book.ITEM_PLACE_TEXT)
)
Box(
modifier = Modifier
.size(24.dp)
.border(
width = 2.dp,
color = if (isSelected) Blue else Color.Gray,
shape = CircleShape
)
.background(
color = if (isSelected) Blue else Color.Transparent,
shape = CircleShape
)
.testTag(Book.ITEM_PLACE_SELECTOR)
) {
if (isSelected) {
Box(
modifier = Modifier
.size(12.dp)
.background(Color.White, CircleShape)
.align(Alignment.Center)
)
}
}
}
}
@Composable
fun BookDateList(
dates: List<String>,
selectedDate: String,
onDateSelected: (String) -> Unit
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(7.dp),
modifier = Modifier.padding(vertical = 15.dp)
) {
dates.forEachIndexed { index, date ->
BookDateListElement(
date = date,
isSelected = date == selectedDate,
onClick = { onDateSelected(date) },
index = index
)
}
}
}
@Composable
fun BookDateListElement(
date: String,
isSelected: Boolean,
onClick: () -> Unit,
index: Int
) {
Button(
contentPadding = PaddingValues(0.dp),
modifier = Modifier
.testTag(Book.getIdDateItemByPosition(index))
.padding(0.dp),
border = BorderStroke(1.dp, if (isSelected) Blue else Black,),
onClick = onClick,
colors = ButtonColors(
contentColor = if (isSelected) White else Black,
containerColor = if (isSelected) Blue else Color.Transparent,
disabledContentColor = Black,
disabledContainerColor = Color.Transparent),
) {
val formattedDate = date.formatBookingDate()
BaseText16(
text = formattedDate,
modifier = Modifier.testTag(Book.ITEM_DATE),
color = if (isSelected) White else Black,
)
}
}

View File

@@ -0,0 +1,15 @@
package ru.myitschool.work.ui.screen.book
import ru.myitschool.work.domain.book.entities.BookingEntity
sealed interface BookState {
object Loading: BookState
data class Data(
val userBooking: BookingEntity,
val selectedDate: String = "",
val selectedPlaceId: Int = -1,
val selectedPlaceName: String = ""
): BookState
object Error: BookState
object Empty: BookState
}

View File

@@ -0,0 +1,165 @@
package ru.myitschool.work.ui.screen.book
import android.app.Application
import androidx.lifecycle.AndroidViewModel
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.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.App
import ru.myitschool.work.data.repo.BookRepository
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.domain.book.BookingUseCase
import ru.myitschool.work.domain.book.LoadBookingUseCase
import ru.myitschool.work.domain.main.LoadDataUseCase
import ru.myitschool.work.ui.screen.main.MainAction
import ru.myitschool.work.ui.screen.main.MainIntent
import ru.myitschool.work.ui.screen.main.MainState
import kotlin.text.isEmpty
class BookViewModel(application: Application) : AndroidViewModel(application) {
private val loadBookingUseCase by lazy { LoadBookingUseCase(BookRepository) }
private val bookingUseCase by lazy { BookingUseCase (BookRepository) }
private val dataStoreManager by lazy {
(getApplication() as App).dataStoreManager
}
private val _uiState = MutableStateFlow<BookState>(BookState.Loading)
val uiState: StateFlow<BookState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<BookAction> = MutableSharedFlow()
val actionFlow: SharedFlow<BookAction> = _actionFlow
init {
loadBooking()
}
private fun bookSelectedPlace() {
viewModelScope.launch(Dispatchers.IO) {
try {
val userCode = dataStoreManager.getUserCode().first()
val currentState = _uiState.value
if (currentState is BookState.Data && currentState.selectedPlaceId != -1) {
bookingUseCase.invoke(
userCode = userCode.code,
date = currentState.selectedDate,
placeId = currentState.selectedPlaceId,
placeName = currentState.selectedPlaceName
).fold(
onSuccess = {
_actionFlow.emit(BookAction.Main)
},
onFailure = { error ->
error.printStackTrace()
_uiState.update { BookState.Error }
}
)
}
} catch (error: Exception) {
error.printStackTrace()
_uiState.update { BookState.Error }
}
}
}
private fun loadBooking() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { BookState.Loading }
try {
val userCode = dataStoreManager.getUserCode().first()
if (userCode.code.isEmpty()) {
_actionFlow.emit(BookAction.Auth)
return@launch
}
loadBookingUseCase.invoke(userCode.code).fold(
onSuccess = { data ->
val availableDates = data.bookings
.filter { it.value.isNotEmpty() }
.keys
.sorted()
if (availableDates.isEmpty()) {
_uiState.update { BookState.Empty }
} else {
val selectedDate = availableDates.first()
val placesForSelectedDate = data.bookings[selectedDate] ?: emptyList()
val selectedPlaceId = placesForSelectedDate.firstOrNull()?.id ?: -1
val selectedPlaceName = placesForSelectedDate.firstOrNull()?.place ?: ""
_uiState.update {
BookState.Data(
userBooking = data,
selectedDate = selectedDate,
selectedPlaceId = selectedPlaceId,
selectedPlaceName = selectedPlaceName
)
}
}
},
onFailure = { error ->
error.printStackTrace()
_uiState.update { BookState.Error }
}
)
} catch (error: Exception) {
error.printStackTrace()
_uiState.update { BookState.Error }
}
}
}
fun onIntent(intent: BookIntent) {
when (intent) {
is BookIntent.LoadBooking -> loadBooking()
is BookIntent.Back -> {
viewModelScope.launch(Dispatchers.Default) {
_actionFlow.emit(BookAction.Main)
}
}
is BookIntent.Book -> bookSelectedPlace()
is BookIntent.SelectDate -> {
val currentState = _uiState.value
if (currentState is BookState.Data) {
val placesForDate =
currentState.userBooking.bookings[intent.date] ?: emptyList()
val newSelectedPlaceId = placesForDate.firstOrNull()?.id ?: -1
val newSelectedPlaceName = placesForDate.firstOrNull()?.place ?: ""
_uiState.update {
currentState.copy(
selectedDate = intent.date,
selectedPlaceId = newSelectedPlaceId,
selectedPlaceName = newSelectedPlaceName
)
}
}
}
is BookIntent.SelectPlace -> {
val currentState = _uiState.value
if (currentState is BookState.Data) {
_uiState.update {
currentState.copy(
selectedPlaceId = intent.placeId,
selectedPlaceName = intent.placeName
)
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
package ru.myitschool.work.ui.screen.main
sealed interface MainAction {
object Booking: MainAction
object Auth: MainAction
}

View File

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

View File

@@ -0,0 +1,307 @@
package ru.myitschool.work.ui.screen.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.fillMaxHeight
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil3.compose.rememberAsyncImagePainter
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds.Main
import ru.myitschool.work.domain.main.entities.BookingInfo
import ru.myitschool.work.domain.main.entities.UserEntity
import ru.myitschool.work.ui.BaseButton
import ru.myitschool.work.ui.BaseNoBackgroundButton
import ru.myitschool.work.ui.BaseText14
import ru.myitschool.work.ui.BaseText16
import ru.myitschool.work.ui.BaseText20
import ru.myitschool.work.ui.BaseText24
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.theme.Black
import ru.myitschool.work.ui.theme.Blue
import ru.myitschool.work.ui.theme.LightGray
import ru.myitschool.work.ui.theme.Typography
import ru.myitschool.work.ui.theme.White
@Composable
fun MainScreen(
navController: NavController,
viewModel: MainViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.actionFlow.collect { action ->
when(action) {
is MainAction.Auth -> navController.navigate(AuthScreenDestination)
is MainAction.Booking -> navController.navigate(BookScreenDestination)
}
}
}
when(state) {
is MainState.Loading -> {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp)
)
}
}
is MainState.Error -> {
ErrorContent(viewModel)
}
is MainState.Data -> {
DataContent(
viewModel,
userData = (state as MainState.Data).userData
)
}
}
}
@Composable
fun ErrorContent(viewModel: MainViewModel){
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(15.dp)
.fillMaxHeight()
.width(320.dp)
) {
Spacer(modifier = Modifier.height(80.dp))
BaseText24(
text = stringResource(R.string.data_error_message),
modifier = Modifier.testTag(Main.ERROR),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
BaseButton(
text = stringResource(R.string.main_update),
modifier = Modifier
.fillMaxWidth()
.testTag(Main.REFRESH_BUTTON),
onClick = { viewModel.onIntent(MainIntent.LoadData) },
btnContentColor = White,
btnColor = Blue
)
}
}
}
@Composable
fun DataContent(
viewModel: MainViewModel,
userData: UserEntity
) {
Column (
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(LightGray)
.fillMaxSize()
.width(400.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp))
.background(Blue)
.fillMaxWidth()
.padding(10.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
BaseNoBackgroundButton(
text = stringResource(R.string.main_update),
onClick = { viewModel.onIntent(MainIntent.LoadData) },
modifier = Modifier.testTag(Main.REFRESH_BUTTON)
)
BaseNoBackgroundButton(
text = stringResource(R.string.main_log_out),
onClick = { viewModel.onIntent(MainIntent.Logout) },
modifier = Modifier.testTag(Main.LOGOUT_BUTTON)
)
}
Image(
painter = rememberAsyncImagePainter(
model = userData.photoUrl,
error = painterResource(R.drawable.avatar)
),
contentDescription = stringResource(R.string.main_avatar_description),
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.testTag(Main.PROFILE_IMAGE)
.width(150.dp)
.height(150.dp)
.padding(20.dp)
)
BaseText20(
text = userData.name,
color = White,
textAlign = TextAlign.Center,
modifier = Modifier
.testTag(Main.PROFILE_NAME)
.width(250.dp),
style = Typography.bodyLarge
)
Spacer(modifier = Modifier.height(20.dp))
}
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
.clip(RoundedCornerShape(16.dp))
.background(White)
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.main_booking_title),
style = Typography.bodyMedium,
color = Black,
fontSize = 16.sp,
modifier = Modifier.padding(
horizontal = 10.dp,
vertical = 20.dp
)
)
if (userData.hasBookings()) {
SortedBookingList(userData = userData)
} else {
EmptyBookings()
}
}
BaseButton(
text = stringResource(R.string.booking_button),
btnColor = Blue,
btnContentColor = White,
onClick = { viewModel.onIntent(MainIntent.Booking) },
modifier = Modifier
.testTag(Main.ADD_BUTTON)
.padding(horizontal = 10.dp, vertical = 15.dp)
.fillMaxWidth(),
icon = {Image(
painter = painterResource(R.drawable.add_icon),
contentDescription = stringResource(R.string.add_icon_description)
)}
)
}
}
}
@Composable
fun SortedBookingList(userData: UserEntity) {
val sortedBookings = remember(userData.booking) {
userData.getSortedBookingsWithFormattedDate()
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
) {
itemsIndexed(
items = sortedBookings
) { index, (originalDate, formattedDate, bookingInfo) ->
BookingItem(
originalDate = originalDate,
formattedDate = formattedDate,
bookingInfo = bookingInfo,
index = index
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
fun BookingItem(
originalDate: String,
formattedDate: String,
bookingInfo: BookingInfo,
index: Int
) {
Row(
modifier = Modifier
.testTag(Main.getIdItemByPosition(index))
.fillMaxWidth()
.padding(vertical = 20.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
BaseText14(
text = bookingInfo.place,
modifier = Modifier.testTag(Main.ITEM_PLACE)
)
BaseText14(
text = formattedDate,
modifier = Modifier.testTag(Main.ITEM_DATE)
)
}
}
@Composable
fun EmptyBookings() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
BaseText16(
text = stringResource(R.string.main_empty_booking)
)
}
}

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.ui.screen.main
import ru.myitschool.work.domain.main.entities.UserEntity
sealed interface MainState {
data class Data(val userData: UserEntity): MainState
object Loading: MainState
object Error: MainState
}

View File

@@ -0,0 +1,82 @@
package ru.myitschool.work.ui.screen.main
import android.app.Application
import androidx.lifecycle.AndroidViewModel
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.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.App
import ru.myitschool.work.data.repo.MainRepository
import ru.myitschool.work.domain.main.LoadDataUseCase
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val dataStoreManager by lazy {
(getApplication() as App).dataStoreManager
}
private val loadDataUseCase by lazy { LoadDataUseCase(MainRepository) }
private val _uiState = MutableStateFlow<MainState>(MainState.Loading)
val uiState: StateFlow<MainState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<MainAction> = MutableSharedFlow()
val actionFlow: SharedFlow<MainAction> = _actionFlow
init {
loadData()
}
private fun loadData() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { MainState.Loading }
try {
val userCode = dataStoreManager.getUserCode().first()
if (userCode.code.isEmpty()) {
_actionFlow.emit(MainAction.Auth)
return@launch
}
loadDataUseCase.invoke(userCode.code).fold(
onSuccess = { data ->
_uiState.update { MainState.Data(data) }
},
onFailure = { error ->
error.printStackTrace()
_uiState.update { MainState.Error }
}
)
} catch (error: Exception) {
error.printStackTrace()
_uiState.update { MainState.Error }
}
}
}
fun onIntent( intent: MainIntent) {
when(intent) {
is MainIntent.LoadData -> loadData()
is MainIntent.Booking -> {
viewModelScope.launch(Dispatchers.Default) {
_actionFlow.emit(MainAction.Booking)
}
}
is MainIntent.Logout -> {
viewModelScope.launch(Dispatchers.IO) {
dataStoreManager.clearUserCode()
_actionFlow.emit(MainAction.Auth)
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
package ru.myitschool.work.ui.screen.splash
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
@Composable
fun SplashScreen(
navController: NavController,
viewModel: SplashViewModel = viewModel()
) {
val splashState by viewModel.splashState.collectAsState()
LaunchedEffect(splashState) {
when (splashState) {
is SplashState.Authenticated -> {
navController.navigate(MainScreenDestination)
}
is SplashState.UnAuthenticated -> {
navController.navigate(AuthScreenDestination)
}
is SplashState.Error -> {
navController.navigate(AuthScreenDestination)
}
SplashState.Loading -> {
}
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(64.dp))
}
}

View File

@@ -0,0 +1,10 @@
package ru.myitschool.work.ui.screen.splash
import android.os.Message
sealed interface SplashState {
object Loading: SplashState
object Authenticated: SplashState
object UnAuthenticated: SplashState
class Error(message: String): SplashState
}

View File

@@ -0,0 +1,44 @@
package ru.myitschool.work.ui.screen.splash
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import ru.myitschool.work.App
class SplashViewModel(application: Application) : AndroidViewModel(application) {
private val dataStoreManager by lazy {
(getApplication() as App).dataStoreManager
}
private val _splashState = MutableStateFlow<SplashState>(SplashState.Loading)
val splashState: StateFlow<SplashState> = _splashState.asStateFlow()
init {
checkAuthStatus()
}
private fun checkAuthStatus() {
viewModelScope.launch {
try {
val userCode = dataStoreManager.getUserCode().first()
val isAuthenticated = if (userCode.code.isEmpty()) false else true
_splashState.value = if (isAuthenticated) {
SplashState.Authenticated
} else {
SplashState.UnAuthenticated
}
} catch (e: Exception) {
_splashState.value = SplashState.Error(e.message ?: "Unknown error")
}
}
}
}

View File

@@ -8,4 +8,18 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4) val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260) val Pink40 = Color(0xFF7D5260)
val Blue = Color(0xFF004BFF)
val Gray = Color(0xFF777777)
val LightBlue = Color(0xFFF2EFFF)
val White = Color(0xFFFFFFFF)
val Red = Color(0xFFFF4D4D)
val LightGray = Color(0xFFF2F1F7)
val Black = Color(0xFF000000)

View File

@@ -2,19 +2,34 @@ package ru.myitschool.work.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ru.myitschool.work.R
// Set of Material typography styles to start with // Set of Material typography styles to start with
val MontserratFontFamily = FontFamily(
Font(R.font.montserrat_bold, FontWeight.Bold),
Font(R.font.montserrat_medium, FontWeight.Medium),
Font(R.font.montserrat_semibold, weight = FontWeight.SemiBold)
)
val Typography = Typography( val Typography = Typography(
bodySmall = TextStyle(
fontFamily = MontserratFontFamily,
fontWeight = FontWeight.Medium,
),
bodyMedium = TextStyle(
fontFamily = MontserratFontFamily,
fontWeight = FontWeight.SemiBold,
),
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = MontserratFontFamily,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Bold,
fontSize = 16.sp, ),
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override /* Other default text styles to override
titleLarge = TextStyle( titleLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,

View File

@@ -0,0 +1,26 @@
package ru.myitschool.work
import java.text.SimpleDateFormat
import java.util.Locale
fun String.formatDate(): String {
return try {
val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val outputFormat = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
val date = inputFormat.parse(this)
outputFormat.format(date)
} catch (e: Exception) {
this
}
}
fun String.formatBookingDate(): String {
return try {
val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val outputFormat = SimpleDateFormat("dd.MM", Locale.getDefault())
val date = inputFormat.parse(this)
outputFormat.format(date)
} catch (e: Exception) {
this
}
}

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="17dp"
android:height="17dp"
android:viewportWidth="17"
android:viewportHeight="17">
<path
android:strokeWidth="1"
android:pathData="M8.296,0.5C8.503,0.5 8.656,0.565 8.786,0.695C8.916,0.826 8.981,0.977 8.98,1.183V7.61H15.405C15.615,7.61 15.767,7.676 15.896,7.805C16.026,7.934 16.09,8.086 16.09,8.294C16.089,8.503 16.024,8.657 15.894,8.788C15.767,8.917 15.616,8.981 15.407,8.98H8.98V15.405C8.98,15.615 8.914,15.767 8.785,15.896C8.656,16.026 8.504,16.09 8.296,16.09C8.087,16.089 7.934,16.024 7.805,15.894C7.676,15.766 7.61,15.615 7.61,15.405V8.98H1.185C0.975,8.98 0.823,8.915 0.695,8.786C0.566,8.656 0.5,8.503 0.5,8.294C0.5,8.086 0.565,7.935 0.694,7.806C0.825,7.675 0.978,7.61 1.185,7.61H7.61V1.185C7.61,0.975 7.676,0.824 7.805,0.695C7.934,0.566 8.087,0.501 8.296,0.5Z"
android:fillColor="#ffffff"
android:strokeColor="#ffffff"/>
</vector>

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M50,50m-48.5,0a48.5,48.5 0,1 1,97 0a48.5,48.5 0,1 1,-97 0"
android:strokeWidth="3"
android:fillColor="#AEC3FF"
android:strokeColor="#ffffff"/>
<path
android:pathData="M61.99,54.17C64.58,54.17 66.67,56.26 66.67,58.85V60.05C66.67,61.91 66.01,63.71 64.8,65.13C61.53,68.95 56.55,70.83 50,70.83C43.45,70.83 38.48,68.95 35.21,65.13C34,63.71 33.34,61.91 33.34,60.05V58.85C33.34,56.26 35.44,54.17 38.03,54.17H61.99ZM61.99,57.29H38.03C37.16,57.29 36.47,57.99 36.47,58.85V60.05C36.47,61.17 36.86,62.25 37.59,63.1C40.2,66.16 44.3,67.71 50,67.71C55.71,67.71 59.8,66.16 62.42,63.1C63.15,62.25 63.55,61.17 63.55,60.05V58.85C63.55,57.99 62.85,57.29 61.99,57.29ZM50,29.18C55.75,29.18 60.42,33.84 60.42,39.59C60.42,45.35 55.75,50.01 50,50.01C44.25,50.01 39.58,45.35 39.58,39.59C39.58,33.84 44.25,29.18 50,29.18ZM50,32.3C45.97,32.3 42.71,35.57 42.71,39.59C42.71,43.62 45.97,46.88 50,46.88C54.03,46.88 57.29,43.62 57.29,39.59C57.29,35.57 54.03,32.3 50,32.3Z"
android:fillColor="#ffffff"/>
</vector>

View File

@@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="53dp"
android:height="99dp"
android:viewportWidth="53"
android:viewportHeight="99">
<path
android:pathData="M33.501,13.467V35.047H52.507C52.507,24.867 44.587,16.26 33.501,13.467Z"
android:fillColor="#004BFF"/>
<path
android:pathData="M0.267,61.92C0.267,72.08 8.187,80.713 19.274,83.507V61.92H0.267Z"
android:fillColor="#004BFF"/>
<path
android:pathData="M52.781,61.92C52.507,46.767 36.027,41.173 28.594,39.6C25.087,38.84 18.814,36.933 19.007,33.713V13.44C8.621,16.053 -0.293,24.873 0.007,35.473C1.714,53.393 19.207,55.613 29.341,58.88C32.581,60.153 33.861,61.78 33.781,63.333V83.513C44.041,80.993 52.901,72.16 52.781,61.92Z"
android:fillColor="#004BFF"/>
<path
android:pathData="M26.374,85.92C22.941,85.92 20.047,84.867 19.234,83.54L26.374,98.3L33.754,83.513C32.714,84.813 29.821,85.92 26.374,85.92Z"
android:fillColor="#004BFF"/>
<path
android:pathData="M26.661,13.467C29.821,13.467 32.467,12.407 32.467,10.8V2.62C32.467,1.32 29.801,0 26.661,0C23.221,0 20.601,1.053 20.601,2.667V10.813C20.574,12.14 23.221,13.467 26.661,13.467Z"
android:fillColor="#004BFF"/>
</vector>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,21 @@
<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>
<string name="main_update">Обновить</string>
<string name="main_log_out">Выйти</string>
<string name="main_avatar_description">Фото пользователя</string>
<string name="main_booking_title">Ваши забронированные места</string>
<string name="booking_button">Бронировать</string>
<string name="add_icon_description">Иконка добавления</string>
<string name="data_error_message">Ошибка загрузки данных</string>
<string name="main_empty_booking">Нет бронирований</string>
<string name="book_new_book">Новая встреча</string>
<string name="book_back">Назад</string>
<string name="book_available_date">Доступные даты</string>
<string name="book_choose_place">Выберите место встречи</string>
<string name="book_all_booked">Всё забронировано</string>
<string name="book_error">Ошибка сервера</string>
</resources> </resources>