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

@@ -4,6 +4,7 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
// id("org.jlleitschuh.gradle.ktlint") version "12.1.1"
@@ -46,9 +47,7 @@ android {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = Versions.composeCompiler
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -61,6 +60,8 @@ dependencies {
implementation(project(":data"))
// [MODULE_DEPENDENCY] Domain module (transitively included via data, but explicit for clarity)
implementation(project(":domain"))
implementation(project(":feature:scan"))
implementation(project(":feature:dashboard"))
// [DEPENDENCY] AndroidX
implementation(Libs.coreKtx)
@@ -68,7 +69,7 @@ dependencies {
implementation(Libs.activityCompose)
// [DEPENDENCY] Compose
implementation(platform(Libs.composeBom))
implementation(Libs.composeUi)
implementation(Libs.composeUiGraphics)
implementation(Libs.composeUiToolingPreview)
@@ -93,7 +94,7 @@ dependencies {
testImplementation("app.cash.turbine:turbine:1.1.0")
androidTestImplementation(Libs.extJunit)
androidTestImplementation(Libs.espressoCore)
androidTestImplementation(platform(Libs.composeBom))
androidTestImplementation(Libs.composeUiTestJunit4)
debugImplementation(Libs.composeUiTooling)
debugImplementation(Libs.composeUiTestManifest)

View File

@@ -15,7 +15,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.homebox.lens.ui.screen.dashboard.DashboardScreen
import com.homebox.lens.feature.dashboard.addDashboardScreen
import com.homebox.lens.ui.screen.inventorylist.InventoryListScreen
import com.homebox.lens.ui.screen.itemdetails.ItemDetailsScreen
import com.homebox.lens.ui.screen.itemedit.ItemEditScreen
@@ -25,6 +25,10 @@ import com.homebox.lens.ui.screen.locationedit.LocationEditScreen
import com.homebox.lens.ui.screen.locationslist.LocationsListScreen
import com.homebox.lens.ui.screen.search.SearchScreen
import com.homebox.lens.ui.screen.setup.SetupScreen
import com.homebox.lens.feature.scan.ScanScreen
import com.homebox.lens.ui.common.MainScaffold
import com.homebox.lens.ui.theme.HomeboxLensTheme
// import com.homebox.lens.ui.screen.settings.SettingsScreen
// [END_IMPORTS]
// [ENTITY: Function('NavGraph')]
@@ -59,12 +63,24 @@ fun NavGraph(
}
})
}
composable(route = Screen.Dashboard.route) {
DashboardScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
addDashboardScreen(
route = Screen.Dashboard.route,
currentRoute = currentRoute,
navigateToScan = navigationActions::navigateToScan,
navigateToSearch = navigationActions::navigateToSearch,
navigateToInventoryListWithLocation = navigationActions::navigateToInventoryListWithLocation,
navigateToInventoryListWithLabel = navigationActions::navigateToInventoryListWithLabel,
MainScaffoldContent = { topBarTitle, currentRoute, topBarActions, content ->
MainScaffold(
topBarTitle = topBarTitle,
currentRoute = currentRoute,
navigationActions = navigationActions,
topBarActions = topBarActions,
content = content
)
},
HomeboxLensTheme = { content -> HomeboxLensTheme(content = content) }
)
composable(route = Screen.InventoryList.route) {
InventoryListScreen(
currentRoute = currentRoute,
@@ -137,6 +153,20 @@ fun NavGraph(
navigationActions = navigationActions
)
}
composable(Screen.Settings.route) {
com.homebox.lens.ui.screen.settings.SettingsScreen(
currentRoute = currentRoute,
navigationActions = navigationActions,
onNavigateUp = { navController.navigateUp() }
)
}
composable(Screen.Scan.route) { backStackEntry ->
ScanScreen(onBarcodeResult = { barcode ->
val previousBackStackEntry = navController.previousBackStackEntry
previousBackStackEntry?.savedStateHandle?.set("barcodeResult", barcode)
navController.popBackStack()
})
}
}
}
// [END_ENTITY: Function('NavGraph')]

View File

@@ -15,7 +15,7 @@ import timber.log.Timber
* @param navController Контроллер Jetpack Navigation.
* @invariant Все навигационные действия должны использовать предоставленный navController.
*/
class NavigationActions(private val navController: NavHostController) {
class NavigationActions(val navController: NavHostController) {
// [ENTITY: Function('navigateToDashboard')]
/**
@@ -65,6 +65,30 @@ class NavigationActions(private val navController: NavHostController) {
}
// [END_ENTITY: Function('navigateToSearch')]
// [ENTITY: Function('navigateToScan')]
/**
* @summary Навигация на экран сканирования QR/штрих-кодов.
*/
fun navigateToScan() {
Timber.i("[INFO][ACTION][navigate_to_scan] Navigating to Scan screen.")
navController.navigate(Screen.Scan.route) {
launchSingleTop = true
}
}
// [END_ENTITY: Function('navigateToScan')]
// [ENTITY: Function('navigateToSettings')]
/**
* @summary Навигация на экран настроек.
*/
fun navigateToSettings() {
Timber.i("[INFO][ACTION][navigate_to_settings] Navigating to Settings.")
navController.navigate(Screen.Settings.route) {
launchSingleTop = true
}
}
// [END_ENTITY: Function('navigateToSettings')]
// [ENTITY: Function('navigateToInventoryListWithLabel')]
fun navigateToInventoryListWithLabel(labelId: String) {
Timber.i("[INFO][ACTION][navigate_to_inventory_with_label] Navigating to Inventory with label: %s", labelId)

View File

@@ -118,6 +118,14 @@ sealed class Screen(val route: String) {
// [ENTITY: Object('Search')]
data object Search : Screen("search_screen")
// [END_ENTITY: Object('Search')]
// [ENTITY: Object('Settings')]
data object Settings : Screen("settings_screen")
// [END_ENTITY: Object('Settings')]
// [ENTITY: Object('Scan')]
data object Scan : Screen("scan_screen")
// [END_ENTITY: Object('Scan')]
}
// [END_ENTITY: SealedClass('Screen')]
// [END_FILE_Screen.kt]

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
@@ -90,6 +91,15 @@ internal fun AppDrawerContent(
onCloseDrawer()
}
)
NavigationDrawerItem(
icon = { Icon(Icons.Default.Settings, contentDescription = null) },
label = { Text("Настройки") },
selected = false,
onClick = {
navigationActions.navigateToSettings()
onCloseDrawer()
}
)
// [AI_NOTE]: Add Profile and Tools items
Divider()
NavigationDrawerItem(

View File

@@ -8,6 +8,7 @@ package com.homebox.lens.ui.common
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
@@ -36,7 +37,10 @@ fun MainScaffold(
topBarTitle: String,
currentRoute: String?,
navigationActions: NavigationActions,
onNavigateUp: (() -> Unit)? = null,
topBarActions: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
@@ -57,16 +61,27 @@ fun MainScaffold(
TopAppBar(
title = { Text(topBarTitle) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = stringResource(id = R.string.cd_open_navigation_drawer)
)
if (onNavigateUp != null) {
IconButton(onClick = onNavigateUp) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.cd_navigate_up)
)
}
} else {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = stringResource(id = R.string.cd_open_navigation_drawer)
)
}
}
},
actions = { topBarActions() }
)
}
},
snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton
) { paddingValues ->
content(paddingValues)
}

View File

@@ -1,357 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.dashboard
// [FILE] DashboardScreen.kt
// [SEMANTICS] ui, screen, dashboard, compose, navigation
package com.homebox.lens.ui.screen.dashboard
// [IMPORTS]
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.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
import com.homebox.lens.domain.model.*
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
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')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
// [RELATION: Function('DashboardScreen')] -> [CALLS] -> [Function('MainScaffold')]
/**
* @summary Главная Composable-функция для экрана "Панель управления".
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
* @sideeffect Вызывает навигационные лямбды при взаимодействии с UI.
*/
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel(),
currentRoute: String?,
navigationActions: NavigationActions
) {
val uiState by viewModel.uiState.collectAsState()
MainScaffold(
topBarTitle = stringResource(id = R.string.dashboard_title),
currentRoute = currentRoute,
navigationActions = navigationActions,
topBarActions = {
IconButton(onClick = { navigationActions.navigateToSearch() }) {
Icon(
Icons.Default.Search,
contentDescription = stringResource(id = R.string.cd_scan_qr_code) // [AI_NOTE]: Rename string resource
)
}
}
) { 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...")
navigationActions.navigateToInventoryListWithLocation(location.id)
},
onLabelClick = { label ->
Timber.i("[INFO][ACTION][navigate_to_inventory_with_label] Label chip clicked: ${label.id}. Navigating...")
navigationActions.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

@@ -1,55 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.dashboard
// [FILE] DashboardUiState.kt
// [SEMANTICS] ui, state, dashboard
package com.homebox.lens.ui.screen.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

@@ -1,85 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.dashboard
// [FILE] DashboardViewModel.kt
// [SEMANTICS] ui_logic, dashboard, state_management, sealed_state, parallel_data_loading, timber_logging
package com.homebox.lens.ui.screen.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()
init {
loadDashboardData()
}
// [ENTITY: Function('loadDashboardData')]
/**
* @summary Загружает все необходимые данные для экрана Dashboard.
* @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`.
* @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`.
*/
fun loadDashboardData() {
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

@@ -5,28 +5,50 @@
package com.homebox.lens.ui.screen.itemedit
// [IMPORTS]
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
@@ -35,7 +57,13 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
import kotlinx.coroutines.launch
import timber.log.Timber
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
// [END_IMPORTS]
// [ENTITY: Function('ItemEditScreen')]
@@ -51,22 +79,33 @@ import timber.log.Timber
* @param viewModel ViewModel для управления состоянием экрана.
* @param onSaveSuccess Callback, вызываемый после успешного сохранения товара.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ItemEditScreen(
currentRoute: String?,
navigationActions: NavigationActions,
itemId: String?,
viewModel: ItemEditViewModel = hiltViewModel(),
onSaveSuccess: () -> Unit
currentRoute: String?,
navigationActions: NavigationActions,
itemId: String?,
viewModel: ItemEditViewModel = hiltViewModel(),
onSaveSuccess: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val navBackStackEntry = navigationActions.navController.currentBackStackEntry
LaunchedEffect(itemId) {
Timber.i("[INFO][ENTRYPOINT][item_edit_screen_init] Initializing ItemEditScreen for item ID: %s", itemId)
viewModel.loadItem(itemId)
}
LaunchedEffect(navBackStackEntry) {
navBackStackEntry?.savedStateHandle?.get<String>("barcodeResult")?.let { barcode ->
viewModel.updateAssetId(barcode)
navBackStackEntry.savedStateHandle?.remove<String>("barcodeResult")
Timber.i("[INFO][ACTION][barcode_received] Received barcode: %s", barcode)
}
}
LaunchedEffect(uiState.error) {
uiState.error?.let {
snackbarHostState.showSnackbar(it)
@@ -75,7 +114,7 @@ fun ItemEditScreen(
}
LaunchedEffect(Unit) {
viewModel.saveCompleted.collect {
viewModel.saveCompleted.collect {
Timber.i("[INFO][ACTION][save_completed_callback] Item save completed. Triggering onSaveSuccess.")
onSaveSuccess()
}
@@ -84,52 +123,310 @@ fun ItemEditScreen(
MainScaffold(
topBarTitle = stringResource(id = R.string.item_edit_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(onClick = {
Timber.i("[INFO][ACTION][save_button_click] Save button clicked.")
viewModel.saveItem()
}) {
Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.save_item))
}
navigationActions = navigationActions,
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(onClick = {
Timber.i("[INFO][ACTION][save_button_click] Save button clicked.")
viewModel.saveItem()
}) {
Icon(Icons.Filled.Save, contentDescription = stringResource(R.string.save_item))
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(16.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
uiState.item?.let { item ->
OutlinedTextField(
value = item.name,
onValueChange = { viewModel.updateName(it) },
label = { Text(stringResource(R.string.item_name)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = item.description ?: "",
onValueChange = { viewModel.updateDescription(it) },
label = { Text(stringResource(R.string.item_description)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = item.quantity.toString(),
onValueChange = { viewModel.updateQuantity(it.toIntOrNull() ?: 0) },
label = { Text(stringResource(R.string.item_quantity)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
// Add more fields as needed
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
uiState.item?.let { item ->
OutlinedTextField(
value = item.name,
onValueChange = { viewModel.updateName(it) },
label = { Text(stringResource(R.string.item_name)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = item.description ?: "",
onValueChange = { viewModel.updateDescription(it) },
label = { Text(stringResource(R.string.item_description)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = item.quantity.toString(),
onValueChange = { viewModel.updateQuantity(it.toIntOrNull() ?: 0) },
label = { Text(stringResource(R.string.item_quantity)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
// Asset ID
OutlinedTextField(
value = item.assetId ?: "",
onValueChange = { viewModel.updateAssetId(it) },
label = { Text(stringResource(R.string.item_asset_id)) },
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
IconButton(onClick = {
Timber.d("[DEBUG][ACTION][scan_qr_code_click] Scan QR code button clicked.")
navigationActions.navigateToScan()
}) {
Icon(Icons.Filled.QrCodeScanner, contentDescription = stringResource(R.string.scan_qr_code))
}
}
)
Spacer(modifier = Modifier.height(8.dp))
// Notes
OutlinedTextField(
value = item.notes ?: "",
onValueChange = { viewModel.updateNotes(it) },
label = { Text(stringResource(R.string.item_notes)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Serial Number
OutlinedTextField(
value = item.serialNumber ?: "",
onValueChange = { viewModel.updateSerialNumber(it) },
label = { Text(stringResource(R.string.item_serial_number)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Purchase Price
OutlinedTextField(
value = item.purchasePrice?.toString() ?: "",
onValueChange = { viewModel.updatePurchasePrice(it.toDoubleOrNull()) },
label = { Text(stringResource(R.string.item_purchase_price)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Purchase Date
var showPurchaseDatePicker by remember { mutableStateOf(false) }
val purchaseDatePickerState = rememberDatePickerState()
val coroutineScope = rememberCoroutineScope()
OutlinedTextField(
value = item.purchaseDate ?: "",
onValueChange = { }, // Read-only
label = { Text(stringResource(R.string.item_purchase_date)) },
modifier = Modifier.fillMaxWidth(),
readOnly = true,
interactionSource = remember { MutableInteractionSource() }
.also { interactionSource ->
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect {
coroutineScope.launch {
showPurchaseDatePicker = true
}
}
}
}
)
if (showPurchaseDatePicker) {
DatePickerDialog(
onDismissRequest = { showPurchaseDatePicker = false },
confirmButton = {
Button(onClick = {
purchaseDatePickerState.selectedDateMillis?.let { millis ->
val selectedDate = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
viewModel.updatePurchaseDate(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
}
showPurchaseDatePicker = false
}) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
Button(onClick = { showPurchaseDatePicker = false }) {
Text(stringResource(R.string.cancel))
}
}
) {
DatePicker(state = purchaseDatePickerState)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Warranty Until
var showWarrantyDatePicker by remember { mutableStateOf(false) }
val warrantyDatePickerState = rememberDatePickerState()
OutlinedTextField(
value = item.warrantyUntil ?: "",
onValueChange = { }, // Read-only
label = { Text(stringResource(R.string.item_warranty_until)) },
modifier = Modifier.fillMaxWidth(),
readOnly = true,
interactionSource = remember { MutableInteractionSource() }
.also { interactionSource ->
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect {
coroutineScope.launch {
showWarrantyDatePicker = true
}
}
}
}
)
if (showWarrantyDatePicker) {
DatePickerDialog(
onDismissRequest = { showWarrantyDatePicker = false },
confirmButton = {
Button(onClick = {
warrantyDatePickerState.selectedDateMillis?.let { millis ->
val selectedDate = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate()
viewModel.updateWarrantyUntil(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
}
showWarrantyDatePicker = false
}) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
Button(onClick = { showWarrantyDatePicker = false }) {
Text(stringResource(R.string.cancel))
}
}
) {
DatePicker(state = warrantyDatePickerState)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Parent ID (simplified for now, ideally a picker)
OutlinedTextField(
value = item.parentId ?: "",
onValueChange = { viewModel.updateParentId(it) },
label = { Text(stringResource(R.string.item_parent_id)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Checkboxes
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.item_is_archived))
Checkbox(
checked = item.isArchived ?: false,
onCheckedChange = { viewModel.updateIsArchived(it) }
)
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.item_insured))
Checkbox(
checked = item.insured ?: false,
onCheckedChange = { viewModel.updateInsured(it) }
)
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.item_lifetime_warranty))
Checkbox(
checked = item.lifetimeWarranty ?: false,
onCheckedChange = { viewModel.updateLifetimeWarranty(it) }
)
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.item_sync_child_items_locations))
Checkbox(
checked = item.syncChildItemsLocations ?: false,
onCheckedChange = { viewModel.updateSyncChildItemsLocations(it) }
)
}
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
// Manufacturer
OutlinedTextField(
value = item.manufacturer ?: "",
onValueChange = { viewModel.updateManufacturer(it) },
label = { Text(stringResource(R.string.item_manufacturer)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Model Number
OutlinedTextField(
value = item.modelNumber ?: "",
onValueChange = { viewModel.updateModelNumber(it) },
label = { Text(stringResource(R.string.item_model_number)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Purchase From
OutlinedTextField(
value = item.purchaseFrom ?: "",
onValueChange = { viewModel.updatePurchaseFrom(it) },
label = { Text(stringResource(R.string.item_purchase_from)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Warranty Details
OutlinedTextField(
value = item.warrantyDetails ?: "",
onValueChange = { viewModel.updateWarrantyDetails(it) },
label = { Text(stringResource(R.string.item_warranty_details)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Sold Details (simplified for now)
OutlinedTextField(
value = item.soldNotes ?: "",
onValueChange = { viewModel.updateSoldNotes(it) },
label = { Text(stringResource(R.string.item_sold_notes)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = item.soldPrice?.toString() ?: "",
onValueChange = { viewModel.updateSoldPrice(it.toDoubleOrNull()) },
label = { Text(stringResource(R.string.item_sold_price)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = item.soldTime ?: "",
onValueChange = { viewModel.updateSoldTime(it) },
label = { Text(stringResource(R.string.item_sold_time)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = item.soldTo ?: "",
onValueChange = { viewModel.updateSoldTo(it) },
label = { Text(stringResource(R.string.item_sold_to)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}

View File

@@ -9,9 +9,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.model.Item
import com.homebox.lens.domain.model.ItemCreate
import com.homebox.lens.domain.model.ItemOut
import com.homebox.lens.domain.model.ItemSummary
import com.homebox.lens.domain.model.ItemUpdate
import com.homebox.lens.domain.model.Label
import com.homebox.lens.domain.model.Location
import com.homebox.lens.domain.model.LocationOut
import com.homebox.lens.domain.usecase.CreateItemUseCase
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
import com.homebox.lens.domain.usecase.GetItemDetailsUseCase
import com.homebox.lens.domain.usecase.UpdateItemUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -23,6 +28,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject
// [END_IMPORTS]
@@ -35,6 +41,8 @@ import javax.inject.Inject
*/
data class ItemEditUiState(
val item: Item? = null,
val locations: List<LocationOut> = emptyList(),
val selectedLocationId: String? = null,
val isLoading: Boolean = false,
val error: String? = null
)
@@ -52,7 +60,8 @@ data class ItemEditUiState(
class ItemEditViewModel @Inject constructor(
private val createItemUseCase: CreateItemUseCase,
private val updateItemUseCase: UpdateItemUseCase,
private val getItemDetailsUseCase: GetItemDetailsUseCase
private val getItemDetailsUseCase: GetItemDetailsUseCase,
private val getAllLocationsUseCase: GetAllLocationsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(ItemEditUiState())
@@ -71,9 +80,41 @@ class ItemEditViewModel @Inject constructor(
Timber.i("[INFO][ENTRYPOINT][loading_item] Attempting to load item with ID: %s", itemId)
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
loadLocations()
if (itemId == null) {
Timber.i("[INFO][ACTION][new_item_preparation] Preparing for new item creation.")
_uiState.value = _uiState.value.copy(isLoading = false, item = Item(id = "", name = "", description = null, quantity = 0, image = null, location = null, labels = emptyList(), value = null, createdAt = null))
_uiState.value = _uiState.value.copy(
isLoading = false, item = Item(
id = "",
name = "",
description = null,
quantity = 0,
image = null,
location = null,
labels = emptyList(),
value = null,
createdAt = null,
assetId = null,
notes = null,
serialNumber = null,
purchasePrice = null,
purchaseDate = null,
warrantyUntil = null,
parentId = null,
isArchived = null,
insured = null,
lifetimeWarranty = null,
manufacturer = null,
modelNumber = null,
purchaseFrom = null,
soldNotes = null,
soldPrice = null,
soldTime = null,
soldTo = null,
syncChildItemsLocations = null,
warrantyDetails = null
)
)
} else {
try {
Timber.i("[INFO][ACTION][fetching_item_details] Fetching details for item ID: %s", itemId)
@@ -84,13 +125,36 @@ class ItemEditViewModel @Inject constructor(
name = itemOut.name,
description = itemOut.description,
quantity = itemOut.quantity,
image = itemOut.images.firstOrNull()?.path, // Assuming first image is the main one
location = itemOut.location?.let { Location(it.id, it.name) }, // Simplified mapping
labels = itemOut.labels.map { Label(it.id, it.name) }, // Simplified mapping
value = itemOut.value?.toBigDecimal(),
createdAt = itemOut.createdAt
image = itemOut.images.firstOrNull()?.path,
location = itemOut.location?.let { Location(it.id, it.name) },
labels = itemOut.labels.map { Label(it.id, it.name) },
value = itemOut.value,
createdAt = itemOut.createdAt,
assetId = itemOut.assetId,
notes = itemOut.notes,
serialNumber = itemOut.serialNumber,
purchasePrice = itemOut.purchasePrice,
purchaseDate = itemOut.purchaseDate,
warrantyUntil = itemOut.warrantyUntil,
parentId = itemOut.parent?.id,
isArchived = itemOut.isArchived,
insured = itemOut.insured,
lifetimeWarranty = itemOut.lifetimeWarranty,
manufacturer = itemOut.manufacturer,
modelNumber = itemOut.modelNumber,
purchaseFrom = itemOut.purchaseFrom,
soldNotes = itemOut.soldNotes,
soldPrice = itemOut.soldPrice,
soldTime = itemOut.soldTime,
soldTo = itemOut.soldTo,
syncChildItemsLocations = itemOut.syncChildItemsLocations,
warrantyDetails = itemOut.warrantyDetails
)
_uiState.value = _uiState.value.copy(
isLoading = false,
item = item,
selectedLocationId = item.location?.id
)
_uiState.value = _uiState.value.copy(isLoading = false, item = item)
Timber.i("[INFO][ACTION][item_details_fetched] Successfully fetched item details for ID: %s", itemId)
} catch (e: Exception) {
Timber.e(e, "[ERROR][FALLBACK][item_load_failed] Failed to load item details for ID: %s", itemId)
@@ -107,43 +171,61 @@ class ItemEditViewModel @Inject constructor(
* @sideeffect Updates `_uiState` with loading, success, or error states. Calls `createItemUseCase` or `updateItemUseCase`.
* @throws IllegalStateException if `uiState.value.item` is null when attempting to save.
*/
private fun loadLocations() {
viewModelScope.launch {
try {
val locations = getAllLocationsUseCase()
_uiState.value = _uiState.value.copy(locations = locations.map { LocationOut(it.id, it.name, it.color, it.isArchived, it.createdAt, it.updatedAt) })
Timber.i("[INFO][ACTION][locations_loaded] Loaded %d locations", locations.size)
} catch (e: Exception) {
Timber.e(e, "[ERROR][FALLBACK][locations_load_failed] Failed to load locations")
_uiState.value = _uiState.value.copy(error = e.localizedMessage)
}
}
}
fun updateSelectedLocationId(locationId: String?) {
Timber.d("[DEBUG][ACTION][updating_selected_location] Selected location ID: %s", locationId)
val location = _uiState.value.locations.find { it.id == locationId }
_uiState.value = _uiState.value.copy(
selectedLocationId = locationId,
item = _uiState.value.item?.copy(location = location?.let { Location(it.id, it.name) })
)
}
fun saveItem() {
Timber.i("[INFO][ENTRYPOINT][saving_item] Attempting to save item.")
viewModelScope.launch {
val currentItem = _uiState.value.item
val selectedLocationId = _uiState.value.selectedLocationId
require(currentItem != null) { "[CONTRACT_VIOLATION][PRECONDITION][item_not_present] Cannot save a null item." }
if (currentItem.id.isBlank() && selectedLocationId == null) {
throw IllegalStateException("Location is required for creating a new item.")
}
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
if (currentItem.id.isBlank()) {
Timber.i("[INFO][ACTION][creating_new_item] Creating new item: %s", currentItem.name)
val createdItemSummary = createItemUseCase(ItemCreate(
name = currentItem.name,
description = currentItem.description,
quantity = currentItem.quantity,
assetId = null, // Item does not have assetId
notes = null, // Item does not have notes
serialNumber = null, // Item does not have serialNumber
value = currentItem.value?.toDouble(), // Convert BigDecimal to Double
purchasePrice = null, // Item does not have purchasePrice
purchaseDate = null, // Item does not have purchaseDate
warrantyUntil = null, // Item does not have warrantyUntil
locationId = currentItem.location?.id,
parentId = null, // Item does not have parentId
labelIds = currentItem.labels.map { it.id }
))
Timber.d("[DEBUG][ACTION][mapping_item_summary_to_item] Mapping ItemSummary to Item for UI state.")
val createdItem = Item(
id = createdItemSummary.id,
name = createdItemSummary.name,
description = null, // ItemSummary does not have description
quantity = 0, // ItemSummary does not have quantity
image = null, // ItemSummary does not have image
location = null, // ItemSummary does not have location
labels = emptyList(), // ItemSummary does not have labels
value = null, // ItemSummary does not have value
createdAt = null // ItemSummary does not have createdAt
val createdItemSummary = createItemUseCase(
ItemCreate(
name = currentItem.name,
description = currentItem.description,
quantity = currentItem.quantity,
assetId = currentItem.assetId,
notes = currentItem.notes,
serialNumber = currentItem.serialNumber,
value = currentItem.value,
purchasePrice = currentItem.purchasePrice,
purchaseDate = currentItem.purchaseDate,
warrantyUntil = currentItem.warrantyUntil,
locationId = selectedLocationId,
parentId = currentItem.parentId,
labelIds = currentItem.labels.map { it.id }
)
)
Timber.d("[DEBUG][ACTION][mapping_item_summary_to_item] Mapping ItemSummary to Item for UI state.")
val createdItem = currentItem.copy(id = createdItemSummary.id, name = createdItemSummary.name)
_uiState.value = _uiState.value.copy(isLoading = false, item = createdItem)
Timber.i("[INFO][ACTION][new_item_created] Successfully created new item with ID: %s", createdItem.id)
_saveCompleted.emit(Unit)
@@ -151,7 +233,7 @@ class ItemEditViewModel @Inject constructor(
Timber.i("[INFO][ACTION][updating_existing_item] Updating existing item with ID: %s", currentItem.id)
val updatedItemOut = updateItemUseCase(currentItem)
Timber.d("[DEBUG][ACTION][mapping_item_out_to_item] Mapping ItemOut to Item for UI state.")
val updatedItem = Item(
val updatedItem = currentItem.copy(
id = updatedItemOut.id,
name = updatedItemOut.name,
description = updatedItemOut.description,
@@ -159,10 +241,33 @@ class ItemEditViewModel @Inject constructor(
image = updatedItemOut.images.firstOrNull()?.path,
location = updatedItemOut.location?.let { Location(it.id, it.name) },
labels = updatedItemOut.labels.map { Label(it.id, it.name) },
value = updatedItemOut.value.toBigDecimal(),
createdAt = updatedItemOut.createdAt
value = updatedItemOut.value,
createdAt = updatedItemOut.createdAt,
assetId = updatedItemOut.assetId,
notes = updatedItemOut.notes,
serialNumber = updatedItemOut.serialNumber,
purchasePrice = updatedItemOut.purchasePrice,
purchaseDate = updatedItemOut.purchaseDate,
warrantyUntil = updatedItemOut.warrantyUntil,
parentId = updatedItemOut.parent?.id,
isArchived = updatedItemOut.isArchived,
insured = updatedItemOut.insured,
lifetimeWarranty = updatedItemOut.lifetimeWarranty,
manufacturer = updatedItemOut.manufacturer,
modelNumber = updatedItemOut.modelNumber,
purchaseFrom = updatedItemOut.purchaseFrom,
soldNotes = updatedItemOut.soldNotes,
soldPrice = updatedItemOut.soldPrice,
soldTime = updatedItemOut.soldTime,
soldTo = updatedItemOut.soldTo,
syncChildItemsLocations = updatedItemOut.syncChildItemsLocations,
warrantyDetails = updatedItemOut.warrantyDetails
)
_uiState.value = _uiState.value.copy(
isLoading = false,
item = updatedItem,
selectedLocationId = updatedItem.location?.id
)
_uiState.value = _uiState.value.copy(isLoading = false, item = updatedItem)
Timber.i("[INFO][ACTION][item_updated] Successfully updated item with ID: %s", updatedItem.id)
_saveCompleted.emit(Unit)
}
@@ -209,6 +314,270 @@ class ItemEditViewModel @Inject constructor(
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(quantity = newQuantity))
}
// [END_ENTITY: Function('updateQuantity')]
// [ENTITY: Function('updateAssetId')]
/**
* @summary Updates the asset ID of the item in the UI state.
* @param newAssetId The new asset ID for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateAssetId(newAssetId: String?) {
Timber.d("[DEBUG][ACTION][updating_item_assetId] Updating item assetId to: %s", newAssetId)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(assetId = newAssetId))
}
// [END_ENTITY: Function('updateAssetId')]
// [ENTITY: Function('updateNotes')]
/**
* @summary Updates the notes of the item in the UI state.
* @param newNotes The new notes for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateNotes(newNotes: String?) {
Timber.d("[DEBUG][ACTION][updating_item_notes] Updating item notes to: %s", newNotes)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(notes = newNotes))
}
// [END_ENTITY: Function('updateNotes')]
// [ENTITY: Function('updateSerialNumber')]
/**
* @summary Updates the serial number of the item in the UI state.
* @param newSerialNumber The new serial number for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateSerialNumber(newSerialNumber: String?) {
Timber.d("[DEBUG][ACTION][updating_item_serialNumber] Updating item serialNumber to: %s", newSerialNumber)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(serialNumber = newSerialNumber))
}
// [END_ENTITY: Function('updateSerialNumber')]
// [ENTITY: Function('updatePurchasePrice')]
/**
* @summary Updates the purchase price of the item in the UI state.
* @param newPurchasePrice The new purchase price for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updatePurchasePrice(newPurchasePrice: Double?) {
Timber.d("[DEBUG][ACTION][updating_item_purchasePrice] Updating item purchasePrice to: %f", newPurchasePrice)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(purchasePrice = newPurchasePrice))
}
// [END_ENTITY: Function('updatePurchasePrice')]
// [ENTITY: Function('updatePurchaseDate')]
/**
* @summary Updates the purchase date of the item in the UI state.
* @param newPurchaseDate The new purchase date for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updatePurchaseDate(newPurchaseDate: String?) {
Timber.d("[DEBUG][ACTION][updating_item_purchaseDate] Updating item purchaseDate to: %s", newPurchaseDate)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(purchaseDate = newPurchaseDate))
}
// [END_ENTITY: Function('updatePurchaseDate')]
// [ENTITY: Function('updateWarrantyUntil')]
/**
* @summary Updates the warranty until date of the item in the UI state.
* @param newWarrantyUntil The new warranty until date for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateWarrantyUntil(newWarrantyUntil: String?) {
Timber.d("[DEBUG][ACTION][updating_item_warrantyUntil] Updating item warrantyUntil to: %s", newWarrantyUntil)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(warrantyUntil = newWarrantyUntil))
}
// [END_ENTITY: Function('updateWarrantyUntil')]
// [ENTITY: Function('updateParentId')]
/**
* @summary Updates the parent ID of the item in the UI state.
* @param newParentId The new parent ID for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateParentId(newParentId: String?) {
Timber.d("[DEBUG][ACTION][updating_item_parentId] Updating item parentId to: %s", newParentId)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(parentId = newParentId))
}
// [END_ENTITY: Function('updateParentId')]
// [ENTITY: Function('updateIsArchived')]
/**
* @summary Updates the archived status of the item in the UI state.
* @param newIsArchived The new archived status for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateIsArchived(newIsArchived: Boolean?) {
Timber.d("[DEBUG][ACTION][updating_item_isArchived] Updating item isArchived to: %b", newIsArchived)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(isArchived = newIsArchived))
}
// [END_ENTITY: Function('updateIsArchived')]
// [ENTITY: Function('updateInsured')]
/**
* @summary Updates the insured status of the item in the UI state.
* @param newInsured The new insured status for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateInsured(newInsured: Boolean?) {
Timber.d("[DEBUG][ACTION][updating_item_insured] Updating item insured to: %b", newInsured)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(insured = newInsured))
}
// [END_ENTITY: Function('updateInsured')]
// [ENTITY: Function('updateLifetimeWarranty')]
/**
* @summary Updates the lifetime warranty status of the item in the UI state.
* @param newLifetimeWarranty The new lifetime warranty status for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateLifetimeWarranty(newLifetimeWarranty: Boolean?) {
Timber.d("[DEBUG][ACTION][updating_item_lifetimeWarranty] Updating item lifetimeWarranty to: %b", newLifetimeWarranty)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(lifetimeWarranty = newLifetimeWarranty))
}
// [END_ENTITY: Function('updateLifetimeWarranty')]
// [ENTITY: Function('updateManufacturer')]
/**
* @summary Updates the manufacturer of the item in the UI state.
* @param newManufacturer The new manufacturer for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateManufacturer(newManufacturer: String?) {
Timber.d("[DEBUG][ACTION][updating_item_manufacturer] Updating item manufacturer to: %s", newManufacturer)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(manufacturer = newManufacturer))
}
// [END_ENTITY: Function('updateManufacturer')]
// [ENTITY: Function('updateModelNumber')]
/**
* @summary Updates the model number of the item in the UI state.
* @param newModelNumber The new model number for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateModelNumber(newModelNumber: String?) {
Timber.d("[DEBUG][ACTION][updating_item_modelNumber] Updating item modelNumber to: %s", newModelNumber)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(modelNumber = newModelNumber))
}
// [END_ENTITY: Function('updateModelNumber')]
// [ENTITY: Function('updatePurchaseFrom')]
/**
* @summary Updates the purchase source of the item in the UI state.
* @param newPurchaseFrom The new purchase source for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updatePurchaseFrom(newPurchaseFrom: String?) {
Timber.d("[DEBUG][ACTION][updating_item_purchaseFrom] Updating item purchaseFrom to: %s", newPurchaseFrom)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(purchaseFrom = newPurchaseFrom))
}
// [END_ENTITY: Function('updatePurchaseFrom')]
// [ENTITY: Function('updateSoldNotes')]
/**
* @summary Updates the sold notes of the item in the UI state.
* @param newSoldNotes The new sold notes for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateSoldNotes(newSoldNotes: String?) {
Timber.d("[DEBUG][ACTION][updating_item_soldNotes] Updating item soldNotes to: %s", newSoldNotes)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(soldNotes = newSoldNotes))
}
// [END_ENTITY: Function('updateSoldNotes')]
// [ENTITY: Function('updateSoldPrice')]
/**
* @summary Updates the sold price of the item in the UI state.
* @param newSoldPrice The new sold price for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateSoldPrice(newSoldPrice: Double?) {
Timber.d("[DEBUG][ACTION][updating_item_soldPrice] Updating item soldPrice to: %f", newSoldPrice)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(soldPrice = newSoldPrice))
}
// [END_ENTITY: Function('updateSoldPrice')]
// [ENTITY: Function('updateSoldTime')]
/**
* @summary Updates the sold time of the item in the UI state.
* @param newSoldTime The new sold time for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateSoldTime(newSoldTime: String?) {
Timber.d("[DEBUG][ACTION][updating_item_soldTime] Updating item soldTime to: %s", newSoldTime)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(soldTime = newSoldTime))
}
// [END_ENTITY: Function('updateSoldTime')]
// [ENTITY: Function('updateSoldTo')]
/**
* @summary Updates the sold to field of the item in the UI state.
* @param newSoldTo The new sold to field for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateSoldTo(newSoldTo: String?) {
Timber.d("[DEBUG][ACTION][updating_item_soldTo] Updating item soldTo to: %s", newSoldTo)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(soldTo = newSoldTo))
}
// [END_ENTITY: Function('updateSoldTo')]
// [ENTITY: Function('updateSyncChildItemsLocations')]
/**
* @summary Updates the sync child items locations status of the item in the UI state.
* @param newSyncChildItemsLocations The new sync child items locations status for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateSyncChildItemsLocations(newSyncChildItemsLocations: Boolean?) {
Timber.d("[DEBUG][ACTION][updating_item_syncChildItemsLocations] Updating item syncChildItemsLocations to: %b", newSyncChildItemsLocations)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(syncChildItemsLocations = newSyncChildItemsLocations))
}
// [END_ENTITY: Function('updateSyncChildItemsLocations')]
// [ENTITY: Function('updateWarrantyDetails')]
/**
* @summary Updates the warranty details of the item in the UI state.
* @param newWarrantyDetails The new warranty details for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateWarrantyDetails(newWarrantyDetails: String?) {
Timber.d("[DEBUG][ACTION][updating_item_warrantyDetails] Updating item warrantyDetails to: %s", newWarrantyDetails)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(warrantyDetails = newWarrantyDetails))
}
// [END_ENTITY: Function('updateWarrantyDetails')]
// [ENTITY: Function('updateLocation')]
/**
* @summary Updates the location of the item in the UI state.
* @param newLocation The new location for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateLocation(newLocation: Location?) {
Timber.d("[DEBUG][ACTION][updating_item_location] Updating item location to: %s", newLocation?.name)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(location = newLocation))
}
// [END_ENTITY: Function('updateLocation')]
// [ENTITY: Function('addLabel')]
/**
* @summary Adds a label to the item in the UI state.
* @param label The label to add.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun addLabel(label: Label) {
Timber.d("[DEBUG][ACTION][adding_label_to_item] Adding label: %s", label.name)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(labels = _uiState.value.item?.labels.orEmpty() + label))
}
// [END_ENTITY: Function('addLabel')]
// [ENTITY: Function('removeLabel')]
/**
* @summary Removes a label from the item in the UI state.
* @param labelId The ID of the label to remove.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun removeLabel(labelId: String) {
Timber.d("[DEBUG][ACTION][removing_label_from_item] Removing label with ID: %s", labelId)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(labels = _uiState.value.item?.labels.orEmpty().filter { it.id != labelId }))
}
// [END_ENTITY: Function('removeLabel')]
}
// [END_ENTITY: ViewModel('ItemEditViewModel')]
// [END_FILE_ItemEditViewModel.kt]

View File

@@ -17,6 +17,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -24,7 +25,6 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -32,9 +32,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -106,6 +103,45 @@ fun LabelsListScreen(
onLabelClick = { label ->
Timber.i("[INFO][ACTION][navigate_to_label_edit] Label clicked: ${label.id}. Navigating to label edit screen.")
navigationActions.navigateToLabelEdit(label.id)
},
onDeleteClick = { label ->
viewModel.onShowDeleteDialog(label)
},
isShowingDeleteDialog = currentState.isShowingDeleteDialog,
labelToDelete = currentState.labelToDelete,
onConfirmDelete = {
currentState.labelToDelete?.let { label ->
viewModel.deleteLabel(label.id)
}
},
onDismissDeleteDialog = {
viewModel.onDismissDeleteDialog()
}
)
}
// Delete confirmation dialog
if (currentState is LabelsListUiState.Success && currentState.isShowingDeleteDialog && currentState.labelToDelete != null) {
AlertDialog(
onDismissRequest = { viewModel.onDismissDeleteDialog() },
title = { Text("Delete Label") },
text = { Text("Are you sure you want to delete the label '${currentState.labelToDelete!!.name}'? This action cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
viewModel.deleteLabel(currentState.labelToDelete!!.id)
viewModel.onDismissDeleteDialog()
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(
onClick = { viewModel.onDismissDeleteDialog() }
) {
Text("Cancel")
}
}
)
}
@@ -129,6 +165,11 @@ fun LabelsListScreen(
private fun LabelsList(
labels: List<Label>,
onLabelClick: (Label) -> Unit,
onDeleteClick: (Label) -> Unit,
isShowingDeleteDialog: Boolean,
labelToDelete: Label?,
onConfirmDelete: () -> Unit,
onDismissDeleteDialog: () -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
@@ -139,7 +180,8 @@ private fun LabelsList(
items(labels, key = { it.id }) { label ->
LabelListItem(
label = label,
onClick = { onLabelClick(label) }
onClick = { onLabelClick(label) },
onDeleteClick = { onDeleteClick(label) }
)
}
}
@@ -156,7 +198,8 @@ private fun LabelsList(
@Composable
private fun LabelListItem(
label: Label,
onClick: () -> Unit
onClick: () -> Unit,
onDeleteClick: () -> Unit
) {
ListItem(
headlineContent = { Text(text = label.name) },
@@ -166,6 +209,14 @@ private fun LabelListItem(
contentDescription = stringResource(id = R.string.content_desc_label_icon)
)
},
trailingContent = {
IconButton(onClick = onDeleteClick) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.content_desc_delete_label)
)
}
},
modifier = Modifier.clickable(onClick = onClick)
)
}

View File

@@ -23,7 +23,9 @@ sealed interface LabelsListUiState {
*/
data class Success(
val labels: List<Label>,
val isShowingCreateDialog: Boolean = false
val isShowingCreateDialog: Boolean = false,
val isShowingDeleteDialog: Boolean = false,
val labelToDelete: Label? = null
) : LabelsListUiState
// [END_ENTITY: DataClass('Success')]

View File

@@ -7,6 +7,7 @@ package com.homebox.lens.ui.screen.labelslist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.model.Label
import com.homebox.lens.domain.usecase.DeleteLabelUseCase
import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,7 +28,8 @@ import javax.inject.Inject
*/
@HiltViewModel
class LabelsListViewModel @Inject constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase
private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val deleteLabelUseCase: DeleteLabelUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
@@ -75,57 +77,73 @@ class LabelsListViewModel @Inject constructor(
}
// [END_ENTITY: Function('loadLabels')]
// [ENTITY: Function('onShowCreateDialog')]
// [ENTITY: Function('onShowDeleteDialog')]
/**
* @summary Инициирует отображение диалога для создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `true`.
* @sideeffect Обновляет `_uiState`.
* @summary Показывает диалог подтверждения удаления метки.
* @param label Метка для удаления.
* @sideeffect Обновляет состояние для показа диалога удаления.
*/
fun onShowCreateDialog() {
Timber.i("[INFO][ACTION][show_create_dialog] Show create label dialog requested.")
fun onShowDeleteDialog(label: Label) {
Timber.i("[INFO][ACTION][show_delete_dialog] Show delete label dialog for: ${label.id}")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = true)
_uiState.update { currentState ->
(currentState as LabelsListUiState.Success).copy(
isShowingDeleteDialog = true,
labelToDelete = label
)
}
}
}
// [END_ENTITY: Function('onShowCreateDialog')]
// [END_ENTITY: Function('onShowDeleteDialog')]
// [ENTITY: Function('onDismissCreateDialog')]
// [ENTITY: Function('onDismissDeleteDialog')]
/**
* @summary Скрывает диалог создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `false`.
* @sideeffect Обновляет `_uiState`.
* @summary Скрывает диалог подтверждения удаления метки.
* @sideeffect Обновляет состояние для скрытия диалога удаления.
*/
fun onDismissCreateDialog() {
Timber.i("[INFO][ACTION][dismiss_create_dialog] Dismiss create label dialog requested.")
fun onDismissDeleteDialog() {
Timber.i("[INFO][ACTION][dismiss_delete_dialog] Dismiss delete label dialog")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = false)
_uiState.update { currentState ->
(currentState as LabelsListUiState.Success).copy(
isShowingDeleteDialog = false,
labelToDelete = null
)
}
}
}
// [END_ENTITY: Function('onDismissCreateDialog')]
// [END_ENTITY: Function('onDismissDeleteDialog')]
// [ENTITY: Function('createLabel')]
// [ENTITY: Function('deleteLabel')]
/**
* @summary Создает новую метку. [MVP_SCOPE] ЗАГЛУШКА.
* @description В текущей реализации (План Б, Этап 1), эта функция только логирует действие
* и скрывает диалог. Реальная логика сохранения будет добавлена на следующем этапе.
* @param name Название новой метки.
* @precondition `name` не должен быть пустым.
* @sideeffect Логирует действие, обновляет `_uiState`, чтобы скрыть диалог.
* @summary Удаляет выбранную метку.
* @param labelId ID метки для удаления.
* @sideeffect Выполняет удаление через UseCase, обновляет состояние UI.
*/
fun createLabel(name: String) {
require(name.isNotBlank()) { "[CONTRACT_VIOLATION] Label name cannot be blank." }
fun deleteLabel(labelId: String) {
viewModelScope.launch {
_uiState.value = LabelsListUiState.Loading
Timber.i("[INFO][ENTRYPOINT][deleting_label] Starting label deletion for ID: $labelId. State -> Loading.")
Timber.i("[INFO][ACTION][create_label] Create label called with name: '$name'. [STUBBED]")
val result = runCatching {
deleteLabelUseCase(labelId)
}
// [AI_NOTE]: Здесь будет вызов CreateLabelUseCase.
onDismissCreateDialog()
result.fold(
onSuccess = {
Timber.i("[INFO][SUCCESS][label_deleted] Label deleted successfully. Reloading labels.")
loadLabels() // Refresh the list
},
onFailure = { exception ->
Timber.e(exception, "[ERROR][EXCEPTION][deletion_failed] Failed to delete label. State -> Error.")
_uiState.value = LabelsListUiState.Error(
message = exception.message ?: "Could not delete label."
)
}
)
}
}
// [END_ENTITY: Function('createLabel')]
// [END_ENTITY: Function('deleteLabel')]
}
// [END_ENTITY: ViewModel('LabelsListViewModel')]
// [END_FILE_LabelsListViewModel.kt]

View File

@@ -0,0 +1,104 @@
// [PACKAGE] com.homebox.lens.ui.screen.settings
// [FILE] SettingsScreen.kt
// [SEMANTICS] ui, screen, settings, compose
package com.homebox.lens.ui.screen.settings
// [IMPORTS]
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.navigation.Screen
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.screen.settings.SettingsUiState
import com.homebox.lens.ui.screen.settings.SettingsViewModel
import com.homebox.lens.ui.common.MainScaffold
// [END_IMPORTS]
// [ENTITY: Function('SettingsScreen')]
/**
* @summary Composable function for the Settings screen.
* @param viewModel The ViewModel for the Settings screen.
* @param onNavigateUp Callback to navigate up in the navigation stack.
* @sideeffect Collects UI state from ViewModel.
*/
@Composable
fun SettingsScreen(
currentRoute: String?,
navigationActions: NavigationActions,
viewModel: SettingsViewModel = hiltViewModel(),
onNavigateUp: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
MainScaffold(
topBarTitle = "Настройки",
currentRoute = currentRoute,
navigationActions = navigationActions,
onNavigateUp = onNavigateUp
) { paddingValues ->
SettingsContent(
modifier = Modifier.padding(paddingValues),
uiState = uiState,
onServerUrlChange = viewModel::onServerUrlChange,
onSaveClick = viewModel::saveSettings
)
}
}
// [END_ENTITY: Function('SettingsScreen')]
// [ENTITY: Function('SettingsContent')]
/**
* @summary Composable function for the content of the Settings screen.
* @param modifier Modifier for the layout.
* @param uiState The current UI state of the settings.
* @param onServerUrlChange Callback for server URL changes.
* @param onSaveClick Callback for save button clicks.
* @sideeffect Displays UI elements based on uiState.
*/
@Composable
fun SettingsContent(
modifier: Modifier = Modifier,
uiState: SettingsUiState,
onServerUrlChange: (String) -> Unit,
onSaveClick: () -> Unit
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
OutlinedTextField(
value = uiState.serverUrl,
onValueChange = onServerUrlChange,
label = { Text("URL Сервера") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSaveClick,
enabled = !uiState.isLoading,
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Text("Сохранить")
}
}
if (uiState.isSaved) {
Text("Настройки сохранены!", color = MaterialTheme.colorScheme.primary)
}
if (uiState.error != null) {
Text(uiState.error, color = MaterialTheme.colorScheme.error)
}
}
}
// [END_ENTITY: Function('SettingsContent')]
// [END_FILE_SettingsScreen.kt]

View File

@@ -0,0 +1,8 @@
package com.homebox.lens.ui.screen.settings
data class SettingsUiState(
val serverUrl: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val isSaved: Boolean = false
)

View File

@@ -0,0 +1,54 @@
package com.homebox.lens.ui.screen.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.repository.CredentialsRepository
import com.homebox.lens.domain.model.Credentials
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val credentialsRepository: CredentialsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState = _uiState.asStateFlow()
init {
loadCurrentSettings()
}
private fun loadCurrentSettings() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
val credentials = credentialsRepository.getCredentials().first()
_uiState.value = _uiState.value.copy(
serverUrl = credentials?.serverUrl ?: "",
isLoading = false
)
}
}
fun onServerUrlChange(newUrl: String) {
_uiState.value = _uiState.value.copy(serverUrl = newUrl, isSaved = false)
}
fun saveSettings() {
Timber.i("[INFO][ACTION][settings_save] Attempting to save settings.")
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
val currentCredentials = credentialsRepository.getCredentials().first()
val updatedCredentials = currentCredentials?.copy(serverUrl = _uiState.value.serverUrl)
?: Credentials(serverUrl = _uiState.value.serverUrl, username = "", password = "") // Create new if no existing credentials
credentialsRepository.saveCredentials(updatedCredentials)
_uiState.value = _uiState.value.copy(isLoading = false, isSaved = true)
}
}
}

View File

@@ -14,7 +14,9 @@
<!-- Content Descriptions -->
<string name="cd_open_navigation_drawer">Open navigation drawer</string>
<string name="cd_scan_qr_code">Scan QR code</string>
<string name="cd_search">Search</string>
<string name="cd_navigate_back">Navigate back</string>
<string name="cd_navigate_up">Go back</string>
<string name="cd_add_new_location">Add new location</string>
<string name="content_desc_add_label">Add new label</string>
@@ -72,6 +74,7 @@
<string name="content_desc_navigate_back">Navigate back</string>
<string name="content_desc_create_label">Create new label</string>
<string name="content_desc_label_icon">Label icon</string>
<string name="content_desc_delete_label">Delete label</string>
<string name="no_labels_found">No labels found.</string>
<string name="dialog_title_create_label">Create Label</string>
<string name="dialog_field_label_name">Label Name</string>
@@ -118,4 +121,26 @@
<string name="label_color">Color</string>
<string name="label_hex_color">HEX color code</string>
<string name="item_asset_id">Asset ID</string>
<string name="item_notes">Notes</string>
<string name="item_serial_number">Serial Number</string>
<string name="item_purchase_price">Purchase Price</string>
<string name="item_purchase_date">Purchase Date</string>
<string name="item_warranty_until">Warranty Until</string>
<string name="item_parent_id">Parent ID</string>
<string name="item_is_archived">Is Archived</string>
<string name="item_insured">Insured</string>
<string name="item_lifetime_warranty">Lifetime Warranty</string>
<string name="item_sync_child_items_locations">Sync Child Items Locations</string>
<string name="item_manufacturer">Manufacturer</string>
<string name="item_model_number">Model Number</string>
<string name="item_purchase_from">Purchase From</string>
<string name="item_warranty_details">Warranty Details</string>
<string name="item_sold_notes">Sold Notes</string>
<string name="item_sold_price">Sold Price</string>
<string name="item_sold_time">Sold Time</string>
<string name="item_sold_to">Sold To</string>
<string name="scan_qr_code">Scan QR Code</string>
<string name="ok">OK</string>
<string name="cancel">Cancel</string>
</resources>

View File

@@ -13,8 +13,10 @@
<!-- Content Descriptions -->
<string name="cd_open_navigation_drawer">Открыть боковое меню</string>
<string name="cd_scan_qr_code">Сканировать QR-код</string>
<string name="cd_scan_qr_code">Сканировать QR/штрих-код</string>
<string name="cd_search">Поиск</string>
<string name="cd_navigate_back">Вернуться назад</string>
<string name="cd_navigate_up">Вернуться</string>
<string name="cd_add_new_location">Добавить новую локацию</string>
<string name="content_desc_add_label">Добавить новую метку</string>
@@ -93,6 +95,7 @@
<string name="content_desc_navigate_back" translatable="false">Вернуться назад</string>
<string name="content_desc_create_label">Создать новую метку</string>
<string name="content_desc_label_icon">Иконка метки</string>
<string name="content_desc_delete_label">Удалить метку</string>
<string name="no_labels_found">Метки не найдены.</string>
<string name="dialog_title_create_label">Создать метку</string>
<string name="dialog_field_label_name">Название метки</string>
@@ -112,4 +115,26 @@
<!-- Color Picker -->
<string name="label_color">Цвет</string>
<string name="label_hex_color">HEX-код цвета</string>
<string name="item_asset_id">Идентификатор актива</string>
<string name="item_notes">Заметки</string>
<string name="item_serial_number">Серийный номер</string>
<string name="item_purchase_price">Цена покупки</string>
<string name="item_purchase_date">Дата покупки</string>
<string name="item_warranty_until">Гарантия до</string>
<string name="item_parent_id">Родительский ID</string>
<string name="item_is_archived">Архивировано</string>
<string name="item_insured">Застраховано</string>
<string name="item_lifetime_warranty">Пожизненная гарантия</string>
<string name="item_sync_child_items_locations">Синхронизировать дочерние элементы</string>
<string name="item_manufacturer">Производитель</string>
<string name="item_model_number">Номер модели</string>
<string name="item_purchase_from">Куплено у</string>
<string name="item_warranty_details">Детали гарантии</string>
<string name="item_sold_notes">Примечания о продаже</string>
<string name="item_sold_price">Цена продажи</string>
<string name="item_sold_time">Время продажи</string>
<string name="item_sold_to">Продано кому</string>
<string name="scan_qr_code">Сканировать QR-код</string>
<string name="ok">ОК</string>
<string name="cancel">Отмена</string>
</resources>

View File

@@ -9,8 +9,10 @@ import com.homebox.lens.domain.model.Item
import com.homebox.lens.domain.model.ItemCreate
import com.homebox.lens.domain.model.ItemOut
import com.homebox.lens.domain.model.ItemSummary
import com.homebox.lens.domain.model.LocationOutCount
import com.homebox.lens.domain.usecase.CreateItemUseCase
import com.homebox.lens.domain.usecase.GetItemDetailsUseCase
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
import com.homebox.lens.domain.usecase.UpdateItemUseCase
import io.mockk.coEvery
import io.mockk.mockk
@@ -37,6 +39,7 @@ class ItemEditViewModelTest {
private lateinit var createItemUseCase: CreateItemUseCase
private lateinit var updateItemUseCase: UpdateItemUseCase
private lateinit var getItemDetailsUseCase: GetItemDetailsUseCase
private lateinit var getAllLocationsUseCase: GetAllLocationsUseCase
private lateinit var viewModel: ItemEditViewModel
@Before
@@ -45,7 +48,11 @@ class ItemEditViewModelTest {
createItemUseCase = mockk()
updateItemUseCase = mockk()
getItemDetailsUseCase = mockk()
viewModel = ItemEditViewModel(createItemUseCase, updateItemUseCase, getItemDetailsUseCase)
getAllLocationsUseCase = mockk<GetAllLocationsUseCase>()
coEvery { getAllLocationsUseCase() } returns listOf(
LocationOutCount("1", "Test Location", "#000000", false, 0, "2025-08-28T12:00:00Z", "2025-08-28T12:00:00Z")
)
viewModel = ItemEditViewModel(createItemUseCase, updateItemUseCase, getItemDetailsUseCase, getAllLocationsUseCase)
}
@After
@@ -56,7 +63,41 @@ class ItemEditViewModelTest {
@Test
fun `loadItem with valid id should update uiState with item`() = runTest {
val itemId = UUID.randomUUID().toString()
val itemOut = ItemOut(id = itemId, name = "Test Item", description = "Description", quantity = 1, images = emptyList(), location = null, labels = emptyList(), value = 10.0, createdAt = "2025-08-28T12:00:00Z", assetId = null, notes = null, serialNumber = null, isArchived = false, purchasePrice = null, purchaseDate = null, warrantyUntil = null, parent = null, children = emptyList(), attachments = emptyList(), fields = emptyList(), maintenance = emptyList(), updatedAt = "2025-08-28T12:00:00Z")
val itemOut = ItemOut(
id = itemId,
name = "Test Item",
assetId = null,
description = "Description",
notes = null,
serialNumber = null,
quantity = 1,
isArchived = false,
value = 10.0,
purchasePrice = null,
purchaseDate = null,
warrantyUntil = null,
location = null,
parent = null,
children = emptyList(),
labels = emptyList(),
attachments = emptyList(),
images = emptyList(),
fields = emptyList(),
maintenance = emptyList(),
createdAt = "2025-08-28T12:00:00Z",
updatedAt = "2025-08-28T12:00:00Z",
insured = null,
lifetimeWarranty = null,
manufacturer = null,
modelNumber = null,
purchaseFrom = null,
soldNotes = null,
soldPrice = null,
soldTime = null,
soldTo = null,
syncChildItemsLocations = null,
warrantyDetails = null
)
coEvery { getItemDetailsUseCase(itemId) } returns itemOut
viewModel.loadItem(itemId)
@@ -91,6 +132,7 @@ class ItemEditViewModelTest {
viewModel.updateName("New Item")
viewModel.updateDescription("New Description")
viewModel.updateQuantity(2)
viewModel.updateSelectedLocationId("1")
testDispatcher.scheduler.advanceUntilIdle()
viewModel.saveItem()
@@ -105,8 +147,76 @@ class ItemEditViewModelTest {
@Test
fun `saveItem should call updateItemUseCase for existing item`() = runTest {
val itemId = UUID.randomUUID().toString()
val updatedItemOut = ItemOut(id = itemId, name = "Updated Item", description = "Updated Description", quantity = 4, images = emptyList(), location = null, labels = emptyList(), value = 12.0, createdAt = "2025-08-28T12:00:00Z", assetId = null, notes = null, serialNumber = null, isArchived = false, purchasePrice = null, purchaseDate = null, warrantyUntil = null, parent = null, children = emptyList(), attachments = emptyList(), fields = emptyList(), maintenance = emptyList(), updatedAt = "2025-08-28T12:00:00Z")
coEvery { getItemDetailsUseCase(itemId) } returns ItemOut(id = itemId, name = "Existing Item", description = "Existing Description", quantity = 3, images = emptyList(), location = null, labels = emptyList(), value = 10.0, createdAt = "2025-08-28T12:00:00Z", assetId = null, notes = null, serialNumber = null, isArchived = false, purchasePrice = null, purchaseDate = null, warrantyUntil = null, parent = null, children = emptyList(), attachments = emptyList(), fields = emptyList(), maintenance = emptyList(), updatedAt = "2025-08-28T12:00:00Z")
val updatedItemOut = ItemOut(
id = itemId,
name = "Updated Item",
assetId = null,
description = "Updated Description",
notes = null,
serialNumber = null,
quantity = 4,
isArchived = false,
value = 12.0,
purchasePrice = null,
purchaseDate = null,
warrantyUntil = null,
location = null,
parent = null,
children = emptyList(),
labels = emptyList(),
attachments = emptyList(),
images = emptyList(),
fields = emptyList(),
maintenance = emptyList(),
createdAt = "2025-08-28T12:00:00Z",
updatedAt = "2025-08-28T12:00:00Z",
insured = null,
lifetimeWarranty = null,
manufacturer = null,
modelNumber = null,
purchaseFrom = null,
soldNotes = null,
soldPrice = null,
soldTime = null,
soldTo = null,
syncChildItemsLocations = null,
warrantyDetails = null
)
coEvery { getItemDetailsUseCase(itemId) } returns ItemOut(
id = itemId,
name = "Existing Item",
assetId = null,
description = "Existing Description",
notes = null,
serialNumber = null,
quantity = 3,
isArchived = false,
value = 10.0,
purchasePrice = null,
purchaseDate = null,
warrantyUntil = null,
location = null,
parent = null,
children = emptyList(),
labels = emptyList(),
attachments = emptyList(),
images = emptyList(),
fields = emptyList(),
maintenance = emptyList(),
createdAt = "2025-08-28T12:00:00Z",
updatedAt = "2025-08-28T12:00:00Z",
insured = null,
lifetimeWarranty = null,
manufacturer = null,
modelNumber = null,
purchaseFrom = null,
soldNotes = null,
soldPrice = null,
soldTime = null,
soldTo = null,
syncChildItemsLocations = null,
warrantyDetails = null
)
coEvery { updateItemUseCase(any()) } returns updatedItemOut
viewModel.loadItem(itemId)