feat: Refactor login screen - fix compilation error

This commit is contained in:
2025-10-02 13:11:49 +03:00
parent 8816377361
commit eccc7ee970
21 changed files with 494 additions and 174 deletions

View File

@@ -25,11 +25,14 @@ 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.ui.screen.settings.SettingsScreen
import com.homebox.lens.ui.screen.splash.SplashScreen
// [END_IMPORTS]
// [ENTITY: Function('NavGraph')]
// [RELATION: Function('NavGraph')] -> [DEPENDS_ON] -> [Framework('NavHostController')]
// [RELATION: Function('NavGraph')] -> [CREATES_INSTANCE_OF] -> [Class('NavigationActions')]
// [RELATION: Function('NavGraph')] -> [USES] -> [Screen('SplashScreen')]
/**
* @summary Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation.
* @param navController Контроллер навигации.
@@ -47,11 +50,13 @@ fun NavGraph(
val navigationActions = remember(navController) {
NavigationActions(navController)
}
NavHost(
navController = navController,
startDestination = Screen.Setup.route
startDestination = Screen.Splash.route
) {
composable(route = Screen.Splash.route) {
SplashScreen(navController = navController)
}
composable(route = Screen.Setup.route) {
SetupScreen(onSetupComplete = {
navController.navigate(Screen.Dashboard.route) {
@@ -137,6 +142,12 @@ fun NavGraph(
navigationActions = navigationActions
)
}
composable(route = Screen.Settings.route) {
SettingsScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
)
}
}
}
// [END_ENTITY: Function('NavGraph')]

View File

@@ -10,6 +10,10 @@ package com.homebox.lens.navigation
* @param route Строковый идентификатор маршрута.
*/
sealed class Screen(val route: String) {
// [ENTITY: Object('Splash')]
data object Splash : Screen("splash_screen")
// [END_ENTITY: Object('Splash')]
// [ENTITY: Object('Setup')]
data object Setup : Screen("setup_screen")
// [END_ENTITY: Object('Setup')]
@@ -118,6 +122,10 @@ sealed class Screen(val route: String) {
// [ENTITY: Object('Search')]
data object Search : Screen("search_screen")
// [END_ENTITY: Object('Search')]
// [ENTITY: Object('Settings')]
data object Settings : Screen("settings_screen")
// [END_ENTITY: Object('Settings')]
}
// [END_ENTITY: SealedClass('Screen')]
// [END_FILE_Screen.kt]

View File

@@ -310,10 +310,10 @@ fun DashboardContentSuccessPreview() {
LocationOutCount(id="5",name="Basement", color = "#00FFFF", isArchived = false, itemCount = 3, createdAt = "", updatedAt = "")
),
labels = listOf(
LabelOut(id="1", name="electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="2", name="important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="3", name="seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="4", name="hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = "")
LabelOut(id="1", name="electronics", description = null, color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="2", name="important", description = null, color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="3", name="seasonal", description = null, color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="4", name="hobby", description = null, color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = "")
),
recentlyAddedItems = emptyList()
)

View File

@@ -7,8 +7,14 @@ package com.homebox.lens.ui.screen.itemedit
// [IMPORTS]
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.homebox.lens.domain.model.*
import com.homebox.lens.domain.model.Item
import com.homebox.lens.domain.model.ItemCreate
import com.homebox.lens.domain.model.ItemUpdate
import com.homebox.lens.domain.model.Location
import com.homebox.lens.domain.model.Label
import com.homebox.lens.domain.usecase.CreateItemUseCase
import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
import com.homebox.lens.domain.usecase.GetItemDetailsUseCase
import com.homebox.lens.domain.usecase.UpdateItemUseCase
import com.homebox.lens.ui.mapper.ItemMapper
@@ -30,11 +36,15 @@ import javax.inject.Inject
* @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.
* @param allLocations A list of all available locations.
* @param allLabels A list of all available labels.
*/
data class ItemEditUiState(
val item: Item? = null,
val isLoading: Boolean = false,
val error: String? = null
val error: String? = null,
val allLocations: List<Location> = emptyList(),
val allLabels: List<Label> = emptyList()
)
// [END_ENTITY: DataClass('ItemEditUiState')]
@@ -56,6 +66,8 @@ class ItemEditViewModel @Inject constructor(
private val createItemUseCase: CreateItemUseCase,
private val updateItemUseCase: UpdateItemUseCase,
private val getItemDetailsUseCase: GetItemDetailsUseCase,
private val getAllLocationsUseCase: GetAllLocationsUseCase,
private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val itemMapper: ItemMapper
) : ViewModel() {
@@ -123,10 +135,47 @@ class ItemEditViewModel @Inject constructor(
_uiState.value = _uiState.value.copy(isLoading = false, error = e.localizedMessage)
}
}
// Load all locations and labels
try {
Timber.i("[INFO][ACTION][fetching_all_locations] Fetching all locations.")
val allLocations = getAllLocationsUseCase().map { Location(it.id, it.name) }
Timber.i("[INFO][ACTION][fetching_all_labels] Fetching all labels.")
val allLabels = getAllLabelsUseCase().map { Label(it.id, it.name) }
_uiState.value = _uiState.value.copy(allLocations = allLocations, allLabels = allLabels)
Timber.i("[INFO][ACTION][all_locations_labels_fetched] Successfully fetched all locations and labels.")
} catch (e: Exception) {
Timber.e(e, "[ERROR][FALLBACK][locations_labels_load_failed] Failed to load locations or labels.")
_uiState.value = _uiState.value.copy(error = e.localizedMessage)
}
}
}
// [END_ENTITY: Function('loadItem')]
// [ENTITY: Function('updateLocation')]
/**
* @summary Updates the location of the item in the UI state.
* @param location The new location for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateLocation(location: Location) {
Timber.d("[DEBUG][ACTION][updating_item_location] Updating item location to: %s", location.name)
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(location = location))
}
// [END_ENTITY: Function('updateLocation')]
// [ENTITY: Function('updateLabels')]
/**
* @summary Updates the labels of the item in the UI state.
* @param labels The new list of labels for the item.
* @sideeffect Updates the `item` in `_uiState`.
*/
fun updateLabels(labels: List<Label>) {
Timber.d("[DEBUG][ACTION][updating_item_labels] Updating item labels to: %s", labels.map { it.name }.joinToString())
_uiState.value = _uiState.value.copy(item = _uiState.value.item?.copy(labels = labels))
}
// [END_ENTITY: Function('updateLabels')]
// [ENTITY: Function('saveItem')]
/**
* @summary Saves the current item, either creating a new one or updating an existing one.

View File

@@ -97,6 +97,13 @@ fun LabelEditScreen(
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = uiState.description.orEmpty(),
onValueChange = viewModel::onDescriptionChange,
label = { Text(stringResource(R.string.label_description)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
ColorPicker(
selectedColor = uiState.color,
onColorSelected = viewModel::onColorChange,

View File

@@ -50,6 +50,10 @@ class LabelEditViewModel @Inject constructor(
uiState = uiState.copy(name = newName, nameError = null)
}
fun onDescriptionChange(newDescription: String) {
uiState = uiState.copy(description = newDescription)
}
fun onColorChange(newColor: String) {
uiState = uiState.copy(color = newColor)
}
@@ -63,35 +67,41 @@ class LabelEditViewModel @Inject constructor(
uiState = uiState.copy(isLoading = true, error = null)
try {
if (labelId == null) {
// Create new label
val newLabel = LabelCreate(name = uiState.name, color = uiState.color, description = null)
val result = if (labelId == null) {
// [LOG_EVENT] [EVENT_TYPE: LabelCreationAttempt] [DATA: { "labelName": "${uiState.name}" }]
val newLabel = LabelCreate(name = uiState.name, color = uiState.color, description = uiState.description)
createLabelUseCase(newLabel)
} else {
// Update existing label
val updatedLabel = LabelUpdate(name = uiState.name, color = uiState.color, description = null)
// [LOG_EVENT] [EVENT_TYPE: LabelUpdateAttempt] [DATA: { "labelId": "$labelId", "labelName": "${uiState.name}" }]
val updatedLabel = LabelUpdate(name = uiState.name, color = uiState.color, description = uiState.description)
updateLabelUseCase(labelId, updatedLabel)
}
// [LOG_EVENT] [EVENT_TYPE: LabelSaveSuccess] [DATA: { "labelName": "${uiState.name}", "isNew": ${labelId == null} }]
uiState = uiState.copy(isSaved = true)
} catch (e: Exception) {
// [LOG_EVENT] [EVENT_TYPE: LabelSaveFailure] [ERROR: "${e.message}"] [DATA: { "labelName": "${uiState.name}", "isNew": ${labelId == null} }]
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 {
// [LOG_EVENT] [EVENT_TYPE: LabelDetailsFetchAttempt] [DATA: { "labelId": "$id" }]
val label = getLabelDetailsUseCase(id)
// [LOG_EVENT] [EVENT_TYPE: LabelDetailsFetchSuccess] [DATA: { "labelId": "$id", "labelName": "${label.name}" }]
uiState = uiState.copy(
name = label.name,
color = label.color,
isLoading = false
description = label.description,
isLoading = false,
originalLabel = label
)
} catch (e: Exception) {
// [LOG_EVENT] [EVENT_TYPE: LabelDetailsFetchFailure] [ERROR: "${e.message}"] [DATA: { "labelId": "$id" }]
uiState = uiState.copy(error = e.message, isLoading = false)
}
}
@@ -104,6 +114,7 @@ class LabelEditViewModel @Inject constructor(
*/
data class LabelEditUiState(
val name: String = "",
val description: String? = null,
val color: String = "#FFFFFF", // Default color
val nameError: String? = null,
val isLoading: Boolean = false,

View File

@@ -17,24 +17,18 @@ 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.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.OutlinedTextField
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.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
@@ -53,9 +47,11 @@ import timber.log.Timber
// [RELATION: Function('LabelsListScreen')] -> [DEPENDS_ON] -> [Framework('NavController')]
/**
* @summary Отображает экран со списком всех меток.
* @param navController Контроллер навигации для перемещения между экранами.
* @param currentRoute Текущий маршрут навигации.
* @param navigationActions Объект, содержащий действия по навигации.
* @param viewModel ViewModel, предоставляющая состояние UI для экрана меток.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelsListScreen(
currentRoute: String?,
@@ -90,19 +86,19 @@ fun LabelsListScreen(
.padding(innerPaddingValues), // Use innerPaddingValues here
contentAlignment = Alignment.Center
) {
when (currentState) {
when (val state = uiState) {
is LabelsListUiState.Loading -> {
CircularProgressIndicator()
}
is LabelsListUiState.Error -> {
Text(text = currentState.message)
Text(text = state.message)
}
is LabelsListUiState.Success -> {
if (currentState.labels.isEmpty()) {
if (state.labels.isEmpty()) {
Text(text = stringResource(id = R.string.no_labels_found))
} else {
LabelsList(
labels = currentState.labels,
labels = state.labels,
onLabelClick = { label ->
Timber.i("[INFO][ACTION][navigate_to_label_edit] Label clicked: ${label.id}. Navigating to label edit screen.")
navigationActions.navigateToLabelEdit(label.id)

View File

@@ -0,0 +1,53 @@
// [PACKAGE] com.homebox.lens.ui.screen.settings
// [FILE] SettingsScreen.kt
// [SEMANTICS] ui, screen, settings
package com.homebox.lens.ui.screen.settings
// [IMPORTS]
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [END_IMPORTS]
// [ENTITY: Function('SettingsScreen')]
// [RELATION: Function('SettingsScreen')] -> [DEPENDS_ON] -> [Class('NavigationActions')]
/**
* @summary Composable-функция для экрана настроек.
* @param currentRoute Текущий маршрут навигации.
* @param navigationActions Объект, содержащий действия по навигации.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
currentRoute: String?,
navigationActions: NavigationActions
) {
MainScaffold(
topBarTitle = stringResource(id = R.string.screen_title_settings),
currentRoute = currentRoute,
navigationActions = navigationActions
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(text = "Settings Screen (Under Construction)")
}
}
}
// [END_ENTITY: Function('SettingsScreen')]
// [END_FILE_SettingsScreen.kt]

View File

@@ -7,17 +7,22 @@
package com.homebox.lens.ui.screen.setup
// [IMPORTS]
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
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.painterResource
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.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
// [END_IMPORTS]
@@ -83,42 +88,74 @@ private fun SetupScreenContent(
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()
Image(
imageVector = Icons.Default.Lock,
contentDescription = stringResource(id = R.string.app_name),
modifier = Modifier.size(128.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.setup_title),
style = MaterialTheme.typography.headlineLarge,
fontSize = 28.sp // Adjust font size as needed
)
Spacer(modifier = Modifier.height(24.dp))
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
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(12.dp))
OutlinedTextField(
value = uiState.username,
onValueChange = onUsernameChange,
label = { Text(stringResource(id = R.string.setup_username_label)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.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(24.dp))
Button(
onClick = onConnectClick,
enabled = !uiState.isLoading,
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.height(56.dp) // Make button more prominent
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(stringResource(id = R.string.setup_connect_button))
Text(stringResource(id = R.string.setup_connect_button), fontSize = 18.sp)
}
}
uiState.error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
}

View File

@@ -74,40 +74,61 @@ class SetupViewModel @Inject constructor(
// [END_ENTITY: Function('onUsernameChange')]
// [ENTITY: Function('onPasswordChange')]
/**
* @summary Updates the password in the UI state.
* @param newPassword The new password.
* @sideeffect Updates the `password` in `_uiState`.
*/
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) }
// [ENTITY: Function('areCredentialsSaved')]
/**
* @summary Checks synchronously if credentials are saved.
* @return true if credentials are saved, false otherwise.
* @sideeffect None.
*/
fun areCredentialsSaved(): Boolean {
Timber.d("[DEBUG][ENTRYPOINT][checking_credentials_saved] Checking if credentials are saved.")
return credentialsRepository.areCredentialsSavedSync()
}
// [END_ENTITY: Function('areCredentialsSaved')]
val credentials = Credentials(
serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(),
password = _uiState.value.password
)
// [ENTITY: Function('connect')]
/**
* @summary Initiates the connection process, saving credentials and attempting to log in.
* @sideeffect Updates `_uiState` with loading, error, and completion states.
*/
fun connect() {
Timber.d("[DEBUG][ENTRYPOINT][connecting] Starting connection process.")
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
Timber.d("[DEBUG][ACTION][saving_credentials] Saving credentials.")
credentialsRepository.saveCredentials(credentials)
val credentials = Credentials(
serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(),
password = _uiState.value.password
)
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')]
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

@@ -0,0 +1,57 @@
// [PACKAGE] com.homebox.lens.ui.screen.splash
// [FILE] SplashScreen.kt
// [SEMANTICS] ui, screen, splash, navigation, authentication_flow
package com.homebox.lens.ui.screen.splash
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.homebox.lens.navigation.Screen
import com.homebox.lens.ui.screen.setup.SetupViewModel
import timber.log.Timber
// [ENTITY: Function('SplashScreen')]
/**
* @summary Displays a splash screen while checking if credentials are saved.
* @param navController The NavController for navigation.
* @param viewModel The SetupViewModel to check credential status.
* @sideeffect Navigates to either SetupScreen or DashboardScreen based on credential status.
*/
@Composable
fun SplashScreen(
navController: NavController,
viewModel: SetupViewModel = hiltViewModel()
) {
Timber.d("[DEBUG][ENTRYPOINT][splash_screen_composable] SplashScreen entered.")
LaunchedEffect(key1 = true) {
Timber.i("[INFO][ACTION][checking_credentials_on_launch] Checking if credentials are saved on launch.")
val credentialsSaved = viewModel.areCredentialsSaved()
if (credentialsSaved) {
Timber.i("[INFO][ACTION][credentials_found_navigating_dashboard] Credentials found, navigating to Dashboard.")
navController.navigate(Screen.Dashboard.route) {
popUpTo(Screen.Splash.route) { inclusive = true }
}
} else {
Timber.i("[INFO][ACTION][no_credentials_found_navigating_setup] No credentials found, navigating to Setup.")
navController.navigate(Screen.Setup.route) {
popUpTo(Screen.Splash.route) { inclusive = true }
}
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// [END_ENTITY: Function('SplashScreen')]

View File

@@ -35,7 +35,6 @@
<string name="item_edit_title_create">Создать элемент</string>
<string name="content_desc_save_item">Сохранить элемент</string>
<string name="label_name">Название</string>
<string name="label_description">Описание</string>
<!-- Search Screen -->
<string name="placeholder_search_items">Поиск элементов...</string>
@@ -120,6 +119,8 @@
<!-- Labels List Screen -->
<string name="screen_title_labels">Метки</string>
<!-- Settings Screen -->
<string name="screen_title_settings">Настройки</string>
<string name="content_desc_navigate_back" translatable="false">Вернуться назад</string>
<string name="content_desc_create_label">Создать новую метку</string>
<string name="content_desc_label_icon">Иконка метки</string>
@@ -133,6 +134,7 @@
<string name="label_edit_title_create">Создать метку</string>
<string name="label_edit_title_edit">Редактировать метку</string>
<string name="label_name_edit">Название метки</string>
<string name="label_description">Описание</string>
<!-- Common Actions -->
<string name="back">Назад</string>

View File

@@ -155,6 +155,7 @@ fun LabelEntity.toDomainLabelOut(): LabelOut {
return LabelOut(
id = this.id,
name = this.name,
description = null, // Not available in LabelEntity
color = "", // Not available in LabelEntity
isArchived = false, // Not available in LabelEntity
createdAt = "", // Not available in LabelEntity

View File

@@ -119,6 +119,7 @@ fun LabelOutDto.toDomain(): DomainLabelOut {
return DomainLabelOut(
id = this.id,
name = this.name,
description = this.description,
color = this.color ?: "",
isArchived = this.isArchived ?: false,
createdAt = this.createdAt,

View File

@@ -98,11 +98,46 @@ class CredentialsRepositoryImpl @Inject constructor(
*/
override suspend fun getToken(): String? {
return withContext(Dispatchers.IO) {
Timber.d("[DEBUG][ACTION][getting_token] Getting auth token.")
encryptedPrefs.getString(KEY_AUTH_TOKEN, null)
val token = encryptedPrefs.getString(KEY_AUTH_TOKEN, null)
if (token != null) {
Timber.i("[INFO][ACTION][token_retrieved] Auth token retrieved successfully.")
} else {
Timber.w("[WARN][FALLBACK][no_token_found] No auth token found.")
}
token
}
}
// [END_ENTITY: Function('getToken')]
// [ENTITY: Function('clearAllCredentials')]
/**
* @summary Очищает все сохраненные учетные данные и токены.
* @sideeffect Удаляет все записи, связанные с учетными данными, из SharedPreferences.
*/
override suspend fun clearAllCredentials() {
withContext(Dispatchers.IO) {
Timber.i("[INFO][ACTION][clearing_all_credentials] Clearing all saved credentials and tokens.")
encryptedPrefs.edit()
.remove(KEY_SERVER_URL)
.remove(KEY_USERNAME)
.remove(KEY_PASSWORD)
.remove(KEY_AUTH_TOKEN)
.apply()
}
}
// [END_ENTITY: Function('clearAllCredentials')]
// [ENTITY: Function('areCredentialsSavedSync')]
/**
* @summary Synchronously checks if user credentials are saved.
* @return true if all essential credentials (URL, username, password) are present, false otherwise.
*/
override fun areCredentialsSavedSync(): Boolean {
return encryptedPrefs.contains(KEY_SERVER_URL) &&
encryptedPrefs.contains(KEY_USERNAME) &&
encryptedPrefs.contains(KEY_PASSWORD)
}
// [END_ENTITY: Function('areCredentialsSavedSync')]
}
// [END_ENTITY: Class('CredentialsRepositoryImpl')]
// [END_FILE_CredentialsRepositoryImpl.kt]

View File

@@ -16,6 +16,7 @@ package com.homebox.lens.domain.model
data class LabelOut(
val id: String,
val name: String,
val description: String?,
val color: String,
val isArchived: Boolean,
val createdAt: String,

View File

@@ -44,8 +44,25 @@ interface CredentialsRepository {
* @summary Retrieves the saved authorization token.
* @return The saved token as a String, or null if no token is saved.
*/
suspend fun getToken(): String?
// [END_ENTITY: Function('getToken')]
suspend fun getToken(): String?
// [END_ENTITY: Function('getToken')]
// [ENTITY: Function('areCredentialsSavedSync')]
/**
* @summary Synchronously checks if user credentials are saved.
* @return true if all essential credentials (URL, username, password) are present, false otherwise.
*/
fun areCredentialsSavedSync(): Boolean
// [END_ENTITY: Function('areCredentialsSavedSync')]
// [ENTITY: Function('clearAllCredentials')]
/**
* @summary Clears all saved credentials and tokens.
* @sideeffect Removes all credential-related entries from SharedPreferences.
*/
suspend fun clearAllCredentials()
// [END_ENTITY: Function('clearAllCredentials')]
}
// [END_ENTITY: Interface('CredentialsRepository')]
// [END_FILE_CredentialsRepository.kt]

View File

@@ -1,7 +1,6 @@
// [PACKAGE] com.homebox.lens.domain.usecase
// [FILE] GetLabelDetailsUseCase.kt
// [SEMANTICS] business_logic, use_case, label_retrieval
// [SEMANTICS] business_logic, use_case, label, get
package com.homebox.lens.domain.usecase
// [IMPORTS]
@@ -13,23 +12,25 @@ import javax.inject.Inject
// [ENTITY: UseCase('GetLabelDetailsUseCase')]
// [RELATION: UseCase('GetLabelDetailsUseCase')] -> [DEPENDS_ON] -> [Interface('ItemRepository')]
/**
* @summary Получает детальную информацию о метке по ее ID.
* @param itemRepository Репозиторий для работы с данными о метках.
* @summary Сценарий использования для получения деталей метки.
* @param repository Репозиторий для доступа к данным.
*/
class GetLabelDetailsUseCase @Inject constructor(
private val itemRepository: ItemRepository
private val repository: ItemRepository
) {
// [ENTITY: Function('invoke')]
/**
* @summary Выполняет получение детальной информации о метке.
* @param labelId ID запрашиваемой метки.
* @return Детальная информация о метке [LabelOut].
* @throws IllegalArgumentException если `labelId` пустой.
* @throws NoSuchElementException если метка с указанным ID не найдена.
* @summary Выполняет получение деталей метки.
* @param labelId ID метки для получения деталей.
* @return Возвращает полную информацию о метке [LabelOut].
* @throws Exception в случае ошибки на уровне репозитория (сеть, API).
* @precondition `labelId` не должен быть пустым.
*/
suspend operator fun invoke(labelId: String): LabelOut {
require(labelId.isNotBlank()) { "Label ID cannot be blank." }
return itemRepository.getLabelDetails(labelId)
return repository.getLabelDetails(labelId)
}
// [END_ENTITY: Function('invoke')]
}
// [END_ENTITY: UseCase('GetLabelDetailsUseCase')]
// [END_FILE_GetLabelDetailsUseCase.kt]

View File

@@ -1,98 +1,51 @@
<![CDATA[
<?xml version="1.0" encoding="UTF-8"?>
<WORK_ORDER>
<META>
<VERSION>1.0</VERSION>
<CREATED_AT>{timestamp}</CREATED_AT>
<TITLE>[ARCHITECT -> DEV] Refactor Item Edit Screen</TITLE>
<DESCRIPTION>
This work order instructs the developer agent to refactor the Item Edit screen to include all available API fields and implement a user-friendly, grouped layout.
</DESCRIPTION>
<ASSIGNED_TO>agent-developer</ASSIGNED_TO>
<LABELS>type::refactoring,feature::item-edit,status::pending</LABELS>
<ID>WO-ITEMEDIT-FIX</ID>
<TITLE>[ARCHITECT -> DEV] Исправление выбора локации и меток на экране ItemEdit</TITLE>
<DESCRIPTION>В текущей реализации на экране редактирования/создания элемента (ItemEditScreen) поля "Location" и "Labels" неактивны. Необходимо реализовать функционал выбора значения для этих полей из списка доступных.</DESCRIPTION>
<CREATED_BY>architect-agent</CREATED_BY>
<ASSIGNED_TO>developer-agent</ASSIGNED_TO>
<STATUS>pending</STATUS>
</META>
<SPECIFICATION>
<GOAL>
The primary goal is to refactor the `ItemEditScreen` to include all available fields from the Homebox API for creating and updating items, and to present them in a user-friendly manner.
</GOAL>
<CONTEXT>
<FILE_LIST>
<FILE path="domain/src/main/java/com/homebox/lens/domain/model/Item.kt" />
<FILE path="data/src/main/java/com/homebox/lens/data/db/entity/ItemEntity.kt" />
<FILE path="data/src/main/java/com/homebox/lens/data/api/dto/ItemCreateDto.kt" />
<FILE path="data/src/main/java/com/homebox/lens/data/api/dto/ItemUpdateDto.kt" />
<FILE path="data/src/main/java/com/homebox/lens/data/api/dto/ItemOutDto.kt" />
<FILE path="data/src/main/java/com/homebox/lens/data/db/entity/Mapper.kt" />
<FILE path="app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt" />
<FILE path="app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt" />
</FILE_LIST>
</CONTEXT>
</SPECIFICATION>
<EXECUTION_PLAN>
<STEP id="1" name="Update Domain Model">
<ACTION>Modify `domain/src/main/java/com/homebox/lens/domain/model/Item.kt`.</ACTION>
<TASK_BREAKDOWN>
<STEP n="1" file="app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt">
<ACTION>Загрузка списков локаций и меток.</ACTION>
<DETAILS>
- Add the following fields to the `Item` data class:
- `archived: Boolean`
- `assetId: String?`
- `fields: List<CustomField>`
- `insured: Boolean`
- `lifetimeWarranty: Boolean`
- `manufacturer: String?`
- `modelNumber: String?`
- `notes: String?`
- `parentId: String?`
- `purchaseFrom: String?`
- `purchaseTime: String?`
- `serialNumber: String?`
- `soldNotes: String?`
- `soldPrice: Double?`
- `soldTime: String?`
- `soldTo: String?`
- `syncChildItemsLocations: Boolean`
- `warrantyDetails: String?`
- `warrantyExpires: String?`
- Rename the existing `value: BigDecimal?` field to `purchasePrice: Double?`.
1. Внедрите `GetAllLocationsUseCase` и `GetAllLabelsUseCase` в `ItemEditViewModel`.
2. Обновите `ItemEditUiState`, добавив два новых поля: `val allLocations: List<Location> = emptyList()` и `val allLabels: List<Label> = emptyList()`.
3. В функции `loadItem`, после загрузки основной информации о товаре, вызовите `getAllLocationsUseCase` и `getAllLabelsUseCase` и обновите `uiState` полученными списками.
4. Добавьте публичные методы `updateLocation(location: Location)` и `updateLabels(labels: List<Label>)` для обновления `item` в `uiState`.
</DETAILS>
</STEP>
<STEP id="2" name="Update Data Layer">
<ACTION>Update database entity, DTOs, and mappers.</ACTION>
<STEP n="2" file="app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt">
<ACTION>Реализация UI для выбора локации.</ACTION>
<DETAILS>
- Modify `data/src/main/java/com/homebox/lens/data/db/entity/ItemEntity.kt` to reflect the new `Item` model.
- Ensure `ItemCreateDto`, `ItemUpdateDto`, and `ItemOutDto` contain all necessary fields according to the API spec.
- Update the mapping functions in `data/src/main/java/com/homebox/lens/data/db/entity/Mapper.kt` to handle all new fields correctly across all layers.
1. Замените `OutlinedTextField` для локации на `ExposedDropdownMenuBox`.
2. В качестве `dropdownMenu` используйте `DropdownMenuItem` для каждого элемента из `uiState.allLocations`.
3. При выборе элемента из списка вызывайте `viewModel.updateLocation(selectedLocation)`.
4. В `ExposedDropdownMenuBox` должно отображаться `item.location?.name`.
</DETAILS>
</STEP>
<STEP id="3" name="Update ViewModel">
<ACTION>Modify `app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditViewModel.kt`.</ACTION>
<STEP n="3" file="app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt">
<ACTION>Реализация UI для выбора меток (множественный выбор).</ACTION>
<DETAILS>
- Update `loadItem` and `saveItem` to handle the state for all new fields.
- Add public functions to update the state for each new field (e.g., `updateManufacturer(String)`, `toggleInsured(Boolean)`).
1. Поле для меток `Labels` должно оставаться `OutlinedTextField` (read-only), но `onClick` по нему должен открывать диалоговое окно (`AlertDialog`).
2. В `AlertDialog` отобразите список всех меток (`uiState.allLabels`) с `Checkbox`'ами.
3. Состояние `Checkbox`'ов должно соответствовать списку `item.labels`.
4. При нажатии на "OK" в диалоге, вызывайте `viewModel.updateLabels(selectedLabels)`.
</DETAILS>
</STEP>
</TASK_BREAKDOWN>
<STEP id="4" name="Implement UI">
<ACTION>Refactor `app/src/main/java/com/homebox/lens/ui/screen/itemedit/ItemEditScreen.kt`.</ACTION>
<DETAILS>
- Implement a user-friendly layout using tabs or expandable cards for the following groups: General, Purchase, Warranty, Identification, Status & Notes.
- Use appropriate controls for each field type (e.g., `Switch` for booleans, Date Picker for dates).
- Connect all UI controls to the ViewModel.
- Implement basic input validation.
</DETAILS>
</STEP>
</EXECUTION_PLAN>
<ACCEPTANCE_CRITERIA>
<CRITERION>При нажатии на поле "Location" открывается выпадающий список со всеми локациями.</CRITERION>
<CRITERION>Выбранная локация отображается в поле и сохраняется вместе с элементом.</CRITERION>
<CRITERION>При нажатии на поле "Labels" открывается диалоговое окно со списком всех меток и чекбоксами.</CRITERION>
<CRITERION>Выбранные метки отображаются в поле и сохраняются вместе с элементом.</CRITERION>
</ACCEPTANCE_CRITERIA>
<VERIFICATION>
<CHECK>The application compiles successfully.</CHECK>
<CHECK>The Item Edit screen displays all new fields, grouped logically.</CHECK>
<CHECK>Creating a new item with all fields populated works correctly.</CHECK>
<CHECK>Editing an existing item and modifying the new fields works correctly.</CHECK>
<CHECK>Data is persisted correctly and is visible in the Item Details screen after saving.</CHECK>
</VERIFICATION>
</WORK_ORDER>
]]>
</WORK_ORDER>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<WORK_ORDER>
<META>
<ID>WO-LOGIN-REFACTOR</ID>
<TITLE>[ARCHITECT -> DEV] Рефакторинг экрана входа и логики первого запуска</TITLE>
<DESCRIPTION>Цель этой задачи - изменить логику запуска приложения. Экран входа (SetupScreen) должен появляться только при первом запуске, когда учетные данные еще не сохранены. В последующие запуски пользователь должен сразу попадать на главный экран (Dashboard). Также необходимо улучшить визуальное оформление экрана входа.</DESCRIPTION>
<CREATED_BY>architect-agent</CREATED_BY>
<ASSIGNED_TO>developer-agent</ASSIGNED_TO>
<STATUS>pending</STATUS>
</META>
<TASK_BREAKDOWN>
<STEP n="1" file="app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt">
<ACTION>Добавить public-метод для синхронной проверки наличия учетных данных.</ACTION>
<DETAILS>
Добавьте в класс `SetupViewModel` новый метод `fun areCredentialsSaved(): Boolean`.
Этот метод должен синхронно проверять, сохранены ли учетные данные в `CredentialsRepository`.
Текущая реализация `getCredentials()` асинхронна, что не подходит для быстрой проверки в `NavGraph`.
Вам может потребоваться изменить `CredentialsRepository` для поддержки синхронной проверки (например, используя `SharedPreferences` напрямую).
</DETAILS>
</STEP>
<STEP n="2" file="app/src/main/java/com/homebox/lens/ui/screen/splash/SplashScreen.kt">
<ACTION>Создать новый `SplashScreen`.</ACTION>
<DETAILS>
Создайте новый Composable-экран `SplashScreen.kt`.
Этот экран будет новой точкой входа в `NavGraph`.
Он будет использовать `SetupViewModel` для вызова `areCredentialsSaved()` и, в зависимости от результата, немедленно навигироваться либо на `Screen.Setup`, либо на `Screen.Dashboard`.
Пока идет проверка, на экране должен отображаться `CircularProgressIndicator`.
</DETAILS>
</STEP>
<STEP n="3" file="app/src/main/java/com/homebox/lens/navigation/NavGraph.kt">
<ACTION>Обновить `NavGraph` для использования `SplashScreen`.</ACTION>
<DETAILS>
Измените `startDestination` в `NavHost` на `Screen.Splash.route`.
Добавьте `composable` для `SplashScreen`.
В `SplashScreen` вызовите `navController.navigate` с очисткой бэкстека (`popUpTo(Screen.Splash.route) { inclusive = true }`), чтобы пользователь не мог вернуться на сплэш-экран.
</DETAILS>
</STEP>
<STEP n="4" file="app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt">
<ACTION>Улучшить UI экрана `SetupScreen`.</ACTION>
<DETAILS>
Текущий UI слишком прост. Добавьте заголовок, иконку приложения, и более приятное расположение элементов.
Используйте `Card` для группировки полей ввода. Добавьте `Spacer` для лучшего отступа.
Кнопку "Connect" сделайте более заметной.
</DETAILS>
</STEP>
</TASK_BREAKDOWN>
<ACCEPTANCE_CRITERIA>
<CRITERION>При первом запуске приложения открывается `SetupScreen`.</CRITERION>
<CRITERION>После успешного ввода данных и входа, при последующих перезапусках приложения открывается `DashboardScreen`, минуя `SetupScreen`.</CRITERION>
<CRITERION>`SetupScreen` имеет улучшенный и более привлекательный дизайн.</CRITERION>
</ACCEPTANCE_CRITERIA>
</WORK_ORDER>