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 06bb5c5..665cf87 100644 --- a/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt +++ b/app/src/main/java/com/homebox/lens/navigation/NavGraph.kt @@ -36,7 +36,16 @@ fun NavGraph() { }) } composable(route = Screen.Dashboard.route) { - DashboardScreen() + 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 } + } + } + ) } composable(route = Screen.InventoryList.route) { InventoryListScreen() 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 54433c3..a139801 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,100 +1,385 @@ // [PACKAGE] com.homebox.lens.ui.screen.dashboard -// [FILE] app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardScreen.kt -// [SEMANTICS] ui, screen, dashboard, compose +// [FILE] DashboardScreen.kt -// [IMPORTS] package com.homebox.lens.ui.screen.dashboard -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +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 +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 timber.log.Timber +import com.homebox.lens.R +import com.homebox.lens.domain.model.* +import com.homebox.lens.ui.theme.HomeboxLensTheme +import kotlinx.coroutines.launch -// [CORE-LOGIC] -/** - * [CONTRACT] - * Главный Composable для экрана "Дэшборд". - * @param viewModel ViewModel для этого экрана, предоставляемая Hilt. - */ +// [ANCHOR] Главная точка входа для экрана Dashboard +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardScreen( - viewModel: DashboardViewModel = hiltViewModel() + viewModel: DashboardViewModel = hiltViewModel(), + onNavigateToLocations: () -> Unit, + onNavigateToSearch: () -> Unit, + onNavigateToCreateItem: () -> Unit, + onLogout: () -> Unit ) { + // [ACTION] Собираем состояние из ViewModel val uiState by viewModel.uiState.collectAsState() + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val scope = rememberCoroutineScope() - Scaffold { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - when (val state = uiState) { - is DashboardUiState.Loading -> { - // [UI-ACTION] Показываем индикатор загрузки - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + // [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)) + } + } + ) + } + ) { paddingValues -> + // [ANCHOR] Основной контент экрана + DashboardContent( + modifier = Modifier.padding(paddingValues), + uiState = uiState, + onLocationClick = { /* TODO */ }, + onLabelClick = { /* TODO */ } + ) + } + } +} + +// [ANCHOR] Компонент основного контента +@Composable +private fun DashboardContent( + modifier: Modifier = Modifier, + uiState: DashboardUiState, + onLocationClick: (LocationOutCount) -> Unit, + onLabelClick: (LabelOut) -> Unit +) { + // [FIX] Based on the UiState, we decide what to show + when (uiState) { + is DashboardUiState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is DashboardUiState.Error -> { + Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Text( + text = uiState.message, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + is DashboardUiState.Success -> { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + item { Spacer(modifier = Modifier.height(8.dp)) } + + // [ANCHOR] Секция "Быстрая статистика" + item { + StatisticsSection(statistics = uiState.statistics) } - is DashboardUiState.Error -> { - // [UI-ACTION] Показываем сообщение об ошибке - val errorMessage = "Error: ${state.message}" - Text( - text = errorMessage, - modifier = Modifier.align(Alignment.Center) - ) - Timber.w("[UI-STATE] Displaying Error: $errorMessage") + + // [ANCHOR] Секция "Недавно добавлено" + item { + // TODO: Add recently added items to UiState and display them here + // RecentlyAddedSection(items = uiState.recentlyAddedItems) } - is DashboardUiState.Success -> { - // [UI-ACTION] Отображаем основной контент - Timber.d("[UI-STATE] Displaying Success") - DashboardContent(state) + + // [ANCHOR] Секция "Места хранения" + item { + LocationsSection(locations = uiState.locations, onLocationClick = onLocationClick) + } + + // [ANCHOR] Секция "Метки" + item { + LabelsSection(labels = uiState.labels, onLabelClick = onLabelClick) + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } + } + } +} + +// [ANCHOR] Секция статистики +@Composable +private fun StatisticsSection(statistics: GroupStatistics) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(id = R.string.dashboard_section_quick_stats), + style = MaterialTheme.typography.titleMedium + ) + Card { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.height(120.dp).fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_items), value = statistics.items.toString()) } + item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_value), value = statistics.totalValue.toString()) } + item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_labels), value = statistics.labels.toString()) } + item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_locations), value = statistics.locations.toString()) } + } + } + } +} + +@Composable +private fun StatisticCard(title: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(text = title, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center) + Text(text = value, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + } +} + +// [ANCHOR] Секция недавно добавленных +@Composable +private fun RecentlyAddedSection(items: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(id = R.string.dashboard_section_recently_added), + style = MaterialTheme.typography.titleMedium + ) + if (items.isEmpty()) { + Text( + text = stringResource(id = R.string.items_not_found), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + textAlign = TextAlign.Center + ) + } else { + LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + items(items) { item -> + ItemCard(item = item) } } } } } -/** - * [CONTRACT] - * Composable для отображения успешного состояния дэшборда. - * @param state Состояние UI с данными. - */ @Composable -fun DashboardContent(state: DashboardUiState.Success) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // [UI-COMPONENT] Статистика - Text(text = "Statistics:") - Text(text = " Items: ${state.statistics.items}") - Text(text = " Locations: ${state.statistics.locations}") - Text(text = " Labels: ${state.statistics.labels}") - Text(text = " Total Value: ${state.statistics.totalValue}") - - // [UI-COMPONENT] Локации - Text(text = "Locations:") - state.locations.forEach { location -> - Text(text = " - ${location.name} (${location.itemCount})") - } - - // [UI-COMPONENT] Метки - Text(text = "Labels:") - state.labels.forEach { label -> - Text(text = " - ${label.name}") +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(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) } } } -// [END_FILE_DashboardScreen.kt] \ No newline at end of file + + +// [ANCHOR] Секция местоположений +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LocationsSection(locations: List, onLocationClick: (LocationOutCount) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(id = R.string.dashboard_section_locations), + style = MaterialTheme.typography.titleMedium + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + locations.forEach { location -> + SuggestionChip( + onClick = { onLocationClick(location) }, + label = { Text("${location.name} (${location.itemCount})") } + ) + } + } + } +} + +// [ANCHOR] Секция меток +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun LabelsSection(labels: List, onLabelClick: (LabelOut) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(id = R.string.dashboard_section_labels), + style = MaterialTheme.typography.titleMedium + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + labels.forEach { label -> + SuggestionChip( + onClick = { onLabelClick(label) }, + label = { Text(label.name) } + ) + } + } + } +} + + +// [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(showBackground = true, name = "Dashboard Success State") +@Composable +fun DashboardContentSuccessPreview() { + val previewState = DashboardUiState.Success( + statistics = GroupStatistics( + items = 123, + totalValue = 9999.99, + locations = 5, + labels = 8 + ), + locations = listOf( + LocationOutCount(id="1", name="Office", color = "#FF0000", isArchived = false, itemCount = 10, createdAt = "", updatedAt = ""), + LocationOutCount(id="2", name="Garage", color = "#00FF00", isArchived = false, itemCount = 5, createdAt = "", updatedAt = ""), + LocationOutCount(id="3",name="Living Room", color = "#0000FF", isArchived = false, itemCount = 15, createdAt = "", updatedAt = ""), + LocationOutCount(id="4",name="Kitchen", color = "#FFFF00", isArchived = false, itemCount = 20, createdAt = "", updatedAt = ""), + LocationOutCount(id="5",name="Basement", color = "#00FFFF", isArchived = false, itemCount = 3, createdAt = "", updatedAt = "") + ), + labels = listOf( + LabelOut(id="1", name="electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""), + 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 = "") + ) + ) + HomeboxLensTheme { + DashboardContent( + uiState = previewState, + onLocationClick = {}, + onLabelClick = {} + ) + } +} + +@Preview(showBackground = true, name = "Dashboard Loading State") +@Composable +fun DashboardContentLoadingPreview() { + HomeboxLensTheme { + DashboardContent( + uiState = DashboardUiState.Loading, + onLocationClick = {}, + onLabelClick = {} + ) + } +} + +@Preview(showBackground = true, name = "Dashboard Error State") +@Composable +fun DashboardContentErrorPreview() { + HomeboxLensTheme { + DashboardContent( + uiState = DashboardUiState.Error(stringResource(id = R.string.error_loading_failed)), + onLocationClick = {}, + onLabelClick = {} + ) + } +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..5519af4 --- /dev/null +++ b/app/src/main/res/values-en/strings.xml @@ -0,0 +1,32 @@ + + Homebox Lens + + + Create + Search + Logout + No location + Items not found + Failed to load data. Please try again. + + + Open navigation drawer + Scan QR code + + + Dashboard + Quick Stats + Recently Added + Locations + Labels + + + Total Items + Total Value + Total Labels + Total Locations + + + Locations + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a66be8f..5ba762b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,32 @@ Homebox Lens - + + + Создать + Поиск + Выйти + Нет локации + Элементы не найдены + Не удалось загрузить данные. Пожалуйста, попробуйте еще раз. + + + Открыть боковое меню + Сканировать QR-код + + + Главная + Быстрая статистика + Недавно добавлено + Места хранения + Метки + + + Всего вещей + Общая стоимость + Всего меток + Всего локаций + + + Локации + + \ No newline at end of file diff --git a/tech_spec/tech_spec.txt b/tech_spec/tech_spec.txt index 8949c42..5354374 100644 --- a/tech_spec/tech_spec.txt +++ b/tech_spec/tech_spec.txt @@ -10,6 +10,41 @@ Библиотека логирования В проекте используется Timber (timber.log.Timber) для всех целей логирования. Он предоставляет простой и расширяемый API для логирования. + + Интернационализация (Мультиязычность) + + Приложение должно поддерживать несколько языков для обеспечения доступности для глобальной аудитории. + Реализация будет основана на стандартном механизме ресурсов Android. + - Все строки, видимые пользователю, должны быть вынесены в файл `app/src/main/res/values/strings.xml`. Использование жестко закодированных строк в коде запрещено. + - Язык по умолчанию - русский (ru). Файл `strings.xml` будет содержать русские строки. + - Для поддержки других языков (например, английского - en) будут создаваться соответствующие каталоги ресурсов (например, `app/src/main/res/values-en/strings.xml`). + - В коде для доступа к строкам необходимо использовать ссылки на ресурсы (например, `R.string.app_name`). + + + + UI Framework + Пользовательский интерфейс приложения построен с использованием Jetpack Compose, современного декларативного UI-фреймворка от Google. Это обеспечивает быстрое создание, гибкость и поддержку динамических данных. + + + Внедрение зависимостей (Dependency Injection) + Для управления зависимостями в проекте используется Hilt. Он интегрирован с компонентами Jetpack и упрощает внедрение зависимостей в Android-приложениях. + + + Навигация + Навигация между экранами (Composable-функциями) реализована с помощью библиотеки Navigation Compose, которая является частью Jetpack Navigation. + + + Асинхронные операции + Все асинхронные операции, такие как сетевые запросы или доступ к базе данных, выполняются с использованием Kotlin Coroutines. Это обеспечивает эффективное управление фоновыми задачами без блокировки основного потока. + + + Сетевое взаимодействие + Для взаимодействия с API сервера Homebox используется стек технологий: Retrofit для создания типобезопасных HTTP-клиентов, OkHttp в качестве HTTP-клиента и Moshi для парсинга JSON. + + + Локальное хранилище + Для кэширования данных на устройстве используется библиотека Room. Она предоставляет абстракцию над SQLite и обеспечивает надежное локальное хранение данных. + @@ -112,6 +147,92 @@ + + + + Главный экран "Панель управления" + + Экран предоставляет обзорную информацию и быстрый доступ к основным функциям. Компоновка должна быть чистой и интуитивно понятной, аналогично веб-интерфейсу HomeBox. + + + + Верхняя панель приложения. Содержит иконку навигационного меню (гамбургер), название/логотип приложения и иконку для запуска сканера (например, QR-кода). + + + Боковое навигационное меню. Открывается по нажатию на иконку в TopAppBar. Содержит основные разделы: Главная, Локации, Поиск, Профиль, Инструменты, а также кнопку "Выйти". + + + Основная область контента. Содержит несколько информационных блоков. + + Сетка из 2x2 карточек, отображающих ключевые метрики. + + + + + + + Горизонтально прокручиваемый список карточек недавно добавленных предметов. Если предметов нет, отображается сообщение "Элементы не найдены". + + + Сетка или гибкий контейнер (FlowRow) с кликабельными чипами, представляющими местоположения. Нажатие на чип ведет к списку предметов в этом местоположении. + + + Сетка или гибкий контейнер (FlowRow) с кликабельными чипами, представляющими метки. Нажатие на чип ведет к списку предметов с этой меткой. + + + + + Вместо плавающей кнопки (FAB), в референсе используется заметная кнопка "Создать" в навигационном меню. Мы будем придерживаться этого подхода для консистентности. Эта кнопка инициирует процесс создания нового предмета. + + + + + + + + Экран "Локации" + + Отображает вертикальный список всех доступных местоположений. Экран должен быть интегрирован в общую структуру навигации приложения (TopAppBar, NavigationDrawer). + + + + Общая верхняя панель приложения, аналогичная экрану "Панель управления". + + + Общее боковое меню навигации. + + + Основная область контента, занимающая все доступное пространство под TopAppBar. + + Заголовок экрана, расположенный вверху основной области контента. + + + Вертикальный, прокручиваемый список (LazyColumn) всех местоположений. + + Элемент списка, представляющий одно местоположение. Состоит из иконки (например, 'place') и названия местоположения. Весь элемент является кликабельным и ведет на экран со списком предметов в данной локации. + + + + + + Плавающая кнопка действия, расположенная в правом нижнем углу. Позволяет пользователю добавить новое местоположение. В веб-версии для этого используются иконки в углу, но FAB является более нативным паттерном для Android. + + + + + + Нажатие на элемент списка локаций + Осуществляется навигация на экран списка инвентаря, отфильтрованного по выбранной локации. + + + Нажатие на FloatingActionButton + Открывается диалоговое окно или новый экран для создания нового местоположения. + + + + + +