This commit is contained in:
2025-08-14 15:34:05 +03:00
parent ecf614e4c2
commit 7816bb3464
27 changed files with 1795 additions and 335 deletions

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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(
}
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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