add location screen

This commit is contained in:
2025-08-11 16:04:04 +03:00
parent a69c5d95ae
commit a71279d450
8 changed files with 369 additions and 56 deletions

View File

@@ -18,6 +18,7 @@ import com.homebox.lens.ui.screen.inventorylist.InventoryListScreen
import com.homebox.lens.ui.screen.itemdetails.ItemDetailsScreen
import com.homebox.lens.ui.screen.itemedit.ItemEditScreen
import com.homebox.lens.ui.screen.labelslist.LabelsListScreen
import com.homebox.lens.ui.screen.locationedit.LocationEditScreen
import com.homebox.lens.ui.screen.locationslist.LocationsListScreen
import com.homebox.lens.ui.screen.search.SearchScreen
import com.homebox.lens.ui.screen.setup.SetupScreen
@@ -109,10 +110,17 @@ fun NavGraph(
navController.navigate(Screen.InventoryList.route)
},
onAddNewLocationClick = {
// TODO: Navigate to a screen for creating a new location
navController.navigate(Screen.LocationEdit.createRoute("new"))
}
)
}
// [COMPOSABLE_LOCATION_EDIT]
composable(route = Screen.LocationEdit.route) { backStackEntry ->
val locationId = backStackEntry.arguments?.getString("locationId")
LocationEditScreen(
locationId = locationId
)
}
// [COMPOSABLE_SEARCH]
composable(route = Screen.Search.route) {
SearchScreen(

View File

@@ -56,6 +56,25 @@ sealed class Screen(val route: String) {
}
data object LabelsList : Screen("labels_list_screen")
data object LocationsList : Screen("locations_list_screen")
data object LocationEdit : Screen("location_edit_screen/{locationId}") {
/**
* [CONTRACT]
* Создает маршрут для экрана редактирования местоположения с указанным ID.
* @param locationId ID местоположения для редактирования.
* @return Строку полного маршрута.
* @throws IllegalArgumentException если locationId пустой.
*/
// [HELPER]
fun createRoute(locationId: String): String {
// [PRECONDITION]
require(locationId.isNotBlank()) { "[PRECONDITION_FAILED] locationId не может быть пустым." }
// [ACTION]
val route = "location_edit_screen/$locationId"
// [POSTCONDITION]
check(route.endsWith(locationId)) { "[POSTCONDITION_FAILED] Маршрут должен заканчиваться на locationId." }
return route
}
}
data object Search : Screen("search_screen")
}
// [END_FILE_Screen.kt]

View File

@@ -0,0 +1,45 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationedit
// [FILE] LocationEditScreen.kt
// [SEMANTICS] ui, screen, location, edit
package com.homebox.lens.ui.screen.locationedit
// [IMPORTS]
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Редактирование местоположения".
* @param locationId ID местоположения для редактирования или "new" для создания.
*/
@Composable
fun LocationEditScreen(
locationId: String?
) {
val title = if (locationId == "new") {
stringResource(id = R.string.location_edit_title_create)
} else {
stringResource(id = R.string.location_edit_title_edit)
}
Scaffold { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(text = "TODO: Location Edit Screen for ID: $locationId")
}
}
}

View File

@@ -5,12 +5,50 @@
package com.homebox.lens.ui.screen.locationslist
// [IMPORTS]
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
import com.homebox.lens.ui.theme.HomeboxLensTheme
// [ENTRYPOINT]
/**
@@ -20,22 +58,230 @@ import com.homebox.lens.ui.common.MainScaffold
* @param navigationActions Объект с навигационными действиями.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
* @param onAddNewLocationClick Лямбда-обработчик нажатия на кнопку добавления нового местоположения.
* @param viewModel ViewModel для этого экрана.
*/
@Composable
fun LocationsListScreen(
currentRoute: String?,
navigationActions: NavigationActions,
onLocationClick: (String) -> Unit,
onAddNewLocationClick: () -> Unit
onAddNewLocationClick: () -> Unit,
viewModel: LocationsListViewModel = hiltViewModel()
) {
// [STATE]
val uiState by viewModel.uiState.collectAsState()
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.locations_list_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) { paddingValues ->
Scaffold(
modifier = Modifier.padding(paddingValues),
floatingActionButton = {
FloatingActionButton(onClick = onAddNewLocationClick) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(id = R.string.cd_add_new_location)
)
}
}
) { innerPadding ->
LocationsListContent(
modifier = Modifier.padding(innerPadding),
uiState = uiState,
onLocationClick = onLocationClick,
onEditLocation = { /* TODO */ },
onDeleteLocation = { /* TODO */ }
)
}
}
}
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает основной контент экрана в зависимости от `uiState`.
* @param modifier Модификатор для стилизации.
* @param uiState Текущее состояние UI.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
* @param onEditLocation Лямбда-обработчик для редактирования местоположения.
* @param onDeleteLocation Лямбда-обработчик для удаления местоположения.
*/
@Composable
private fun LocationsListContent(
modifier: Modifier = Modifier,
uiState: LocationsListUiState,
onLocationClick: (String) -> Unit,
onEditLocation: (String) -> Unit,
onDeleteLocation: (String) -> Unit
) {
// [CORE-LOGIC]
Text(text = "TODO: Locations List Screen")
Box(modifier = modifier.fillMaxSize()) {
when (uiState) {
is LocationsListUiState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
is LocationsListUiState.Error -> {
Text(
text = uiState.message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
)
}
is LocationsListUiState.Success -> {
if (uiState.locations.isEmpty()) {
Text(
text = stringResource(id = R.string.locations_not_found),
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(uiState.locations, key = { it.id }) { location ->
LocationCard(
location = location,
onClick = { onLocationClick(location.id) },
onEditClick = { onEditLocation(location.id) },
onDeleteClick = { onDeleteLocation(location.id) }
)
}
}
}
}
}
}
}
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Карточка для отображения одного местоположения.
* @param location Данные о местоположении.
* @param onClick Лямбда-обработчик нажатия на карточку.
* @param onEditClick Лямбда-обработчик нажатия на "Редактировать".
* @param onDeleteClick Лямбда-обработчик нажатия на "Удалить".
*/
@Composable
private fun LocationCard(
location: LocationOutCount,
onClick: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit
) {
var menuExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 16.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = location.name, style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(id = R.string.item_count, location.itemCount),
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(Modifier.width(16.dp))
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(id = R.string.cd_more_options))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(id = R.string.edit)) },
onClick = {
menuExpanded = false
onEditClick()
}
)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.delete)) },
onClick = {
menuExpanded = false
onDeleteClick()
}
)
}
}
}
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Locations List Success")
@Composable
fun LocationsListSuccessPreview() {
val previewLocations = listOf(
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
LocationOutCount("2", "Kitchen", "#00FF00", false, 5, "", ""),
LocationOutCount("3", "Office", "#0000FF", false, 23, "", "")
)
HomeboxLensTheme {
LocationsListContent(
uiState = LocationsListUiState.Success(previewLocations),
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
)
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Locations List Empty")
@Composable
fun LocationsListEmptyPreview() {
HomeboxLensTheme {
LocationsListContent(
uiState = LocationsListUiState.Success(emptyList()),
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
)
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Locations List Loading")
@Composable
fun LocationsListLoadingPreview() {
HomeboxLensTheme {
LocationsListContent(
uiState = LocationsListUiState.Loading,
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
)
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Locations List Error")
@Composable
fun LocationsListErrorPreview() {
HomeboxLensTheme {
LocationsListContent(
uiState = LocationsListUiState.Error("Failed to load locations. Please try again."),
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
)
}
// [END_FUNCTION_LocationsListScreen]
}

View File

@@ -1,36 +1,35 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListUiState.kt
// [SEMANTICS] ui, state, locations_list
// [SEMANTICS] ui, state, locations
package com.homebox.lens.ui.screen.locationslist
import com.homebox.lens.domain.model.LocationOutCount
// [CORE-LOGIC]
// [ENTITY: SealedInterface('LocationsListUiState')]
/**
* [CONTRACT]
* Определяет все возможные состояния для экрана "Список локаций".
* @invariant В любой момент времени экран может находиться только в одном из этих состояний.
* @summary Определяет возможные состояния UI для экрана списка местоположений.
* @see LocationsListViewModel
*/
sealed interface LocationsListUiState {
/**
* [CONTRACT]
* Состояние успешной загрузки данных.
* @property locations Список локаций со счетчиками.
* [STATE]
* @summary Состояние успешной загрузки данных.
* @param locations Список местоположений для отображения.
*/
data class Success(val locations: List<LocationOutCount>) : LocationsListUiState
/**
* [CONTRACT]
* Состояние ошибки во время загрузки данных.
* @property message Человекочитаемое сообщение об ошибке.
* [STATE]
* @summary Состояние ошибки.
* @param message Сообщение об ошибке.
*/
data class Error(val message: String) : LocationsListUiState
/**
* [CONTRACT]
* Состояние, когда данные для экрана загружаются.
* [STATE]
* @summary Состояние загрузки данных.
*/
data object Loading : LocationsListUiState
object Loading : LocationsListUiState
}
// [END_FILE_LocationsListUiState.kt]

View File

@@ -1,6 +1,7 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListViewModel.kt
// [SEMANTICS] ui_logic, locations_list, state_management
// [SEMANTICS] ui, viewmodel, locations, hilt
package com.homebox.lens.ui.screen.locationslist
import androidx.lifecycle.ViewModel
@@ -8,18 +9,18 @@ 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
// [VIEWMODEL]
// [ENTITY: ViewModel('LocationsListViewModel')]
// [CORE-LOGIC]
/**
* [CONTRACT]
* @summary ViewModel для экрана со списком локаций.
* @description Управляет состоянием экрана, загружает список локаций и обрабатывает ошибки.
* @invariant `uiState` всегда является одним из состояний, определенных в `LocationsListUiState`.
* @summary ViewModel для экрана списка местоположений.
* @param getAllLocationsUseCase Use case для получения всех местоположений.
* @property uiState Поток, содержащий текущее состояние UI.
* @invariant `uiState` всегда отражает результат последней операции загрузки.
*/
@HiltViewModel
class LocationsListViewModel @Inject constructor(
@@ -28,44 +29,28 @@ class LocationsListViewModel @Inject constructor(
// [STATE]
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
val uiState = _uiState.asStateFlow()
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow()
// [LIFECYCLE_HANDLER]
// [INITIALIZER]
init {
loadLocations()
}
// [ACTION]
/**
* [CONTRACT]
* @summary Загружает список локаций.
* @description Выполняет `GetAllLocationsUseCase` и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error`.
* @sideeffect Асинхронно обновляет `_uiState`.
* @summary Загружает список местоположений из репозитория.
* @sideeffect Обновляет `_uiState` в зависимости от результата: Loading -> Success/Error.
*/
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.")
try {
val locations = getAllLocationsUseCase()
_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."
)
} catch (e: Exception) {
_uiState.value = LocationsListUiState.Error(e.message ?: "Unknown error")
}
)
}
}
// [END_CLASS_LocationsListViewModel]

View File

@@ -3,6 +3,8 @@
<!-- Common -->
<string name="create">Создать</string>
<string name="edit">Редактировать</string>
<string name="delete">Удалить</string>
<string name="search">Поиск</string>
<string name="logout">Выйти</string>
<string name="no_location">Нет локации</string>
@@ -42,6 +44,15 @@
<string name="locations_list_title">Места хранения</string>
<string name="search_title">Поиск</string>
<!-- Location Edit Screen -->
<string name="location_edit_title_create">Создать локацию</string>
<string name="location_edit_title_edit">Редактировать локацию</string>
<!-- Locations List Screen -->
<string name="locations_not_found">Местоположения не найдены. Нажмите +, чтобы добавить новое.</string>
<string name="item_count">Предметов: %1$d</string>
<string name="cd_more_options">Больше опций</string>
<!-- Setup Screen -->
<string name="setup_title">Настройка сервера</string>
<string name="setup_server_url_label">URL сервера</string>

View File

@@ -50,7 +50,7 @@
<file name="app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListViewModel.kt" status="implemented" spec_ref_id="screen_labels_list">
<purpose_summary>ViewModel для экрана списка меток.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt" status="stub" spec_ref_id="screen_locations_list">
<file name="app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt" status="implemented" spec_ref_id="screen_locations_list">
<purpose_summary>UI для экрана списка местоположений.</purpose_summary>
<coherence_note>Использует модель LocationOutCount для отображения количества элементов в каждой локации.</coherence_note>
</file>