Navigation refactor

This commit is contained in:
2025-08-11 15:20:30 +03:00
parent 585ae0eb5f
commit a69c5d95ae
25 changed files with 784 additions and 580 deletions

View File

@@ -60,6 +60,11 @@
<Phase id="2" name="ExpansionAndRobustness">Добавление обработки исключений, граничных условий и альтернативных сценариев, описанных в контрактах.</Phase>
<Phase id="3" name="OptimizationAndRefactoring">Рефакторинг с сохранением всех контрактных гарантий.</Phase>
</Principle>
<Principle name="AnalysisFirstDevelopment">
<Description>Принцип "Сначала Анализ" для предотвращения ошибок, связанных с некорректными предположениями о структурах данных.</Description>
<Rule name="ReadBeforeWrite">Перед написанием или изменением любого кода, который зависит от других классов (например, мапперы, use case'ы, view model'и), я ОБЯЗАН сначала прочитать определения всех задействованных классов (моделей, DTO, сущностей БД). Я не должен делать никаких предположений об их полях или типах.</Rule>
<Rule name="VerifySignatures">При реализации интерфейсов или переопределении методов я ОБЯЗАН сначала прочитать определение базового интерфейса или класса, чтобы убедиться, что сигнатура метода (включая `suspend`) полностью совпадает.</Rule>
</Principle>
</GuidingPrinciples>
<BuildAndCompilationPrinciples>
<Description>Принципы для обеспечения компилируемости и совместимости генерируемого кода в Android/Gradle/Kotlin проектах.</Description>
@@ -104,6 +109,10 @@
<Practice name="Linearity_and_Sequence">Поддерживать поток чтения "сверху вниз": KDoc-контракт -> `require` -> `логика` -> `check` -> `return`.</Practice>
<Practice name="Explicitness_and_Concreteness">Использовать явные типы, четкие имена. DbC усиливает этот принцип.</Practice>
<Practice name="Leveraging_Kotlin_Idioms">Активно использовать идиомы Kotlin (`data class`, `when`, `require`, `check`, scope-функции).</Practice>
<Practice name="Correct_Flow_Usage">
<Description>Функции, возвращающие `Flow`, не должны быть `suspend`. `Flow` сам по себе является асинхронным. `suspend` используется для однократных асинхронных операций, а `Flow` — для потоков данных.</Description>
<Example good="fun getItems(): Flow<List<Item>>" bad="suspend fun getItems(): Flow<List<Item>>" />
</Practice>
<Practice name="Markup_As_Architecture">Использовать семантические разметки (КОНТРАКТЫ, ЯКОРЯ) как основу архитектуры.</Practice>
</AIFriendlyPractices>

View File

@@ -1,11 +1,17 @@
// [PACKAGE] com.homebox.lens.navigation
// [FILE] NavGraph.kt
// [SEMANTICS] navigation, compose, nav_host
package com.homebox.lens.navigation
// [IMPORTS]
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.homebox.lens.ui.screen.dashboard.DashboardScreen
import com.homebox.lens.ui.screen.inventorylist.InventoryListScreen
@@ -19,15 +25,31 @@ import com.homebox.lens.ui.screen.setup.SetupScreen
// [CORE-LOGIC]
/**
* [CONTRACT]
* Определяет граф навигации для приложения.
* Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation.
* @param navController Контроллер навигации.
* @see Screen
* @sideeffect Регистрирует все экраны и управляет состоянием навигации.
* @invariant Стартовый экран - `Screen.Setup`.
*/
@Composable
fun NavGraph() {
val navController = rememberNavController()
fun NavGraph(
navController: NavHostController = rememberNavController()
) {
// [STATE]
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
// [HELPER]
val navigationActions = remember(navController) {
NavigationActions(navController)
}
// [ACTION]
NavHost(
navController = navController,
startDestination = Screen.Setup.route
) {
// [COMPOSABLE_SETUP]
composable(route = Screen.Setup.route) {
SetupScreen(onSetupComplete = {
navController.navigate(Screen.Dashboard.route) {
@@ -35,30 +57,39 @@ fun NavGraph() {
}
})
}
// [COMPOSABLE_DASHBOARD]
composable(route = Screen.Dashboard.route) {
DashboardScreen(
onNavigateToLocations = { navController.navigate(Screen.LocationsList.route) },
onNavigateToSearch = { navController.navigate(Screen.Search.route) },
onNavigateToCreateItem = { navController.navigate(Screen.ItemEdit.createRoute("new")) },
onLogout = {
navController.navigate(Screen.Setup.route) {
popUpTo(Screen.Dashboard.route) { inclusive = true }
}
}
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
// [COMPOSABLE_INVENTORY_LIST]
composable(route = Screen.InventoryList.route) {
InventoryListScreen()
InventoryListScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
// [COMPOSABLE_ITEM_DETAILS]
composable(route = Screen.ItemDetails.route) {
ItemDetailsScreen()
ItemDetailsScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
// [COMPOSABLE_ITEM_EDIT]
composable(route = Screen.ItemEdit.route) {
ItemEditScreen()
ItemEditScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
// [COMPOSABLE_LABELS_LIST]
composable(route = Screen.LabelsList.route) {
LabelsListScreen(
onNavigateBack = { navController.popBackStack() },
currentRoute = currentRoute,
navigationActions = navigationActions,
onLabelClick = { labelId ->
// TODO: Navigate to a pre-filtered inventory list screen
navController.navigate(Screen.InventoryList.route)
@@ -68,9 +99,11 @@ fun NavGraph() {
}
)
}
// [COMPOSABLE_LOCATIONS_LIST]
composable(route = Screen.LocationsList.route) {
LocationsListScreen(
onNavigateBack = { navController.popBackStack() },
currentRoute = currentRoute,
navigationActions = navigationActions,
onLocationClick = { locationId ->
// TODO: Navigate to a pre-filtered inventory list screen
navController.navigate(Screen.InventoryList.route)
@@ -80,9 +113,14 @@ fun NavGraph() {
}
)
}
// [COMPOSABLE_SEARCH]
composable(route = Screen.Search.route) {
SearchScreen()
SearchScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
}
// [END_FUNCTION_NavGraph]
}
// [END_FILE_NavGraph.kt]

View File

@@ -0,0 +1,64 @@
// [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.
*/
class NavigationActions(private val navController: NavHostController) {
// [ACTION]
/**
* [CONTRACT]
* @summary Навигация на главный экран.
* @sideeffect Очищает back stack до главного экрана, чтобы избежать циклов.
*/
fun navigateToDashboard() {
navController.navigate(Screen.Dashboard.route) {
// Используем popUpTo для удаления всех экранов до dashboard из back stack
// Это предотвращает создание большой стопки экранов при навигации через drawer
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
}
}
// [ACTION]
fun navigateToLocations() {
navController.navigate(Screen.LocationsList.route) {
launchSingleTop = true
}
}
// [ACTION]
fun navigateToSearch() {
navController.navigate(Screen.Search.route) {
launchSingleTop = true
}
}
// [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]

View File

@@ -1,9 +1,10 @@
// [PACKAGE] com.homebox.lens.navigation
// [FILE] app/src/main/java/com/homebox/lens/navigation/Screen.kt
// [FILE] Screen.kt
// [SEMANTICS] navigation, routes, sealed_class
package com.homebox.lens.navigation
// [CORE-LOGIC]
/**
* [CONTRACT]
* Запечатанный класс для определения маршрутов навигации в приложении.
@@ -11,14 +12,47 @@ package com.homebox.lens.navigation
* @property route Строковый идентификатор маршрута.
*/
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 ItemDetails : Screen("item_details_screen/{itemId}") {
fun createRoute(itemId: String) = "item_details_screen/$itemId"
/**
* [CONTRACT]
* Создает маршрут для экрана деталей элемента с указанным ID.
* @param itemId ID элемента для отображения.
* @return Строку полного маршрута.
* @throws IllegalArgumentException если itemId пустой.
*/
// [HELPER]
fun createRoute(itemId: String): String {
// [PRECONDITION]
require(itemId.isNotBlank()) { "[PRECONDITION_FAILED] itemId не может быть пустым." }
// [ACTION]
val route = "item_details_screen/$itemId"
// [POSTCONDITION]
check(route.endsWith(itemId)) { "[POSTCONDITION_FAILED] Маршрут должен заканчиваться на itemId." }
return route
}
}
data object ItemEdit : Screen("item_edit_screen/{itemId}") {
fun createRoute(itemId: String) = "item_edit_screen/$itemId"
/**
* [CONTRACT]
* Создает маршрут для экрана редактирования элемента с указанным ID.
* @param itemId ID элемента для редактирования.
* @return Строку полного маршрута.
* @throws IllegalArgumentException если itemId пустой.
*/
// [HELPER]
fun createRoute(itemId: String): String {
// [PRECONDITION]
require(itemId.isNotBlank()) { "[PRECONDITION_FAILED] itemId не может быть пустым." }
// [ACTION]
val route = "item_edit_screen/$itemId"
// [POSTCONDITION]
check(route.endsWith(itemId)) { "[POSTCONDITION_FAILED] Маршрут должен заканчиваться на itemId." }
return route
}
}
data object LabelsList : Screen("labels_list_screen")
data object LocationsList : Screen("locations_list_screen")

View File

@@ -0,0 +1,92 @@
// [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
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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 Лямбда для закрытия бокового меню.
*/
@Composable
internal fun AppDrawerContent(
currentRoute: String?,
navigationActions: NavigationActions,
onCloseDrawer: () -> Unit
) {
ModalDrawerSheet {
Spacer(Modifier.height(12.dp))
Button(
onClick = {
navigationActions.navigateToCreateItem()
onCloseDrawer()
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(id = R.string.create))
}
Spacer(Modifier.height(12.dp))
Divider()
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.dashboard_title)) },
selected = currentRoute == Screen.Dashboard.route,
onClick = {
navigationActions.navigateToDashboard()
onCloseDrawer()
}
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.nav_locations)) },
selected = currentRoute == Screen.LocationsList.route,
onClick = {
navigationActions.navigateToLocations()
onCloseDrawer()
}
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.search)) },
selected = currentRoute == Screen.Search.route,
onClick = {
navigationActions.navigateToSearch()
onCloseDrawer()
}
)
// TODO: Add Profile and Tools items
Divider()
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.logout)) },
selected = false,
onClick = {
navigationActions.navigateToLogout()
onCloseDrawer()
}
)
}
}

View File

@@ -0,0 +1,77 @@
// [PACKAGE] com.homebox.lens.ui.common
// [FILE] MainScaffold.kt
// [SEMANTICS] ui, common, scaffold, navigation_drawer
package com.homebox.lens.ui.common
// [IMPORTS]
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import kotlinx.coroutines.launch
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Общая обертка для экранов, включающая Scaffold и Navigation Drawer.
* @param topBarTitle Заголовок для TopAppBar.
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
* @param topBarActions Composable-функция для отображения действий (иконок) в TopAppBar.
* @param content Основное содержимое экрана, которое будет отображено внутри Scaffold.
* @sideeffect Управляет состоянием (открыто/закрыто) бокового меню (ModalNavigationDrawer).
* @invariant TopAppBar всегда отображается с иконкой меню.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScaffold(
topBarTitle: String,
currentRoute: String?,
navigationActions: NavigationActions,
topBarActions: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
// [STATE]
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
// [CORE-LOGIC]
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawerContent(
currentRoute = currentRoute,
navigationActions = navigationActions,
onCloseDrawer = { scope.launch { drawerState.close() } }
)
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(topBarTitle) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = stringResource(id = R.string.cd_open_navigation_drawer)
)
}
},
actions = { topBarActions() }
)
}
) { paddingValues ->
// [ACTION]
content(paddingValues)
}
}
// [END_FUNCTION_MainScaffold]
}
// [END_FILE_MainScaffold.kt]

View File

@@ -1,8 +1,10 @@
// [PACKAGE] com.homebox.lens.ui.screen.dashboard
// [FILE] DashboardScreen.kt
// [SEMANTICS] ui, screen, dashboard, compose
package com.homebox.lens.ui.screen.dashboard
// [IMPORTS]
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -11,14 +13,11 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
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.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -28,56 +27,42 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
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 kotlinx.coroutines.launch
// [ANCHOR] Главная точка входа для экрана Dashboard
@OptIn(ExperimentalMaterial3Api::class)
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Главная Composable-функция для экрана "Панель управления".
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
* @sideeffect Вызывает навигационные лямбды при взаимодействии с UI.
*/
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel(),
onNavigateToLocations: () -> Unit,
onNavigateToSearch: () -> Unit,
onNavigateToCreateItem: () -> Unit,
onLogout: () -> Unit
currentRoute: String?,
navigationActions: NavigationActions
) {
// [ACTION] Собираем состояние из ViewModel
// [STATE]
val uiState by viewModel.uiState.collectAsState()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
// [ANCHOR] Определяем навигационное меню
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
DrawerContent(
onNavigateToLocations = onNavigateToLocations,
onNavigateToSearch = onNavigateToSearch,
onNavigateToCreateItem = onNavigateToCreateItem,
onLogout = onLogout,
onCloseDrawer = { scope.launch { drawerState.close() } }
)
}
) {
// [ANCHOR] Основной Scaffold экрана
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(id = R.string.dashboard_title)) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Default.Menu, contentDescription = stringResource(id = R.string.cd_open_navigation_drawer))
}
},
actions = {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.dashboard_title),
currentRoute = currentRoute,
navigationActions = navigationActions,
topBarActions = {
IconButton(onClick = { /* TODO: Handle scanner click */ }) {
Icon(Icons.Default.Search, contentDescription = stringResource(id = R.string.cd_scan_qr_code))
}
}
Icon(
Icons.Default.Search,
contentDescription = stringResource(id = R.string.cd_scan_qr_code)
)
}
}
) { paddingValues ->
// [ANCHOR] Основной контент экрана
DashboardContent(
modifier = Modifier.padding(paddingValues),
uiState = uiState,
@@ -85,10 +70,18 @@ fun DashboardScreen(
onLabelClick = { /* TODO */ }
)
}
}
// [END_FUNCTION_DashboardScreen]
}
// [ANCHOR] Компонент основного контента
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает основной контент экрана в зависимости от `uiState`.
* @param modifier Модификатор для стилизации.
* @param uiState Текущее состояние UI экрана.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
* @param onLabelClick Лямбда-обработчик нажатия на метку.
*/
@Composable
private fun DashboardContent(
modifier: Modifier = Modifier,
@@ -96,7 +89,7 @@ private fun DashboardContent(
onLocationClick: (LocationOutCount) -> Unit,
onLabelClick: (LabelOut) -> Unit
) {
// [FIX] Based on the UiState, we decide what to show
// [CORE-LOGIC]
when (uiState) {
is DashboardUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -120,35 +113,23 @@ private fun DashboardContent(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
item { Spacer(modifier = Modifier.height(8.dp)) }
// [ANCHOR] Секция "Быстрая статистика"
item {
StatisticsSection(statistics = uiState.statistics)
}
// [ANCHOR] Секция "Недавно добавлено"
item {
// TODO: Add recently added items to UiState and display them here
// RecentlyAddedSection(items = uiState.recentlyAddedItems)
}
// [ANCHOR] Секция "Места хранения"
item {
LocationsSection(locations = uiState.locations, onLocationClick = onLocationClick)
}
// [ANCHOR] Секция "Метки"
item {
LabelsSection(labels = uiState.labels, onLabelClick = onLabelClick)
}
item { StatisticsSection(statistics = uiState.statistics) }
item { RecentlyAddedSection(items = uiState.recentlyAddedItems) }
item { LocationsSection(locations = uiState.locations, onLocationClick = onLocationClick) }
item { LabelsSection(labels = uiState.labels, onLabelClick = onLabelClick) }
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
// [END_FUNCTION_DashboardContent]
}
// [ANCHOR] Секция статистики
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Секция для отображения общей статистики.
* @param statistics Объект со статистическими данными.
*/
@Composable
private fun StatisticsSection(statistics: GroupStatistics) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -159,7 +140,10 @@ private fun StatisticsSection(statistics: GroupStatistics) {
Card {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.height(120.dp).fillMaxWidth().padding(16.dp),
modifier = Modifier
.height(120.dp)
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -172,6 +156,13 @@ private fun StatisticsSection(statistics: GroupStatistics) {
}
}
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Карточка для отображения одного статистического показателя.
* @param title Название показателя.
* @param value Значение показателя.
*/
@Composable
private fun StatisticCard(title: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
@@ -180,7 +171,12 @@ private fun StatisticCard(title: String, value: String) {
}
}
// [ANCHOR] Секция недавно добавленных
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Секция для отображения недавно добавленных элементов.
* @param items Список элементов для отображения.
*/
@Composable
private fun RecentlyAddedSection(items: List<ItemSummary>) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -207,12 +203,21 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
}
}
// [UI_COMPONENT]
/**
* [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
Spacer(modifier = Modifier.height(80.dp).fillMaxWidth().background(MaterialTheme.colorScheme.secondaryContainer))
Spacer(modifier = Modifier
.height(80.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer))
Spacer(modifier = Modifier.height(8.dp))
Text(text = item.name, style = MaterialTheme.typography.titleSmall, maxLines = 1)
Text(text = item.location?.name ?: stringResource(id = R.string.no_location), style = MaterialTheme.typography.bodySmall, maxLines = 1)
@@ -221,7 +226,13 @@ private fun ItemCard(item: ItemSummary) {
}
// [ANCHOR] Секция местоположений
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Секция для отображения местоположений в виде чипсов.
* @param locations Список местоположений.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick: (LocationOutCount) -> Unit) {
@@ -243,7 +254,13 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
}
}
// [ANCHOR] Секция меток
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Секция для отображения меток в виде чипсов.
* @param labels Список меток.
* @param onLabelClick Лямбда-обработчик нажатия на метку.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Unit) {
@@ -266,67 +283,7 @@ private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Un
}
// [ANCHOR] Контент бокового меню
@Composable
private fun DrawerContent(
onNavigateToLocations: () -> Unit,
onNavigateToSearch: () -> Unit,
onNavigateToCreateItem: () -> Unit,
onLogout: () -> Unit,
onCloseDrawer: () -> Unit
) {
ModalDrawerSheet {
Spacer(Modifier.height(12.dp))
Button(
onClick = {
onNavigateToCreateItem()
onCloseDrawer()
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(id = R.string.create))
}
Spacer(Modifier.height(12.dp))
Divider()
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.dashboard_title)) },
selected = true,
onClick = { onCloseDrawer() }
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.nav_locations)) },
selected = false,
onClick = {
onNavigateToLocations()
onCloseDrawer()
}
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.search)) },
selected = false,
onClick = {
onNavigateToSearch()
onCloseDrawer()
}
)
// TODO: Add Profile and Tools items
Divider()
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.logout)) },
selected = false,
onClick = {
onLogout()
onCloseDrawer()
}
)
}
}
// [ANCHOR] Preview для DashboardContent
// [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Success State")
@Composable
fun DashboardContentSuccessPreview() {
@@ -349,7 +306,8 @@ fun DashboardContentSuccessPreview() {
LabelOut(id="2", name="important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="3", name="seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="4", name="hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = "")
)
),
recentlyAddedItems = emptyList()
)
HomeboxLensTheme {
DashboardContent(
@@ -360,6 +318,7 @@ fun DashboardContentSuccessPreview() {
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Loading State")
@Composable
fun DashboardContentLoadingPreview() {
@@ -372,6 +331,7 @@ fun DashboardContentLoadingPreview() {
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Error State")
@Composable
fun DashboardContentErrorPreview() {
@@ -383,3 +343,4 @@ fun DashboardContentErrorPreview() {
)
}
}
// [END_FILE_DashboardScreen.kt]

View File

@@ -23,11 +23,13 @@ sealed interface DashboardUiState {
* @property statistics Статистика по инвентарю.
* @property locations Список локаций со счетчиками.
* @property labels Список всех меток.
* @property recentlyAddedItems Список недавно добавленных товаров.
*/
data class Success(
val statistics: GroupStatistics,
val locations: List<LocationOutCount>,
val labels: List<LabelOut>
val labels: List<LabelOut>,
val recentlyAddedItems: List<com.homebox.lens.domain.model.ItemSummary>
) : DashboardUiState
/**

View File

@@ -7,13 +7,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
import com.homebox.lens.domain.usecase.GetRecentlyAddedItemsUseCase
import com.homebox.lens.domain.usecase.GetStatisticsUseCase
import com.homebox.lens.ui.screen.dashboard.DashboardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -31,7 +31,8 @@ import javax.inject.Inject
class DashboardViewModel @Inject constructor(
private val getStatisticsUseCase: GetStatisticsUseCase,
private val getAllLocationsUseCase: GetAllLocationsUseCase,
private val getAllLabelsUseCase: GetAllLabelsUseCase
private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase
) : ViewModel() {
// [STATE]
@@ -57,47 +58,29 @@ class DashboardViewModel @Inject constructor(
// [ENTRYPOINT]
viewModelScope.launch {
_uiState.value = DashboardUiState.Loading
// [FIX] Используем Timber для логирования.
Timber.i("[ACTION] Starting parallel dashboard data load. State -> Loading.")
Timber.i("[ACTION] Starting dashboard data collection.")
// [CORE-LOGIC: PARALLEL_FETCH]
val result = runCatching {
coroutineScope {
val statsDeferred = async { getStatisticsUseCase() }
val locationsDeferred = async { getAllLocationsUseCase() }
val labelsDeferred = async { getAllLabelsUseCase() }
val statsFlow = flow { emit(getStatisticsUseCase()) }
val locationsFlow = flow { emit(getAllLocationsUseCase()) }
val labelsFlow = flow { emit(getAllLabelsUseCase()) }
val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10)
val stats = statsDeferred.await()
val locations = locationsDeferred.await()
val labels = labelsDeferred.await()
// [POSTCONDITION_CHECK]
check(stats != null && locations != null && labels != null) {
"[POSTCONDITION_FAILED] One or more dashboard data sources returned null."
}
Triple(stats, locations, labels)
}
}
// [RESULT_HANDLER]
result.fold(
onSuccess = { (stats, locations, labels) ->
// [FIX] Используем Timber для логирования.
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
_uiState.value = DashboardUiState.Success(
combine(statsFlow, locationsFlow, labelsFlow, recentItemsFlow) { stats, locations, labels, recentItems ->
DashboardUiState.Success(
statistics = stats,
locations = locations,
labels = labels
labels = labels,
recentlyAddedItems = recentItems
)
},
onFailure = { exception ->
// [FIX] Используем Timber для логирования ошибок с передачей исключения.
}.catch { exception ->
Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.")
_uiState.value = DashboardUiState.Error(
message = exception.message ?: "Could not load dashboard data."
)
}.collect { successState ->
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
_uiState.value = successState
}
)
}
}
// [END_CLASS_DashboardViewModel]

View File

@@ -1,22 +1,37 @@
// [PACKAGE] com.homebox.lens.ui.screen.inventorylist
// [FILE] InventoryListScreen.kt
// [SEMANTICS] ui, screen, inventory, list
package com.homebox.lens.ui.screen.inventorylist
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Список инвентаря".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
*/
@Composable
fun InventoryListScreen() {
// [ACTION]
Text(text = "Inventory List Screen")
fun InventoryListScreen(
currentRoute: String?,
navigationActions: NavigationActions
) {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.inventory_list_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
// [CORE-LOGIC]
Text(text = "TODO: Inventory List Screen")
}
@Preview(showBackground = true)
@Composable
fun InventoryListScreenPreview() {
InventoryListScreen()
// [END_FUNCTION_InventoryListScreen]
}
// [END_FILE_InventoryListScreen.kt]

View File

@@ -1,22 +1,37 @@
// [PACKAGE] com.homebox.lens.ui.screen.itemdetails
// [FILE] ItemDetailsScreen.kt
// [SEMANTICS] ui, screen, item, details
package com.homebox.lens.ui.screen.itemdetails
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Детали элемента".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
*/
@Composable
fun ItemDetailsScreen() {
// [ACTION]
Text(text = "Item Details Screen")
fun ItemDetailsScreen(
currentRoute: String?,
navigationActions: NavigationActions
) {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.item_details_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
// [CORE-LOGIC]
Text(text = "TODO: Item Details Screen")
}
@Preview(showBackground = true)
@Composable
fun ItemDetailsScreenPreview() {
ItemDetailsScreen()
// [END_FUNCTION_ItemDetailsScreen]
}
// [END_FILE_ItemDetailsScreen.kt]

View File

@@ -1,22 +1,37 @@
// [PACKAGE] com.homebox.lens.ui.screen.itemedit
// [FILE] ItemEditScreen.kt
// [SEMANTICS] ui, screen, item, edit
package com.homebox.lens.ui.screen.itemedit
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Редактирование элемента".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
*/
@Composable
fun ItemEditScreen() {
// [ACTION]
Text(text = "Item Edit Screen")
fun ItemEditScreen(
currentRoute: String?,
navigationActions: NavigationActions
) {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.item_edit_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
// [CORE-LOGIC]
Text(text = "TODO: Item Edit Screen")
}
@Preview(showBackground = true)
@Composable
fun ItemEditScreenPreview() {
ItemEditScreen()
// [END_FUNCTION_ItemEditScreen]
}
// [END_FILE_ItemEditScreen.kt]

View File

@@ -1,172 +1,41 @@
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
// [FILE] LabelsListScreen.kt
// [SEMANTICS] ui, screen, labels_list, compose
// [SEMANTICS] ui, screen, labels, list
package com.homebox.lens.ui.screen.labelslist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.LabelOut
import com.homebox.lens.ui.theme.HomeboxLensTheme
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Главная Composable-функция для экрана списка меток.
* @param onNavigateBack Функция для навигации на предыдущий экран.
* @param onLabelClick Функция, вызываемая при нажатии на метку.
* @param onAddNewLabelClick Функция, вызываемая при нажатии на FAB для добавления новой метки.
* @summary Composable-функция для экрана "Список меток".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
* @param onLabelClick Лямбда-обработчик нажатия на метку.
* @param onAddNewLabelClick Лямбда-обработчик нажатия на кнопку добавления новой метки.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelsListScreen(
viewModel: LabelsListViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
currentRoute: String?,
navigationActions: NavigationActions,
onLabelClick: (String) -> Unit,
onAddNewLabelClick: () -> Unit
) {
// [STATE]
val uiState by viewModel.uiState.collectAsState()
// [UI]
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(id = R.string.nav_labels)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.cd_navigate_back)
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAddNewLabelClick) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.cd_add_new_label)
)
}
}
) { paddingValues ->
// [ANCHOR] Основной контент в зависимости от состояния
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.labels_list_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
when (val state = uiState) {
is LabelsListUiState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
// [CORE-LOGIC]
Text(text = "TODO: Labels List Screen")
}
is LabelsListUiState.Error -> {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center).padding(16.dp)
)
// [END_FUNCTION_LabelsListScreen]
}
is LabelsListUiState.Success -> {
LabelsList(
labels = state.labels,
onLabelClick = onLabelClick
)
}
}
}
}
}
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает список меток.
*/
@Composable
private fun LabelsList(
labels: List<LabelOut>,
onLabelClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(labels) { label ->
LabelListItem(
label = label,
onClick = { onLabelClick(label.id) }
)
}
}
}
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает один элемент списка меток.
*/
@Composable
private fun LabelListItem(
label: LabelOut,
onClick: () -> Unit
) {
ListItem(
headlineContent = { Text(label.name) },
leadingContent = {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null
)
},
modifier = Modifier.clickable(onClick = onClick)
)
}
// [PREVIEW]
@Preview(showBackground = true, name = "Labels List Success")
@Composable
private fun LabelsListScreenSuccessPreview() {
val labels = listOf(
LabelOut("1", "Electronics", "#FF0000", false, "", ""),
LabelOut("2", "Books", "#00FF00", false, "", ""),
LabelOut("3", "Winter Clothes", "#0000FF", false, "", "")
)
HomeboxLensTheme {
LabelsList(labels = labels, onLabelClick = {})
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Labels List Empty")
@Composable
private fun LabelsListScreenEmptyPreview() {
HomeboxLensTheme {
LabelsList(labels = emptyList(), onLabelClick = {})
}
}
// [END_FILE_LabelsListScreen.kt]

View File

@@ -1,175 +1,41 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListScreen.kt
// [SEMANTICS] ui, screen, locations_list, compose
// [SEMANTICS] ui, screen, locations, list
package com.homebox.lens.ui.screen.locationslist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Place
import androidx.compose.material3.*
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.ui.theme.HomeboxLensTheme
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Главная Composable-функция для экрана списка локаций.
* @param onNavigateBack Функция для навигации на предыдущий экран.
* @param onLocationClick Функция, вызываемая при нажатии на локацию.
* @param onAddNewLocationClick Функция, вызываемая при нажатии на FAB для добавления новой локации.
* @summary Composable-функция для экрана "Список местоположений".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
* @param onAddNewLocationClick Лямбда-обработчик нажатия на кнопку добавления нового местоположения.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationsListScreen(
viewModel: LocationsListViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
currentRoute: String?,
navigationActions: NavigationActions,
onLocationClick: (String) -> Unit,
onAddNewLocationClick: () -> Unit
) {
// [STATE]
val uiState by viewModel.uiState.collectAsState()
// [UI]
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(id = R.string.nav_locations)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.cd_navigate_back)
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAddNewLocationClick) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.cd_add_new_location)
)
}
}
) { paddingValues ->
// [ANCHOR] Основной контент в зависимости от состояния
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.locations_list_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
when (val state = uiState) {
is LocationsListUiState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
// [CORE-LOGIC]
Text(text = "TODO: Locations List Screen")
}
is LocationsListUiState.Error -> {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center).padding(16.dp)
)
// [END_FUNCTION_LocationsListScreen]
}
is LocationsListUiState.Success -> {
LocationsList(
locations = state.locations,
onLocationClick = onLocationClick
)
}
}
}
}
}
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает список локаций.
*/
@Composable
private fun LocationsList(
locations: List<LocationOutCount>,
onLocationClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(locations) { location ->
LocationListItem(
location = location,
onClick = { onLocationClick(location.id) }
)
}
}
}
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает один элемент списка локаций.
*/
@Composable
private fun LocationListItem(
location: LocationOutCount,
onClick: () -> Unit
) {
ListItem(
headlineContent = { Text(location.name) },
leadingContent = {
Icon(
imageVector = Icons.Default.Place,
contentDescription = null
)
},
trailingContent = {
Text(text = location.itemCount.toString())
},
modifier = Modifier.clickable(onClick = onClick)
)
}
// [PREVIEW]
@Preview(showBackground = true, name = "Locations List Success")
@Composable
private fun LocationsListScreenSuccessPreview() {
val locations = listOf(
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
LocationOutCount("2", "Kitchen", "#00FF00", false, 3, "", ""),
LocationOutCount("3", "Office", "#0000FF", false, 25, "", "")
)
HomeboxLensTheme {
LocationsList(locations = locations, onLocationClick = {})
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Locations List Empty")
@Composable
private fun LocationsListScreenEmptyPreview() {
HomeboxLensTheme {
LocationsList(locations = emptyList(), onLocationClick = {})
}
}
// [END_FILE_LocationsListScreen.kt]

View File

@@ -1,22 +1,37 @@
// [PACKAGE] com.homebox.lens.ui.screen.search
// [FILE] SearchScreen.kt
// [SEMANTICS] ui, screen, search
package com.homebox.lens.ui.screen.search
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Поиск".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
*/
@Composable
fun SearchScreen() {
// [ACTION]
Text(text = "Search Screen")
fun SearchScreen(
currentRoute: String?,
navigationActions: NavigationActions
) {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.search_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
// [CORE-LOGIC]
Text(text = "TODO: Search Screen")
}
@Preview(showBackground = true)
@Composable
fun SearchScreenPreview() {
SearchScreen()
// [END_FUNCTION_SearchScreen]
}
// [END_FILE_SearchScreen.kt]

View File

@@ -1,9 +1,12 @@
// [PACKAGE] com.homebox.lens.ui.screen.setup
// [FILE] SetupScreen.kt
// [SEMANTICS] ui, screen, setup, compose
@file:OptIn(ExperimentalMaterial3Api::class)
package com.homebox.lens.ui.screen.setup
// [IMPORTS]
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -11,25 +14,35 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
// [FIX] Opt-in for experimental Material 3 APIs
@OptIn(ExperimentalMaterial3Api::class)
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Главная Composable-функция для экрана настройки соединения с сервером.
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
* @param onSetupComplete Лямбда, вызываемая после успешной настройки и входа.
* @sideeffect Вызывает `onSetupComplete` при изменении `uiState.isSetupComplete`.
*/
@Composable
fun SetupScreen(
viewModel: SetupViewModel = hiltViewModel(),
onSetupComplete: () -> Unit
) {
// [STATE]
val uiState by viewModel.uiState.collectAsState()
// [CORE-LOGIC]
if (uiState.isSetupComplete) {
onSetupComplete()
}
// [UI_COMPONENT]
SetupScreenContent(
uiState = uiState,
onServerUrlChange = viewModel::onServerUrlChange,
@@ -37,11 +50,19 @@ fun SetupScreen(
onPasswordChange = viewModel::onPasswordChange,
onConnectClick = viewModel::connect
)
// [END_FUNCTION_SetupScreen]
}
// [FIX] Opt-in for experimental Material 3 APIs
@OptIn(ExperimentalMaterial3Api::class)
// [CONTENT]
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает контент экрана настройки: поля ввода и кнопку.
* @param uiState Текущее состояние UI.
* @param onServerUrlChange Лямбда-обработчик изменения URL сервера.
* @param onUsernameChange Лямбда-обработчик изменения имени пользователя.
* @param onPasswordChange Лямбда-обработчик изменения пароля.
* @param onConnectClick Лямбда-обработчик нажатия на кнопку "Подключиться".
*/
@Composable
private fun SetupScreenContent(
uiState: SetupUiState,
@@ -102,10 +123,10 @@ private fun SetupScreenContent(
}
}
}
// [END_FUNCTION_SetupScreenContent]
}
// [FIX] Opt-in for experimental Material 3 APIs
@OptIn(ExperimentalMaterial3Api::class)
// [PREVIEW]
@Preview(showBackground = true)
@Composable
fun SetupScreenPreview() {

View File

@@ -34,6 +34,14 @@
<string name="nav_locations">Локации</string>
<string name="nav_labels">Метки</string>
<!-- Screen Titles -->
<string name="inventory_list_title">Инвентарь</string>
<string name="item_details_title">Детали</string>
<string name="item_edit_title">Редактирование</string>
<string name="labels_list_title">Метки</string>
<string name="locations_list_title">Места хранения</string>
<string name="search_title">Поиск</string>
<!-- Setup Screen -->
<string name="setup_title">Настройка сервера</string>
<string name="setup_server_url_label">URL сервера</string>

View File

@@ -3,7 +3,7 @@
plugins {
// [PLUGIN] Android Application plugin
id("com.android.application") version "8.11.0" apply false
id("com.android.application") version "8.11.1" apply false
// [PLUGIN] Kotlin Android plugin
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
// [PLUGIN] Hilt Android plugin

View File

@@ -7,6 +7,7 @@ import androidx.room.*
import com.homebox.lens.data.db.entity.ItemEntity
import com.homebox.lens.data.db.entity.ItemLabelCrossRef
import com.homebox.lens.data.db.entity.ItemWithLabels
import kotlinx.coroutines.flow.Flow
// [CONTRACT]
/**
@@ -16,6 +17,10 @@ import com.homebox.lens.data.db.entity.ItemWithLabels
@Dao
interface ItemDao {
@Transaction
@Query("SELECT * FROM items ORDER BY createdAt DESC LIMIT :limit")
fun getRecentlyAddedItems(limit: Int): Flow<List<ItemWithLabels>>
@Transaction
@Query("SELECT * FROM items")
suspend fun getItems(): List<ItemWithLabels>

View File

@@ -0,0 +1,53 @@
// [PACKAGE] com.homebox.lens.data.db.entity
// [FILE] Mapper.kt
package com.homebox.lens.data.db.entity
import com.homebox.lens.domain.model.Image
import com.homebox.lens.domain.model.ItemSummary
import com.homebox.lens.domain.model.LabelOut
import com.homebox.lens.domain.model.LocationOut
/**
* [CONTRACT]
* Преобразует [ItemWithLabels] (сущность БД) в [ItemSummary] (доменную модель).
*
* [COHERENCE_NOTE] Так как сущности БД содержат только подмножество полей доменной модели,
* недостающие поля заполняются значениями по умолчанию (false, 0.0, пустые строки) или null.
* Это компромисс для обеспечения компиляции и базовой функциональности.
*/
fun ItemWithLabels.toDomain(): ItemSummary {
return ItemSummary(
id = this.item.id,
name = this.item.name,
// Предполагаем, что `image` в БД - это URL. Создаем объект Image или null.
image = this.item.image?.let { Image(id = "", path = it, isPrimary = true) },
// `location` в ItemEntity - это только ID. Создаем базовый LocationOut.
location = this.item.locationId?.let { LocationOut(id = it, name = "", color = "", isArchived = false, createdAt = "", updatedAt = "") },
labels = this.labels.map { it.toDomain() },
// Заполняем недостающие поля значениями по умолчанию.
assetId = null,
isArchived = false,
value = this.item.value?.toDouble() ?: 0.0,
createdAt = this.item.createdAt ?: "",
updatedAt = ""
)
}
/**
* [CONTRACT]
* Преобразует [LabelEntity] (сущность БД) в [LabelOut] (доменную модель).
*
* [COHERENCE_NOTE] Заполняет недостающие поля значениями по умолчанию.
*/
fun LabelEntity.toDomain(): LabelOut {
return LabelOut(
id = this.id,
name = this.name,
// Заполняем недостающие поля значениями по умолчанию.
color = "#CCCCCC", // Серый цвет по умолчанию
isArchived = false,
createdAt = "",
updatedAt = ""
)
}

View File

@@ -8,8 +8,12 @@ package com.homebox.lens.data.repository
import com.homebox.lens.data.api.HomeboxApiService
import com.homebox.lens.data.api.dto.toDomain
import com.homebox.lens.data.api.dto.toDto
import com.homebox.lens.data.db.dao.ItemDao
import com.homebox.lens.data.db.entity.toDomain
import com.homebox.lens.domain.model.*
import com.homebox.lens.domain.repository.ItemRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@@ -18,12 +22,14 @@ import javax.inject.Singleton
* [CONTRACT]
* Реализация репозитория для работы с данными о вещах.
* @param apiService Сервис для взаимодействия с Homebox API.
* @param itemDao DAO для доступа к локальной базе данных.
* [COHERENCE_NOTE] Метод 'login' был полностью удален из этого класса, так как его ответственность
* была передана в AuthRepositoryImpl. Это устраняет ошибку компиляции "'login' overrides nothing".
*/
@Singleton
class ItemRepositoryImpl @Inject constructor(
private val apiService: HomeboxApiService,
private val itemDao: ItemDao
) : ItemRepository {
// [DELETED] Метод login был здесь, но теперь он удален.
@@ -100,5 +106,14 @@ class ItemRepositoryImpl @Inject constructor(
val resultDto = apiService.getItems(query = query)
return resultDto.toDomain { it.toDomain() }
}
/**
* [CONTRACT] @see ItemRepository.getRecentlyAddedItems
*/
override fun getRecentlyAddedItems(limit: Int): Flow<List<ItemSummary>> {
return itemDao.getRecentlyAddedItems(limit).map { entities ->
entities.map { it.toDomain() }
}
}
}
// [END_FILE_ItemRepositoryImpl.kt]

View File

@@ -25,5 +25,6 @@ interface ItemRepository {
suspend fun getAllLocations(): List<LocationOutCount>
suspend fun getAllLabels(): List<LabelOut>
suspend fun searchItems(query: String): PaginationResult<ItemSummary>
fun getRecentlyAddedItems(limit: Int): kotlinx.coroutines.flow.Flow<List<ItemSummary>>
}
// [END_FILE_ItemRepository.kt]

View File

@@ -0,0 +1,33 @@
// [PACKAGE] com.homebox.lens.domain.usecase
// [FILE] GetRecentlyAddedItemsUseCase.kt
package com.homebox.lens.domain.usecase
import com.homebox.lens.domain.model.ItemSummary
import com.homebox.lens.domain.repository.ItemRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* [CONTRACT]
* Сценарий использования для получения списка недавно добавленных товаров.
*
* @param itemRepository Репозиторий для доступа к данным о товарах.
* @return Поток (Flow), содержащий список [ItemSummary].
* @precondition Количество запрашиваемых элементов (limit) должно быть положительным.
* @postcondition Возвращает Flow со списком товаров, отсортированных по дате создания в порядке убывания.
* Если товаров нет, возвращает пустой список.
*/
class GetRecentlyAddedItemsUseCase @Inject constructor(
private val itemRepository: ItemRepository
) {
// [ACTION]
operator fun invoke(limit: Int): Flow<List<ItemSummary>> {
// [PRECONDITION]
require(limit > 0) { "[PRECONDITION_FAILED] Limit must be positive." }
// [CORE-LOGIC]
return itemRepository.getRecentlyAddedItems(limit)
}
}
// [END_FILE_GetRecentlyAddedItemsUseCase.kt]

View File

@@ -26,43 +26,44 @@
<file name="app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt" status="implemented" spec_ref_id="screen_dashboard">
<purpose_summary>ViewModel для экрана панели управления, обрабатывает бизнес-логику.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt" status="implemented" spec_ref_id="screen_inventory_list">
<file name="app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt" status="stub" spec_ref_id="screen_inventory_list">
<purpose_summary>UI для экрана списка инвентаря.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListViewModel.kt" status="implemented" spec_ref_id="screen_inventory_list">
<purpose_summary>ViewModel для экрана списка инвентаря.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt" status="implemented" spec_ref_id="screen_item_details">
<file name="app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt" status="stub" spec_ref_id="screen_item_details">
<purpose_summary>UI для экрана сведений о товаре.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsViewModel.kt" status="implemented" spec_ref_id="screen_item_details">
<purpose_summary>ViewModel для экрана сведений о товаре.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt" status="implemented" spec_ref_id="screen_item_edit">
<file name="app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt" status="stub" spec_ref_id="screen_item_edit">
<purpose_summary>UI для экрана редактирования товара.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt" status="implemented" spec_ref_id="screen_item_edit">
<purpose_summary>ViewModel для экрана редактирования товара.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt" status="implemented" spec_ref_id="screen_labels_list">
<file name="app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt" status="stub" spec_ref_id="screen_labels_list">
<purpose_summary>UI для экрана списка меток.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListViewModel.kt" status="implemented" spec_ref_id="screen_labels_list">
<purpose_summary>ViewModel для экрана списка меток.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt" status="implemented" spec_ref_id="screen_locations_list">
<file name="app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt" status="stub" spec_ref_id="screen_locations_list">
<purpose_summary>UI для экрана списка местоположений.</purpose_summary>
<coherence_note>Использует модель LocationOutCount для отображения количества элементов в каждой локации.</coherence_note>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListViewModel.kt" status="implemented" spec_ref_id="screen_locations_list">
<purpose_summary>ViewModel для экрана списка местоположений.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/search/SearchScreen.kt" status="implemented" spec_ref_id="screen_search">
<file name="app/src/main/java/com/homebox/lens/ui/screen/search/SearchScreen.kt" status="stub" spec_ref_id="screen_search">
<purpose_summary>UI для экрана поиска.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/search/SearchViewModel.kt" status="implemented" spec_ref_id="screen_search">
<purpose_summary>ViewModel для экрана поиска.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt" status="implemented" spec_ref_id="screen_setup">
<file name="app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt" status="stub" spec_ref_id="screen_setup">
<purpose_summary>UI для экрана настройки.</purpose_summary>
</file>
<file name="app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt" status="implemented" spec_ref_id="screen_setup">
@@ -131,11 +132,15 @@
<purpose_summary>Сценарий использования для получения всех меток.</purpose_summary>
</file>
<file name="domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLocationsUseCase.kt" status="implemented" spec_ref_id="uc_get_all_locations">
<purpose_summary>Сценарий использования для получения всех местоположений.</purpose_summary>
<purpose_summary>Сценарий использования для получения всех местоположений со счетчиками элементов.</purpose_summary>
<coherence_note>Возвращает List<LocationOutCount>, а не базовую модель Location.</coherence_note>
</file>
<file name="domain/src/main/java/com/homebox/lens/domain/usecase/GetItemDetailsUseCase.kt" status="implemented" spec_ref_id="uc_get_item_details">
<purpose_summary>Сценарий использования для получения сведений о конкретном товаре.</purpose_summary>
</file>
<file name="domain/src/main/java/com/homebox/lens/domain/usecase/GetRecentlyAddedItemsUseCase.kt" status="implemented" spec_ref_id="uc_get_recent_items">
<purpose_summary>Сценарий использования для получения недавно добавленных товаров.</purpose_summary>
</file>
<file name="domain/src/main/java/com/homebox/lens/domain/usecase/GetStatisticsUseCase.kt" status="implemented" spec_ref_id="uc_get_stats">
<purpose_summary>Сценарий использования для получения статистики по инвентарю.</purpose_summary>
</file>

View File

@@ -95,6 +95,14 @@
<implementation_ref id="uc_get_stats" />
<implementation_note>Использован Flow для reactive обновлений; обработка ошибок через sealed class.</implementation_note>
</FUNCTION>
<FUNCTION id="func_get_recent_items" status="implemented">
<summary>Получение и отображение недавно добавленных товаров</summary>
<description>Получает список последних N добавленных товаров из локальной базы данных.</description>
<precondition>Пользователь аутентифицирован.</precondition>
<postcondition>Возвращает Flow со списком ItemSummary; список отсортирован по дате создания.</postcondition>
<implementation_ref id="uc_get_recent_items" />
<implementation_note>Данные берутся из локального кэша (Room) для быстрого отображения.</implementation_note>
</FUNCTION>
</FUNCTIONALITY>
</FEATURE>