Labels + Location list
This commit is contained in:
@@ -57,10 +57,28 @@ fun NavGraph() {
|
|||||||
ItemEditScreen()
|
ItemEditScreen()
|
||||||
}
|
}
|
||||||
composable(route = Screen.LabelsList.route) {
|
composable(route = Screen.LabelsList.route) {
|
||||||
LabelsListScreen()
|
LabelsListScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onLabelClick = { labelId ->
|
||||||
|
// TODO: Navigate to a pre-filtered inventory list screen
|
||||||
|
navController.navigate(Screen.InventoryList.route)
|
||||||
|
},
|
||||||
|
onAddNewLabelClick = {
|
||||||
|
// TODO: Navigate to a screen for creating a new label
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(route = Screen.LocationsList.route) {
|
composable(route = Screen.LocationsList.route) {
|
||||||
LocationsListScreen()
|
LocationsListScreen(
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
onLocationClick = { locationId ->
|
||||||
|
// TODO: Navigate to a pre-filtered inventory list screen
|
||||||
|
navController.navigate(Screen.InventoryList.route)
|
||||||
|
},
|
||||||
|
onAddNewLocationClick = {
|
||||||
|
// TODO: Navigate to a screen for creating a new location
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(route = Screen.Search.route) {
|
composable(route = Screen.Search.route) {
|
||||||
SearchScreen()
|
SearchScreen()
|
||||||
|
|||||||
@@ -1,22 +1,172 @@
|
|||||||
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
||||||
// [FILE] LabelsListScreen.kt
|
// [FILE] LabelsListScreen.kt
|
||||||
|
// [SEMANTICS] ui, screen, labels_list, compose
|
||||||
package com.homebox.lens.ui.screen.labelslist
|
package com.homebox.lens.ui.screen.labelslist
|
||||||
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.homebox.lens.R
|
||||||
|
import com.homebox.lens.domain.model.LabelOut
|
||||||
|
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||||
|
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary Главная Composable-функция для экрана списка меток.
|
||||||
|
* @param onNavigateBack Функция для навигации на предыдущий экран.
|
||||||
|
* @param onLabelClick Функция, вызываемая при нажатии на метку.
|
||||||
|
* @param onAddNewLabelClick Функция, вызываемая при нажатии на FAB для добавления новой метки.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LabelsListScreen() {
|
fun LabelsListScreen(
|
||||||
// [ACTION]
|
viewModel: LabelsListViewModel = hiltViewModel(),
|
||||||
Text(text = "Labels List Screen")
|
onNavigateBack: () -> Unit,
|
||||||
|
onLabelClick: (String) -> Unit,
|
||||||
|
onAddNewLabelClick: () -> Unit
|
||||||
|
) {
|
||||||
|
// [STATE]
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
// [UI]
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(id = R.string.nav_labels)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(id = R.string.cd_navigate_back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = onAddNewLabelClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(id = R.string.cd_add_new_label)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
// [ANCHOR] Основной контент в зависимости от состояния
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
when (val state = uiState) {
|
||||||
|
is LabelsListUiState.Loading -> {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
is LabelsListUiState.Error -> {
|
||||||
|
Text(
|
||||||
|
text = state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.align(Alignment.Center).padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is LabelsListUiState.Success -> {
|
||||||
|
LabelsList(
|
||||||
|
labels = state.labels,
|
||||||
|
onLabelClick = onLabelClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
// [HELPER]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary Отображает список меток.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LabelsListScreenPreview() {
|
private fun LabelsList(
|
||||||
LabelsListScreen()
|
labels: List<LabelOut>,
|
||||||
|
onLabelClick: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(labels) { label ->
|
||||||
|
LabelListItem(
|
||||||
|
label = label,
|
||||||
|
onClick = { onLabelClick(label.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// [END_FILE_LabelsListScreen.kt]
|
|
||||||
|
// [HELPER]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary Отображает один элемент списка меток.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LabelListItem(
|
||||||
|
label: LabelOut,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(label.name) },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(onClick = onClick)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [PREVIEW]
|
||||||
|
@Preview(showBackground = true, name = "Labels List Success")
|
||||||
|
@Composable
|
||||||
|
private fun LabelsListScreenSuccessPreview() {
|
||||||
|
val labels = listOf(
|
||||||
|
LabelOut("1", "Electronics", "#FF0000", false, "", ""),
|
||||||
|
LabelOut("2", "Books", "#00FF00", false, "", ""),
|
||||||
|
LabelOut("3", "Winter Clothes", "#0000FF", false, "", "")
|
||||||
|
)
|
||||||
|
HomeboxLensTheme {
|
||||||
|
LabelsList(labels = labels, onLabelClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [PREVIEW]
|
||||||
|
@Preview(showBackground = true, name = "Labels List Empty")
|
||||||
|
@Composable
|
||||||
|
private fun LabelsListScreenEmptyPreview() {
|
||||||
|
HomeboxLensTheme {
|
||||||
|
LabelsList(labels = emptyList(), onLabelClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [END_FILE_LabelsListScreen.kt]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
||||||
|
// [FILE] LabelsListUiState.kt
|
||||||
|
// [SEMANTICS] ui, state, labels_list
|
||||||
|
package com.homebox.lens.ui.screen.labelslist
|
||||||
|
|
||||||
|
import com.homebox.lens.domain.model.LabelOut
|
||||||
|
|
||||||
|
// [CORE-LOGIC]
|
||||||
|
// [ENTITY: SealedInterface('LabelsListUiState')]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Определяет все возможные состояния для экрана "Список меток".
|
||||||
|
* @invariant В любой момент времени экран может находиться только в одном из этих состояний.
|
||||||
|
*/
|
||||||
|
sealed interface LabelsListUiState {
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Состояние успешной загрузки данных.
|
||||||
|
* @property labels Список меток.
|
||||||
|
*/
|
||||||
|
data class Success(val labels: List<LabelOut>) : LabelsListUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Состояние ошибки во время загрузки данных.
|
||||||
|
* @property message Человекочитаемое сообщение об ошибке.
|
||||||
|
*/
|
||||||
|
data class Error(val message: String) : LabelsListUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Состояние, когда данные для экрана загружаются.
|
||||||
|
*/
|
||||||
|
data object Loading : LabelsListUiState
|
||||||
|
}
|
||||||
|
// [END_FILE_LabelsListUiState.kt]
|
||||||
@@ -1,16 +1,73 @@
|
|||||||
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
||||||
// [FILE] LabelsListViewModel.kt
|
// [FILE] LabelsListViewModel.kt
|
||||||
|
// [SEMANTICS] ui_logic, labels_list, state_management
|
||||||
package com.homebox.lens.ui.screen.labelslist
|
package com.homebox.lens.ui.screen.labelslist
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
|
// [ENTITY: ViewModel('LabelsListViewModel')]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary ViewModel для экрана со списком меток.
|
||||||
|
* @description Управляет состоянием экрана, загружает список меток и обрабатывает ошибки.
|
||||||
|
* @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`.
|
||||||
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LabelsListViewModel @Inject constructor() : ViewModel() {
|
class LabelsListViewModel @Inject constructor(
|
||||||
|
private val getAllLabelsUseCase: GetAllLabelsUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
// [STATE]
|
// [STATE]
|
||||||
// TODO: Implement UI state
|
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
// [LIFECYCLE_HANDLER]
|
||||||
|
init {
|
||||||
|
loadLabels()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary Загружает список меток.
|
||||||
|
* @description Выполняет `GetAllLabelsUseCase` и обновляет UI, переключая его
|
||||||
|
* между состояниями `Loading`, `Success` и `Error`.
|
||||||
|
* @sideeffect Асинхронно обновляет `_uiState`.
|
||||||
|
*/
|
||||||
|
fun loadLabels() {
|
||||||
|
// [ENTRYPOINT]
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = LabelsListUiState.Loading
|
||||||
|
Timber.i("[ACTION] Starting labels list load. State -> Loading.")
|
||||||
|
|
||||||
|
// [CORE-LOGIC]
|
||||||
|
val result = runCatching {
|
||||||
|
getAllLabelsUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [RESULT_HANDLER]
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { labels ->
|
||||||
|
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labels.size}. State -> Success.")
|
||||||
|
_uiState.value = LabelsListUiState.Success(labels)
|
||||||
|
},
|
||||||
|
onFailure = { exception ->
|
||||||
|
Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.")
|
||||||
|
_uiState.value = LabelsListUiState.Error(
|
||||||
|
message = exception.message ?: "Could not load labels."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [END_CLASS_LabelsListViewModel]
|
||||||
}
|
}
|
||||||
// [END_FILE_LabelsListViewModel.kt]
|
// [END_FILE_LabelsListViewModel.kt]
|
||||||
@@ -1,22 +1,175 @@
|
|||||||
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
|
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
|
||||||
// [FILE] LocationsListScreen.kt
|
// [FILE] LocationsListScreen.kt
|
||||||
|
// [SEMANTICS] ui, screen, locations_list, compose
|
||||||
package com.homebox.lens.ui.screen.locationslist
|
package com.homebox.lens.ui.screen.locationslist
|
||||||
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.homebox.lens.R
|
||||||
|
import com.homebox.lens.domain.model.LocationOutCount
|
||||||
|
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||||
|
|
||||||
// [ENTRYPOINT]
|
// [ENTRYPOINT]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary Главная Composable-функция для экрана списка локаций.
|
||||||
|
* @param onNavigateBack Функция для навигации на предыдущий экран.
|
||||||
|
* @param onLocationClick Функция, вызываемая при нажатии на локацию.
|
||||||
|
* @param onAddNewLocationClick Функция, вызываемая при нажатии на FAB для добавления новой локации.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LocationsListScreen() {
|
fun LocationsListScreen(
|
||||||
// [ACTION]
|
viewModel: LocationsListViewModel = hiltViewModel(),
|
||||||
Text(text = "Locations List Screen")
|
onNavigateBack: () -> Unit,
|
||||||
|
onLocationClick: (String) -> Unit,
|
||||||
|
onAddNewLocationClick: () -> Unit
|
||||||
|
) {
|
||||||
|
// [STATE]
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
// [UI]
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(id = R.string.nav_locations)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(id = R.string.cd_navigate_back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = onAddNewLocationClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = stringResource(id = R.string.cd_add_new_location)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
// [ANCHOR] Основной контент в зависимости от состояния
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
when (val state = uiState) {
|
||||||
|
is LocationsListUiState.Loading -> {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
is LocationsListUiState.Error -> {
|
||||||
|
Text(
|
||||||
|
text = state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.align(Alignment.Center).padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is LocationsListUiState.Success -> {
|
||||||
|
LocationsList(
|
||||||
|
locations = state.locations,
|
||||||
|
onLocationClick = onLocationClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
// [HELPER]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary Отображает список локаций.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LocationsListScreenPreview() {
|
private fun LocationsList(
|
||||||
LocationsListScreen()
|
locations: List<LocationOutCount>,
|
||||||
|
onLocationClick: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
items(locations) { location ->
|
||||||
|
LocationListItem(
|
||||||
|
location = location,
|
||||||
|
onClick = { onLocationClick(location.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// [END_FILE_LocationsListScreen.kt]
|
|
||||||
|
// [HELPER]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary Отображает один элемент списка локаций.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LocationListItem(
|
||||||
|
location: LocationOutCount,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(location.name) },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Place,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Text(text = location.itemCount.toString())
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(onClick = onClick)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// [PREVIEW]
|
||||||
|
@Preview(showBackground = true, name = "Locations List Success")
|
||||||
|
@Composable
|
||||||
|
private fun LocationsListScreenSuccessPreview() {
|
||||||
|
val locations = listOf(
|
||||||
|
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
|
||||||
|
LocationOutCount("2", "Kitchen", "#00FF00", false, 3, "", ""),
|
||||||
|
LocationOutCount("3", "Office", "#0000FF", false, 25, "", "")
|
||||||
|
)
|
||||||
|
HomeboxLensTheme {
|
||||||
|
LocationsList(locations = locations, onLocationClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [PREVIEW]
|
||||||
|
@Preview(showBackground = true, name = "Locations List Empty")
|
||||||
|
@Composable
|
||||||
|
private fun LocationsListScreenEmptyPreview() {
|
||||||
|
HomeboxLensTheme {
|
||||||
|
LocationsList(locations = emptyList(), onLocationClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [END_FILE_LocationsListScreen.kt]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
|
||||||
|
// [FILE] LocationsListUiState.kt
|
||||||
|
// [SEMANTICS] ui, state, locations_list
|
||||||
|
package com.homebox.lens.ui.screen.locationslist
|
||||||
|
|
||||||
|
import com.homebox.lens.domain.model.LocationOutCount
|
||||||
|
|
||||||
|
// [CORE-LOGIC]
|
||||||
|
// [ENTITY: SealedInterface('LocationsListUiState')]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Определяет все возможные состояния для экрана "Список локаций".
|
||||||
|
* @invariant В любой момент времени экран может находиться только в одном из этих состояний.
|
||||||
|
*/
|
||||||
|
sealed interface LocationsListUiState {
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Состояние успешной загрузки данных.
|
||||||
|
* @property locations Список локаций со счетчиками.
|
||||||
|
*/
|
||||||
|
data class Success(val locations: List<LocationOutCount>) : LocationsListUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Состояние ошибки во время загрузки данных.
|
||||||
|
* @property message Человекочитаемое сообщение об ошибке.
|
||||||
|
*/
|
||||||
|
data class Error(val message: String) : LocationsListUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* Состояние, когда данные для экрана загружаются.
|
||||||
|
*/
|
||||||
|
data object Loading : LocationsListUiState
|
||||||
|
}
|
||||||
|
// [END_FILE_LocationsListUiState.kt]
|
||||||
@@ -1,16 +1,73 @@
|
|||||||
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
|
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
|
||||||
// [FILE] LocationsListViewModel.kt
|
// [FILE] LocationsListViewModel.kt
|
||||||
|
// [SEMANTICS] ui_logic, locations_list, state_management
|
||||||
package com.homebox.lens.ui.screen.locationslist
|
package com.homebox.lens.ui.screen.locationslist
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
// [VIEWMODEL]
|
// [VIEWMODEL]
|
||||||
|
// [ENTITY: ViewModel('LocationsListViewModel')]
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary ViewModel для экрана со списком локаций.
|
||||||
|
* @description Управляет состоянием экрана, загружает список локаций и обрабатывает ошибки.
|
||||||
|
* @invariant `uiState` всегда является одним из состояний, определенных в `LocationsListUiState`.
|
||||||
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LocationsListViewModel @Inject constructor() : ViewModel() {
|
class LocationsListViewModel @Inject constructor(
|
||||||
|
private val getAllLocationsUseCase: GetAllLocationsUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
// [STATE]
|
// [STATE]
|
||||||
// TODO: Implement UI state
|
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
// [LIFECYCLE_HANDLER]
|
||||||
|
init {
|
||||||
|
loadLocations()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [CONTRACT]
|
||||||
|
* @summary Загружает список локаций.
|
||||||
|
* @description Выполняет `GetAllLocationsUseCase` и обновляет UI, переключая его
|
||||||
|
* между состояниями `Loading`, `Success` и `Error`.
|
||||||
|
* @sideeffect Асинхронно обновляет `_uiState`.
|
||||||
|
*/
|
||||||
|
fun loadLocations() {
|
||||||
|
// [ENTRYPOINT]
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = LocationsListUiState.Loading
|
||||||
|
Timber.i("[ACTION] Starting locations list load. State -> Loading.")
|
||||||
|
|
||||||
|
// [CORE-LOGIC]
|
||||||
|
val result = runCatching {
|
||||||
|
getAllLocationsUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [RESULT_HANDLER]
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { locations ->
|
||||||
|
Timber.i("[SUCCESS] Locations loaded successfully. Count: ${locations.size}. State -> Success.")
|
||||||
|
_uiState.value = LocationsListUiState.Success(locations)
|
||||||
|
},
|
||||||
|
onFailure = { exception ->
|
||||||
|
Timber.e(exception, "[ERROR] Failed to load locations. State -> Error.")
|
||||||
|
_uiState.value = LocationsListUiState.Error(
|
||||||
|
message = exception.message ?: "Could not load locations."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [END_CLASS_LocationsListViewModel]
|
||||||
}
|
}
|
||||||
// [END_FILE_LocationsListViewModel.kt]
|
// [END_FILE_LocationsListViewModel.kt]
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
<!-- Content Descriptions -->
|
<!-- Content Descriptions -->
|
||||||
<string name="cd_open_navigation_drawer">Open navigation drawer</string>
|
<string name="cd_open_navigation_drawer">Open navigation drawer</string>
|
||||||
<string name="cd_scan_qr_code">Scan QR code</string>
|
<string name="cd_scan_qr_code">Scan QR code</string>
|
||||||
|
<string name="cd_navigate_back">Navigate back</string>
|
||||||
|
<string name="cd_add_new_location">Add new location</string>
|
||||||
|
<string name="cd_add_new_label">Add new label</string>
|
||||||
|
|
||||||
<!-- Dashboard Screen -->
|
<!-- Dashboard Screen -->
|
||||||
<string name="dashboard_title">Dashboard</string>
|
<string name="dashboard_title">Dashboard</string>
|
||||||
@@ -28,5 +31,6 @@
|
|||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<string name="nav_locations">Locations</string>
|
<string name="nav_locations">Locations</string>
|
||||||
|
<string name="nav_labels">Labels</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
<!-- Content Descriptions -->
|
<!-- Content Descriptions -->
|
||||||
<string name="cd_open_navigation_drawer">Открыть боковое меню</string>
|
<string name="cd_open_navigation_drawer">Открыть боковое меню</string>
|
||||||
<string name="cd_scan_qr_code">Сканировать QR-код</string>
|
<string name="cd_scan_qr_code">Сканировать QR-код</string>
|
||||||
|
<string name="cd_navigate_back">Вернуться назад</string>
|
||||||
|
<string name="cd_add_new_location">Добавить новую локацию</string>
|
||||||
|
<string name="cd_add_new_label">Добавить новую метку</string>
|
||||||
|
|
||||||
<!-- Dashboard Screen -->
|
<!-- Dashboard Screen -->
|
||||||
<string name="dashboard_title">Главная</string>
|
<string name="dashboard_title">Главная</string>
|
||||||
@@ -28,5 +31,6 @@
|
|||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<string name="nav_locations">Локации</string>
|
<string name="nav_locations">Локации</string>
|
||||||
|
<string name="nav_labels">Метки</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
@@ -231,6 +231,44 @@
|
|||||||
</USER_INTERACTIONS>
|
</USER_INTERACTIONS>
|
||||||
</SCREEN>
|
</SCREEN>
|
||||||
<!-- [ЯКОРЬ] Конец спецификации UI для экрана Локаций. -->
|
<!-- [ЯКОРЬ] Конец спецификации UI для экрана Локаций. -->
|
||||||
|
|
||||||
|
<!-- [ЯКОРЬ] Начало спецификации UI для экрана Меток. -->
|
||||||
|
<SCREEN id="screen_labels_list" status="defined">
|
||||||
|
<summary>Экран "Метки"</summary>
|
||||||
|
<description>
|
||||||
|
Отображает вертикальный список всех доступных меток. Экран должен быть интегрирован в общую структуру навигации приложения.
|
||||||
|
</description>
|
||||||
|
<LAYOUT>
|
||||||
|
<COMPONENT type="TopAppBar">
|
||||||
|
<description>Общая верхняя панель приложения с заголовком "Метки" и кнопкой "назад".</description>
|
||||||
|
</COMPONENT>
|
||||||
|
<COMPONENT type="MainContent" orientation="vertical">
|
||||||
|
<description>Основная область контента, занимающая все доступное пространство под TopAppBar.</description>
|
||||||
|
<SUB_COMPONENT type="List" name="LabelsList">
|
||||||
|
<description>Вертикальный, прокручиваемый список (LazyColumn) всех меток.</description>
|
||||||
|
<ELEMENT type="ListItem">
|
||||||
|
<description>Элемент списка, представляющий одну метку. Состоит из иконки (например, 'label') и названия метки. Весь элемент является кликабельным и ведет на экран со списком предметов с данной меткой.</description>
|
||||||
|
</ELEMENT>
|
||||||
|
</SUB_COMPONENT>
|
||||||
|
</COMPONENT>
|
||||||
|
<COMPONENT type="FloatingActionButton" icon="add">
|
||||||
|
<description>
|
||||||
|
Плавающая кнопка действия, расположенная в правом нижнем углу. Позволяет пользователю добавить новую метку.
|
||||||
|
</description>
|
||||||
|
</COMPONENT>
|
||||||
|
</LAYOUT>
|
||||||
|
<USER_INTERACTIONS>
|
||||||
|
<INTERACTION>
|
||||||
|
<action>Нажатие на элемент списка меток</action>
|
||||||
|
<reaction>Осуществляется навигация на экран списка инвентаря, отфильтрованного по выбранной метке.</reaction>
|
||||||
|
</INTERACTION>
|
||||||
|
<INTERACTION>
|
||||||
|
<action>Нажатие на FloatingActionButton</action>
|
||||||
|
<reaction>Открывается диалоговое окно или новый экран для создания новой метки.</reaction>
|
||||||
|
</INTERACTION>
|
||||||
|
</USER_INTERACTIONS>
|
||||||
|
</SCREEN>
|
||||||
|
<!-- [ЯКОРЬ] Конец спецификации UI для экрана Меток. -->
|
||||||
|
|
||||||
</UI_SPECIFICATIONS>
|
</UI_SPECIFICATIONS>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user