Labels
This commit is contained in:
@@ -72,9 +72,13 @@ dependencies {
|
||||
implementation(Libs.composeUiGraphics)
|
||||
implementation(Libs.composeUiToolingPreview)
|
||||
implementation(Libs.composeMaterial3)
|
||||
implementation("androidx.compose.material:material-icons-extended-android:1.6.8")
|
||||
implementation(Libs.navigationCompose)
|
||||
implementation(Libs.hiltNavigationCompose)
|
||||
|
||||
|
||||
|
||||
|
||||
// [DEPENDENCY] DI (Hilt)
|
||||
implementation(Libs.hiltAndroid)
|
||||
kapt(Libs.hiltCompiler)
|
||||
|
||||
@@ -87,18 +87,8 @@ fun NavGraph(
|
||||
)
|
||||
}
|
||||
// [COMPOSABLE_LABELS_LIST]
|
||||
composable(route = Screen.LabelsList.route) {
|
||||
LabelsListScreen(
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions,
|
||||
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(Screen.LabelsList.route) {
|
||||
LabelsListScreen(navController = navController)
|
||||
}
|
||||
// [COMPOSABLE_LOCATIONS_LIST]
|
||||
composable(route = Screen.LocationsList.route) {
|
||||
|
||||
@@ -1,64 +1,71 @@
|
||||
// [PACKAGE] com.homebox.lens.navigation
|
||||
// [FILE] NavigationActions.kt
|
||||
// [SEMANTICS] navigation, controller, actions
|
||||
|
||||
package com.homebox.lens.navigation
|
||||
|
||||
import androidx.navigation.NavHostController
|
||||
|
||||
// [CORE-LOGIC]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий.
|
||||
* @param navController Контроллер Jetpack Navigation.
|
||||
* @invariant Все навигационные действия должны использовать предоставленный navController.
|
||||
[CONTRACT]
|
||||
@summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий.
|
||||
@param navController Контроллер Jetpack Navigation.
|
||||
@invariant Все навигационные действия должны использовать предоставленный navController.
|
||||
*/
|
||||
class NavigationActions(private val navController: NavHostController) {
|
||||
|
||||
// [ACTION]
|
||||
// [ACTION]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Навигация на главный экран.
|
||||
* @sideeffect Очищает back stack до главного экрана, чтобы избежать циклов.
|
||||
[CONTRACT]
|
||||
@summary Навигация на главный экран.
|
||||
@sideeffect Очищает back stack до главного экрана, чтобы избежать циклов.
|
||||
*/
|
||||
fun navigateToDashboard() {
|
||||
navController.navigate(Screen.Dashboard.route) {
|
||||
// Используем popUpTo для удаления всех экранов до dashboard из back stack
|
||||
// Это предотвращает создание большой стопки экранов при навигации через drawer
|
||||
// Используем popUpTo для удаления всех экранов до dashboard из back stack
|
||||
// Это предотвращает создание большой стопки экранов при навигации через drawer
|
||||
popUpTo(navController.graph.startDestinationId)
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
// [ACTION]
|
||||
fun navigateToLocations() {
|
||||
navController.navigate(Screen.LocationsList.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
// [ACTION]
|
||||
fun navigateToLabels() {
|
||||
navController.navigate(Screen.LabelsList.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
// [ACTION]
|
||||
fun navigateToSearch() {
|
||||
navController.navigate(Screen.Search.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
// [ACTION]
|
||||
fun navigateToInventoryListWithLabel(labelId: String) {
|
||||
val route = Screen.InventoryList.withFilter("label", labelId)
|
||||
navController.navigate(route)
|
||||
}
|
||||
// [ACTION]
|
||||
fun navigateToInventoryListWithLocation(locationId: String) {
|
||||
val route = Screen.InventoryList.withFilter("location", locationId)
|
||||
navController.navigate(route)
|
||||
}
|
||||
// [ACTION]
|
||||
fun navigateToCreateItem() {
|
||||
navController.navigate(Screen.ItemEdit.createRoute("new"))
|
||||
}
|
||||
|
||||
// [ACTION]
|
||||
fun navigateToLogout() {
|
||||
navController.navigate(Screen.Setup.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
|
||||
// [ACTION]
|
||||
fun navigateBack() {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
// [END_FILE_NavigationActions.kt]
|
||||
// [END_FILE_NavigationActions.kt]
|
||||
@@ -1,7 +1,6 @@
|
||||
// [PACKAGE] com.homebox.lens.navigation
|
||||
// [FILE] Screen.kt
|
||||
// [SEMANTICS] navigation, routes, sealed_class
|
||||
|
||||
package com.homebox.lens.navigation
|
||||
|
||||
// [CORE-LOGIC]
|
||||
@@ -15,7 +14,29 @@ sealed class Screen(val route: String) {
|
||||
// [STATE]
|
||||
data object Setup : Screen("setup_screen")
|
||||
data object Dashboard : Screen("dashboard_screen")
|
||||
data object InventoryList : Screen("inventory_list_screen")
|
||||
data object InventoryList : Screen("inventory_list_screen") {
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Создает маршрут для экрана списка инвентаря с параметром фильтра.
|
||||
* @param key Ключ фильтра (например, "label" или "location").
|
||||
* @param value Значение фильтра (например, ID метки или местоположения).
|
||||
* @return Строку полного маршрута с query-параметром.
|
||||
* @throws IllegalArgumentException если ключ или значение пустые.
|
||||
* @sideeffect [ARCH-IMPLICATION] NavGraph должен быть настроен для приема этого опционального query-параметра (например, 'navArgument("label") { nullable = true }').
|
||||
*/
|
||||
// [HELPER]
|
||||
fun withFilter(key: String, value: String): String {
|
||||
// [PRECONDITION]
|
||||
require(key.isNotBlank()) { "[PRECONDITION_FAILED] Filter key cannot be blank." }
|
||||
require(value.isNotBlank()) { "[PRECONDITION_FAILED] Filter value cannot be blank." }
|
||||
// [ACTION]
|
||||
val constructedRoute = "inventory_list_screen?$key=$value"
|
||||
// [POSTCONDITION]
|
||||
check(constructedRoute.contains("?$key=$value")) { "[POSTCONDITION_FAILED] Route must contain the filter query." }
|
||||
return constructedRoute
|
||||
}
|
||||
}
|
||||
|
||||
data object ItemDetails : Screen("item_details_screen/{itemId}") {
|
||||
/**
|
||||
* [CONTRACT]
|
||||
@@ -77,4 +98,4 @@ sealed class Screen(val route: String) {
|
||||
}
|
||||
data object Search : Screen("search_screen")
|
||||
}
|
||||
// [END_FILE_Screen.kt]
|
||||
// [END_FILE_Screen.kt]
|
||||
@@ -1,7 +1,6 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.common
|
||||
// [FILE] AppDrawer.kt
|
||||
package com.homebox.lens.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -23,13 +22,12 @@ import androidx.compose.ui.unit.dp
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.navigation.Screen
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Контент для бокового навигационного меню (Drawer).
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @param onCloseDrawer Лямбда для закрытия бокового меню.
|
||||
[CONTRACT]
|
||||
@summary Контент для бокового навигационного меню (Drawer).
|
||||
@param currentRoute Текущий маршрут для подсветки активного элемента.
|
||||
@param navigationActions Объект с навигационными действиями.
|
||||
@param onCloseDrawer Лямбда для закрытия бокового меню.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AppDrawerContent(
|
||||
@@ -70,6 +68,14 @@ internal fun AppDrawerContent(
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.nav_labels)) },
|
||||
selected = currentRoute == Screen.LabelsList.route,
|
||||
onClick = {
|
||||
navigationActions.navigateToLabels()
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.search)) },
|
||||
selected = currentRoute == Screen.Search.route,
|
||||
@@ -78,7 +84,7 @@ internal fun AppDrawerContent(
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
// TODO: Add Profile and Tools items
|
||||
// TODO: Add Profile and Tools items
|
||||
Divider()
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.logout)) },
|
||||
@@ -89,4 +95,4 @@ internal fun AppDrawerContent(
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.dashboard
|
||||
// [FILE] DashboardScreen.kt
|
||||
// [SEMANTICS] ui, screen, dashboard, compose
|
||||
|
||||
// [SEMANTICS] ui, screen, dashboard, compose, navigation
|
||||
package com.homebox.lens.ui.screen.dashboard
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -30,15 +28,15 @@ import com.homebox.lens.domain.model.*
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||
|
||||
import timber.log.Timber
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Главная Composable-функция для экрана "Панель управления".
|
||||
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @sideeffect Вызывает навигационные лямбды при взаимодействии с UI.
|
||||
[CONTRACT]
|
||||
@summary Главная Composable-функция для экрана "Панель управления".
|
||||
@param viewModel ViewModel для этого экрана, предоставляется через Hilt.
|
||||
@param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
@param navigationActions Объект с навигационными действиями.
|
||||
@sideeffect Вызывает навигационные лямбды при взаимодействии с UI.
|
||||
*/
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
@@ -46,19 +44,18 @@ fun DashboardScreen(
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions
|
||||
) {
|
||||
// [STATE]
|
||||
// [STATE]
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// [UI_COMPONENT]
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.dashboard_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions,
|
||||
topBarActions = {
|
||||
IconButton(onClick = { /* TODO: Handle scanner click */ }) {
|
||||
IconButton(onClick = { navigationActions.navigateToSearch() }) {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = stringResource(id = R.string.cd_scan_qr_code)
|
||||
contentDescription = stringResource(id = R.string.cd_scan_qr_code) // TODO: Rename string resource
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -66,21 +63,26 @@ fun DashboardScreen(
|
||||
DashboardContent(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
uiState = uiState,
|
||||
onLocationClick = { /* TODO */ },
|
||||
onLabelClick = { /* TODO */ }
|
||||
onLocationClick = { location ->
|
||||
Timber.i("[ACTION] Location chip clicked: ${location.id}. Navigating...")
|
||||
navigationActions.navigateToInventoryListWithLocation(location.id)
|
||||
},
|
||||
onLabelClick = { label ->
|
||||
Timber.i("[ACTION] Label chip clicked: ${label.id}. Navigating...")
|
||||
navigationActions.navigateToInventoryListWithLabel(label.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
// [END_FUNCTION_DashboardScreen]
|
||||
// [END_FUNCTION_DashboardScreen]
|
||||
}
|
||||
|
||||
// [HELPER]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Отображает основной контент экрана в зависимости от `uiState`.
|
||||
* @param modifier Модификатор для стилизации.
|
||||
* @param uiState Текущее состояние UI экрана.
|
||||
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
|
||||
* @param onLabelClick Лямбда-обработчик нажатия на метку.
|
||||
[CONTRACT]
|
||||
@summary Отображает основной контент экрана в зависимости от uiState.
|
||||
@param modifier Модификатор для стилизации.
|
||||
@param uiState Текущее состояние UI экрана.
|
||||
@param onLocationClick Лямбда-обработчик нажатия на местоположение.
|
||||
@param onLabelClick Лямбда-обработчик нажатия на метку.
|
||||
*/
|
||||
@Composable
|
||||
private fun DashboardContent(
|
||||
@@ -89,7 +91,7 @@ private fun DashboardContent(
|
||||
onLocationClick: (LocationOutCount) -> Unit,
|
||||
onLabelClick: (LabelOut) -> Unit
|
||||
) {
|
||||
// [CORE-LOGIC]
|
||||
// [CORE-LOGIC]
|
||||
when (uiState) {
|
||||
is DashboardUiState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -121,14 +123,13 @@ private fun DashboardContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_FUNCTION_DashboardContent]
|
||||
// [END_FUNCTION_DashboardContent]
|
||||
}
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Секция для отображения общей статистики.
|
||||
* @param statistics Объект со статистическими данными.
|
||||
[CONTRACT]
|
||||
@summary Секция для отображения общей статистики.
|
||||
@param statistics Объект со статистическими данными.
|
||||
*/
|
||||
@Composable
|
||||
private fun StatisticsSection(statistics: GroupStatistics) {
|
||||
@@ -155,13 +156,12 @@ private fun StatisticsSection(statistics: GroupStatistics) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Карточка для отображения одного статистического показателя.
|
||||
* @param title Название показателя.
|
||||
* @param value Значение показателя.
|
||||
[CONTRACT]
|
||||
@summary Карточка для отображения одного статистического показателя.
|
||||
@param title Название показателя.
|
||||
@param value Значение показателя.
|
||||
*/
|
||||
@Composable
|
||||
private fun StatisticCard(title: String, value: String) {
|
||||
@@ -170,12 +170,11 @@ private fun StatisticCard(title: String, value: String) {
|
||||
Text(text = value, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Секция для отображения недавно добавленных элементов.
|
||||
* @param items Список элементов для отображения.
|
||||
[CONTRACT]
|
||||
@summary Секция для отображения недавно добавленных элементов.
|
||||
@param items Список элементов для отображения.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecentlyAddedSection(items: List<ItemSummary>) {
|
||||
@@ -202,18 +201,17 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Карточка для отображения краткой информации об элементе.
|
||||
* @param item Элемент для отображения.
|
||||
[CONTRACT]
|
||||
@summary Карточка для отображения краткой информации об элементе.
|
||||
@param item Элемент для отображения.
|
||||
*/
|
||||
@Composable
|
||||
private fun ItemCard(item: ItemSummary) {
|
||||
Card(modifier = Modifier.width(150.dp)) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
// TODO: Add image here from item.image
|
||||
// TODO: Add image here from item.image
|
||||
Spacer(modifier = Modifier
|
||||
.height(80.dp)
|
||||
.fillMaxWidth()
|
||||
@@ -224,14 +222,12 @@ private fun ItemCard(item: ItemSummary) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Секция для отображения местоположений в виде чипсов.
|
||||
* @param locations Список местоположений.
|
||||
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
|
||||
[CONTRACT]
|
||||
@summary Секция для отображения местоположений в виде чипсов.
|
||||
@param locations Список местоположений.
|
||||
@param onLocationClick Лямбда-обработчик нажатия на местоположение.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -253,13 +249,12 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Секция для отображения меток в виде чипсов.
|
||||
* @param labels Список меток.
|
||||
* @param onLabelClick Лямбда-обработчик нажатия на метку.
|
||||
[CONTRACT]
|
||||
@summary Секция для отображения меток в виде чипсов.
|
||||
@param labels Список меток.
|
||||
@param onLabelClick Лямбда-обработчик нажатия на метку.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -281,8 +276,6 @@ private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Un
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Dashboard Success State")
|
||||
@Composable
|
||||
@@ -317,7 +310,6 @@ fun DashboardContentSuccessPreview() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Dashboard Loading State")
|
||||
@Composable
|
||||
@@ -330,7 +322,6 @@ fun DashboardContentLoadingPreview() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Dashboard Error State")
|
||||
@Composable
|
||||
@@ -343,4 +334,4 @@ fun DashboardContentErrorPreview() {
|
||||
)
|
||||
}
|
||||
}
|
||||
// [END_FILE_DashboardScreen.kt]
|
||||
// [END_FILE_DashboardScreen.kt]
|
||||
@@ -1,41 +1,257 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
||||
// [FILE] LabelsListScreen.kt
|
||||
// [SEMANTICS] ui, screen, labels, list
|
||||
|
||||
// [SEMANTICS] ui, labels_list, state_management, compose, dialog
|
||||
package com.homebox.lens.ui.screen.labelslist
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Label
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
import com.homebox.lens.domain.model.Label
|
||||
import com.homebox.lens.navigation.Screen
|
||||
import timber.log.Timber
|
||||
|
||||
// [SECTION] Main Screen Composable
|
||||
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Composable-функция для экрана "Список меток".
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @param onLabelClick Лямбда-обработчик нажатия на метку.
|
||||
* @param onAddNewLabelClick Лямбда-обработчик нажатия на кнопку добавления новой метки.
|
||||
* @summary Отображает экран со списком всех меток.
|
||||
* @description Главная Composable-функция для экрана меток. Она использует Scaffold для структуры,
|
||||
* получает состояние от `LabelsListViewModel`, обрабатывает навигацию и делегирует отображение
|
||||
* списка и диалогов вспомогательным Composable-функциям.
|
||||
*
|
||||
* @param navController Контроллер навигации для перемещения между экранами.
|
||||
* @param viewModel ViewModel, предоставляющая состояние UI для экрана меток.
|
||||
*
|
||||
* @precondition `navController` должен быть корректно инициализирован и способен обрабатывать навигационные события.
|
||||
* @precondition `viewModel` должен быть доступен через Hilt.
|
||||
* @postcondition Экран исчерпывающе обрабатывает все состояния из `LabelsListUiState` (Loading, Success, Error).
|
||||
* @sideeffect Пользовательские действия (клики) инициируют вызовы ViewModel и навигационные команды через `navController`.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelsListScreen(
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions,
|
||||
onLabelClick: (String) -> Unit,
|
||||
onAddNewLabelClick: () -> Unit
|
||||
navController: NavController,
|
||||
viewModel: LabelsListViewModel = hiltViewModel()
|
||||
) {
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.labels_list_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
) {
|
||||
// [CORE-LOGIC]
|
||||
Text(text = "TODO: Labels List Screen")
|
||||
// [ENTRYPOINT]
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// [CORE-LOGIC]
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.screen_title_labels)) },
|
||||
navigationIcon = {
|
||||
// [ACTION] Handle back navigation
|
||||
IconButton(onClick = {
|
||||
Timber.i("[ACTION] Navigate up initiated.")
|
||||
navController.navigateUp()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.content_desc_navigate_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
// [ACTION] Handle create new label initiation
|
||||
FloatingActionButton(onClick = {
|
||||
Timber.i("[ACTION] FAB clicked: Initiate create new label flow.")
|
||||
viewModel.onShowCreateDialog()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(id = R.string.content_desc_create_label)
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
val currentState = uiState
|
||||
if (currentState is LabelsListUiState.Success && currentState.isShowingCreateDialog) {
|
||||
CreateLabelDialog(
|
||||
onConfirm = { labelName ->
|
||||
viewModel.createLabel(labelName)
|
||||
},
|
||||
onDismiss = {
|
||||
viewModel.onDismissCreateDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// [CORE-LOGIC] State-driven UI rendering
|
||||
when (currentState) {
|
||||
is LabelsListUiState.Loading -> {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
is LabelsListUiState.Error -> {
|
||||
Text(text = currentState.message)
|
||||
}
|
||||
is LabelsListUiState.Success -> {
|
||||
if (currentState.labels.isEmpty()) {
|
||||
Text(text = stringResource(id = R.string.labels_list_empty))
|
||||
} else {
|
||||
LabelsList(
|
||||
labels = currentState.labels,
|
||||
onLabelClick = { label ->
|
||||
// [ACTION] Handle label click
|
||||
Timber.i("[ACTION] Label clicked: ${label.id}. Navigating to inventory list.")
|
||||
// [DESIGN-DECISION] Использовать существующий экран списка инвентаря, передавая фильтр.
|
||||
val route = Screen.InventoryList.withFilter("label", label.id)
|
||||
navController.navigate(route)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_FUNCTION_LabelsListScreen]
|
||||
}
|
||||
// [COHERENCE_CHECK_PASSED]
|
||||
}
|
||||
// [END_FUNCTION] LabelsListScreen
|
||||
|
||||
// [SECTION] Helper Composables
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Composable-функция для отображения списка меток.
|
||||
* @param labels Список объектов `Label` для отображения.
|
||||
* @param onLabelClick Лямбда-функция, вызываемая при нажатии на элемент списка.
|
||||
* @param modifier Модификатор для настройки внешнего вида.
|
||||
*/
|
||||
@Composable
|
||||
private fun LabelsList(
|
||||
labels: List<Label>,
|
||||
onLabelClick: (Label) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// [CORE-LOGIC]
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(labels, key = { it.id }) { label ->
|
||||
LabelListItem(
|
||||
label = label,
|
||||
onClick = { onLabelClick(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_FUNCTION] LabelsList
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Composable-функция для отображения одного элемента в списке меток.
|
||||
* @param label Объект `Label`, который нужно отобразить.
|
||||
* @param onClick Лямбда-функция, вызываемая при нажатии на элемент.
|
||||
*/
|
||||
@Composable
|
||||
private fun LabelListItem(
|
||||
label: Label,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
// [CORE-LOGIC]
|
||||
ListItem(
|
||||
headlineContent = { Text(text = label.name) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Label,
|
||||
contentDescription = stringResource(id = R.string.content_desc_label_icon)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
// [END_FUNCTION] LabelListItem
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Диалоговое окно для создания новой метки.
|
||||
* @param onConfirm Лямбда-функция, вызываемая при подтверждении создания с именем метки.
|
||||
* @param onDismiss Лямбда-функция, вызываемая при закрытии диалога.
|
||||
*/
|
||||
@Composable
|
||||
private fun CreateLabelDialog(
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
// [STATE]
|
||||
var text by remember { mutableStateOf("") }
|
||||
val isConfirmEnabled = text.isNotBlank()
|
||||
|
||||
// [CORE-LOGIC]
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.dialog_title_create_label)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text(stringResource(R.string.dialog_field_label_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(text) },
|
||||
enabled = isConfirmEnabled
|
||||
) {
|
||||
Text(stringResource(R.string.dialog_button_create))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_button_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// [END_FUNCTION] CreateLabelDialog
|
||||
|
||||
// [END_FILE] LabelsListScreen.kt
|
||||
@@ -1,35 +1,35 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
||||
// [FILE] LabelsListUiState.kt
|
||||
// [SEMANTICS] ui, state, labels_list
|
||||
// [SEMANTICS] ui_state, sealed_interface, contract
|
||||
package com.homebox.lens.ui.screen.labelslist
|
||||
|
||||
import com.homebox.lens.domain.model.LabelOut
|
||||
|
||||
// [CORE-LOGIC]
|
||||
// [ENTITY: SealedInterface('LabelsListUiState')]
|
||||
// [IMPORTS]
|
||||
import com.homebox.lens.domain.model.Label
|
||||
// [CONTRACT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Определяет все возможные состояния для экрана "Список меток".
|
||||
* @invariant В любой момент времени экран может находиться только в одном из этих состояний.
|
||||
[CONTRACT]
|
||||
@summary Определяет все возможные состояния для UI экрана со списком меток.
|
||||
@description Использование sealed-интерфейса позволяет исчерпывающе обрабатывать все состояния в Composable-функциях.
|
||||
*/
|
||||
sealed interface LabelsListUiState {
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Состояние успешной загрузки данных.
|
||||
* @property labels Список меток.
|
||||
@summary Состояние успеха, содержит список меток и состояние диалога.
|
||||
@property labels Список меток для отображения.
|
||||
@property isShowingCreateDialog Флаг, показывающий, должен ли быть отображен диалог создания метки.
|
||||
@invariant labels не может быть null.
|
||||
*/
|
||||
data class Success(val labels: List<LabelOut>) : LabelsListUiState
|
||||
|
||||
data class Success(
|
||||
val labels: List<Label>,
|
||||
val isShowingCreateDialog: Boolean = false
|
||||
) : LabelsListUiState
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Состояние ошибки во время загрузки данных.
|
||||
* @property message Человекочитаемое сообщение об ошибке.
|
||||
@summary Состояние ошибки.
|
||||
@property message Текст ошибки для отображения пользователю.
|
||||
@invariant message не может быть пустой.
|
||||
*/
|
||||
data class Error(val message: String) : LabelsListUiState
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Состояние, когда данные для экрана загружаются.
|
||||
@summary Состояние загрузки данных.
|
||||
@description Указывает, что идет процесс загрузки меток.
|
||||
*/
|
||||
data object Loading : LabelsListUiState
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
||||
// [FILE] LabelsListViewModel.kt
|
||||
// [SEMANTICS] ui_logic, labels_list, state_management
|
||||
// [SEMANTICS] ui_logic, labels_list, state_management, dialog_management
|
||||
package com.homebox.lens.ui.screen.labelslist
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homebox.lens.domain.model.Label
|
||||
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.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -18,7 +21,7 @@ import javax.inject.Inject
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary ViewModel для экрана со списком меток.
|
||||
* @description Управляет состоянием экрана, загружает список меток и обрабатывает ошибки.
|
||||
* @description Управляет состоянием экрана, загружает список меток, обрабатывает ошибки и управляет диалогом создания новой метки.
|
||||
* @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`.
|
||||
*/
|
||||
@HiltViewModel
|
||||
@@ -30,7 +33,7 @@ class LabelsListViewModel @Inject constructor(
|
||||
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
// [LIFECYCLE_HANDLER]
|
||||
// [INIT]
|
||||
init {
|
||||
loadLabels()
|
||||
}
|
||||
@@ -42,6 +45,7 @@ class LabelsListViewModel @Inject constructor(
|
||||
* между состояниями `Loading`, `Success` и `Error`.
|
||||
* @sideeffect Асинхронно обновляет `_uiState`.
|
||||
*/
|
||||
// [ACTION]
|
||||
fun loadLabels() {
|
||||
// [ENTRYPOINT]
|
||||
viewModelScope.launch {
|
||||
@@ -55,9 +59,17 @@ class LabelsListViewModel @Inject constructor(
|
||||
|
||||
// [RESULT_HANDLER]
|
||||
result.fold(
|
||||
onSuccess = { labels ->
|
||||
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labels.size}. State -> Success.")
|
||||
_uiState.value = LabelsListUiState.Success(labels)
|
||||
onSuccess = { labelOuts ->
|
||||
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
|
||||
// [DATA-FLOW] Map List<LabelOut> to List<Label> for the UI state.
|
||||
// The 'Label' model for the UI is simpler and only contains 'id' and 'name'.
|
||||
val labels = labelOuts.map { labelOut ->
|
||||
Label(
|
||||
id = labelOut.id,
|
||||
name = labelOut.name
|
||||
)
|
||||
}
|
||||
_uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false)
|
||||
},
|
||||
onFailure = { exception ->
|
||||
Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.")
|
||||
@@ -68,6 +80,61 @@ class LabelsListViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
// [END_CLASS_LabelsListViewModel]
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Инициирует отображение диалога для создания метки.
|
||||
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `true`.
|
||||
* @sideeffect Обновляет `_uiState`.
|
||||
*/
|
||||
// [ACTION]
|
||||
fun onShowCreateDialog() {
|
||||
Timber.i("[ACTION] Show create label dialog requested.")
|
||||
if (_uiState.value is LabelsListUiState.Success) {
|
||||
_uiState.update {
|
||||
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Скрывает диалог создания метки.
|
||||
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `false`.
|
||||
* @sideeffect Обновляет `_uiState`.
|
||||
*/
|
||||
// [ACTION]
|
||||
fun onDismissCreateDialog() {
|
||||
Timber.i("[ACTION] Dismiss create label dialog requested.")
|
||||
if (_uiState.value is LabelsListUiState.Success) {
|
||||
_uiState.update {
|
||||
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Создает новую метку. [MVP_SCOPE] ЗАГЛУШКА.
|
||||
* @description В текущей реализации (План Б, Этап 1), эта функция только логирует действие
|
||||
* и скрывает диалог. Реальная логика сохранения будет добавлена на следующем этапе.
|
||||
* @param name Название новой метки.
|
||||
* @precondition `name` не должен быть пустым.
|
||||
* @sideeffect Логирует действие, обновляет `_uiState`, чтобы скрыть диалог.
|
||||
*/
|
||||
// [ACTION]
|
||||
fun createLabel(name: String) {
|
||||
// [PRECONDITION]
|
||||
require(name.isNotBlank()) { "[CONTRACT_VIOLATION] Label name cannot be blank." }
|
||||
|
||||
// [ENTRYPOINT]
|
||||
Timber.i("[ACTION] Create label called with name: '$name'. [STUBBED]")
|
||||
|
||||
// [CORE-LOGIC] - Заглушка. Здесь будет вызов CreateLabelUseCase.
|
||||
|
||||
// [POSTCONDITION] Скрываем диалог после "создания".
|
||||
onDismissCreateDialog()
|
||||
// [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список.
|
||||
}
|
||||
}
|
||||
// [END_FILE_LabelsListViewModel.kt]
|
||||
// [END_CLASS_LabelsListViewModel]
|
||||
@@ -60,4 +60,17 @@
|
||||
<string name="setup_password_label">Пароль</string>
|
||||
<string name="setup_connect_button">Подключиться</string>
|
||||
|
||||
<!-- Labels List Screen -->
|
||||
<string name="screen_title_labels">Метки</string>
|
||||
<string name="content_desc_navigate_back">Вернуться назад</string>
|
||||
<string name="content_desc_create_label">Создать новую метку</string>
|
||||
<string name="content_desc_label_icon">Иконка метки</string>
|
||||
<string name="labels_list_empty">Метки еще не созданы.</string>
|
||||
<string name="dialog_title_create_label">Создать метку</string>
|
||||
<string name="dialog_field_label_name">Название метки</string>
|
||||
<string name="dialog_button_create">Создать</string>
|
||||
<string name="dialog_button_cancel">Отмена</string>
|
||||
|
||||
|
||||
|
||||
</resources>
|
||||
Reference in New Issue
Block a user