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.itemdetails.ItemDetailsScreen
import com.homebox.lens.ui.screen.itemedit.ItemEditScreen import com.homebox.lens.ui.screen.itemedit.ItemEditScreen
import com.homebox.lens.ui.screen.labelslist.LabelsListScreen 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.locationslist.LocationsListScreen
import com.homebox.lens.ui.screen.search.SearchScreen import com.homebox.lens.ui.screen.search.SearchScreen
import com.homebox.lens.ui.screen.setup.SetupScreen import com.homebox.lens.ui.screen.setup.SetupScreen
@@ -109,10 +110,17 @@ fun NavGraph(
navController.navigate(Screen.InventoryList.route) navController.navigate(Screen.InventoryList.route)
}, },
onAddNewLocationClick = { 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_SEARCH]
composable(route = Screen.Search.route) { composable(route = Screen.Search.route) {
SearchScreen( SearchScreen(

View File

@@ -56,6 +56,25 @@ sealed class Screen(val route: String) {
} }
data object LabelsList : Screen("labels_list_screen") data object LabelsList : Screen("labels_list_screen")
data object LocationsList : Screen("locations_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") data object Search : Screen("search_screen")
} }
// [END_FILE_Screen.kt] // [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 package com.homebox.lens.ui.screen.locationslist
// [IMPORTS] // [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.material3.Text
import androidx.compose.runtime.Composable 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.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.R
import com.homebox.lens.domain.model.LocationOutCount
import com.homebox.lens.navigation.NavigationActions import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold import com.homebox.lens.ui.common.MainScaffold
import com.homebox.lens.ui.theme.HomeboxLensTheme
// [ENTRYPOINT] // [ENTRYPOINT]
/** /**
@@ -20,22 +58,230 @@ import com.homebox.lens.ui.common.MainScaffold
* @param navigationActions Объект с навигационными действиями. * @param navigationActions Объект с навигационными действиями.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение. * @param onLocationClick Лямбда-обработчик нажатия на местоположение.
* @param onAddNewLocationClick Лямбда-обработчик нажатия на кнопку добавления нового местоположения. * @param onAddNewLocationClick Лямбда-обработчик нажатия на кнопку добавления нового местоположения.
* @param viewModel ViewModel для этого экрана.
*/ */
@Composable @Composable
fun LocationsListScreen( fun LocationsListScreen(
currentRoute: String?, currentRoute: String?,
navigationActions: NavigationActions, navigationActions: NavigationActions,
onLocationClick: (String) -> Unit, onLocationClick: (String) -> Unit,
onAddNewLocationClick: () -> Unit onAddNewLocationClick: () -> Unit,
viewModel: LocationsListViewModel = hiltViewModel()
) { ) {
// [STATE]
val uiState by viewModel.uiState.collectAsState()
// [UI_COMPONENT] // [UI_COMPONENT]
MainScaffold( MainScaffold(
topBarTitle = stringResource(id = R.string.locations_list_title), topBarTitle = stringResource(id = R.string.locations_list_title),
currentRoute = currentRoute, currentRoute = currentRoute,
navigationActions = navigationActions navigationActions = navigationActions
) { ) { paddingValues ->
// [CORE-LOGIC] Scaffold(
Text(text = "TODO: Locations List Screen") 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
) {
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 // [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListUiState.kt // [FILE] LocationsListUiState.kt
// [SEMANTICS] ui, state, locations_list // [SEMANTICS] ui, state, locations
package com.homebox.lens.ui.screen.locationslist package com.homebox.lens.ui.screen.locationslist
import com.homebox.lens.domain.model.LocationOutCount import com.homebox.lens.domain.model.LocationOutCount
// [CORE-LOGIC]
// [ENTITY: SealedInterface('LocationsListUiState')]
/** /**
* [CONTRACT] * [CONTRACT]
* Определяет все возможные состояния для экрана "Список локаций". * @summary Определяет возможные состояния UI для экрана списка местоположений.
* @invariant В любой момент времени экран может находиться только в одном из этих состояний. * @see LocationsListViewModel
*/ */
sealed interface LocationsListUiState { sealed interface LocationsListUiState {
/** /**
* [CONTRACT] * [STATE]
* Состояние успешной загрузки данных. * @summary Состояние успешной загрузки данных.
* @property locations Список локаций со счетчиками. * @param locations Список местоположений для отображения.
*/ */
data class Success(val locations: List<LocationOutCount>) : LocationsListUiState data class Success(val locations: List<LocationOutCount>) : LocationsListUiState
/** /**
* [CONTRACT] * [STATE]
* Состояние ошибки во время загрузки данных. * @summary Состояние ошибки.
* @property message Человекочитаемое сообщение об ошибке. * @param message Сообщение об ошибке.
*/ */
data class Error(val message: String) : LocationsListUiState data class Error(val message: String) : LocationsListUiState
/** /**
* [CONTRACT] * [STATE]
* Состояние, когда данные для экрана загружаются. * @summary Состояние загрузки данных.
*/ */
data object Loading : LocationsListUiState object Loading : LocationsListUiState
} }
// [END_FILE_LocationsListUiState.kt] // [END_FILE_LocationsListUiState.kt]

View File

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

View File

@@ -3,6 +3,8 @@
<!-- Common --> <!-- Common -->
<string name="create">Создать</string> <string name="create">Создать</string>
<string name="edit">Редактировать</string>
<string name="delete">Удалить</string>
<string name="search">Поиск</string> <string name="search">Поиск</string>
<string name="logout">Выйти</string> <string name="logout">Выйти</string>
<string name="no_location">Нет локации</string> <string name="no_location">Нет локации</string>
@@ -42,6 +44,15 @@
<string name="locations_list_title">Места хранения</string> <string name="locations_list_title">Места хранения</string>
<string name="search_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 --> <!-- Setup Screen -->
<string name="setup_title">Настройка сервера</string> <string name="setup_title">Настройка сервера</string>
<string name="setup_server_url_label">URL сервера</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"> <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> <purpose_summary>ViewModel для экрана списка меток.</purpose_summary>
</file> </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> <purpose_summary>UI для экрана списка местоположений.</purpose_summary>
<coherence_note>Использует модель LocationOutCount для отображения количества элементов в каждой локации.</coherence_note> <coherence_note>Использует модель LocationOutCount для отображения количества элементов в каждой локации.</coherence_note>
</file> </file>