Compare commits

...

6 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
88ccddcdbb Add bookscreen#2 2025-12-05 18:59:45 +07:00
262573b71e Add bookscreen 2025-12-05 18:52:09 +07:00
b81bc48de5 Add save code 2025-12-05 15:40:51 +07:00
d0d2e1f849 Autorization screen 2025-12-03 19:09:08 +07:00
30 changed files with 1421 additions and 65 deletions

View File

@@ -35,6 +35,11 @@ android {
}
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()
implementation("androidx.datastore:datastore-preferences:1.1.7")
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.content.Context
import ru.myitschool.work.data.repo.AuthRepository
class App: Application() {
override fun onCreate() {
super.onCreate()
context = this
}
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
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 INFO_URL = "/info"
const val BOOKING_URL = "/booking"
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
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.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
suspend fun checkAndSave(text: String): Result<Boolean> {
return NetworkDataSource.checkAuth(text).onSuccess { success ->
return networkDataSource.checkAuth(text).onSuccess { success ->
if (success) {
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
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
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 {
HttpClient(CIO) {
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 {
val response = client.get(getUrl(code, Constants.AUTH_URL))
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
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
@Serializable
data object AuthScreenDestination: AppDestination
object AuthScreenDestination {
const val route = "auth"
}

View File

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

View File

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

View File

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

View File

@@ -5,45 +5,116 @@ import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import ru.myitschool.work.ui.nav.AuthScreenDestination
import ru.myitschool.work.AppModule
import ru.myitschool.work.ui.nav.AppDestination
import ru.myitschool.work.ui.nav.BookScreenDestination
import ru.myitschool.work.ui.nav.MainScreenDestination
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.BookViewModel
import ru.myitschool.work.ui.screen.main.MainScreen
import ru.myitschool.work.ui.screen.main.MainViewModel
@Composable
fun AppNavHost(
modifier: Modifier = Modifier,
fun NavigationGraph(
navController: NavHostController = rememberNavController()
) {
NavHost(
modifier = modifier,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
val isAuthorized by AppModule.authRepository.isAuthorizedFlow()
.collectAsState(initial = false)
val startDestination = remember(isAuthorized) {
if (isAuthorized) AppDestination.Main.route else AppDestination.Auth.route
}
NavHost(
navController = navController,
startDestination = AuthScreenDestination,
startDestination = startDestination
) {
composable<AuthScreenDestination> {
AuthScreen(navController = navController)
}
composable<MainScreenDestination> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
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> {
Box(
contentAlignment = Alignment.Center
) {
Text(text = "Hello")
composable(AppDestination.Main.route) {
val viewModel: MainViewModel = viewModel()
val state = viewModel.state.collectAsState()
MainScreen(
state = state.value,
onIntent = viewModel::processIntent
)
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,20 +31,27 @@ import androidx.navigation.NavController
import ru.myitschool.work.R
import ru.myitschool.work.core.TestIds
import ru.myitschool.work.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.main.MainIntent
import ru.myitschool.work.ui.screen.main.MainState
@Composable
fun AuthScreen(
viewModel: AuthViewModel = viewModel(),
navController: NavController
navController: NavController,
state: AuthState
) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
LaunchedEffect(Unit) {
viewModel.actionFlow.collect {
navController.navigate(MainScreenDestination)
}
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -52,11 +59,7 @@ fun AuthScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.auth_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
when (val currentState = state) {
is AuthState.Data -> Content(viewModel, currentState)
is AuthState.Loading -> {
@@ -64,6 +67,7 @@ fun AuthScreen(
modifier = Modifier.size(64.dp)
)
}
is AuthState.Error -> Content(viewModel, currentState)
}
}
}
@@ -71,15 +75,23 @@ fun AuthScreen(
@Composable
private fun Content(
viewModel: AuthViewModel,
state: AuthState.Data
state: AuthState
) {
var inputText by remember { mutableStateOf("") }
val inputText = when (state) {
is AuthState.Data -> state.text
is AuthState.Error -> state.text
else -> "" }
Text(
text = stringResource(R.string.auth_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.size(16.dp))
TextField(
modifier = Modifier.testTag(TestIds.Auth.CODE_INPUT).fillMaxWidth(),
value = inputText,
onValueChange = {
inputText = it
viewModel.onIntent(AuthIntent.TextInput(it))
},
label = { Text(stringResource(R.string.auth_label)) }
@@ -89,9 +101,20 @@ private fun Content(
modifier = Modifier.testTag(TestIds.Auth.SIGN_BUTTON).fillMaxWidth(),
onClick = {
viewModel.onIntent(AuthIntent.Send(inputText))
},
enabled = true
) {
enabled = inputText.length == 4 && !inputText.isNullOrEmpty() && inputText.matches("[a-zA-Z0-9]+".toRegex()),
) {
Text(stringResource(R.string.auth_sign_in))
}
}
Spacer(modifier = Modifier.size(24.dp))
if (state is AuthState.Error) {
Text(
modifier = Modifier.testTag(TestIds.Auth.ERROR),
text = state.message,
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
color = Color.Red
)
}
}

View File

@@ -2,5 +2,6 @@ package ru.myitschool.work.ui.screen.auth
sealed interface AuthState {
object Loading: AuthState
object Data: AuthState
class Data(val text: String = ""): AuthState
data class Error(val text: String, val message: String) : AuthState
}

View File

@@ -1,43 +1,88 @@
package ru.myitschool.work.ui.screen.auth
import androidx.lifecycle.ViewModel
import android.app.Application
import android.content.Context
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.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.myitschool.work.data.repo.AuthRepository
import ru.myitschool.work.domain.auth.CheckAndSaveAuthCodeUseCase
import ru.myitschool.work.ui.nav.AppDestination
import ru.myitschool.work.ui.screen.main.MainIntent
class AuthViewModel : ViewModel() {
private val checkAndSaveAuthCodeUseCase by lazy { CheckAndSaveAuthCodeUseCase(AuthRepository) }
private val _uiState = MutableStateFlow<AuthState>(AuthState.Data)
class AuthViewModel(application: Application) : AndroidViewModel(application) {
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())
val uiState: StateFlow<AuthState> = _uiState.asStateFlow()
private val _actionFlow: MutableSharedFlow<Unit> = MutableSharedFlow()
val actionFlow: SharedFlow<Unit> = _actionFlow
private val context = getApplication<Application>().applicationContext
private val prefs by lazy {
context.getSharedPreferences("auth", Context.MODE_PRIVATE)
}
init {
val savedCode = prefs.getString("saved_code", null)
if (!savedCode.isNullOrEmpty()) {
viewModelScope.launch {
_actionFlow.emit(Unit)
}
}
}
fun onIntent(intent: AuthIntent) {
when (intent) {
is AuthIntent.Send -> {
viewModelScope.launch(Dispatchers.Default) {
val currentText = when (val s = _uiState.value) {
is AuthState.Data -> s.text
is AuthState.Error -> s.text
else -> ""
}
_uiState.update { AuthState.Loading }
checkAndSaveAuthCodeUseCase.invoke("9999").fold(
checkAndSaveAuthCodeUseCase.invoke(intent.text).fold(
onSuccess = {
_actionFlow.emit(Unit)
prefs.edit()
.putString("saved_code", intent.text)
.apply()
_navigation.emit(AppDestination.Main)
},
onFailure = { error ->
error.printStackTrace()
_actionFlow.emit(Unit)
_uiState.value = AuthState.Error(text = currentText,error.message ?: "Ошибка")
}
)
}
}
is AuthIntent.TextInput -> Unit
is AuthIntent.TextInput -> {
when (val stateValue = _uiState.value) {
is AuthState.Data -> _uiState.value = AuthState.Data(text = intent.text)
is AuthState.Error -> _uiState.value = AuthState.Data(intent.text)
is AuthState.Loading -> Unit
}
}
}
}
}

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

@@ -0,0 +1,299 @@
package ru.myitschool.work.ui.screen.book
import android.R
import androidx.annotation.ColorRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.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.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
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.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.RadioButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
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.ui.nav.MainScreenDestination
import ru.myitschool.work.ui.screen.auth.AuthState
import ru.myitschool.work.ui.screen.auth.AuthViewModel
import kotlin.collections.getOrNull
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookScreen(
state: BookState,
onIntent: (BookIntent) -> Unit
) {
if (state.showError) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
modifier = Modifier.testTag(TestIds.Book.ERROR),
text = state.error ?: "Ошибка загрузки",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge
)
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = { onIntent(BookIntent.Back) }) {
Text("Назад")
}
Button(
modifier = Modifier.testTag(TestIds.Book.REFRESH_BUTTON),
onClick = { onIntent(BookIntent.Refresh) }) {
Text("Обновить")
}
}
}
return
}
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)
) {
LazyRow(
modifier = Modifier
.padding(start = 32.dp, top = 32.dp, end = 32.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
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
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
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 = "Нет доступных мест на выбранную дату",
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
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("Забронировать")
}
}
}
}
}

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

@@ -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>

View File

@@ -7,4 +7,5 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="primary_blue">#517DFF</color>
</resources>