From a71279d450e3fb0e2d1bd5823883c7c69363bb68 Mon Sep 17 00:00:00 2001 From: busya Date: Mon, 11 Aug 2025 16:04:04 +0300 Subject: [PATCH] add location screen --- .../com/homebox/lens/navigation/NavGraph.kt | 10 +- .../com/homebox/lens/navigation/Screen.kt | 19 ++ .../screen/locationedit/LocationEditScreen.kt | 45 +++ .../locationslist/LocationsListScreen.kt | 258 +++++++++++++++++- .../locationslist/LocationsListUiState.kt | 29 +- .../locationslist/LocationsListViewModel.kt | 51 ++-- app/src/main/res/values/strings.xml | 11 + tech_spec/project_structure.txt | 2 +- 8 files changed, 369 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/homebox/lens/ui/screen/locationedit/LocationEditScreen.kt diff --git a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt index 6a5486d..f5b8a29 100644 --- a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt +++ b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt @@ -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( diff --git a/app/src/main/java/com/homebox/lens/navigation/Screen.kt b/app/src/main/java/com/homebox/lens/navigation/Screen.kt index 0d4cda8..688fe9d 100644 --- a/app/src/main/java/com/homebox/lens/navigation/Screen.kt +++ b/app/src/main/java/com/homebox/lens/navigation/Screen.kt @@ -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] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/locationedit/LocationEditScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/locationedit/LocationEditScreen.kt new file mode 100644 index 0000000..7022911 --- /dev/null +++ b/app/src/main/java/com/homebox/lens/ui/screen/locationedit/LocationEditScreen.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt index b449f80..970c4b2 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt @@ -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 - ) { - // [CORE-LOGIC] - Text(text = "TODO: Locations List Screen") + ) { 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 */ } + ) + } } - // [END_FUNCTION_LocationsListScreen] -} \ No newline at end of file +} + +// [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 = {} + ) + } +} diff --git a/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListUiState.kt b/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListUiState.kt index 334c5d8..c4824c9 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListUiState.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListUiState.kt @@ -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) : 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] \ No newline at end of file +// [END_FILE_LocationsListUiState.kt] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListViewModel.kt index 17bc201..a825787 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListViewModel.kt @@ -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.Loading) - val uiState = _uiState.asStateFlow() + val uiState: StateFlow = _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() + try { + val locations = getAllLocationsUseCase() + _uiState.value = LocationsListUiState.Success(locations) + } catch (e: Exception) { + _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] diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee31f54..d984aa2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,8 @@ Создать + Редактировать + Удалить Поиск Выйти Нет локации @@ -42,6 +44,15 @@ Места хранения Поиск + + Создать локацию + Редактировать локацию + + + Местоположения не найдены. Нажмите +, чтобы добавить новое. + Предметов: %1$d + Больше опций + Настройка сервера URL сервера diff --git a/tech_spec/project_structure.txt b/tech_spec/project_structure.txt index 3ccec02..d9ba342 100644 --- a/tech_spec/project_structure.txt +++ b/tech_spec/project_structure.txt @@ -50,7 +50,7 @@ ViewModel для экрана списка меток. - + UI для экрана списка местоположений. Использует модель LocationOutCount для отображения количества элементов в каждой локации.