REFACTOR END

This commit is contained in:
2025-09-28 10:10:01 +03:00
parent 394e0040de
commit 9b914b2904
117 changed files with 3070 additions and 5447 deletions

View File

@@ -7,7 +7,6 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
// id("org.jlleitschuh.gradle.ktlint") version "12.1.1"
}
android {
@@ -62,6 +61,16 @@ dependencies {
implementation(project(":domain"))
implementation(project(":feature:scan"))
implementation(project(":feature:dashboard"))
implementation(project(":feature:inventorylist"))
implementation(project(":feature:itemdetails"))
implementation(project(":feature:itemedit"))
implementation(project(":feature:labeledit"))
implementation(project(":feature:labelslist"))
implementation(project(":feature:locationedit"))
implementation(project(":feature:locationslist"))
implementation(project(":feature:search"))
implementation(project(":feature:settings"))
implementation(project(":feature:setup"))
// [DEPENDENCY] AndroidX
implementation(Libs.coreKtx)
@@ -74,11 +83,10 @@ dependencies {
implementation(Libs.composeUiGraphics)
implementation(Libs.composeUiToolingPreview)
implementation(Libs.composeMaterial3)
implementation("androidx.compose.material:material-icons-extended-android:1.6.8")
implementation(Libs.composeMaterialIconsExtended)
implementation(Libs.navigationCompose)
implementation(Libs.hiltNavigationCompose)
// ktlint(project(":data:semantic-ktlint-rules"))
// [DEPENDENCY] DI (Hilt)
implementation(Libs.hiltAndroid)
kapt(Libs.hiltCompiler)

View File

@@ -1,5 +1,4 @@
// [PACKAGE] com.homebox.lens
// [FILE] MainActivity.kt
// [FILE] app/src/main/java/com/homebox/lens/MainActivity.kt
// [SEMANTICS] ui, activity, entrypoint
package com.homebox.lens
@@ -14,21 +13,31 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.homebox.lens.navigation.NavGraph
import com.homebox.lens.ui.theme.HomeboxLensTheme
import com.homebox.lens.feature.dashboard.ui.theme.HomeboxLensTheme
import com.homebox.lens.feature.dashboard.navigation.navGraph
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
// [END_IMPORTS]
// [ENTITY: Activity('MainActivity')]
/**
* @summary Главная и единственная Activity в приложении.
*/
// [ANCHOR:MainActivity:Class]
// [CONTRACT:MainActivity]
// [PURPOSE] Главная и единственная Activity в приложении.
// [END_CONTRACT:MainActivity]
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// [ENTITY: Function('onCreate')]
// [RELATION: Function('onCreate')] -> [CALLS] -> [Function('HomeboxLensTheme')]
// [RELATION: Function('onCreate')] -> [CALLS] -> [Function('NavGraph')]
// [ANCHOR:onCreate:Function]
// [CONTRACT:onCreate]
// [PURPOSE] Инициализация Activity.
// [PARAM:savedInstanceState:Bundle?] Сохраненное состояние.
// [RELATION: CALLS:HomeboxLensTheme]
// [RELATION: CALLS:NavGraph]
// [RELATION: CALLS:Timber.d]
// [END_CONTRACT:onCreate]
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.d("[DEBUG][LIFECYCLE][creating_activity] MainActivity created.")
@@ -36,35 +45,48 @@ class MainActivity : ComponentActivity() {
HomeboxLensTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
color = MaterialTheme.colorScheme.background,
) {
NavGraph()
navGraph()
}
}
}
}
// [END_ENTITY: Function('onCreate')]
// [END_ANCHOR:onCreate]
}
// [END_ENTITY: Activity('MainActivity')]
// [END_ANCHOR:MainActivity]
// [ENTITY: Function('Greeting')]
// [ANCHOR:greeting:Function]
// [CONTRACT:greeting]
// [PURPOSE] Отображает приветствие.
// [PARAM:name:String] Имя для приветствия.
// [PARAM:modifier:Modifier] Модификатор для элемента.
// [END_CONTRACT:greeting]
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
fun greeting(
name: String,
modifier: Modifier = Modifier,
) {
Text(
text = "Hello $name!",
modifier = modifier
modifier = modifier,
)
}
// [END_ENTITY: Function('Greeting')]
// [END_ANCHOR:greeting]
// [ENTITY: Function('GreetingPreview')]
// [ANCHOR:greetingPreview:Function]
// [CONTRACT:greetingPreview]
// [PURPOSE] Предварительный просмотр функции greeting.
// [END_CONTRACT:greetingPreview]
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
fun greetingPreview() {
HomeboxLensTheme {
Greeting("Android")
greeting("Android")
}
}
// [END_ENTITY: Function('GreetingPreview')]
// [END_FILE_MainActivity.kt]
// [END_ANCHOR:greetingPreview]
// [END_FILE_app/src/main/java/com/homebox/lens/MainActivity.kt]
// [END_FILE_app/src/main/java/com/homebox/lens/MainActivity.kt]

View File

@@ -10,12 +10,12 @@ import timber.log.Timber
// [END_IMPORTS]
// [ENTITY: Application('MainApplication')]
/**
* @summary Точка входа в приложение. Инициализирует Hilt и Timber.
*/
@HiltAndroidApp
class MainApplication : Application() {
// [ENTITY: Function('onCreate')]
override fun onCreate() {
super.onCreate()
@@ -27,4 +27,4 @@ class MainApplication : Application() {
// [END_ENTITY: Function('onCreate')]
}
// [END_ENTITY: Application('MainApplication')]
// [END_FILE_MainApplication.kt]
// [END_FILE_MainApplication.kt]

View File

@@ -1,173 +0,0 @@
// [PACKAGE] com.homebox.lens.navigation
// [FILE] NavGraph.kt
// [SEMANTICS] navigation, compose, nav_host
package com.homebox.lens.navigation
// [IMPORTS]
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
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
import com.homebox.lens.ui.screen.labelslist.LabelsListScreen
import com.homebox.lens.ui.screen.labeledit.LabelEditScreen
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')]
// [RELATION: Function('NavGraph')] -> [DEPENDS_ON] -> [Framework('NavHostController')]
// [RELATION: Function('NavGraph')] -> [CREATES_INSTANCE_OF] -> [Class('NavigationActions')]
/**
* @summary Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation.
* @param navController Контроллер навигации.
* @see Screen
* @sideeffect Регистрирует все экраны и управляет состоянием навигации.
* @invariant Стартовый экран - `Screen.Setup`.
*/
@Composable
fun NavGraph(
navController: NavHostController = rememberNavController()
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val navigationActions = remember(navController) {
NavigationActions(navController)
}
NavHost(
navController = navController,
startDestination = Screen.Setup.route
) {
composable(route = Screen.Setup.route) {
SetupScreen(onSetupComplete = {
navController.navigate(Screen.Dashboard.route) {
popUpTo(Screen.Setup.route) { inclusive = true }
}
})
}
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,
navigationActions = navigationActions
)
}
composable(route = Screen.ItemDetails.route) {
ItemDetailsScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
composable(
route = Screen.ItemEdit.route,
arguments = listOf(navArgument("itemId") { nullable = true })
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")
ItemEditScreen(
currentRoute = currentRoute,
navigationActions = navigationActions,
itemId = itemId,
onSaveSuccess = { navController.popBackStack() }
)
}
composable(Screen.LabelsList.route) {
LabelsListScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
composable(route = Screen.LocationsList.route) {
LocationsListScreen(
currentRoute = currentRoute,
navigationActions = navigationActions,
onLocationClick = { locationId ->
// [AI_NOTE]: Navigate to a pre-filtered inventory list screen
navController.navigate(Screen.InventoryList.route)
},
onAddNewLocationClick = {
navController.navigate(Screen.LocationEdit.createRoute("new"))
}
)
}
composable(route = Screen.LocationEdit.route) { backStackEntry ->
val locationId = backStackEntry.arguments?.getString("locationId")
LocationEditScreen(
locationId = locationId
)
}
composable(route = Screen.LocationEdit.route) { backStackEntry ->
val locationId = backStackEntry.arguments?.getString("locationId")
LocationEditScreen(
locationId = locationId
)
}
composable(
route = Screen.LabelEdit.route,
arguments = listOf(navArgument("labelId") { nullable = true })
) { backStackEntry ->
val labelId = backStackEntry.arguments?.getString("labelId")
LabelEditScreen(
labelId = labelId,
onBack = { navController.popBackStack() },
onLabelSaved = { navController.popBackStack() }
)
}
composable(route = Screen.Search.route) {
SearchScreen(
currentRoute = currentRoute,
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')]
// [END_FILE_NavGraph.kt]

View File

@@ -1,132 +0,0 @@
// [PACKAGE] com.homebox.lens.navigation
// [FILE] NavigationActions.kt
// [SEMANTICS] navigation, controller, actions
package com.homebox.lens.navigation
// [IMPORTS]
import androidx.navigation.NavHostController
import timber.log.Timber
// [END_IMPORTS]
// [ENTITY: Class('NavigationActions')]
// [RELATION: Class('NavigationActions')] -> [DEPENDS_ON] -> [Framework('NavHostController')]
/**
* @summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий.
* @param navController Контроллер Jetpack Navigation.
* @invariant Все навигационные действия должны использовать предоставленный navController.
*/
class NavigationActions(val navController: NavHostController) {
// [ENTITY: Function('navigateToDashboard')]
/**
* @summary Навигация на главный экран.
* @sideeffect Очищает back stack до главного экрана, чтобы избежать циклов.
*/
fun navigateToDashboard() {
Timber.i("[INFO][ACTION][navigate_to_dashboard] Navigating to Dashboard.")
navController.navigate(Screen.Dashboard.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
}
}
// [END_ENTITY: Function('navigateToDashboard')]
// [ENTITY: Function('navigateToLocations')]
fun navigateToLocations() {
Timber.i("[INFO][ACTION][navigate_to_locations] Navigating to Locations.")
navController.navigate(Screen.LocationsList.route) {
launchSingleTop = true
}
}
// [END_ENTITY: Function('navigateToLocations')]
// [ENTITY: Function('navigateToLabels')]
fun navigateToLabels() {
Timber.i("[INFO][ACTION][navigate_to_labels] Navigating to Labels.")
navController.navigate(Screen.LabelsList.route) {
launchSingleTop = true
}
}
// [END_ENTITY: Function('navigateToLabels')]
// [ENTITY: Function('navigateToLabelEdit')]
fun navigateToLabelEdit(labelId: String? = null) {
Timber.i("[INFO][ACTION][navigate_to_label_edit] Navigating to Label Edit with ID: %s", labelId)
navController.navigate(Screen.LabelEdit.createRoute(labelId))
}
// [END_ENTITY: Function('navigateToLabelEdit')]
// [ENTITY: Function('navigateToSearch')]
fun navigateToSearch() {
Timber.i("[INFO][ACTION][navigate_to_search] Navigating to Search.")
navController.navigate(Screen.Search.route) {
launchSingleTop = true
}
}
// [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)
val route = Screen.InventoryList.withFilter("label", labelId)
navController.navigate(route)
}
// [END_ENTITY: Function('navigateToInventoryListWithLabel')]
// [ENTITY: Function('navigateToInventoryListWithLocation')]
fun navigateToInventoryListWithLocation(locationId: String) {
Timber.i("[INFO][ACTION][navigate_to_inventory_with_location] Navigating to Inventory with location: %s", locationId)
val route = Screen.InventoryList.withFilter("location", locationId)
navController.navigate(route)
}
// [END_ENTITY: Function('navigateToInventoryListWithLocation')]
// [ENTITY: Function('navigateToCreateItem')]
fun navigateToCreateItem() {
Timber.i("[INFO][ACTION][navigate_to_create_item] Navigating to Create Item.")
navController.navigate(Screen.ItemEdit.createRoute())
}
// [END_ENTITY: Function('navigateToCreateItem')]
// [ENTITY: Function('navigateToLogout')]
fun navigateToLogout() {
Timber.i("[INFO][ACTION][navigate_to_logout] Navigating to Logout.")
navController.navigate(Screen.Setup.route) {
popUpTo(Screen.Dashboard.route) { inclusive = true }
}
}
// [END_ENTITY: Function('navigateToLogout')]
// [ENTITY: Function('navigateBack')]
fun navigateBack() {
Timber.i("[INFO][ACTION][navigate_back] Navigating back.")
navController.popBackStack()
}
// [END_ENTITY: Function('navigateBack')]
}
// [END_ENTITY: Class('NavigationActions')]
// [END_FILE_NavigationActions.kt]

View File

@@ -1,131 +0,0 @@
// [PACKAGE] com.homebox.lens.navigation
// [FILE] Screen.kt
// [SEMANTICS] navigation, routes, sealed_class
package com.homebox.lens.navigation
// [ENTITY: SealedClass('Screen')]
/**
* @summary Запечатанный класс для определения маршрутов навигации в приложении.
* @description Обеспечивает типобезопасность при навигации.
* @param route Строковый идентификатор маршрута.
*/
sealed class Screen(val route: String) {
// [ENTITY: Object('Setup')]
data object Setup : Screen("setup_screen")
// [END_ENTITY: Object('Setup')]
// [ENTITY: Object('Dashboard')]
data object Dashboard : Screen("dashboard_screen")
// [END_ENTITY: Object('Dashboard')]
// [ENTITY: Object('InventoryList')]
data object InventoryList : Screen("inventory_list_screen") {
// [ENTITY: Function('withFilter')]
/**
* @summary Создает маршрут для экрана списка инвентаря с параметром фильтра.
* @param key Ключ фильтра (например, "label" или "location").
* @param value Значение фильтра (например, ID метки или местоположения).
* @return Строку полного маршрута с query-параметром.
* @throws IllegalArgumentException если ключ или значение пустые.
*/
fun withFilter(key: String, value: String): String {
require(key.isNotBlank()) { "Filter key cannot be blank." }
require(value.isNotBlank()) { "Filter value cannot be blank." }
val constructedRoute = "inventory_list_screen?$key=$value"
check(constructedRoute.contains("?$key=$value")) { "Route must contain the filter query." }
return constructedRoute
}
// [END_ENTITY: Function('withFilter')]
}
// [END_ENTITY: Object('InventoryList')]
// [ENTITY: Object('ItemDetails')]
data object ItemDetails : Screen("item_details_screen/{itemId}") {
// [ENTITY: Function('createRoute')]
/**
* @summary Создает маршрут для экрана деталей элемента с указанным ID.
* @param itemId ID элемента для отображения.
* @return Строку полного маршрута.
* @throws IllegalArgumentException если itemId пустой.
*/
fun createRoute(itemId: String): String {
require(itemId.isNotBlank()) { "itemId не может быть пустым." }
val route = "item_details_screen/$itemId"
check(route.endsWith(itemId)) { "Маршрут должен заканчиваться на itemId." }
return route
}
// [END_ENTITY: Function('createRoute')]
}
// [END_ENTITY: Object('ItemDetails')]
// [ENTITY: Object('ItemEdit')]
data object ItemEdit : Screen("item_edit_screen?itemId={itemId}") {
// [ENTITY: Function('createRoute')]
/**
* @summary Создает маршрут для экрана редактирования элемента с указанным ID.
* @param itemId ID элемента для редактирования. Null, если создается новый элемент.
* @return Строку полного маршрута.
*/
fun createRoute(itemId: String? = null): String {
return itemId?.let { "item_edit_screen?itemId=$it" } ?: "item_edit_screen"
}
// [END_ENTITY: Function('createRoute')]
}
// [END_ENTITY: Object('ItemEdit')]
// [ENTITY: Object('LabelsList')]
data object LabelsList : Screen("labels_list_screen")
// [END_ENTITY: Object('LabelsList')]
// [ENTITY: Object('LabelEdit')]
data object LabelEdit : Screen("label_edit_screen?labelId={labelId}") {
// [ENTITY: Function('createRoute')]
/**
* @summary Создает маршрут для экрана редактирования метки с указанным ID.
* @param labelId ID метки для редактирования. Null, если создается новая метка.
* @return Строку полного маршрута.
*/
fun createRoute(labelId: String? = null): String {
return labelId?.let { "label_edit_screen?labelId=$it" } ?: "label_edit_screen"
}
// [END_ENTITY: Function('createRoute')]
}
// [END_ENTITY: Object('LabelEdit')]
// [ENTITY: Object('LocationsList')]
data object LocationsList : Screen("locations_list_screen")
// [END_ENTITY: Object('LocationsList')]
// [ENTITY: Object('LocationEdit')]
data object LocationEdit : Screen("location_edit_screen/{locationId}") {
// [ENTITY: Function('createRoute')]
/**
* @summary Создает маршрут для экрана редактирования местоположения с указанным ID.
* @param locationId ID местоположения для редактирования.
* @return Строку полного маршрута.
* @throws IllegalArgumentException если locationId пустой.
*/
fun createRoute(locationId: String): String {
require(locationId.isNotBlank()) { "locationId не может быть пустым." }
val route = "location_edit_screen/$locationId"
check(route.endsWith(locationId)) { "Маршрут должен заканчиваться на locationId." }
return route
}
// [END_ENTITY: Function('createRoute')]
}
// [END_ENTITY: Object('LocationEdit')]
// [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

@@ -1,116 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.common
// [FILE] AppDrawer.kt
// [SEMANTICS] ui, common, navigation_drawer
package com.homebox.lens.ui.common
// [IMPORTS]
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.navigation.Screen
// [END_IMPORTS]
// [ENTITY: Function('AppDrawerContent')]
// [RELATION: Function('AppDrawerContent')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
/**
* @summary Контент для бокового навигационного меню (Drawer).
* @param currentRoute Текущий маршрут для подсветки активного элемента.
* @param navigationActions Объект с навигационными действиями.
* @param onCloseDrawer Лямбда для закрытия бокового меню.
*/
@Composable
internal fun AppDrawerContent(
currentRoute: String?,
navigationActions: NavigationActions,
onCloseDrawer: () -> Unit
) {
ModalDrawerSheet {
Spacer(Modifier.height(12.dp))
Button(
onClick = {
navigationActions.navigateToCreateItem()
onCloseDrawer()
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(id = R.string.create))
}
Spacer(Modifier.height(12.dp))
Divider()
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.dashboard_title)) },
selected = currentRoute == Screen.Dashboard.route,
onClick = {
navigationActions.navigateToDashboard()
onCloseDrawer()
}
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.nav_locations)) },
selected = currentRoute == Screen.LocationsList.route,
onClick = {
navigationActions.navigateToLocations()
onCloseDrawer()
}
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.nav_labels)) },
selected = currentRoute == Screen.LabelsList.route,
onClick = {
navigationActions.navigateToLabels()
onCloseDrawer()
}
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.search)) },
selected = currentRoute == Screen.Search.route,
onClick = {
navigationActions.navigateToSearch()
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(
label = { Text(stringResource(id = R.string.logout)) },
selected = false,
onClick = {
navigationActions.navigateToLogout()
onCloseDrawer()
}
)
}
}
// [END_ENTITY: Function('AppDrawerContent')]
// [END_FILE_AppDrawer.kt]

View File

@@ -1,91 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.common
// [FILE] MainScaffold.kt
// [SEMANTICS] ui, common, scaffold, navigation_drawer
package com.homebox.lens.ui.common
// [IMPORTS]
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import kotlinx.coroutines.launch
// [END_IMPORTS]
// [ENTITY: Function('MainScaffold')]
// [RELATION: Function('MainScaffold')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
// [RELATION: Function('MainScaffold')] -> [CALLS] -> [Function('AppDrawerContent')]
/**
* @summary Общая обертка для экранов, включающая Scaffold и Navigation Drawer.
* @param topBarTitle Заголовок для TopAppBar.
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
* @param topBarActions Composable-функция для отображения действий (иконок) в TopAppBar.
* @param content Основное содержимое экрана, которое будет отображено внутри Scaffold.
* @sideeffect Управляет состоянием (открыто/закрыто) бокового меню (ModalNavigationDrawer).
* @invariant TopAppBar всегда отображается с иконкой меню.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScaffold(
topBarTitle: String,
currentRoute: String?,
navigationActions: NavigationActions,
onNavigateUp: (() -> Unit)? = null,
topBarActions: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawerContent(
currentRoute = currentRoute,
navigationActions = navigationActions,
onCloseDrawer = { scope.launch { drawerState.close() } }
)
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(topBarTitle) },
navigationIcon = {
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)
}
}
}
// [END_ENTITY: Function('MainScaffold')]
// [END_FILE_MainScaffold.kt]

View File

@@ -1,76 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.components
// [FILE] ColorPicker.kt
// [SEMANTICS] ui, component, color_selection
package com.homebox.lens.ui.components
// [IMPORTS]
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.homebox.lens.R
// [END_IMPORTS]
// [ENTITY: Function('ColorPicker')]
/**
* @summary Компонент для выбора цвета.
* @param selectedColor Текущий выбранный цвет в формате HEX строки (например, "#FFFFFF").
* @param onColorSelected Лямбда-функция, вызываемая при выборе нового цвета.
* @param modifier Модификатор для настройки внешнего вида.
*/
@Composable
fun ColorPicker(
selectedColor: String,
onColorSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(text = stringResource(R.string.label_color), style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(
modifier = Modifier
.size(48.dp)
.background(
if (selectedColor.isEmpty()) Color.Transparent else Color(android.graphics.Color.parseColor(selectedColor)),
CircleShape
)
.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
.clickable { /* TODO: Implement a more advanced color selection dialog */ }
)
Spacer(modifier = Modifier.width(16.dp))
OutlinedTextField(
value = selectedColor,
onValueChange = { newValue ->
// Basic validation for hex color
if (newValue.matches(Regex("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"))) {
onColorSelected(newValue)
} else if (newValue.isEmpty() || newValue == "#") {
onColorSelected("#FFFFFF") // Default to white if input is cleared
}
},
label = { Text(stringResource(R.string.label_hex_color)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
}
}
// [END_ENTITY: Function('ColorPicker')]
// [END_FILE_ColorPicker.kt]

View File

@@ -1,35 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.components
// [FILE] LoadingOverlay.kt
// [SEMANTICS] ui, component, loading
package com.homebox.lens.ui.components
// [IMPORTS]
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
// [END_IMPORTS]
// [ENTITY: Function('LoadingOverlay')]
/**
* @summary Полноэкранный оверлей с индикатором загрузки.
*/
@Composable
fun LoadingOverlay() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// [END_ENTITY: Function('LoadingOverlay')]
// [END_FILE_LoadingOverlay.kt]

View File

@@ -1,39 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.inventorylist
// [FILE] InventoryListScreen.kt
// [SEMANTICS] ui, screen, inventory, list
package com.homebox.lens.ui.screen.inventorylist
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [END_IMPORTS]
// [ENTITY: Function('InventoryListScreen')]
// [RELATION: Function('InventoryListScreen')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
// [RELATION: Function('InventoryListScreen')] -> [CALLS] -> [Function('MainScaffold')]
/**
* @summary Composable-функция для экрана "Список инвентаря".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
*/
@Composable
fun InventoryListScreen(
currentRoute: String?,
navigationActions: NavigationActions
) {
MainScaffold(
topBarTitle = stringResource(id = R.string.inventory_list_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
// [AI_NOTE]: Implement Inventory List Screen UI
Text(text = "Inventory List Screen")
}
}
// [END_ENTITY: Function('InventoryListScreen')]
// [END_FILE_InventoryListScreen.kt]

View File

@@ -1,21 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.inventorylist
// [FILE] InventoryListViewModel.kt
// [SEMANTICS] ui, viewmodel, inventory_list
package com.homebox.lens.ui.screen.inventorylist
// [IMPORTS]
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
// [END_IMPORTS]
// [ENTITY: ViewModel('InventoryListViewModel')]
/**
* @summary ViewModel for the inventory list screen.
*/
@HiltViewModel
class InventoryListViewModel @Inject constructor() : ViewModel() {
// [AI_NOTE]: Implement UI state
}
// [END_ENTITY: ViewModel('InventoryListViewModel')]
// [END_FILE_InventoryListViewModel.kt]

View File

@@ -1,39 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.itemdetails
// [FILE] ItemDetailsScreen.kt
// [SEMANTICS] ui, screen, item, details
package com.homebox.lens.ui.screen.itemdetails
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [END_IMPORTS]
// [ENTITY: Function('ItemDetailsScreen')]
// [RELATION: Function('ItemDetailsScreen')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
// [RELATION: Function('ItemDetailsScreen')] -> [CALLS] -> [Function('MainScaffold')]
/**
* @summary Composable-функция для экрана "Детали элемента".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
*/
@Composable
fun ItemDetailsScreen(
currentRoute: String?,
navigationActions: NavigationActions
) {
MainScaffold(
topBarTitle = stringResource(id = R.string.item_details_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
// [AI_NOTE]: Implement Item Details Screen UI
Text(text = "Item Details Screen")
}
}
// [END_ENTITY: Function('ItemDetailsScreen')]
// [END_FILE_ItemDetailsScreen.kt]

View File

@@ -1,21 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.itemdetails
// [FILE] ItemDetailsViewModel.kt
// [SEMANTICS] ui, viewmodel, item_details
package com.homebox.lens.ui.screen.itemdetails
// [IMPORTS]
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
// [END_IMPORTS]
// [ENTITY: ViewModel('ItemDetailsViewModel')]
/**
* @summary ViewModel for the item details screen.
*/
@HiltViewModel
class ItemDetailsViewModel @Inject constructor() : ViewModel() {
// [AI_NOTE]: Implement UI state
}
// [END_ENTITY: ViewModel('ItemDetailsViewModel')]
// [END_FILE_ItemDetailsViewModel.kt]

View File

@@ -1,436 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.itemedit
// [FILE] ItemEditScreen.kt
// [SEMANTICS] ui, screen, item, edit
package com.homebox.lens.ui.screen.itemedit
// [IMPORTS]
import androidx.compose.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.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
import androidx.compose.ui.unit.dp
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')]
// [RELATION: Function('ItemEditScreen')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
// [RELATION: Function('ItemEditScreen')] -> [DEPENDS_ON] -> [ViewModel('ItemEditViewModel')]
// [RELATION: Function('ItemEditScreen')] -> [CONSUMES_STATE] -> [DataClass('ItemEditUiState')]
// [RELATION: Function('ItemEditScreen')] -> [CALLS] -> [Function('MainScaffold')]
/**
* @summary Composable-функция для экрана "Редактирование элемента".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
* @param itemId ID элемента для редактирования. Null, если создается новый элемент.
* @param viewModel ViewModel для управления состоянием экрана.
* @param onSaveSuccess Callback, вызываемый после успешного сохранения товара.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ItemEditScreen(
currentRoute: String?,
navigationActions: NavigationActions,
itemId: String?,
viewModel: ItemEditViewModel = hiltViewModel(),
onSaveSuccess: () -> Unit
) {
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)
Timber.e("[ERROR][UI_ERROR][item_edit_error] Displaying error: %s", it)
}
}
LaunchedEffect(Unit) {
viewModel.saveCompleted.collect {
Timber.i("[INFO][ACTION][save_completed_callback] Item save completed. Triggering onSaveSuccess.")
onSaveSuccess()
}
}
MainScaffold(
topBarTitle = stringResource(id = R.string.item_edit_title),
currentRoute = currentRoute,
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)
) {
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))
}
}
}
}
}
// [END_ENTITY: Function('ItemEditScreen')]
// [END_FILE_ItemEditScreen.kt]

View File

@@ -1,583 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.itemedit
// [FILE] ItemEditViewModel.kt
// [SEMANTICS] ui, viewmodel, item_edit
package com.homebox.lens.ui.screen.itemedit
// [IMPORTS]
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
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
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]
// [ENTITY: DataClass('ItemEditUiState')]
/**
* @summary UI state for the item edit screen.
* @param item The item being edited, or null if creating a new item.
* @param isLoading Whether data is currently being loaded or saved.
* @param error An error message if an operation failed.
*/
data class ItemEditUiState(
val item: Item? = null,
val locations: List<LocationOut> = emptyList(),
val selectedLocationId: String? = null,
val isLoading: Boolean = false,
val error: String? = null
)
// [END_ENTITY: DataClass('ItemEditUiState')]
// [ENTITY: ViewModel('ItemEditViewModel')]
// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('CreateItemUseCase')]
// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('UpdateItemUseCase')]
// [RELATION: ViewModel('ItemEditViewModel')] -> [DEPENDS_ON] -> [UseCase('GetItemDetailsUseCase')]
// [RELATION: ViewModel('ItemEditViewModel')] -> [EMITS_STATE] -> [DataClass('ItemEditUiState')]
/**
* @summary ViewModel for the item edit screen.
*/
@HiltViewModel
class ItemEditViewModel @Inject constructor(
private val createItemUseCase: CreateItemUseCase,
private val updateItemUseCase: UpdateItemUseCase,
private val getItemDetailsUseCase: GetItemDetailsUseCase,
private val getAllLocationsUseCase: GetAllLocationsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(ItemEditUiState())
val uiState: StateFlow<ItemEditUiState> = _uiState.asStateFlow()
private val _saveCompleted = MutableSharedFlow<Unit>()
val saveCompleted: SharedFlow<Unit> = _saveCompleted.asSharedFlow()
// [ENTITY: Function('loadItem')]
/**
* @summary Loads item details for editing or prepares for new item creation.
* @param itemId The ID of the item to load. If null, a new item is being created.
* @sideeffect Updates `_uiState` with loading, success, or error states.
*/
fun loadItem(itemId: String?) {
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,
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)
val itemOut = getItemDetailsUseCase(itemId)
Timber.d("[DEBUG][ACTION][mapping_item_out_to_item] Mapping ItemOut to Item for UI state.")
val item = Item(
id = itemOut.id,
name = itemOut.name,
description = itemOut.description,
quantity = itemOut.quantity,
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
)
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)
_uiState.value = _uiState.value.copy(isLoading = false, error = e.localizedMessage)
}
}
}
}
// [END_ENTITY: Function('loadItem')]
// [ENTITY: Function('saveItem')]
/**
* @summary Saves the current item, either creating a new one or updating an existing one.
* @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 = 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)
} else {
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 = currentItem.copy(
id = updatedItemOut.id,
name = updatedItemOut.name,
description = updatedItemOut.description,
quantity = updatedItemOut.quantity,
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,
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
)
Timber.i("[INFO][ACTION][item_updated] Successfully updated item with ID: %s", updatedItem.id)
_saveCompleted.emit(Unit)
}
} catch (e: Exception) {
Timber.e(e, "[ERROR][FALLBACK][item_save_failed] Failed to save item.")
_uiState.value = _uiState.value.copy(isLoading = false, error = e.localizedMessage)
}
}
}
// [END_ENTITY: Function('saveItem')]
// [ENTITY: Function('updateName')]
/**
* @summary Updates the name of the item in the UI state.
* @param newName The new name for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateName(newName: String) {
Timber.d("[DEBUG][ACTION][updating_item_name] Updating item name to: %s", newName)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(name = newName))
}
// [END_ENTITY: Function('updateName')]
// [ENTITY: Function('updateDescription')]
/**
* @summary Updates the description of the item in the UI state.
* @param newDescription The new description for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateDescription(newDescription: String) {
Timber.d("[DEBUG][ACTION][updating_item_description] Updating item description to: %s", newDescription)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(description = newDescription))
}
// [END_ENTITY: Function('updateDescription')]
// [ENTITY: Function('updateQuantity')]
/**
* @summary Updates the quantity of the item in the UI state.
* @param newQuantity The new quantity for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateQuantity(newQuantity: Int) {
Timber.d("[DEBUG][ACTION][updating_item_quantity] Updating item quantity to: %d", newQuantity)
_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

@@ -1,113 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.labeledit
// [FILE] LabelEditScreen.kt
// [SEMANTICS] ui, screen, label, edit
package com.homebox.lens.ui.screen.labeledit
// [IMPORTS]
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
import com.homebox.lens.ui.components.ColorPicker
import com.homebox.lens.ui.components.LoadingOverlay
// [END_IMPORTS]
// [ENTITY: Function('LabelEditScreen')]
// [RELATION: Function('LabelEditScreen')] -> [DEPENDS_ON] -> [ViewModel('LabelEditViewModel')]
/**
* @summary Composable-функция для экрана "Редактирование метки".
* @param labelId ID метки для редактирования или null для создания новой.
* @param onBack Навигация назад.
* @param onLabelSaved Действие после сохранения метки.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelEditScreen(
labelId: String?,
onBack: () -> Unit,
onLabelSaved: () -> Unit,
viewModel: LabelEditViewModel = hiltViewModel()
) {
val uiState = viewModel.uiState
val snackbarHostState = SnackbarHostState()
LaunchedEffect(uiState.isSaved) {
if (uiState.isSaved) {
onLabelSaved()
}
}
LaunchedEffect(uiState.error) {
uiState.error?.let {
snackbarHostState.showSnackbar(
message = it,
actionLabel = "Dismiss",
duration = SnackbarDuration.Short
)
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = if (labelId == null) {
stringResource(id = R.string.label_edit_title_create)
} else {
stringResource(id = R.string.label_edit_title_edit)
}
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
IconButton(onClick = viewModel::saveLabel) {
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
OutlinedTextField(
value = uiState.name,
onValueChange = viewModel::onNameChange,
label = { Text(stringResource(R.string.label_name)) },
isError = uiState.nameError != null,
supportingText = { uiState.nameError?.let { Text(it) } },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
ColorPicker(
selectedColor = uiState.color,
onColorSelected = viewModel::onColorChange,
modifier = Modifier.fillMaxWidth()
)
}
if (uiState.isLoading) {
LoadingOverlay()
}
}
}
// [END_ENTITY: Function('LabelEditScreen')]
// [END_FILE_LabelEditScreen.kt]

View File

@@ -1,115 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.labeledit
// [FILE] LabelEditViewModel.kt
// [SEMANTICS] ui, viewmodel, label_management
package com.homebox.lens.ui.screen.labeledit
// [IMPORTS]
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.model.LabelCreate
import com.homebox.lens.domain.model.LabelOut
import com.homebox.lens.domain.model.LabelUpdate
import com.homebox.lens.domain.usecase.CreateLabelUseCase
import com.homebox.lens.domain.usecase.GetLabelDetailsUseCase
import com.homebox.lens.domain.usecase.UpdateLabelUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
// [END_IMPORTS]
// [ENTITY: ViewModel('LabelEditViewModel')]
// [RELATION: ViewModel('LabelEditViewModel')] -> [DEPENDS_ON] -> [UseCase('GetLabelDetailsUseCase')]
// [RELATION: ViewModel('LabelEditViewModel')] -> [DEPENDS_ON] -> [UseCase('CreateLabelUseCase')]
// [RELATION: ViewModel('LabelEditViewModel')] -> [DEPENDS_ON] -> [UseCase('UpdateLabelUseCase')]
// [RELATION: ViewModel('LabelEditViewModel')] -> [EMITS_STATE] -> [DataClass('LabelEditUiState')]
@HiltViewModel
class LabelEditViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val getLabelDetailsUseCase: GetLabelDetailsUseCase,
private val createLabelUseCase: CreateLabelUseCase,
private val updateLabelUseCase: UpdateLabelUseCase
) : ViewModel() {
var uiState by mutableStateOf(LabelEditUiState())
private set
private val labelId: String? = savedStateHandle["labelId"]
init {
if (labelId != null) {
loadLabelDetails(labelId)
}
}
fun onNameChange(newName: String) {
uiState = uiState.copy(name = newName, nameError = null)
}
fun onColorChange(newColor: String) {
uiState = uiState.copy(color = newColor)
}
fun saveLabel() {
viewModelScope.launch {
if (uiState.name.isBlank()) {
uiState = uiState.copy(nameError = "Label name cannot be empty.")
return@launch
}
uiState = uiState.copy(isLoading = true, error = null)
try {
if (labelId == null) {
// Create new label
val newLabel = LabelCreate(name = uiState.name, color = uiState.color)
createLabelUseCase(newLabel)
} else {
// Update existing label
val updatedLabel = LabelUpdate(name = uiState.name, color = uiState.color)
updateLabelUseCase(labelId, updatedLabel)
}
uiState = uiState.copy(isSaved = true)
} catch (e: Exception) {
uiState = uiState.copy(error = e.message, isLoading = false)
} finally {
uiState = uiState.copy(isLoading = false)
}
}
}
private fun loadLabelDetails(id: String) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, error = null)
try {
val label = getLabelDetailsUseCase(id)
uiState = uiState.copy(
name = label.name,
color = label.color,
isLoading = false
)
} catch (e: Exception) {
uiState = uiState.copy(error = e.message, isLoading = false)
}
}
}
}
// [ENTITY: DataClass('LabelEditUiState')]
/**
* @summary Состояние UI для экрана редактирования метки.
*/
data class LabelEditUiState(
val name: String = "",
val color: String = "#FFFFFF", // Default color
val nameError: String? = null,
val isLoading: Boolean = false,
val error: String? = null,
val isSaved: Boolean = false,
val originalLabel: LabelOut? = null // To hold original label details if editing
)
// [END_ENTITY: DataClass('LabelEditUiState')]
// [END_FILE_LabelEditViewModel.kt]

View File

@@ -1,225 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
// [FILE] LabelsListScreen.kt
// [SEMANTICS] ui, labels_list, state_management, compose, dialog
package com.homebox.lens.ui.screen.labelslist
// [IMPORTS]
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.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
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
import com.homebox.lens.domain.model.Label
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.navigation.Screen
import com.homebox.lens.ui.common.MainScaffold
import timber.log.Timber
// [END_IMPORTS]
// [ENTITY: Function('LabelsListScreen')]
// [RELATION: Function('LabelsListScreen')] -> [DEPENDS_ON] -> [ViewModel('LabelsListViewModel')]
// [RELATION: Function('LabelsListScreen')] -> [DEPENDS_ON] -> [Framework('NavController')]
/**
* @summary Отображает экран со списком всех меток.
* @param navController Контроллер навигации для перемещения между экранами.
* @param viewModel ViewModel, предоставляющая состояние UI для экрана меток.
*/
@Composable
fun LabelsListScreen(
currentRoute: String?,
navigationActions: NavigationActions,
viewModel: LabelsListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
MainScaffold(
topBarTitle = stringResource(id = R.string.screen_title_labels),
currentRoute = currentRoute,
navigationActions = navigationActions
) { paddingValues ->
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = {
Timber.i("[INFO][ACTION][navigate_to_label_edit] FAB clicked: Navigate to create new label screen.")
navigationActions.navigateToLabelEdit(null)
}) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.content_desc_create_label)
)
}
}
) { innerPaddingValues ->
val currentState = uiState
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPaddingValues), // Use innerPaddingValues here
contentAlignment = Alignment.Center
) {
when (currentState) {
is LabelsListUiState.Loading -> {
CircularProgressIndicator()
}
is LabelsListUiState.Error -> {
Text(text = currentState.message)
}
is LabelsListUiState.Success -> {
if (currentState.labels.isEmpty()) {
Text(text = stringResource(id = R.string.no_labels_found))
} else {
LabelsList(
labels = currentState.labels,
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")
}
}
)
}
}
}
}
}
}
}
// [END_ENTITY: Function('LabelsListScreen')]
// [ENTITY: Function('LabelsList')]
// [RELATION: Function('LabelsList')] -> [DEPENDS_ON] -> [DataClass('Label')]
/**
* @summary Composable-функция для отображения списка меток.
* @param labels Список объектов `Label` для отображения.
* @param onLabelClick Лямбда-функция, вызываемая при нажатии на элемент списка.
* @param modifier Модификатор для настройки внешнего вида.
*/
@Composable
private fun LabelsList(
labels: List<Label>,
onLabelClick: (Label) -> Unit,
onDeleteClick: (Label) -> Unit,
isShowingDeleteDialog: Boolean,
labelToDelete: Label?,
onConfirmDelete: () -> Unit,
onDismissDeleteDialog: () -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(labels, key = { it.id }) { label ->
LabelListItem(
label = label,
onClick = { onLabelClick(label) },
onDeleteClick = { onDeleteClick(label) }
)
}
}
}
// [END_ENTITY: Function('LabelsList')]
// [ENTITY: Function('LabelListItem')]
// [RELATION: Function('LabelListItem')] -> [DEPENDS_ON] -> [DataClass('Label')]
/**
* @summary Composable-функция для отображения одного элемента в списке меток.
* @param label Объект `Label`, который нужно отобразить.
* @param onClick Лямбда-функция, вызываемая при нажатии на элемент.
*/
@Composable
private fun LabelListItem(
label: Label,
onClick: () -> Unit,
onDeleteClick: () -> Unit
) {
ListItem(
headlineContent = { Text(text = label.name) },
leadingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Label,
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)
)
}
// [END_ENTITY: Function('LabelListItem')]
// [END_FILE_LabelsListScreen.kt]

View File

@@ -1,50 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
// [FILE] LabelsListUiState.kt
// [SEMANTICS] ui_state, sealed_interface, contract
package com.homebox.lens.ui.screen.labelslist
// [IMPORTS]
import com.homebox.lens.domain.model.Label
// [END_IMPORTS]
// [ENTITY: SealedInterface('LabelsListUiState')]
/**
* @summary Определяет все возможные состояния для UI экрана со списком меток.
* @description Использование sealed-интерфейса позволяет исчерпывающе обрабатывать все состояния в Composable-функциях.
*/
sealed interface LabelsListUiState {
// [ENTITY: DataClass('Success')]
// [RELATION: DataClass('Success')] -> [DEPENDS_ON] -> [DataClass('Label')]
/**
* @summary Состояние успеха, содержит список меток и состояние диалога.
* @param labels Список меток для отображения.
* @param isShowingCreateDialog Флаг, показывающий, должен ли быть отображен диалог создания метки.
* @invariant labels не может быть null.
*/
data class Success(
val labels: List<Label>,
val isShowingCreateDialog: Boolean = false,
val isShowingDeleteDialog: Boolean = false,
val labelToDelete: Label? = null
) : LabelsListUiState
// [END_ENTITY: DataClass('Success')]
// [ENTITY: DataClass('Error')]
/**
* @summary Состояние ошибки.
* @param message Текст ошибки для отображения пользователю.
* @invariant message не может быть пустой.
*/
data class Error(val message: String) : LabelsListUiState
// [END_ENTITY: DataClass('Error')]
// [ENTITY: Object('Loading')]
/**
* @summary Состояние загрузки данных.
* @description Указывает, что идет процесс загрузки меток.
*/
data object Loading : LabelsListUiState
// [END_ENTITY: Object('Loading')]
}
// [END_ENTITY: SealedInterface('LabelsListUiState')]
// [END_FILE_LabelsListUiState.kt]

View File

@@ -1,149 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
// [FILE] LabelsListViewModel.kt
// [SEMANTICS] ui_logic, labels_list, state_management, dialog_management
package com.homebox.lens.ui.screen.labelslist
// [IMPORTS]
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
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
// [END_IMPORTS]
// [ENTITY: ViewModel('LabelsListViewModel')]
// [RELATION: ViewModel('LabelsListViewModel')] -> [DEPENDS_ON] -> [UseCase('GetAllLabelsUseCase')]
// [RELATION: ViewModel('LabelsListViewModel')] -> [EMITS_STATE] -> [SealedInterface('LabelsListUiState')]
/**
* @summary ViewModel для экрана со списком меток.
* @description Управляет состоянием экрана, загружает список меток, обрабатывает ошибки и управляет диалогом создания новой метки.
* @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`.
*/
@HiltViewModel
class LabelsListViewModel @Inject constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val deleteLabelUseCase: DeleteLabelUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
val uiState = _uiState.asStateFlow()
init {
loadLabels()
}
// [ENTITY: Function('loadLabels')]
/**
* @summary Загружает список меток.
* @description Выполняет `GetAllLabelsUseCase` и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error`.
* @sideeffect Асинхронно обновляет `_uiState`.
*/
fun loadLabels() {
viewModelScope.launch {
_uiState.value = LabelsListUiState.Loading
Timber.i("[INFO][ENTRYPOINT][loading_labels] Starting labels list load. State -> Loading.")
val result = runCatching {
getAllLabelsUseCase()
}
result.fold(
onSuccess = { labelOuts ->
Timber.i("[INFO][SUCCESS][labels_loaded] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
val labels = labelOuts.map { labelOut ->
Label(
id = labelOut.id,
name = labelOut.name
)
}
_uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false)
},
onFailure = { exception ->
Timber.e(exception, "[ERROR][EXCEPTION][loading_failed] Failed to load labels. State -> Error.")
_uiState.value = LabelsListUiState.Error(
message = exception.message ?: "Could not load labels."
)
}
)
}
}
// [END_ENTITY: Function('loadLabels')]
// [ENTITY: Function('onShowDeleteDialog')]
/**
* @summary Показывает диалог подтверждения удаления метки.
* @param label Метка для удаления.
* @sideeffect Обновляет состояние для показа диалога удаления.
*/
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 { currentState ->
(currentState as LabelsListUiState.Success).copy(
isShowingDeleteDialog = true,
labelToDelete = label
)
}
}
}
// [END_ENTITY: Function('onShowDeleteDialog')]
// [ENTITY: Function('onDismissDeleteDialog')]
/**
* @summary Скрывает диалог подтверждения удаления метки.
* @sideeffect Обновляет состояние для скрытия диалога удаления.
*/
fun onDismissDeleteDialog() {
Timber.i("[INFO][ACTION][dismiss_delete_dialog] Dismiss delete label dialog")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update { currentState ->
(currentState as LabelsListUiState.Success).copy(
isShowingDeleteDialog = false,
labelToDelete = null
)
}
}
}
// [END_ENTITY: Function('onDismissDeleteDialog')]
// [ENTITY: Function('deleteLabel')]
/**
* @summary Удаляет выбранную метку.
* @param labelId ID метки для удаления.
* @sideeffect Выполняет удаление через UseCase, обновляет состояние UI.
*/
fun deleteLabel(labelId: String) {
viewModelScope.launch {
_uiState.value = LabelsListUiState.Loading
Timber.i("[INFO][ENTRYPOINT][deleting_label] Starting label deletion for ID: $labelId. State -> Loading.")
val result = runCatching {
deleteLabelUseCase(labelId)
}
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('deleteLabel')]
}
// [END_ENTITY: ViewModel('LabelsListViewModel')]
// [END_FILE_LabelsListViewModel.kt]

View File

@@ -1,48 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationedit
// [FILE] LocationEditScreen.kt
// [SEMANTICS] ui, screen, location, edit
package com.homebox.lens.ui.screen.locationedit
// [IMPORTS]
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
// [END_IMPORTS]
// [ENTITY: Function('LocationEditScreen')]
/**
* @summary Composable-функция для экрана "Редактирование местоположения".
* @param locationId ID местоположения для редактирования или "new" для создания.
*/
@Composable
fun LocationEditScreen(
locationId: String?
) {
val title = if (locationId == "new") {
stringResource(id = R.string.location_edit_title_create)
} else {
stringResource(id = R.string.location_edit_title_edit)
}
Scaffold { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
// [AI_NOTE]: Implement Location Edit Screen UI
Text(text = "Location Edit Screen for ID: $locationId")
}
}
}
// [END_ENTITY: Function('LocationEditScreen')]
// [END_FILE_LocationEditScreen.kt]

View File

@@ -1,296 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListScreen.kt
// [SEMANTICS] ui, screen, locations, list
package com.homebox.lens.ui.screen.locationslist
// [IMPORTS]
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
import com.homebox.lens.domain.model.LocationOutCount
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
import com.homebox.lens.ui.theme.HomeboxLensTheme
// [END_IMPORTS]
// [ENTITY: Function('LocationsListScreen')]
// [RELATION: Function('LocationsListScreen')] -> [DEPENDS_ON] -> [ViewModel('LocationsListViewModel')]
// [RELATION: Function('LocationsListScreen')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
// [RELATION: Function('LocationsListScreen')] -> [CALLS] -> [Function('MainScaffold')]
/**
* @summary Composable-функция для экрана "Список местоположений".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
* @param onAddNewLocationClick Лямбда-обработчик нажатия на кнопку добавления нового местоположения.
* @param viewModel ViewModel для этого экрана.
*/
@Composable
fun LocationsListScreen(
currentRoute: String?,
navigationActions: NavigationActions,
onLocationClick: (String) -> Unit,
onAddNewLocationClick: () -> Unit,
viewModel: LocationsListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
MainScaffold(
topBarTitle = stringResource(id = R.string.locations_list_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) { paddingValues ->
Scaffold(
modifier = Modifier.padding(paddingValues),
floatingActionButton = {
FloatingActionButton(onClick = onAddNewLocationClick) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(id = R.string.cd_add_new_location)
)
}
}
) { innerPadding ->
LocationsListContent(
modifier = Modifier.padding(innerPadding),
uiState = uiState,
onLocationClick = onLocationClick,
onEditLocation = { /* [AI_NOTE]: Implement onEditLocation */ },
onDeleteLocation = { /* [AI_NOTE]: Implement onDeleteLocation */ }
)
}
}
}
// [END_ENTITY: Function('LocationsListScreen')]
// [ENTITY: Function('LocationsListContent')]
// [RELATION: Function('LocationsListContent')] -> [CONSUMES_STATE] -> [SealedInterface('LocationsListUiState')]
/**
* @summary Отображает основной контент экрана в зависимости от `uiState`.
* @param modifier Модификатор для стилизации.
* @param uiState Текущее состояние UI.
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
* @param onEditLocation Лямбда-обработчик для редактирования местоположения.
* @param onDeleteLocation Лямбда-обработчик для удаления местоположения.
*/
@Composable
private fun LocationsListContent(
modifier: Modifier = Modifier,
uiState: LocationsListUiState,
onLocationClick: (String) -> Unit,
onEditLocation: (String) -> Unit,
onDeleteLocation: (String) -> Unit
) {
Box(modifier = modifier.fillMaxSize()) {
when (uiState) {
is LocationsListUiState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
is LocationsListUiState.Error -> {
Text(
text = uiState.message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
)
}
is LocationsListUiState.Success -> {
if (uiState.locations.isEmpty()) {
Text(
text = stringResource(id = R.string.locations_not_found),
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(uiState.locations, key = { it.id }) { location ->
LocationCard(
location = location,
onClick = { onLocationClick(location.id) },
onEditClick = { onEditLocation(location.id) },
onDeleteClick = { onDeleteLocation(location.id) }
)
}
}
}
}
}
}
}
// [END_ENTITY: Function('LocationsListContent')]
// [ENTITY: Function('LocationCard')]
// [RELATION: Function('LocationCard')] -> [DEPENDS_ON] -> [DataClass('LocationOutCount')]
/**
* @summary Карточка для отображения одного местоположения.
* @param location Данные о местоположении.
* @param onClick Лямбда-обработчик нажатия на карточку.
* @param onEditClick Лямбда-обработчик нажатия на "Редактировать".
* @param onDeleteClick Лямбда-обработчик нажатия на "Удалить".
*/
@Composable
private fun LocationCard(
location: LocationOutCount,
onClick: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit
) {
var menuExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 16.dp, bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = location.name, style = MaterialTheme.typography.titleMedium)
Text(
text = stringResource(id = R.string.item_count, location.itemCount),
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(Modifier.width(16.dp))
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(id = R.string.cd_more_options))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(id = R.string.edit)) },
onClick = {
menuExpanded = false
onEditClick()
}
)
DropdownMenuItem(
text = { Text(stringResource(id = R.string.delete)) },
onClick = {
menuExpanded = false
onDeleteClick()
}
)
}
}
}
}
}
// [END_ENTITY: Function('LocationCard')]
// [ENTITY: Function('LocationsListSuccessPreview')]
@Preview(showBackground = true, name = "Locations List Success")
@Composable
fun LocationsListSuccessPreview() {
val previewLocations = listOf(
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
LocationOutCount("2", "Kitchen", "#00FF00", false, 5, "", ""),
LocationOutCount("3", "Office", "#0000FF", false, 23, "", "")
)
HomeboxLensTheme {
LocationsListContent(
uiState = LocationsListUiState.Success(previewLocations),
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
)
}
}
// [END_ENTITY: Function('LocationsListSuccessPreview')]
// [ENTITY: Function('LocationsListEmptyPreview')]
@Preview(showBackground = true, name = "Locations List Empty")
@Composable
fun LocationsListEmptyPreview() {
HomeboxLensTheme {
LocationsListContent(
uiState = LocationsListUiState.Success(emptyList()),
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
)
}
}
// [END_ENTITY: Function('LocationsListEmptyPreview')]
// [ENTITY: Function('LocationsListLoadingPreview')]
@Preview(showBackground = true, name = "Locations List Loading")
@Composable
fun LocationsListLoadingPreview() {
HomeboxLensTheme {
LocationsListContent(
uiState = LocationsListUiState.Loading,
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
)
}
}
// [END_ENTITY: Function('LocationsListLoadingPreview')]
// [ENTITY: Function('LocationsListErrorPreview')]
@Preview(showBackground = true, name = "Locations List Error")
@Composable
fun LocationsListErrorPreview() {
HomeboxLensTheme {
LocationsListContent(
uiState = LocationsListUiState.Error("Failed to load locations. Please try again."),
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
)
}
}
// [END_ENTITY: Function('LocationsListErrorPreview')]
// [END_FILE_LocationsListScreen.kt]

View File

@@ -1,42 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListUiState.kt
// [SEMANTICS] ui, state, locations
package com.homebox.lens.ui.screen.locationslist
// [IMPORTS]
import com.homebox.lens.domain.model.LocationOutCount
// [END_IMPORTS]
// [ENTITY: SealedInterface('LocationsListUiState')]
/**
* @summary Определяет возможные состояния UI для экрана списка местоположений.
* @see LocationsListViewModel
*/
sealed interface LocationsListUiState {
// [ENTITY: DataClass('Success')]
// [RELATION: DataClass('Success')] -> [DEPENDS_ON] -> [DataClass('LocationOutCount')]
/**
* @summary Состояние успешной загрузки данных.
* @param locations Список местоположений для отображения.
*/
data class Success(val locations: List<LocationOutCount>) : LocationsListUiState
// [END_ENTITY: DataClass('Success')]
// [ENTITY: DataClass('Error')]
/**
* @summary Состояние ошибки.
* @param message Сообщение об ошибке.
*/
data class Error(val message: String) : LocationsListUiState
// [END_ENTITY: DataClass('Error')]
// [ENTITY: Object('Loading')]
/**
* @summary Состояние загрузки данных.
*/
object Loading : LocationsListUiState
// [END_ENTITY: Object('Loading')]
}
// [END_ENTITY: SealedInterface('LocationsListUiState')]
// [END_FILE_LocationsListUiState.kt]

View File

@@ -1,64 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
// [FILE] LocationsListViewModel.kt
// [SEMANTICS] ui, viewmodel, locations, hilt
package com.homebox.lens.ui.screen.locationslist
// [IMPORTS]
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
// [END_IMPORTS]
// [ENTITY: ViewModel('LocationsListViewModel')]
// [RELATION: ViewModel('LocationsListViewModel')] -> [DEPENDS_ON] -> [UseCase('GetAllLocationsUseCase')]
// [RELATION: ViewModel('LocationsListViewModel')] -> [EMITS_STATE] -> [SealedInterface('LocationsListUiState')]
/**
* @summary ViewModel для экрана списка местоположений.
* @param getAllLocationsUseCase Use case для получения всех местоположений.
* @property uiState Поток, содержащий текущее состояние UI.
* @invariant `uiState` всегда отражает результат последней операции загрузки.
*/
@HiltViewModel
class LocationsListViewModel @Inject constructor(
private val getAllLocationsUseCase: GetAllLocationsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow()
init {
loadLocations()
}
// [ENTITY: Function('loadLocations')]
/**
* @summary Загружает список местоположений из репозитория.
* @sideeffect Обновляет `_uiState` в зависимости от результата: Loading -> Success/Error.
*/
fun loadLocations() {
Timber.d("[DEBUG][ENTRYPOINT][loading_locations] Starting to load locations.")
viewModelScope.launch {
_uiState.value = LocationsListUiState.Loading
try {
Timber.d("[DEBUG][ACTION][fetching_locations] Fetching locations from use case.")
val locations = getAllLocationsUseCase()
_uiState.value = LocationsListUiState.Success(locations)
Timber.d("[DEBUG][SUCCESS][locations_loaded] Successfully loaded locations.")
} catch (e: Exception) {
Timber.e(e, "[ERROR][EXCEPTION][loading_failed] Failed to load locations.")
_uiState.value = LocationsListUiState.Error(e.message ?: "Unknown error")
}
}
}
// [END_ENTITY: Function('loadLocations')]
}
// [END_ENTITY: ViewModel('LocationsListViewModel')]
// [END_FILE_LocationsListViewModel.kt]

View File

@@ -1,39 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.search
// [FILE] SearchScreen.kt
// [SEMANTICS] ui, screen, search
package com.homebox.lens.ui.screen.search
// [IMPORTS]
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [END_IMPORTS]
// [ENTITY: Function('SearchScreen')]
// [RELATION: Function('SearchScreen')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
// [RELATION: Function('SearchScreen')] -> [CALLS] -> [Function('MainScaffold')]
/**
* @summary Composable-функция для экрана "Поиск".
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
* @param navigationActions Объект с навигационными действиями.
*/
@Composable
fun SearchScreen(
currentRoute: String?,
navigationActions: NavigationActions
) {
MainScaffold(
topBarTitle = stringResource(id = R.string.search_title),
currentRoute = currentRoute,
navigationActions = navigationActions
) {
// [AI_NOTE]: Implement Search Screen UI
Text(text = "Search Screen")
}
}
// [END_ENTITY: Function('SearchScreen')]
// [END_FILE_SearchScreen.kt]

View File

@@ -1,21 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.search
// [FILE] SearchViewModel.kt
// [SEMANTICS] ui, viewmodel, search
package com.homebox.lens.ui.screen.search
// [IMPORTS]
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
// [END_IMPORTS]
// [ENTITY: ViewModel('SearchViewModel')]
/**
* @summary ViewModel for the search screen.
*/
@HiltViewModel
class SearchViewModel @Inject constructor() : ViewModel() {
// [AI_NOTE]: Implement UI state
}
// [END_ENTITY: ViewModel('SearchViewModel')]
// [END_FILE_SearchViewModel.kt]

View File

@@ -1,104 +0,0 @@
// [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

@@ -1,8 +0,0 @@
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

@@ -1,54 +0,0 @@
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

@@ -1,141 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.setup
// [FILE] SetupScreen.kt
// [SEMANTICS] ui, screen, setup, compose
@file:OptIn(ExperimentalMaterial3Api::class)
package com.homebox.lens.ui.screen.setup
// [IMPORTS]
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
// [END_IMPORTS]
// [ENTITY: Function('SetupScreen')]
// [RELATION: Function('SetupScreen')] -> [DEPENDS_ON] -> [ViewModel('SetupViewModel')]
// [RELATION: Function('SetupScreen')] -> [CALLS] -> [Function('SetupScreenContent')]
/**
* @summary Главная Composable-функция для экрана настройки соединения с сервером.
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
* @param onSetupComplete Лямбда, вызываемая после успешной настройки и входа.
* @sideeffect Вызывает `onSetupComplete` при изменении `uiState.isSetupComplete`.
*/
@Composable
fun SetupScreen(
viewModel: SetupViewModel = hiltViewModel(),
onSetupComplete: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
if (uiState.isSetupComplete) {
onSetupComplete()
}
SetupScreenContent(
uiState = uiState,
onServerUrlChange = viewModel::onServerUrlChange,
onUsernameChange = viewModel::onUsernameChange,
onPasswordChange = viewModel::onPasswordChange,
onConnectClick = viewModel::connect
)
}
// [END_ENTITY: Function('SetupScreen')]
// [ENTITY: Function('SetupScreenContent')]
// [RELATION: Function('SetupScreenContent')] -> [CONSUMES_STATE] -> [DataClass('SetupUiState')]
/**
* @summary Отображает контент экрана настройки: поля ввода и кнопку.
* @param uiState Текущее состояние UI.
* @param onServerUrlChange Лямбда-обработчик изменения URL сервера.
* @param onUsernameChange Лямбда-обработчик изменения имени пользователя.
* @param onPasswordChange Лямбда-обработчик изменения пароля.
* @param onConnectClick Лямбда-обработчик нажатия на кнопку "Подключиться".
*/
@Composable
private fun SetupScreenContent(
uiState: SetupUiState,
onServerUrlChange: (String) -> Unit,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onConnectClick: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(id = R.string.setup_title)) })
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
value = uiState.serverUrl,
onValueChange = onServerUrlChange,
label = { Text(stringResource(id = R.string.setup_server_url_label)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = uiState.username,
onValueChange = onUsernameChange,
label = { Text(stringResource(id = R.string.setup_username_label)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = uiState.password,
onValueChange = onPasswordChange,
label = { Text(stringResource(id = R.string.setup_password_label)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onConnectClick,
enabled = !uiState.isLoading,
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Text(stringResource(id = R.string.setup_connect_button))
}
}
uiState.error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}
// [END_ENTITY: Function('SetupScreenContent')]
// [ENTITY: Function('SetupScreenPreview')]
@Preview(showBackground = true)
@Composable
fun SetupScreenPreview() {
SetupScreenContent(
uiState = SetupUiState(error = "Failed to connect"),
onServerUrlChange = {},
onUsernameChange = {},
onPasswordChange = {},
onConnectClick = {}
)
}
// [END_ENTITY: Function('SetupScreenPreview')]
// [END_FILE_SetupScreen.kt]

View File

@@ -1,27 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.setup
// [FILE] SetupUiState.kt
// [SEMANTICS] ui_state, data_model, immutable
package com.homebox.lens.ui.screen.setup
// [ENTITY: DataClass('SetupUiState')]
/**
* @summary Неизменяемая модель данных, представляющая полное состояние экрана настройки (Setup Screen).
* @description Использование `data class` предоставляет метод `copy()` для легкого создания новых состояний.
* @param serverUrl URL-адрес сервера Homebox.
* @param username Имя пользователя для входа.
* @param password Пароль пользователя.
* @param isLoading Флаг, указывающий, выполняется ли в данный момент сетевой запрос.
* @param error Сообщение об ошибке для отображения пользователю, или `null` при отсутствии ошибки.
* @param isSetupComplete Флаг, указывающий на успешное завершение настройки и входа.
*/
data class SetupUiState(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val isSetupComplete: Boolean = false
)
// [END_ENTITY: DataClass('SetupUiState')]
// [END_FILE_SetupUiState.kt]

View File

@@ -1,113 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.setup
// [FILE] SetupViewModel.kt
// [SEMANTICS] ui_logic, viewmodel, state_management, user_setup, authentication_flow
package com.homebox.lens.ui.screen.setup
// [IMPORTS]
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.model.Credentials
import com.homebox.lens.domain.repository.CredentialsRepository
import com.homebox.lens.domain.usecase.LoginUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
// [END_IMPORTS]
// [ENTITY: ViewModel('SetupViewModel')]
// [RELATION: ViewModel('SetupViewModel')] -> [DEPENDS_ON] -> [Repository('CredentialsRepository')]
// [RELATION: ViewModel('SetupViewModel')] -> [DEPENDS_ON] -> [UseCase('LoginUseCase')]
// [RELATION: ViewModel('SetupViewModel')] -> [EMITS_STATE] -> [DataClass('SetupUiState')]
/**
* @summary ViewModel для экрана первоначальной настройки (Setup).
* @param credentialsRepository Репозиторий для операций с учетными данными.
* @param loginUseCase Use case для выполнения логики входа.
* @invariant Состояние `uiState` всегда является единственным источником истины для UI.
*/
@HiltViewModel
class SetupViewModel @Inject constructor(
private val credentialsRepository: CredentialsRepository,
private val loginUseCase: LoginUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(SetupUiState())
val uiState = _uiState.asStateFlow()
init {
loadCredentials()
}
// [ENTITY: Function('loadCredentials')]
private fun loadCredentials() {
Timber.d("[DEBUG][ENTRYPOINT][loading_credentials] Loading credentials from repository.")
viewModelScope.launch {
credentialsRepository.getCredentials().collect { credentials ->
if (credentials != null) {
Timber.d("[DEBUG][ACTION][updating_state] Credentials found, updating UI state.")
_uiState.update {
it.copy(
serverUrl = credentials.serverUrl,
username = credentials.username,
password = credentials.password
)
}
}
}
}
}
// [END_ENTITY: Function('loadCredentials')]
// [ENTITY: Function('onServerUrlChange')]
fun onServerUrlChange(newUrl: String) {
_uiState.update { it.copy(serverUrl = newUrl) }
}
// [END_ENTITY: Function('onServerUrlChange')]
// [ENTITY: Function('onUsernameChange')]
fun onUsernameChange(newUsername: String) {
_uiState.update { it.copy(username = newUsername) }
}
// [END_ENTITY: Function('onUsernameChange')]
// [ENTITY: Function('onPasswordChange')]
fun onPasswordChange(newPassword: String) {
_uiState.update { it.copy(password = newPassword) }
}
// [END_ENTITY: Function('onPasswordChange')]
// [ENTITY: Function('connect')]
fun connect() {
Timber.d("[DEBUG][ENTRYPOINT][connecting] Starting connection process.")
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val credentials = Credentials(
serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(),
password = _uiState.value.password
)
Timber.d("[DEBUG][ACTION][saving_credentials] Saving credentials.")
credentialsRepository.saveCredentials(credentials)
Timber.d("[DEBUG][ACTION][executing_login] Executing login use case.")
loginUseCase(credentials).fold(
onSuccess = {
Timber.d("[DEBUG][SUCCESS][login_successful] Login successful.")
_uiState.update { it.copy(isLoading = false, isSetupComplete = true) }
},
onFailure = { exception ->
Timber.e(exception, "[ERROR][EXCEPTION][login_failed] Login failed.")
_uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") }
}
)
}
}
// [END_ENTITY: Function('connect')]
}
// [END_ENTITY: ViewModel('SetupViewModel')]
// [END_FILE_SetupViewModel.kt]

View File

@@ -1,18 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.theme
// [FILE] Color.kt
// [SEMANTICS] ui, theme, color
package com.homebox.lens.ui.theme
// [IMPORTS]
import androidx.compose.ui.graphics.Color
// [END_IMPORTS]
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// [END_FILE_Color.kt]

View File

@@ -1,74 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.theme
// [FILE] Theme.kt
// [SEMANTICS] ui, theme
package com.homebox.lens.ui.theme
// [IMPORTS]
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// [END_IMPORTS]
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
// [ENTITY: Function('HomeboxLensTheme')]
// [RELATION: Function('HomeboxLensTheme')] -> [DEPENDS_ON] -> [DataStructure('Typography')]
/**
* @summary The main theme for the Homebox Lens application.
* @param darkTheme Whether the theme should be dark or light.
* @param dynamicColor Whether to use dynamic color (on Android 12+).
* @param content The content to be displayed within the theme.
*/
@Composable
fun HomeboxLensTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
// [END_ENTITY: Function('HomeboxLensTheme')]
// [END_FILE_Theme.kt]

View File

@@ -1,29 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.theme
// [FILE] Typography.kt
// [SEMANTICS] ui, theme, typography
package com.homebox.lens.ui.theme
// [IMPORTS]
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// [END_IMPORTS]
// [ENTITY: DataStructure('Typography')]
/**
* @summary Defines the typography for the application.
*/
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
)
// [END_ENTITY: DataStructure('Typography')]
// [END_FILE_Typography.kt]

View File

@@ -1,239 +0,0 @@
// [PACKAGE] com.homebox.lens.ui.screen.itemedit
// [FILE] ItemEditViewModelTest.kt
// [SEMANTICS] ui, viewmodel, testing
package com.homebox.lens.ui.screen.itemedit
import app.cash.turbine.test
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.UUID
@ExperimentalCoroutinesApi
class ItemEditViewModelTest {
private val testDispatcher = StandardTestDispatcher()
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
fun setUp() {
Dispatchers.setMain(testDispatcher)
createItemUseCase = mockk()
updateItemUseCase = mockk()
getItemDetailsUseCase = mockk()
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
fun tearDown() {
Dispatchers.resetMain()
}
@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",
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)
testDispatcher.scheduler.advanceUntilIdle()
val uiState = viewModel.uiState.value
assertFalse(uiState.isLoading)
assertNotNull(uiState.item)
assertEquals(itemId, uiState.item?.id)
assertEquals("Test Item", uiState.item?.name)
}
@Test
fun `loadItem with null id should prepare a new item`() = runTest {
viewModel.loadItem(null)
testDispatcher.scheduler.advanceUntilIdle()
val uiState = viewModel.uiState.value
assertFalse(uiState.isLoading)
assertNotNull(uiState.item)
assertEquals("", uiState.item?.id)
assertEquals("", uiState.item?.name)
}
@Test
fun `saveItem should call createItemUseCase for new item`() = runTest {
val createdItemSummary = ItemSummary(id = UUID.randomUUID().toString(), name = "New Item", assetId = null, image = null, isArchived = false, labels = emptyList(), location = null, value = 0.0, createdAt = "2025-08-28T12:00:00Z", updatedAt = "2025-08-28T12:00:00Z")
coEvery { createItemUseCase(any()) } returns createdItemSummary
viewModel.loadItem(null)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.updateName("New Item")
viewModel.updateDescription("New Description")
viewModel.updateQuantity(2)
viewModel.updateSelectedLocationId("1")
testDispatcher.scheduler.advanceUntilIdle()
viewModel.saveItem()
testDispatcher.scheduler.advanceUntilIdle()
val uiState = viewModel.uiState.value
assertFalse(uiState.isLoading)
assertNotNull(uiState.item)
assertEquals(createdItemSummary.id, uiState.item?.id)
}
@Test
fun `saveItem should call updateItemUseCase for existing item`() = runTest {
val itemId = UUID.randomUUID().toString()
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)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.updateName("Updated Item")
viewModel.updateDescription("Updated Description")
viewModel.updateQuantity(4)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.saveItem()
testDispatcher.scheduler.advanceUntilIdle()
val uiState = viewModel.uiState.value
assertFalse(uiState.isLoading)
assertNotNull(uiState.item)
assertEquals(itemId, uiState.item?.id)
assertEquals("Updated Item", uiState.item?.name)
assertEquals(4, uiState.item?.quantity)
}
}