Compare commits

...

2 Commits

Author SHA1 Message Date
01b75dda8e Project 2025-12-10 16:24:59 +07:00
eb5eacfa11 Rename .java to .kt 2025-12-10 16:24:57 +07:00
29 changed files with 1281 additions and 166 deletions

View File

@@ -35,6 +35,11 @@ android {
} }
dependencies { dependencies {
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
debugImplementation("androidx.compose.ui:ui-tooling")
defaultComposeLibrary() defaultComposeLibrary()
implementation("androidx.datastore:datastore-preferences:1.1.7") implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0") implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")

View File

@@ -2,11 +2,13 @@ package ru.myitschool.work
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import ru.myitschool.work.data.repo.AuthRepository
class App: Application() { class App: Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
context = this context = this
} }
companion object { companion object {

View File

@@ -0,0 +1,41 @@
package ru.myitschool.work
import android.content.Context
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
import ru.myitschool.work.domain.book.CreateBookingUseCase
import ru.myitschool.work.domain.book.GetAvailableBookingsUseCase
import ru.myitschool.work.domain.main.GetUserInfoUseCase
import ru.myitschool.work.domain.main.LogoutUseCase
object AppModule {
private lateinit var _authRepository: AuthRepository
val authRepository: AuthRepository
get() = _authRepository
lateinit var checkAndSaveAuthCodeUseCase: CheckAndSaveAuthCodeUseCase
private set
lateinit var getUserInfoUseCase: GetUserInfoUseCase
private set
lateinit var getAvailableBookingsUseCase: GetAvailableBookingsUseCase
private set
lateinit var createBookingUseCase: CreateBookingUseCase
private set
lateinit var logoutUseCase: LogoutUseCase
private set
fun init(context: Context) {
_authRepository = AuthRepository(context)
val networkDataSource = _authRepository.getNetworkDataSource()
checkAndSaveAuthCodeUseCase = CheckAndSaveAuthCodeUseCase(_authRepository)
getUserInfoUseCase = GetUserInfoUseCase(_authRepository, networkDataSource)
getAvailableBookingsUseCase = GetAvailableBookingsUseCase(_authRepository, networkDataSource)
createBookingUseCase = CreateBookingUseCase(_authRepository, networkDataSource)
logoutUseCase = LogoutUseCase(_authRepository)
}
}

View File

@@ -1,9 +1,12 @@
package ru.myitschool.work.core package ru.myitschool.work.core
object Constants { object Constants {
const val HOST = "http://10.0.2.2:8080" const val HOST = "http://10.0.2.2:8080/"
const val AUTH_URL = "/auth" const val AUTH_URL = "/auth"
const val INFO_URL = "/info" const val INFO_URL = "/info"
const val BOOKING_URL = "/booking" const val BOOKING_URL = "/booking"
const val BOOK_URL = "/book" const val BOOK_URL = "/book"
const val AUTH_CODE_KEY = "auth_code"
const val AUTH_PREFS_NAME = "auth_prefs"
} }

View File

@@ -1,16 +1,61 @@
package ru.myitschool.work.data.repo package ru.myitschool.work.data.repo
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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.core.Constants
import ru.myitschool.work.data.source.NetworkDataSource import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.data.source.NetworkDataSourceImpl
object AuthRepository { val Context.authDataStore: DataStore<Preferences> by preferencesDataStore(name = Constants.AUTH_PREFS_NAME)
class AuthRepository(
private val context: Context
)
{
private val networkDataSource: NetworkDataSource = NetworkDataSourceImpl()
private val authCodeKey = stringPreferencesKey(Constants.AUTH_CODE_KEY)
private var codeCache: String? = null 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).onSuccess { success ->
if (success) { if (success) {
codeCache = text codeCache = text
saveAuthCode(text)
} }
} }
} }
fun getAuthCode(): Flow<String?> {
return context.authDataStore.data.map { preferences ->
preferences[authCodeKey]
}
}
private suspend fun saveAuthCode(code: String) {
context.authDataStore.edit { preferences ->
preferences[authCodeKey] = code
}
}
suspend fun isAuthorized(): Boolean {
return try {
val code = getAuthCode().first()
code != null
} catch (e: Exception) {
false
}
}
suspend fun logout() {
context.authDataStore.edit { preferences ->
preferences.remove(authCodeKey)
}
}
fun isAuthorizedFlow(): Flow<Boolean> {
return getAuthCode().map { it != null }
}
fun getNetworkDataSource(): NetworkDataSource = networkDataSource
} }

View File

@@ -1,18 +1,55 @@
package ru.myitschool.work.data.source package ru.myitschool.work.data.source
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.myitschool.work.core.Constants import ru.myitschool.work.core.Constants
@Serializable
data class UserInfoResponse(
val name: String,
val photoUrl: String,
val booking: Map<String, BookingInfo>
)
object NetworkDataSource { @Serializable
data class BookingInfo(
val id: Int,
val place: String
)
@Serializable
data class BookingPlace(
val id: Int,
val place: String
)
@Serializable
data class BookRequest(
val date: String,
val placeId: Int
)
interface NetworkDataSource {
suspend fun checkAuth(code: String): Result<Boolean>
suspend fun getUserInfo(code: String): Result<UserInfoResponse>
suspend fun getAvailableBookings(code: String): Result<Map<String, List<BookingPlace>>>
suspend fun createBooking(code: String, date: String, placeId: Int): Result<Boolean>
}
class NetworkDataSourceImpl : NetworkDataSource {
private val client by lazy { private val client by lazy {
HttpClient(CIO) { HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
@@ -28,7 +65,7 @@ object NetworkDataSource {
} }
} }
suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) { override suspend fun checkAuth(code: String): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching { return@withContext runCatching {
val response = client.get(getUrl(code, Constants.AUTH_URL)) val response = client.get(getUrl(code, Constants.AUTH_URL))
when (response.status) { when (response.status) {
@@ -38,5 +75,53 @@ object NetworkDataSource {
} }
} }
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}/api/$code$targetUrl"
override suspend fun getUserInfo(code: String): Result<UserInfoResponse> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get("${Constants.HOST}api/$code/info")
when (response.status) {
HttpStatusCode.OK -> response.body()
HttpStatusCode.Unauthorized -> throw Exception("Invalid auth code")
HttpStatusCode.BadRequest -> throw Exception("Bad request")
else -> throw Exception("Failed to get user info: ${response.status}")
}
}
}
override suspend fun getAvailableBookings(code: String): Result<Map<String, List<BookingPlace>>> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val response = client.get("${Constants.HOST}api/$code/booking")
when (response.status) {
HttpStatusCode.OK -> response.body()
HttpStatusCode.Unauthorized -> throw Exception("Invalid auth code")
HttpStatusCode.BadRequest -> throw Exception("Bad request")
else -> throw Exception("Failed to get available bookings: ${response.status}")
}
}
}
override suspend fun createBooking(code: String, date: String, placeId: Int): Result<Boolean> = withContext(Dispatchers.IO) {
return@withContext runCatching {
val request = BookRequest(date, placeId)
val response = client.post("${Constants.HOST}api/$code/book") {
contentType(ContentType.Application.Json)
setBody(request)
}
when (response.status) {
HttpStatusCode.Created -> true
HttpStatusCode.Conflict -> throw Exception("Already booked")
HttpStatusCode.Unauthorized -> throw Exception("Invalid auth code")
HttpStatusCode.BadRequest -> throw Exception("Bad request")
else -> throw Exception("Failed to create booking: ${response.status}")
}
}
}
private fun getUrl(code: String, targetUrl: String) = "${Constants.HOST}api/$code$targetUrl"
} }

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.domain.book
import ru.myitschool.work.data.source.BookingPlace
data class AvailableBookingDate(
val date: String,
val originalDate: String,
val places: List<BookingPlace>
)

View File

@@ -0,0 +1,36 @@
package ru.myitschool.work.domain.book
import kotlinx.coroutines.flow.first
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.source.NetworkDataSource
class CreateBookingUseCase(
private val authRepository: AuthRepository,
private val networkDataSource: NetworkDataSource
) {
suspend operator fun invoke(date: String, placeId: Int): Result<Unit> {
val code = getCurrentCode()
return if (code != null) {
try {
val result = networkDataSource.createBooking(code, date, placeId)
if (result.isSuccess && result.getOrDefault(false)) {
Result.success(Unit)
} else {
Result.failure(result.exceptionOrNull() ?: Exception("Ошибка бронирования"))
}
} catch (e: Exception) {
Result.failure(e)
}
} else {
Result.failure(Exception("Пользователь не авторизован"))
}
}
private suspend fun getCurrentCode(): String? {
return try {
authRepository.getAuthCode().first()
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,60 @@
package ru.myitschool.work.domain.book
import kotlinx.coroutines.flow.first
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.source.NetworkDataSource
import java.text.SimpleDateFormat
import java.util.Locale
class GetAvailableBookingsUseCase(
private val authRepository: AuthRepository,
private val networkDataSource: NetworkDataSource
) {
suspend operator fun invoke(): Result<List<AvailableBookingDate>> {
val code = getCurrentCode()
return if (code != null) {
try {
val response = networkDataSource.getAvailableBookings(code)
if (response.isSuccess) {
val bookingsMap = response.getOrThrow()
val availableDates = bookingsMap.entries.mapNotNull { entry ->
try {
val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val displayFormatter = SimpleDateFormat("dd.MM", Locale.getDefault())
val date = dateFormatter.parse(entry.key)
AvailableBookingDate(
date = displayFormatter.format(date),
originalDate = entry.key,
places = entry.value
)
} catch (e: Exception) {
null
}
}.sortedBy {
try {
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it.originalDate)
} catch (e: Exception) {
null
}
}
Result.success(availableDates)
} else {
Result.failure(response.exceptionOrNull() ?: Exception("Ошибка получения данных"))
}
} catch (e: Exception) {
Result.failure(e)
}
} else {
Result.failure(Exception("Пользователь не авторизован"))
}
}
private suspend fun getCurrentCode(): String? {
return try {
authRepository.getAuthCode().first()
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,82 @@
package ru.myitschool.work.domain.main
import kotlinx.coroutines.flow.first
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.data.source.NetworkDataSource
import ru.myitschool.work.ui.screen.main.BookingItem
import java.text.SimpleDateFormat
import java.util.Locale
import ru.myitschool.work.data.source.BookingInfo as SourceBookingInfo
class GetUserInfoUseCase(
private val authRepository: AuthRepository,
private val networkDataSource: NetworkDataSource
) {
suspend operator fun invoke(): Result<UserInfo> {
val code = getCurrentCode()
return if (code != null) {
try {
val response = networkDataSource.getUserInfo(code)
if (response.isSuccess) {
val userInfoResponse = response.getOrThrow()
val bookings = userInfoResponse.booking.entries.mapNotNull { entry ->
try {
val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val displayFormatter = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
val date = dateFormatter.parse(entry.key)
// Получаем место из BookingInfo
val place = when (val bookingInfo = entry.value) {
is SourceBookingInfo -> bookingInfo.place
else -> entry.value.toString()
}
BookingItem(
date = displayFormatter.format(date),
place = place,
originalDate = entry.key
)
} catch (e: Exception) {
null
}
}.sortedBy {
try {
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it.originalDate)
} catch (e: Exception) {
null
}
}
Result.success(
UserInfo(
name = userInfoResponse.name,
photoUrl = userInfoResponse.photoUrl,
bookings = bookings
)
)
} else {
Result.failure(response.exceptionOrNull() ?: Exception("Ошибка получения данных"))
}
} catch (e: Exception) {
Result.failure(e)
}
} else {
Result.failure(Exception("Пользователь не авторизован"))
}
}
private suspend fun getCurrentCode(): String? {
return try {
authRepository.getAuthCode().first()
} catch (e: Exception) {
null
}
}
}
data class UserInfo(
val name: String,
val photoUrl: String,
val bookings: List<BookingItem>
)

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.domain.main
import ru.myitschool.work.data.repo.AuthRepository
class LogoutUseCase(private val authRepository: AuthRepository) {
suspend operator fun invoke() {
authRepository.logout()
}
}

View File

@@ -1,3 +1,7 @@
package ru.myitschool.work.ui.nav package ru.myitschool.work.ui.nav
sealed interface AppDestination sealed class AppDestination(val route: String) {
object Auth : AppDestination("auth")
object Main : AppDestination("main")
object Book : AppDestination("book")
}

View File

@@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable object AuthScreenDestination {
data object AuthScreenDestination: AppDestination const val route = "auth"
}

View File

@@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable object BookScreenDestination {
data object BookScreenDestination: AppDestination const val route = "book"
}

View File

@@ -2,5 +2,6 @@ package ru.myitschool.work.ui.nav
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable object MainScreenDestination {
data object MainScreenDestination: AppDestination const val route = "main"
}

View File

@@ -6,23 +6,26 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import ru.myitschool.work.ui.screen.AppNavHost import ru.myitschool.work.AppModule
import ru.myitschool.work.ui.screen.NavigationGraph
import ru.myitschool.work.ui.theme.WorkTheme import ru.myitschool.work.ui.theme.WorkTheme
class RootActivity : ComponentActivity() { class RootActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() AppModule.init(applicationContext)
setContent { setContent {
WorkTheme { WorkTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Surface(
AppNavHost( modifier = Modifier.fillMaxSize(),
modifier = Modifier color = MaterialTheme.colorScheme.background
.fillMaxSize() ) {
.padding(innerPadding) NavigationGraph()
)
} }
} }
} }

View File

@@ -5,52 +5,116 @@ import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import ru.myitschool.work.ui.nav.AuthScreenDestination import ru.myitschool.work.AppModule
import ru.myitschool.work.ui.nav.AppDestination
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.screen.auth.AuthScreen import ru.myitschool.work.ui.screen.auth.AuthScreen
import ru.myitschool.work.ui.screen.auth.AuthViewModel
import ru.myitschool.work.ui.screen.book.BookScreen import ru.myitschool.work.ui.screen.book.BookScreen
import ru.myitschool.work.ui.screen.book.BookViewModel
import ru.myitschool.work.ui.screen.main.MainScreen
import ru.myitschool.work.ui.screen.main.MainViewModel
@Composable @Composable
fun AppNavHost( fun NavigationGraph(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController() navController: NavHostController = rememberNavController()
) { ) {
NavHost(
modifier = modifier,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None }, val isAuthorized by AppModule.authRepository.isAuthorizedFlow()
navController = navController, .collectAsState(initial = false)
startDestination = AuthScreenDestination,
) {
composable<AuthScreenDestination> { val startDestination = remember(isAuthorized) {
AuthScreen(navController = navController) if (isAuthorized) AppDestination.Main.route else AppDestination.Auth.route
} }
composable<MainScreenDestination> {
Box(
contentAlignment = Alignment.Center NavHost(
) { navController = navController,
Text(text = "MainScreen") startDestination = startDestination
) {
composable(AppDestination.Auth.route) {
val viewModel: AuthViewModel = viewModel()
val state = viewModel.uiState.collectAsState()
AuthScreen(
state = state.value,
navController = navController
//onIntent = viewModel::processIntent
)
LaunchedEffect(Unit) {
viewModel.navigation.collect { destination ->
navController.navigate(destination.route) {
popUpTo(AppDestination.Auth.route) { inclusive = true }
}
}
} }
} }
composable<BookScreenDestination> {
BookScreen( composable(AppDestination.Main.route) {
selectedDateIndex = 0, val viewModel: MainViewModel = viewModel()
onDateClick = {}, val state = viewModel.state.collectAsState()
//selectedPlaceIndex = null,
//onPlaceSelect = {}, MainScreen(
error = null, state = state.value,
isEmpty = false, onIntent = viewModel::processIntent
onRefresh = {},
onBack = { navController.popBackStack() },
onBook = {}
) )
LaunchedEffect(Unit) {
viewModel.navigation.collect { destination ->
when (destination) {
AppDestination.Auth -> {
navController.navigate(destination.route) {
popUpTo(AppDestination.Main.route) { inclusive = true }
}
}
AppDestination.Book -> {
navController.navigate(destination.route)
}
else -> {}
}
}
}
} }
composable(AppDestination.Book.route) {
val viewModel: BookViewModel = viewModel()
val state = viewModel.state.collectAsState()
BookScreen(
state = state.value,
onIntent = viewModel::processIntent
)
LaunchedEffect(Unit) {
viewModel.navigation.collect { destination ->
when (destination) {
AppDestination.Main -> {
navController.navigate(destination.route) {
popUpTo(AppDestination.Book.route) { inclusive = true }
}
}
else -> {}
}
}
}
} }
} }}

View File

@@ -31,15 +31,19 @@ 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.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.main.MainIntent
import ru.myitschool.work.ui.screen.main.MainState
@Composable @Composable
fun AuthScreen( fun AuthScreen(
viewModel: AuthViewModel = viewModel(), viewModel: AuthViewModel = viewModel(),
navController: NavController navController: NavController,
state: AuthState
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.actionFlow.collect { viewModel.actionFlow.collect {
navController.navigate(MainScreenDestination) navController.navigate(MainScreenDestination)
} }

View File

@@ -2,24 +2,31 @@ package ru.myitschool.work.ui.screen.auth
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
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
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
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.data.repo.AuthRepository import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
import ru.myitschool.work.ui.nav.AppDestination
import ru.myitschool.work.ui.screen.main.MainIntent
class AuthViewModel(application: Application) : AndroidViewModel(application) { class AuthViewModel(application: Application) : AndroidViewModel(application) {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _navigation = MutableSharedFlow<AppDestination>()
val navigation: SharedFlow<AppDestination> = _navigation.asSharedFlow()
private val repository by lazy {
AuthRepository(getApplication<Application>().applicationContext)
}
private val checkAndSaveAuthCodeUseCase by lazy {
CheckAndSaveAuthCodeUseCase(repository) }
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()
@@ -37,10 +44,12 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) {
_actionFlow.emit(Unit) _actionFlow.emit(Unit)
} }
} }
} }
fun onIntent(intent: AuthIntent) { fun onIntent(intent: AuthIntent) {
when (intent) { when (intent) {
is AuthIntent.Send -> { is AuthIntent.Send -> {
@@ -56,7 +65,8 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) {
prefs.edit() prefs.edit()
.putString("saved_code", intent.text) .putString("saved_code", intent.text)
.apply() .apply()
_actionFlow.emit(Unit) _navigation.emit(AppDestination.Main)
}, },
onFailure = { error -> onFailure = { error ->
error.printStackTrace() error.printStackTrace()

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.ui.screen.book
sealed class BookIntent {
data class DateSelected(val index: Int) : BookIntent()
data class PlaceSelected(val placeId: Int) : BookIntent()
object Book : BookIntent()
object Refresh : BookIntent()
object Back : BookIntent()
}

View File

@@ -2,6 +2,7 @@ package ru.myitschool.work.ui.screen.book
import android.R import android.R
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -15,15 +16,31 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CheckboxDefaults.colors import androidx.compose.material3.CheckboxDefaults.colors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -34,139 +51,249 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.android.material.bottomappbar.BottomAppBar
import com.google.android.material.progressindicator.CircularProgressIndicator
import ru.myitschool.work.core.TestIds import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.auth.AuthState import ru.myitschool.work.ui.screen.auth.AuthState
import ru.myitschool.work.ui.screen.auth.AuthViewModel import ru.myitschool.work.ui.screen.auth.AuthViewModel
import kotlin.collections.getOrNull
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BookScreen( fun BookScreen(
dates: List<String> = listOf("19.04", "20.04", "21.04", "22.04"), state: BookState,
selectedDateIndex: Int, onIntent: (BookIntent) -> Unit
onDateClick: (Int) -> Unit,
places: List<String> = listOf(
"Рабочее место у окна",
"Переговорная комната №1",
"Коворкинг A"
),
myColor: Color = Color(0xFF0090FF),
error: String?,
isEmpty: Boolean,
onRefresh: () -> Unit,
onBack: () -> Unit,
onBook: () -> Unit
) { ) {
if (state.showError) {
var selectedPlaceIndex by remember { mutableStateOf<Int?>(null) } Column(
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Button(
modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON),
onClick = onBack,
colors = ButtonDefaults.buttonColors( containerColor = myColor ),
)
{Text("Назад")}
Row(
modifier = Modifier modifier = Modifier
.horizontalScroll(rememberScrollState()) .fillMaxSize()
.padding(top = 16.dp) .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
dates.forEachIndexed { index, date -> Text(
Box( modifier = Modifier.testTag(TestIds.Book.ERROR),
modifier = Modifier text = state.error ?: "Ошибка загрузки",
.width(60.dp) color = MaterialTheme.colorScheme.error,
.height(40.dp) style = MaterialTheme.typography.bodyLarge
.border( )
width = 2.dp, Spacer(Modifier.height(20.dp))
color = if (index == selectedDateIndex) myColor else Color.LightGray,
shape = RoundedCornerShape(6.dp) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
) OutlinedButton(
.background(Color.White, RoundedCornerShape(6.dp)) onClick = { onIntent(BookIntent.Back) }) {
.clickable { onDateClick(index) } Text("Назад")
.padding(8.dp) }
) { Button(
Text( modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON),
text = date, onClick = { onIntent(BookIntent.Refresh) }) {
modifier = Modifier.align(Alignment.Center), Text("Обновить")
textAlign = TextAlign.Center
)
} }
Spacer(modifier = Modifier.width(8.dp))
} }
} }
return
}
Spacer(Modifier.height(20.dp)) if (state.isEmpty) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Всё забронировано",
modifier = Modifier.testTag(TestIds.Book.EMPTY),
style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(16.dp))
OutlinedButton(onClick = { onIntent(BookIntent.Back) }) {
Text("Назад")
}
}
return
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Бронирование") }
)
}
) { padding ->
if (state.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Column { LazyRow(
places.forEachIndexed { index, place -> modifier = Modifier
Row( .padding(start = 32.dp, top = 32.dp, end = 32.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.SpaceBetween, ) {
itemsIndexed(state.availableDates) { index, date ->
val isSelected = state.selectedDateIndex == index
Box(
modifier = Modifier
.testTag("book_date_pos_$index")
.clip(RoundedCornerShape(8.dp))
.border(
width = 1.dp,
color = if (isSelected)
Color(0xFF2962FF)
else
Color(0xFFBDBDBD),
shape = RoundedCornerShape(8.dp)
)
.clickable {
onIntent(BookIntent.DateSelected(index))
}
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Text(
modifier = Modifier.testTag(TestIds.Book.ITEM_DATE),
text = date.date,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
val selectedDate = state.availableDates
.getOrNull(state.selectedDateIndex)
if (selectedDate != null && selectedDate.places.isNotEmpty()) {
LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp) .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text(place) itemsIndexed(selectedDate.places) {index, place ->
val isSelected =
state.selectedPlaceId == place.id
Row(
modifier = Modifier
.testTag("book_place_pos_$index")
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.Transparent)
.clickable {
onIntent(
BookIntent.PlaceSelected(place.id)
)
}
.padding(vertical = 16.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
modifier = Modifier.testTag(TestIds.Book.ITEM_PLACE_TEXT).weight(1f),
text = place.place,
style = MaterialTheme.typography.bodyLarge,
)
Box(
modifier = Modifier
.testTag(TestIds.Book.ITEM_PLACE_SELECTOR)
.size(20.dp)
.clip(CircleShape)
.selectable(
selected = isSelected,
onClick = {
onIntent(BookIntent.PlaceSelected(place.id))
},
role = Role.RadioButton
)
.border(
2.dp,
if (isSelected)
Color(0xFF2962FF)
else
Color(0xFF9E9E9E),
CircleShape
)
) {
if (isSelected) {
Box(
modifier = Modifier
.size(10.dp)
.align(Alignment.Center)
.clip(CircleShape)
.background(Color(0xFF2962FF))
)
}
}
}
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Нет доступных мест на выбранную дату",
RadioButton(
selected = selectedPlaceIndex == index,
onClick = { selectedPlaceIndex = index },
colors = RadioButtonDefaults.colors(
selectedColor = myColor,
unselectedColor = Color.Gray
)
) )
} }
} }
} Row(
modifier = Modifier
if (error != null) { .fillMaxWidth()
Text( .padding(horizontal = 16.dp, vertical = 32.dp),
text = error, horizontalArrangement = Arrangement.SpaceBetween
color = Color.Red,
modifier = Modifier.padding(top = 8.dp).testTag(TestIds.Book.ERROR)
)
}
if (isEmpty) {
Text(
text = "Всё забронировано",
modifier = Modifier.padding(top = 12.dp).testTag(TestIds.Book.EMPTY)
)
Button(
onClick = onRefresh,
modifier = Modifier.padding(top = 12.dp).testTag(TestIds.Book.REFRESH_BUTTON),
colors = ButtonDefaults.buttonColors( containerColor = myColor )
) { ) {
Text("Обновить") OutlinedButton(
modifier = Modifier.testTag(TestIds.Book.BOOK_BUTTON),
onClick = { onIntent(BookIntent.Back) }) {
Text("Назад")
}
Button(
modifier = Modifier.testTag(TestIds.Book.BACK_BUTTON),
onClick = { onIntent(BookIntent.Book) },
enabled = state.selectedPlaceId != null
) {
Text("Забронировать")
}
} }
} }
Button(
onClick = onBook,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
colors = ButtonDefaults.buttonColors( containerColor = myColor )
) {
Text("Забронировать")
}
} }
} }

View File

@@ -0,0 +1,13 @@
package ru.myitschool.work.ui.screen.book
import ru.myitschool.work.domain.book.AvailableBookingDate
data class BookState(
val selectedDateIndex: Int = 0,
val selectedPlaceId: Int? = null,
val availableDates: List<AvailableBookingDate> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val isEmpty: Boolean = false,
val showError: Boolean = false
)

View File

@@ -1,6 +0,0 @@
package ru.myitschool.work.ui.screen.book;
import android.app.Application;
class BookViewModel {
}

View File

@@ -0,0 +1,129 @@
package ru.myitschool.work.ui.screen.book
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.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.AppModule
import ru.myitschool.work.ui.nav.AppDestination
import kotlin.collections.getOrNull
class BookViewModel : ViewModel() {
private val _state = MutableStateFlow(BookState())
val state: StateFlow<BookState> = _state.asStateFlow()
private val _navigation = MutableSharedFlow<AppDestination>()
val navigation: SharedFlow<AppDestination> = _navigation.asSharedFlow()
init {
loadAvailableBookings()
}
fun processIntent(intent: BookIntent) {
when (intent) {
is BookIntent.DateSelected -> {
_state.update {
it.copy(
selectedDateIndex = intent.index,
selectedPlaceId = null
)
}
}
is BookIntent.PlaceSelected -> {
_state.update {
it.copy(selectedPlaceId = intent.placeId)
}
}
BookIntent.Book -> {
createBooking()
}
BookIntent.Refresh -> {
loadAvailableBookings()
}
BookIntent.Back -> {
backToMain()
}
}
}
private fun loadAvailableBookings() {
viewModelScope.launch {
_state.update {
it.copy(
isLoading = true,
error = null,
showError = false
)
}
val result = AppModule.getAvailableBookingsUseCase()
if (result.isSuccess) {
val availableDates = result.getOrThrow()
_state.update {
it.copy(
isLoading = false,
availableDates = availableDates,
isEmpty = availableDates.isEmpty(),
selectedDateIndex = if (availableDates.isNotEmpty()) 0 else 0,
selectedPlaceId = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = result.exceptionOrNull()?.message ?: "Ошибка загрузки",
showError = true
)
}
}
}
}
private fun createBooking() {
val currentState = _state.value
val selectedDate = currentState.availableDates.getOrNull(currentState.selectedDateIndex)
val selectedPlaceId = currentState.selectedPlaceId
if (selectedDate == null || selectedPlaceId == null) {
_state.update { it.copy(error = "Выберите место для бронирования") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = AppModule.createBookingUseCase(
selectedDate.originalDate,
selectedPlaceId
)
if (result.isSuccess) {
_navigation.emit(AppDestination.Main)
} else {
_state.update {
it.copy(
isLoading = false,
error = result.exceptionOrNull()?.message ?: "Ошибка бронирования"
)
}
}
}
}
private fun backToMain() {
viewModelScope.launch {
_navigation.emit(AppDestination.Main)
}
}
}

View File

@@ -0,0 +1,9 @@
package ru.myitschool.work.ui.screen.main
sealed class MainIntent {
object LoadData : MainIntent()
object Logout : MainIntent()
object Refresh : MainIntent()
object NavigateToBooking : MainIntent()
data class BookingSelected(val index: Int) : MainIntent()
}

View File

@@ -0,0 +1,264 @@
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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
state: MainState,
onIntent: (MainIntent) -> Unit
) {
if (state.showError) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
modifier = Modifier.testTag(TestIds.Main.ERROR),
text = state.error ?: "Ошибка загрузки",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge
)
Spacer(Modifier.height(20.dp))
TextButton(onClick = { onIntent(MainIntent.Refresh) }) {
Text("Повторить")
}
}
}
return
}
Scaffold(
floatingActionButton = {
FloatingActionButton(
modifier = Modifier.testTag(TestIds.Main.ADD_BUTTON),
onClick = { onIntent(MainIntent.NavigateToBooking) }) {
Icon(painterResource(id = R.drawable.icon_add), contentDescription = "Бронировать")
}
}
) { padding ->
when {
state.isLoading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
item {
Card(
shape = RoundedCornerShape(20.dp),
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 100.dp)
.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val painter = rememberAsyncImagePainter(state.userPhotoUrl)
val painterState = painter.state
Box(
modifier = Modifier
.size(96.dp)
.clip(CircleShape)
) {
Image(
painter = painter,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().testTag(TestIds.Main.PROFILE_IMAGE)
)
when (painterState) {
is AsyncImagePainter.State.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
is AsyncImagePainter.State.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.surfaceVariant
)
)
}
else -> Unit
}
}
Spacer(Modifier.height(16.dp))
Text(
modifier = Modifier
.testTag(TestIds.Main.PROFILE_NAME),
text = state.userName,
style = MaterialTheme.typography.headlineMedium
)
}
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = { onIntent(MainIntent.Logout) },
modifier = Modifier.weight(1f).testTag(TestIds.Main.LOGOUT_BUTTON)
) {
Text("Выйти")
}
FilledTonalButton(
onClick = { onIntent(MainIntent.Refresh) },
modifier = Modifier.weight(1f).testTag(TestIds.Main.REFRESH_BUTTON)
) {
Text("Обновить")
}
}
}
item {
Text(
text = "Мои бронирования",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
if (state.bookings.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Пока нет бронирований",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
itemsIndexed(state.bookings) { index, booking ->
Card(
shape = RoundedCornerShape(16.dp),
modifier = Modifier
.testTag("main_book_pos_$index")
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
modifier = Modifier.testTag(TestIds.Main.ITEM_DATE),
text = booking.date,
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
Text(
modifier = Modifier.testTag(TestIds.Main.ITEM_PLACE),
text = booking.place,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
package ru.myitschool.work.ui.screen.main
data class MainState(
val isLoading: Boolean = false,
val userName: String = "",
val userPhotoUrl: String = "",
val bookings: List<BookingItem> = emptyList(),
val error: String? = null,
val showError: Boolean = false
)
data class BookingItem(
val date: String,
val place: String,
val originalDate: String
)

View File

@@ -0,0 +1,84 @@
package ru.myitschool.work.ui.screen.main
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.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.AppModule
import ru.myitschool.work.ui.nav.AppDestination
class MainViewModel : ViewModel() {
private val _state = MutableStateFlow(MainState())
val state: StateFlow<MainState> = _state.asStateFlow()
private val _navigation = MutableSharedFlow<AppDestination>()
val navigation: SharedFlow<AppDestination> = _navigation.asSharedFlow()
init {
loadUserInfo()
}
fun processIntent(intent: MainIntent) {
when (intent) {
MainIntent.LoadData -> loadUserInfo()
MainIntent.Logout -> logout()
MainIntent.Refresh -> refresh()
MainIntent.NavigateToBooking -> navigateToBooking()
is MainIntent.BookingSelected -> {
}
}
}
private fun loadUserInfo() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, showError = false) }
val result = AppModule.getUserInfoUseCase()
if (result.isSuccess) {
val userInfo = result.getOrThrow()
_state.update {
it.copy(
isLoading = false,
userName = userInfo.name,
userPhotoUrl = userInfo.photoUrl,
bookings = userInfo.bookings
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = result.exceptionOrNull()?.message ?: "Ошибка загрузки",
showError = true
)
}
}
}
}
private fun logout() {
viewModelScope.launch {
AppModule.logoutUseCase()
_navigation.emit(AppDestination.Auth)
}
}
private fun refresh() {
loadUserInfo()
}
private fun navigateToBooking() {
viewModelScope.launch {
_navigation.emit(AppDestination.Book)
}
}
}

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>