Labels + Location list

This commit is contained in:
2025-08-09 11:53:33 +03:00
parent 8db12a7599
commit c69f255fff
10 changed files with 581 additions and 28 deletions

View File

@@ -57,10 +57,28 @@ fun NavGraph() {
ItemEditScreen()
}
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) {
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) {
SearchScreen()

View File

@@ -1,22 +1,172 @@
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
// [FILE] LabelsListScreen.kt
// [SEMANTICS] ui, screen, labels_list, compose
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.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.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]
/**
* [CONTRACT]
* @summary Главная Composable-функция для экрана списка меток.
* @param onNavigateBack Функция для навигации на предыдущий экран.
* @param onLabelClick Функция, вызываемая при нажатии на метку.
* @param onAddNewLabelClick Функция, вызываемая при нажатии на FAB для добавления новой метки.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelsListScreen() {
// [ACTION]
Text(text = "Labels List Screen")
fun LabelsListScreen(
viewModel: LabelsListViewModel = hiltViewModel(),
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
fun LabelsListScreenPreview() {
LabelsListScreen()
private fun LabelsList(
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]

View File

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

View File

@@ -1,16 +1,73 @@
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
// [FILE] LabelsListViewModel.kt
// [SEMANTICS] ui_logic, labels_list, state_management
package com.homebox.lens.ui.screen.labelslist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
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
// [VIEWMODEL]
// [ENTITY: ViewModel('LabelsListViewModel')]
/**
* [CONTRACT]
* @summary ViewModel для экрана со списком меток.
* @description Управляет состоянием экрана, загружает список меток и обрабатывает ошибки.
* @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`.
*/
@HiltViewModel
class LabelsListViewModel @Inject constructor() : ViewModel() {
class LabelsListViewModel @Inject constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase
) : ViewModel() {
// [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]

View File

@@ -1,22 +1,175 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListScreen.kt
// [SEMANTICS] ui, screen, locations_list, compose
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.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.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]
/**
* [CONTRACT]
* @summary Главная Composable-функция для экрана списка локаций.
* @param onNavigateBack Функция для навигации на предыдущий экран.
* @param onLocationClick Функция, вызываемая при нажатии на локацию.
* @param onAddNewLocationClick Функция, вызываемая при нажатии на FAB для добавления новой локации.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationsListScreen() {
// [ACTION]
Text(text = "Locations List Screen")
fun LocationsListScreen(
viewModel: LocationsListViewModel = hiltViewModel(),
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
fun LocationsListScreenPreview() {
LocationsListScreen()
private fun LocationsList(
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]

View File

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

View File

@@ -1,16 +1,73 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListViewModel.kt
// [SEMANTICS] ui_logic, locations_list, state_management
package com.homebox.lens.ui.screen.locationslist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
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
// [VIEWMODEL]
// [ENTITY: ViewModel('LocationsListViewModel')]
/**
* [CONTRACT]
* @summary ViewModel для экрана со списком локаций.
* @description Управляет состоянием экрана, загружает список локаций и обрабатывает ошибки.
* @invariant `uiState` всегда является одним из состояний, определенных в `LocationsListUiState`.
*/
@HiltViewModel
class LocationsListViewModel @Inject constructor() : ViewModel() {
class LocationsListViewModel @Inject constructor(
private val getAllLocationsUseCase: GetAllLocationsUseCase
) : ViewModel() {
// [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]

View File

@@ -12,6 +12,9 @@
<!-- Content Descriptions -->
<string name="cd_open_navigation_drawer">Open navigation drawer</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 -->
<string name="dashboard_title">Dashboard</string>
@@ -28,5 +31,6 @@
<!-- Navigation -->
<string name="nav_locations">Locations</string>
<string name="nav_labels">Labels</string>
</resources>

View File

@@ -12,6 +12,9 @@
<!-- Content Descriptions -->
<string name="cd_open_navigation_drawer">Открыть боковое меню</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 -->
<string name="dashboard_title">Главная</string>
@@ -28,5 +31,6 @@
<!-- Navigation -->
<string name="nav_locations">Локации</string>
<string name="nav_labels">Метки</string>
</resources>