diff --git a/GEMINI.md b/GEMINI.md index b0ea732..e47b6b0 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -60,6 +60,11 @@ Добавление обработки исключений, граничных условий и альтернативных сценариев, описанных в контрактах. Рефакторинг с сохранением всех контрактных гарантий. + + Принцип "Сначала Анализ" для предотвращения ошибок, связанных с некорректными предположениями о структурах данных. + Перед написанием или изменением любого кода, который зависит от других классов (например, мапперы, use case'ы, view model'и), я ОБЯЗАН сначала прочитать определения всех задействованных классов (моделей, DTO, сущностей БД). Я не должен делать никаких предположений об их полях или типах. + При реализации интерфейсов или переопределении методов я ОБЯЗАН сначала прочитать определение базового интерфейса или класса, чтобы убедиться, что сигнатура метода (включая `suspend`) полностью совпадает. + Принципы для обеспечения компилируемости и совместимости генерируемого кода в Android/Gradle/Kotlin проектах. @@ -104,6 +109,10 @@ Поддерживать поток чтения "сверху вниз": KDoc-контракт -> `require` -> `логика` -> `check` -> `return`. Использовать явные типы, четкие имена. DbC усиливает этот принцип. Активно использовать идиомы Kotlin (`data class`, `when`, `require`, `check`, scope-функции). + + Функции, возвращающие `Flow`, не должны быть `suspend`. `Flow` сам по себе является асинхронным. `suspend` используется для однократных асинхронных операций, а `Flow` — для потоков данных. + + Использовать семантические разметки (КОНТРАКТЫ, ЯКОРЯ) как основу архитектуры. diff --git a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt index ed43c9c..6a5486d 100644 --- a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt +++ b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt @@ -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] \ No newline at end of file +// [END_FILE_NavGraph.kt] diff --git a/app/src/main/java/com/homebox/lens/navigation/NavigationActions.kt b/app/src/main/java/com/homebox/lens/navigation/NavigationActions.kt new file mode 100644 index 0000000..21bc293 --- /dev/null +++ b/app/src/main/java/com/homebox/lens/navigation/NavigationActions.kt @@ -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] diff --git a/app/src/main/java/com/homebox/lens/navigation/Screen.kt b/app/src/main/java/com/homebox/lens/navigation/Screen.kt index 9f6e8d4..0d4cda8 100644 --- a/app/src/main/java/com/homebox/lens/navigation/Screen.kt +++ b/app/src/main/java/com/homebox/lens/navigation/Screen.kt @@ -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,17 +12,50 @@ 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") data object Search : Screen("search_screen") } -// [END_FILE_Screen.kt] \ No newline at end of file +// [END_FILE_Screen.kt] diff --git a/app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt b/app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt new file mode 100644 index 0000000..134fa36 --- /dev/null +++ b/app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt @@ -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() + } + ) + } +} diff --git a/app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt b/app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt new file mode 100644 index 0000000..b366974 --- /dev/null +++ b/app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt @@ -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] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt index 7798ada..43e0bf0 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt @@ -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,67 +27,61 @@ 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 = { - IconButton(onClick = { /* TODO: Handle scanner click */ }) { - Icon(Icons.Default.Search, contentDescription = stringResource(id = R.string.cd_scan_qr_code)) - } - } + // [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) ) } - ) { paddingValues -> - // [ANCHOR] Основной контент экрана - DashboardContent( - modifier = Modifier.padding(paddingValues), - uiState = uiState, - onLocationClick = { /* TODO */ }, - onLabelClick = { /* TODO */ } - ) } + ) { paddingValues -> + DashboardContent( + modifier = Modifier.padding(paddingValues), + uiState = uiState, + onLocationClick = { /* TODO */ }, + 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) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -207,12 +203,21 @@ private fun RecentlyAddedSection(items: List) { } } +// [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, onLocationClick: (LocationOutCount) -> Unit) { @@ -243,7 +254,13 @@ private fun LocationsSection(locations: List, onLocationClick: } } -// [ANCHOR] Секция меток +// [UI_COMPONENT] +/** + * [CONTRACT] + * @summary Секция для отображения меток в виде чипсов. + * @param labels Список меток. + * @param onLabelClick Лямбда-обработчик нажатия на метку. + */ @OptIn(ExperimentalLayoutApi::class) @Composable private fun LabelsSection(labels: List, onLabelClick: (LabelOut) -> Unit) { @@ -266,67 +283,7 @@ private fun LabelsSection(labels: List, 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] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt index 3c3a70d..a4fe49e 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardUiState.kt @@ -23,11 +23,13 @@ sealed interface DashboardUiState { * @property statistics Статистика по инвентарю. * @property locations Список локаций со счетчиками. * @property labels Список всех меток. + * @property recentlyAddedItems Список недавно добавленных товаров. */ data class Success( val statistics: GroupStatistics, val locations: List, - val labels: List + val labels: List, + val recentlyAddedItems: List ) : DashboardUiState /** diff --git a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt index b776332..2dce373 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt @@ -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) - } + combine(statsFlow, locationsFlow, labelsFlow, recentItemsFlow) { stats, locations, labels, recentItems -> + DashboardUiState.Success( + statistics = stats, + locations = locations, + labels = labels, + recentlyAddedItems = recentItems + ) + }.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 } - - // [RESULT_HANDLER] - result.fold( - onSuccess = { (stats, locations, labels) -> - // [FIX] Используем Timber для логирования. - Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.") - _uiState.value = DashboardUiState.Success( - statistics = stats, - locations = locations, - labels = labels - ) - }, - onFailure = { exception -> - // [FIX] Используем Timber для логирования ошибок с передачей исключения. - Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.") - _uiState.value = DashboardUiState.Error( - message = exception.message ?: "Could not load dashboard data." - ) - } - ) } } // [END_CLASS_DashboardViewModel] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt index 3ee5bf8..abf1924 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/inventorylist/InventoryListScreen.kt @@ -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") -} - -@Preview(showBackground = true) -@Composable -fun InventoryListScreenPreview() { - InventoryListScreen() -} -// [END_FILE_InventoryListScreen.kt] +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") + } + // [END_FUNCTION_InventoryListScreen] +} \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt index 0222537..6b78f9e 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/itemdetails/ItemDetailsScreen.kt @@ -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") -} - -@Preview(showBackground = true) -@Composable -fun ItemDetailsScreenPreview() { - ItemDetailsScreen() -} -// [END_FILE_ItemDetailsScreen.kt] +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") + } + // [END_FUNCTION_ItemDetailsScreen] +} \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt index 4421b85..957024f 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt @@ -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") + } + // [END_FUNCTION_ItemEditScreen] } - -@Preview(showBackground = true) -@Composable -fun ItemEditScreenPreview() { - ItemEditScreen() -} -// [END_FILE_ItemEditScreen.kt] diff --git a/app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt index a7d4f4d..b2fb35d 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/labelslist/LabelsListScreen.kt @@ -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) - ) { - when (val state = uiState) { - is LabelsListUiState.Loading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - is LabelsListUiState.Error -> { - Text( - text = state.message, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center).padding(16.dp) - ) - } - is LabelsListUiState.Success -> { - LabelsList( - labels = state.labels, - onLabelClick = onLabelClick - ) - } - } - } - } -} - -// [HELPER] -/** - * [CONTRACT] - * @summary Отображает список меток. - */ -@Composable -private fun LabelsList( - labels: List, - onLabelClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp) + // [UI_COMPONENT] + MainScaffold( + topBarTitle = stringResource(id = R.string.labels_list_title), + currentRoute = currentRoute, + navigationActions = navigationActions ) { - items(labels) { label -> - LabelListItem( - label = label, - onClick = { onLabelClick(label.id) } - ) - } + // [CORE-LOGIC] + Text(text = "TODO: Labels List Screen") } -} - -// [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] \ No newline at end of file + // [END_FUNCTION_LabelsListScreen] +} \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt index 93d837f..b449f80 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/locationslist/LocationsListScreen.kt @@ -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) - ) { - when (val state = uiState) { - is LocationsListUiState.Loading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - is LocationsListUiState.Error -> { - Text( - text = state.message, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center).padding(16.dp) - ) - } - is LocationsListUiState.Success -> { - LocationsList( - locations = state.locations, - onLocationClick = onLocationClick - ) - } - } - } - } -} - -// [HELPER] -/** - * [CONTRACT] - * @summary Отображает список локаций. - */ -@Composable -private fun LocationsList( - locations: List, - onLocationClick: (String) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp) + // [UI_COMPONENT] + MainScaffold( + topBarTitle = stringResource(id = R.string.locations_list_title), + currentRoute = currentRoute, + navigationActions = navigationActions ) { - items(locations) { location -> - LocationListItem( - location = location, - onClick = { onLocationClick(location.id) } - ) - } + // [CORE-LOGIC] + Text(text = "TODO: Locations List Screen") } -} - -// [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] \ No newline at end of file + // [END_FUNCTION_LocationsListScreen] +} \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/search/SearchScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/search/SearchScreen.kt index 4d355bd..6b17a5b 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/search/SearchScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/search/SearchScreen.kt @@ -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") -} - -@Preview(showBackground = true) -@Composable -fun SearchScreenPreview() { - SearchScreen() -} -// [END_FILE_SearchScreen.kt] +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") + } + // [END_FUNCTION_SearchScreen] +} \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt index 6da987f..15a266c 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt @@ -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() { @@ -117,4 +138,4 @@ fun SetupScreenPreview() { onConnectClick = {} ) } -// [END_FILE_SetupScreen.kt] \ No newline at end of file +// [END_FILE_SetupScreen.kt] diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc19c67..ee31f54 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,14 @@ Локации Метки + + Инвентарь + Детали + Редактирование + Метки + Места хранения + Поиск + Настройка сервера URL сервера diff --git a/build.gradle.kts b/build.gradle.kts index 65fbedd..06a2e72 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 diff --git a/data/src/main/java/com/homebox/lens/data/db/dao/ItemDao.kt b/data/src/main/java/com/homebox/lens/data/db/dao/ItemDao.kt index cbd01a6..25c8455 100644 --- a/data/src/main/java/com/homebox/lens/data/db/dao/ItemDao.kt +++ b/data/src/main/java/com/homebox/lens/data/db/dao/ItemDao.kt @@ -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> + @Transaction @Query("SELECT * FROM items") suspend fun getItems(): List diff --git a/data/src/main/java/com/homebox/lens/data/db/entity/Mapper.kt b/data/src/main/java/com/homebox/lens/data/db/entity/Mapper.kt new file mode 100644 index 0000000..a89dcd1 --- /dev/null +++ b/data/src/main/java/com/homebox/lens/data/db/entity/Mapper.kt @@ -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 = "" + ) +} \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt b/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt index 18a85f1..f90eb01 100644 --- a/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt +++ b/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt @@ -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> { + return itemDao.getRecentlyAddedItems(limit).map { entities -> + entities.map { it.toDomain() } + } + } } // [END_FILE_ItemRepositoryImpl.kt] \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt b/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt index e05e915..7e0b1d1 100644 --- a/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt +++ b/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt @@ -25,5 +25,6 @@ interface ItemRepository { suspend fun getAllLocations(): List suspend fun getAllLabels(): List suspend fun searchItems(query: String): PaginationResult + fun getRecentlyAddedItems(limit: Int): kotlinx.coroutines.flow.Flow> } // [END_FILE_ItemRepository.kt] \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/usecase/GetRecentlyAddedItemsUseCase.kt b/domain/src/main/java/com/homebox/lens/domain/usecase/GetRecentlyAddedItemsUseCase.kt new file mode 100644 index 0000000..0464c20 --- /dev/null +++ b/domain/src/main/java/com/homebox/lens/domain/usecase/GetRecentlyAddedItemsUseCase.kt @@ -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> { + // [PRECONDITION] + require(limit > 0) { "[PRECONDITION_FAILED] Limit must be positive." } + + // [CORE-LOGIC] + return itemRepository.getRecentlyAddedItems(limit) + } +} +// [END_FILE_GetRecentlyAddedItemsUseCase.kt] diff --git a/tech_spec/project_structure.txt b/tech_spec/project_structure.txt index 97d3d53..3ccec02 100644 --- a/tech_spec/project_structure.txt +++ b/tech_spec/project_structure.txt @@ -26,43 +26,44 @@ ViewModel для экрана панели управления, обрабатывает бизнес-логику. - + UI для экрана списка инвентаря. ViewModel для экрана списка инвентаря. - + UI для экрана сведений о товаре. ViewModel для экрана сведений о товаре. - + UI для экрана редактирования товара. ViewModel для экрана редактирования товара. - + UI для экрана списка меток. ViewModel для экрана списка меток. - + UI для экрана списка местоположений. + Использует модель LocationOutCount для отображения количества элементов в каждой локации. ViewModel для экрана списка местоположений. - + UI для экрана поиска. ViewModel для экрана поиска. - + UI для экрана настройки. @@ -131,11 +132,15 @@ Сценарий использования для получения всех меток. - Сценарий использования для получения всех местоположений. + Сценарий использования для получения всех местоположений со счетчиками элементов. + Возвращает List, а не базовую модель Location. Сценарий использования для получения сведений о конкретном товаре. + + Сценарий использования для получения недавно добавленных товаров. + Сценарий использования для получения статистики по инвентарю. diff --git a/tech_spec/tech_spec.txt b/tech_spec/tech_spec.txt index 02b79f7..93fbabe 100644 --- a/tech_spec/tech_spec.txt +++ b/tech_spec/tech_spec.txt @@ -95,6 +95,14 @@ Использован Flow для reactive обновлений; обработка ошибок через sealed class. + + Получение и отображение недавно добавленных товаров + Получает список последних N добавленных товаров из локальной базы данных. + Пользователь аутентифицирован. + Возвращает Flow со списком ItemSummary; список отсортирован по дате создания. + + Данные берутся из локального кэша (Room) для быстрого отображения. +