211
This commit is contained in:
95
feature/dashboard/build.gradle.kts
Normal file
95
feature/dashboard/build.gradle.kts
Normal 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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
25
feature/dashboard/src/main/res/values/strings.xml
Normal file
25
feature/dashboard/src/main/res/values/strings.xml
Normal 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>
|
||||
72
feature/scan/build.gradle.kts
Normal file
72
feature/scan/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user