This commit is contained in:
2025-09-26 10:30:59 +03:00
parent aa69776807
commit 394e0040de
82 changed files with 5324 additions and 1998 deletions

View File

@@ -0,0 +1,95 @@
// [FILE] build.gradle.kts
// [SEMANTICS] build, configuration, module, feature, dashboard
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
}
android {
namespace = "com.homebox.lens.feature.dashboard"
compileSdk = Versions.compileSdk
defaultConfig {
minSdk = Versions.minSdk
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// [MODULE_DEPENDENCY] Core modules
implementation(project(":domain"))
implementation(project(":data"))
// [DEPENDENCY] AndroidX
implementation(Libs.coreKtx)
implementation(Libs.lifecycleRuntime)
implementation(Libs.activityCompose)
// [DEPENDENCY] Compose
implementation(Libs.composeUi)
implementation(Libs.composeUiGraphics)
implementation(Libs.composeUiToolingPreview)
implementation(Libs.composeMaterial3)
implementation("androidx.compose.material:material-icons-extended-android:1.6.8")
implementation(Libs.navigationCompose)
implementation(Libs.hiltNavigationCompose)
// [DEPENDENCY] DI (Hilt)
implementation(Libs.hiltAndroid)
kapt(Libs.hiltCompiler)
// [DEPENDENCY] Logging
implementation(Libs.timber)
// [DEPENDENCY] Testing
testImplementation(Libs.junit)
testImplementation(Libs.kotestRunnerJunit5)
testImplementation(Libs.kotestAssertionsCore)
testImplementation(Libs.mockk)
testImplementation("app.cash.turbine:turbine:1.1.0")
androidTestImplementation(Libs.extJunit)
androidTestImplementation(Libs.espressoCore)
androidTestImplementation(Libs.composeUiTestJunit4)
debugImplementation(Libs.composeUiTooling)
debugImplementation(Libs.composeUiTestManifest)
}
kapt {
correctErrorTypes = true
}
// [END_FILE_build.gradle.kts]

View File

@@ -0,0 +1,63 @@
// [PACKAGE] com.homebox.lens.feature.dashboard
// [FILE] DashboardNavigation.kt
// [SEMANTICS] navigation, compose, nav_host, dashboard
package com.homebox.lens.feature.dashboard
// [IMPORTS]
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import com.homebox.lens.navigation.NavigationActions
// [END_IMPORTS]
// [ENTITY: Function('addDashboardScreen')]
// [RELATION: Function('addDashboardScreen')] -> [DEPENDS_ON] -> [Function('DashboardScreen')]
/**
* @summary Extension function for NavGraphBuilder to add the Dashboard screen to the navigation graph.
* @description Registers the Dashboard route and composes the DashboardScreen with appropriate navigation actions and common UI components.
* @param route The route string for the Dashboard screen.
* @param currentRoute The current navigation route, used for highlighting.
* @param navigateToScan Lambda for navigating to the scan screen.
* @param navigateToSearch Lambda for navigating to the search screen.
* @param navigateToInventoryListWithLocation Lambda for navigating to inventory filtered by location.
* @param navigateToInventoryListWithLabel Lambda for navigating to inventory filtered by label.
* @param MainScaffoldContent Composable lambda for the main scaffold structure.
* @param HomeboxLensTheme Composable lambda for applying the application theme.
* @sideeffect Adds a composable route for the Dashboard screen.
*/
fun NavGraphBuilder.addDashboardScreen(
route: String,
currentRoute: String?,
navigateToScan: () -> Unit,
navigateToSearch: () -> Unit,
navigateToInventoryListWithLocation: (String) -> Unit,
navigateToInventoryListWithLabel: (String) -> Unit,
navigationActions: NavigationActions,
navController: NavHostController,
MainScaffoldContent: @Composable (
topBarTitle: String,
currentRoute: String?,
navigationActions: NavigationActions,
topBarActions: @Composable () -> Unit,
content: @Composable (PaddingValues) -> Unit
) -> Unit,
HomeboxLensTheme: @Composable (content: @Composable () -> Unit) -> Unit
) {
composable(route = route) {
DashboardScreen(
currentRoute = currentRoute,
navigateToScan = navigateToScan,
navigateToSearch = navigateToSearch,
navigateToInventoryListWithLocation = navigateToInventoryListWithLocation,
navigateToInventoryListWithLabel = navigateToInventoryListWithLabel,
MainScaffoldContent = MainScaffoldContent,
HomeboxLensTheme = HomeboxLensTheme,
navigationActions = navigationActions,
navController = navController
)
}
}
// [END_ENTITY: Function('addDashboardScreen')]
// [END_FILE_DashboardNavigation.kt]

View File

@@ -0,0 +1,398 @@
// [PACKAGE] com.homebox.lens.feature.dashboard
// [FILE] DashboardScreen.kt
// Semantic information: ui, screen, dashboard, compose, navigation
package com.homebox.lens.feature.dashboard
// [IMPORTS]
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ExperimentalLayoutApi
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.QrCodeScanner
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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 androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.homebox.lens.domain.model.*
import com.homebox.lens.feature.dashboard.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.theme.HomeboxLensTheme
import timber.log.Timber
// [END_IMPORTS]
// [ENTITY: Function('DashboardScreen')]
// [RELATION: Function('DashboardScreen')] -> [DEPENDS_ON] -> [ViewModel('DashboardViewModel')]
// [RELATION: Function('DashboardScreen')] -> [CALLS] -> [Function('MainScaffold')]
/**
* @summary Главная Composable-функция для экрана "Панель управления".
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigateToScan Лямбда для навигации на экран сканирования.
* @param navigateToSearch Лямбда для навигации на экран поиска.
* @param navigateToInventoryListWithLocation Лямбда для навигации на список инвентаря с фильтром по локации.
* @param navigateToInventoryListWithLabel Лямбда для навигации на список инвентаря с фильтром по метке.
* @param MainScaffoldContent Composable-функция для отображения основного Scaffold.
* @param HomeboxLensTheme Composable-функция для применения темы.
* @sideeffect Вызывает навигационные лямбды при взаимодействии с UI.
*/
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel(),
currentRoute: String?,
navigateToScan: () -> Unit,
navigateToSearch: () -> Unit,
navigateToInventoryListWithLocation: (String) -> Unit,
navigateToInventoryListWithLabel: (String) -> Unit,
navigationActions: NavigationActions,
navController: NavHostController,
MainScaffoldContent: @Composable (
topBarTitle: String,
currentRoute: String?,
navigationActions: NavigationActions,
onNavigateUp: (() -> Unit)?,
topBarActions: @Composable () -> Unit,
snackbarHost: @Composable () -> Unit,
floatingActionButton: @Composable () -> Unit,
content: @Composable (PaddingValues) -> Unit
) -> Unit,
HomeboxLensTheme: @Composable (content: @Composable () -> Unit) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadDashboardData()
}
HomeboxLensTheme {
MainScaffoldContent(
topBarTitle = stringResource(id = R.string.dashboard_title),
currentRoute = currentRoute,
navigationActions = navigationActions,
onNavigateUp = null, // Dashboard doesn't have an "Up" button
topBarActions = {
IconButton(onClick = navigateToScan) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(id = R.string.cd_scan_qr_code)
)
}
IconButton(onClick = navigateToSearch) {
Icon(
Icons.Default.Search,
contentDescription = stringResource(id = R.string.cd_search)
)
}
},
snackbarHost = {}, // Not used in Dashboard
floatingActionButton = {} // Not used in Dashboard
) { paddingValues ->
DashboardContent(
modifier = Modifier.padding(paddingValues),
uiState = uiState,
onLocationClick = { location ->
Timber.i("[INFO][ACTION][navigate_to_inventory_with_location] Location chip clicked: ${location.id}. Navigating...")
navigateToInventoryListWithLocation(location.id)
},
onLabelClick = { label ->
Timber.i("[INFO][ACTION][navigate_to_inventory_with_label] Label chip clicked: ${label.id}. Navigating...")
navigateToInventoryListWithLabel(label.id)
}
)
}
}
}
// [END_ENTITY: Function('DashboardScreen')]
// [ENTITY: Function('DashboardContent')]
// [RELATION: Function('DashboardContent')] -> [CONSUMES_STATE] -> [SealedInterface('DashboardUiState')]
/**
* @summary Отображает основной контент экрана в зависимости от uiState.
* @param modifier Модификатор для стилизации.
* @param uiState Текущее состояние UI экрана.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
* @param onLabelClick Лямбда-обработчик нажатия на метку.
*/
@Composable
private fun DashboardContent(
modifier: Modifier = Modifier,
uiState: DashboardUiState,
onLocationClick: (LocationOutCount) -> Unit,
onLabelClick: (LabelOut) -> Unit
) {
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)) }
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_ENTITY: Function('DashboardContent')]
// [ENTITY: Function('StatisticsSection')]
// [RELATION: Function('StatisticsSection')] -> [DEPENDS_ON] -> [DataClass('GroupStatistics')]
/**
* @summary Секция для отображения общей статистики.
* @param statistics Объект со статистическими данными.
*/
@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()) }
}
}
}
}
// [END_ENTITY: Function('StatisticsSection')]
// [ENTITY: Function('StatisticCard')]
/**
* @summary Карточка для отображения одного статистического показателя.
* @param title Название показателя.
* @param value Значение показателя.
*/
@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)
}
}
// [END_ENTITY: Function('StatisticCard')]
// [ENTITY: Function('RecentlyAddedSection')]
// [RELATION: Function('RecentlyAddedSection')] -> [DEPENDS_ON] -> [DataClass('ItemSummary')]
/**
* @summary Секция для отображения недавно добавленных элементов.
* @param items Список элементов для отображения.
*/
@Composable
private fun RecentlyAddedSection(items: List<ItemSummary>) {
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)
}
}
}
}
}
// [END_ENTITY: Function('RecentlyAddedSection')]
// [ENTITY: Function('ItemCard')]
// [RELATION: Function('ItemCard')] -> [DEPENDS_ON] -> [DataClass('ItemSummary')]
/**
* @summary Карточка для отображения краткой информации об элементе.
* @param item Элемент для отображения.
*/
@Composable
private fun ItemCard(item: ItemSummary) {
Card(modifier = Modifier.width(150.dp)) {
Column(modifier = Modifier.padding(8.dp)) {
// [AI_NOTE]: 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_ENTITY: Function('ItemCard')]
// [ENTITY: Function('LocationsSection')]
// [RELATION: Function('LocationsSection')] -> [DEPENDS_ON] -> [DataClass('LocationOutCount')]
/**
* @summary Секция для отображения местоположений в виде чипсов.
* @param locations Список местоположений.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LocationsSection(locations: List<LocationOutCount>, 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(stringResource(id = R.string.location_chip_label, location.name, location.itemCount)) }
)
}
}
}
}
// [END_ENTITY: Function('LocationsSection')]
// [ENTITY: Function('LabelsSection')]
// [RELATION: Function('LabelsSection')] -> [DEPENDS_ON] -> [DataClass('LabelOut')]
/**
* @summary Секция для отображения меток в виде чипсов.
* @param labels Список меток.
* @param onLabelClick Лямбда-обработчик нажатия на метку.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LabelsSection(labels: List<LabelOut>, 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) }
)
}
}
}
}
// [END_ENTITY: Function('LabelsSection')]
// [ENTITY: Function('DashboardContentSuccessPreview')]
@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 = "")
),
recentlyAddedItems = emptyList()
)
HomeboxLensTheme {
DashboardContent(
uiState = previewState,
onLocationClick = {},
onLabelClick = {}
)
}
}
// [END_ENTITY: Function('DashboardContentSuccessPreview')]
// [ENTITY: Function('DashboardContentLoadingPreview')]
@Preview(showBackground = true, name = "Dashboard Loading State")
@Composable
fun DashboardContentLoadingPreview() {
HomeboxLensTheme {
DashboardContent(
uiState = DashboardUiState.Loading,
onLocationClick = {},
onLabelClick = {}
)
}
}
// [END_ENTITY: Function('DashboardContentLoadingPreview')]
// [ENTITY: Function('DashboardContentErrorPreview')]
@Preview(showBackground = true, name = "Dashboard Error State")
@Composable
fun DashboardContentErrorPreview() {
HomeboxLensTheme {
DashboardContent(
uiState = DashboardUiState.Error(stringResource(id = R.string.error_loading_failed)),
onLocationClick = {},
onLabelClick = {}
)
}
}
// [END_ENTITY: Function('DashboardContentErrorPreview')]
// [END_FILE_DashboardScreen.kt]

View File

@@ -0,0 +1,55 @@
// [PACKAGE] com.homebox.lens.feature.dashboard
// [FILE] DashboardUiState.kt
// [SEMANTICS] ui, state, dashboard
package com.homebox.lens.feature.dashboard
// [IMPORTS]
import com.homebox.lens.domain.model.GroupStatistics
import com.homebox.lens.domain.model.ItemSummary
import com.homebox.lens.domain.model.LabelOut
import com.homebox.lens.domain.model.LocationOutCount
// [END_IMPORTS]
// [ENTITY: SealedInterface('DashboardUiState')]
/**
* @summary Определяет все возможные состояния для экрана "Дэшборд".
* @invariant В любой момент времени экран может находиться только в одном из этих состояний.
*/
sealed interface DashboardUiState {
// [ENTITY: DataClass('Success')]
// [RELATION: DataClass('Success')] -> [DEPENDS_ON] -> [DataClass('GroupStatistics')]
// [RELATION: DataClass('Success')] -> [DEPENDS_ON] -> [DataClass('LocationOutCount')]
// [RELATION: DataClass('Success')] -> [DEPENDS_ON] -> [DataClass('LabelOut')]
// [RELATION: DataClass('Success')] -> [DEPENDS_ON] -> [DataClass('ItemSummary')]
/**
* @summary Состояние успешной загрузки данных.
* @param statistics Статистика по инвентарю.
* @param locations Список локаций со счетчиками.
* @param labels Список всех меток.
* @param recentlyAddedItems Список недавно добавленных товаров.
*/
data class Success(
val statistics: GroupStatistics,
val locations: List<LocationOutCount>,
val labels: List<LabelOut>,
val recentlyAddedItems: List<ItemSummary>
) : DashboardUiState
// [END_ENTITY: DataClass('Success')]
// [ENTITY: DataClass('Error')]
/**
* @summary Состояние ошибки во время загрузки данных.
* @param message Человекочитаемое сообщение об ошибке.
*/
data class Error(val message: String) : DashboardUiState
// [END_ENTITY: DataClass('Error')]
// [ENTITY: Object('Loading')]
/**
* @summary Состояние, когда данные для экрана загружаются.
*/
data object Loading : DashboardUiState
// [END_ENTITY: Object('Loading')]
}
// [END_ENTITY: SealedInterface('DashboardUiState')]
// [END_FILE_DashboardUiState.kt]

View File

@@ -0,0 +1,87 @@
// [PACKAGE] com.homebox.lens.feature.dashboard
// [FILE] DashboardViewModel.kt
// [SEMANTICS] ui_logic, dashboard, state_management, sealed_state, parallel_data_loading, timber_logging
package com.homebox.lens.feature.dashboard
// [IMPORTS]
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
// [END_IMPORTS]
// [ENTITY: ViewModel('DashboardViewModel')]
// [RELATION: ViewModel('DashboardViewModel')] -> [DEPENDS_ON] -> [UseCase('GetStatisticsUseCase')]
// [RELATION: ViewModel('DashboardViewModel')] -> [DEPENDS_ON] -> [UseCase('GetAllLocationsUseCase')]
// [RELATION: ViewModel('DashboardViewModel')] -> [DEPENDS_ON] -> [UseCase('GetAllLabelsUseCase')]
// [RELATION: ViewModel('DashboardViewModel')] -> [DEPENDS_ON] -> [UseCase('GetRecentlyAddedItemsUseCase')]
// [RELATION: ViewModel('DashboardViewModel')] -> [EMITS_STATE] -> [SealedInterface('DashboardUiState')]
/**
* @summary ViewModel для главного экрана (Dashboard).
* @description Оркестрирует загрузку данных для Dashboard, используя строгую модель состояний
* (`DashboardUiState`), и обрабатывает параллельные запросы без состояний гонки.
* @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`.
*/
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val getStatisticsUseCase: GetStatisticsUseCase,
private val getAllLocationsUseCase: GetAllLocationsUseCase,
private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
val uiState = _uiState.asStateFlow()
// [ENTITY: Function('loadDashboardData')]
/**
* @summary Загружает все необходимые данные для экрана Dashboard.
* @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`.
* @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`.
*/
fun loadDashboardData() {
if (uiState.value is DashboardUiState.Success || uiState.value is DashboardUiState.Loading) {
Timber.i("[INFO][SKIP][already_loaded] Dashboard data load skipped - already in progress or loaded.")
return
}
viewModelScope.launch {
_uiState.value = DashboardUiState.Loading
Timber.i("[INFO][ENTRYPOINT][loading_dashboard_data] Starting dashboard data collection.")
val statsFlow = flow { emit(getStatisticsUseCase()) }
val locationsFlow = flow { emit(getAllLocationsUseCase()) }
val labelsFlow = flow { emit(getAllLabelsUseCase()) }
val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10)
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][EXCEPTION][loading_failed] Failed to load dashboard data. State -> Error.")
_uiState.value = DashboardUiState.Error(
message = exception.message ?: "Could not load dashboard data."
)
}.collect { successState ->
Timber.i("[INFO][SUCCESS][dashboard_data_loaded] Dashboard data loaded successfully. State -> Success.")
_uiState.value = successState
}
}
}
// [END_ENTITY: Function('loadDashboardData')]
}
// [END_ENTITY: ViewModel('DashboardViewModel')]
// [END_FILE_DashboardViewModel.kt]

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dashboard Screen -->
<string name="dashboard_title">Главная</string>
<string name="dashboard_section_quick_stats">Быстрая статистика</string>
<string name="dashboard_section_recently_added">Недавно добавлено</string>
<string name="dashboard_section_locations">Места хранения</string>
<string name="dashboard_section_labels">Метки</string>
<string name="location_chip_label">%1$s (%2$d)</string>
<!-- Dashboard Statistics -->
<string name="dashboard_stat_total_items">Всего вещей</string>
<string name="dashboard_stat_total_value">Общая стоимость</string>
<string name="dashboard_stat_total_labels">Всего меток</string>
<string name="dashboard_stat_total_locations">Всего локаций</string>
<!-- Common -->
<string name="items_not_found">Элементы не найдены</string>
<string name="no_location">Нет локации</string>
<string name="error_loading_failed">Не удалось загрузить данные. Пожалуйста, попробуйте еще раз.</string>
<!-- Content Descriptions -->
<string name="cd_scan_qr_code">Сканировать QR/штрих-код</string>
<string name="cd_search">Поиск</string>
</resources>

View File

@@ -0,0 +1,72 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.homebox.lens.feature.scan"
compileSdk = 36
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(project(":domain"))
implementation(project(":data"))
implementation(Libs.coreKtx)
implementation(Libs.lifecycleRuntime)
implementation(Libs.activityCompose)
// CameraX
// CameraX
implementation("androidx.camera:camera-core:1.3.4")
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
// ML Kit Barcode Scanning
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// Compose
implementation(Libs.composeUi)
implementation(Libs.composeUiGraphics)
implementation(Libs.composeUiToolingPreview)
implementation(Libs.composeMaterial3)
implementation(Libs.navigationCompose)
implementation(Libs.hiltNavigationCompose)
// Hilt
implementation(Libs.hiltAndroid)
ksp(Libs.hiltCompiler)
// Logging
implementation(Libs.timber)
// Testing
testImplementation(Libs.junit)
testImplementation(Libs.kotestRunnerJunit5)
testImplementation(Libs.kotestAssertionsCore)
testImplementation(Libs.mockk)
testImplementation("app.cash.turbine:turbine:1.1.0")
androidTestImplementation(Libs.extJunit)
androidTestImplementation(Libs.espressoCore)
androidTestImplementation(Libs.composeUiTestJunit4)
debugImplementation(Libs.composeUiTooling)
debugImplementation(Libs.composeUiTestManifest)
}

View File

@@ -0,0 +1,62 @@
// [FILE] BarcodeAnalyzer.kt
// [SEMANTICS] camera, barcode_scanning, utility
package com.homebox.lens.feature.scan
// [IMPORTS]
import android.annotation.SuppressLint
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
// [END_IMPORTS]
// [ENTITY: Class('BarcodeAnalyzer')]
// [RELATION: Class('BarcodeAnalyzer')] -> [DEPENDS_ON] -> [Class('BarcodeScanning')]
// [RELATION: Class('BarcodeAnalyzer')] -> [DEPENDS_ON] -> [Class('InputImage')]
/**
* @summary Анализатор изображений для обнаружения штрих-кодов с использованием ML Kit.
* @param onBarcodeDetected Лямбда-функция, вызываемая при обнаружении штрих-кода.
* @description Этот класс реализует [ImageAnalysis.Analyzer] для обработки кадров с камеры и извлечения информации о штрих-кодах.
*/
class BarcodeAnalyzer(private val onBarcodeDetected: (String) -> Unit) : ImageAnalysis.Analyzer {
// [ENTITY: Property('options')]
private val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.build()
// [END_ENTITY: Property('options')]
// [ENTITY: Property('scanner')]
private val scanner = BarcodeScanning.getClient(options)
// [END_ENTITY: Property('scanner')]
// [ENTITY: Function('analyze')]
/**
* @summary Анализирует кадр изображения на наличие штрих-кодов.
* @param imageProxy Объект [ImageProxy], содержащий данные изображения с камеры.
* @sideeffect Вызывает `onBarcodeDetected` при успешном обнаружении штрих-кода.
* @precondition `imageProxy.image` не должен быть null.
*/
@SuppressLint("UnsafeOptInUsageError")
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(image)
.addOnSuccessListener {
if (it.isNotEmpty()) {
onBarcodeDetected(it.first().rawValue ?: "")
}
}
.addOnCompleteListener { imageProxy.close() }
}
}
// [END_ENTITY: Function('analyze')]
}
// [END_ENTITY: Class('BarcodeAnalyzer')]
// [END_FILE_BarcodeAnalyzer.kt]

View File

@@ -0,0 +1,132 @@
// [FILE] ScanScreen.kt
// [SEMANTICS] ui, screen, scan, compose, camera, barcode_scanning
package com.homebox.lens.feature.scan
// [IMPORTS]
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import java.util.concurrent.Executors
// [END_IMPORTS]
// [ENTITY: Function('ScanScreen')]
// [RELATION: Function('ScanScreen')] -> [DEPENDS_ON] -> [ViewModel('ScanViewModel')]
/**
* @summary Composable-функция для экрана сканирования QR/штрих-кодов.
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
* @sideeffect Запрашивает разрешение на использование камеры, управляет жизненным циклом камеры.
* @invariant Состояние UI отображается в соответствии с `ScanUiState`.
*/
@Composable
fun ScanScreen(
viewModel: ScanViewModel = viewModel(),
onBarcodeResult: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
// Permission granted, set up camera
} else {
viewModel.onError("Camera permission denied")
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_CREATE) {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
// Permission already granted, set up camera
} else {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
cameraExecutor.shutdown()
}
}
Column(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = {
PreviewView(it).apply {
this.scaleType = PreviewView.ScaleType.FILL_CENTER
}
},
modifier = Modifier.fillMaxSize(),
update = { view ->
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also { preview ->
preview.setSurfaceProvider(view.surfaceProvider)
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(cameraExecutor, BarcodeAnalyzer { barcode ->
viewModel.onBarcodeScanned(barcode)
onBarcodeResult(barcode)
})
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
} catch (e: Exception) {
viewModel.onError("Camera initialization failed: ${e.message}")
}
}
)
when (uiState) {
is ScanUiState.Success -> Text(text = "Scanned: ${(uiState as ScanUiState.Success).barcode}")
is ScanUiState.Error -> Text(text = "Error: ${(uiState as ScanUiState.Error).message}")
ScanUiState.Loading -> Text(text = "Scanning...")
ScanUiState.Idle -> Text(text = "Waiting to scan...")
else -> {}
}
}
}
// [END_ENTITY: Function('ScanScreen')]
// [END_FILE_ScanScreen.kt]

View File

@@ -0,0 +1,52 @@
// [FILE] ScanUiState.kt
// [SEMANTICS] ui, state_management, scan, item_creation
package com.homebox.lens.feature.scan
// [ENTITY: SealedInterface('ScanUiState')]
/**
* @summary Определяет все возможные состояния UI для экрана сканирования.
* @description Использование sealed-интерфейса позволяет исчерпывающе обрабатывать все состояния в Composable-функциях.
*/
sealed interface ScanUiState {
// [ENTITY: DataClass('Success')]
/**
* @summary Состояние успешного сканирования.
* @param barcode Обнаруженный штрих-код или QR-код.
* @invariant barcode не может быть пустым.
*/
data class Success(val barcode: String) : ScanUiState {
init { require(barcode.isNotBlank()) { "Barcode cannot be blank." } }
}
// [END_ENTITY: DataClass('Success')]
// [ENTITY: Object('Loading')]
/**
* @summary Состояние загрузки/сканирования.
* @description Указывает, что процесс сканирования активен.
*/
object Loading : ScanUiState
// [END_ENTITY: Object('Loading')]
// [ENTITY: DataClass('Error')]
/**
* @summary Состояние ошибки.
* @param message Сообщение об ошибке для отображения пользователю.
* @invariant message не может быть пустым.
*/
data class Error(val message: String) : ScanUiState {
init { require(message.isNotBlank()) { "Error message cannot be blank." } }
}
// [END_ENTITY: DataClass('Error')]
// [ENTITY: Object('Idle')]
/**
* @summary Начальное или бездействующее состояние.
* @description Указывает, что сканер ожидает начала работы.
*/
object Idle : ScanUiState
// [END_ENTITY: Object('Idle')]
}
// [END_ENTITY: SealedInterface('ScanUiState')]
// [END_FILE_ScanUiState.kt]

View File

@@ -0,0 +1,75 @@
// [FILE] ScanViewModel.kt
// [SEMANTICS] ui, viewmodel, state_management, scan
package com.homebox.lens.feature.scan
// [IMPORTS]
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber
// [END_IMPORTS]
// [ENTITY: Class('ScanViewModel')]
// [RELATION: Class('ScanViewModel')] -> [EMITS_STATE] -> [SealedInterface('ScanUiState')]
/**
* @summary ViewModel для экрана сканирования.
* @description Управляет состоянием UI экрана сканирования, обрабатывая результаты сканирования и ошибки.
* @invariant `uiState` всегда является одним из состояний, определенных в `ScanUiState`.
*/
class ScanViewModel : ViewModel() {
// [ENTITY: Property('_uiState')]
private val _uiState = MutableStateFlow<ScanUiState>(ScanUiState.Idle)
// [END_ENTITY: Property('_uiState')]
// [ENTITY: Property('uiState')]
/**
* @summary Текущее состояние UI экрана сканирования.
* @return [StateFlow] с текущим состоянием UI.
*/
val uiState: StateFlow<ScanUiState> = _uiState
// [END_ENTITY: Property('uiState')]
// [ENTITY: Function('onBarcodeScanned')]
/**
* @summary Обрабатывает событие успешного сканирования штрих-кода.
* @param barcode Обнаруженный штрих-код или QR-код.
* @sideeffect Обновляет `uiState` до [ScanUiState.Success].
* @precondition barcode не должен быть пустым.
*/
fun onBarcodeScanned(barcode: String) {
require(barcode.isNotBlank()) { "Scanned barcode cannot be blank." }
_uiState.value = ScanUiState.Success(barcode)
Timber.i("[INFO][SCAN_EVENT][BARCODE_SCANNED] Barcode: %s. State -> Success.", barcode)
}
// [END_ENTITY: Function('onBarcodeScanned')]
// [ENTITY: Function('onError')]
/**
* @summary Обрабатывает событие ошибки сканирования.
* @param message Сообщение об ошибке.
* @sideeffect Обновляет `uiState` до [ScanUiState.Error].
* @precondition message не должен быть пустым.
*/
fun onError(message: String) {
require(message.isNotBlank()) { "Error message cannot be blank." }
_uiState.value = ScanUiState.Error(message)
Timber.e("[ERROR][SCAN_EVENT][SCAN_ERROR] Error: %s. State -> Error.", message)
}
// [END_ENTITY: Function('onError')]
// [ENTITY: Function('resetState')]
/**
* @summary Сбрасывает состояние UI к начальному (Idle).
* @sideeffect Обновляет `uiState` до [ScanUiState.Idle].
*/
fun resetState() {
_uiState.value = ScanUiState.Idle
Timber.i("[INFO][SCAN_EVENT][STATE_RESET] State -> Idle.")
}
// [END_ENTITY: Function('resetState')]
}
// [END_ENTITY: Class('ScanViewModel')]
// [END_FILE_ScanViewModel.kt]