This commit is contained in:
2025-08-18 08:55:39 +03:00
parent ded957517a
commit 7e2e6009f7
43 changed files with 2623 additions and 1184 deletions

View File

@@ -18,6 +18,7 @@ import com.homebox.lens.ui.theme.HomeboxLensTheme
import dagger.hilt.android.AndroidEntryPoint
// [CONTRACT]
/**
* [ENTITY: Activity('MainActivity')]
* [PURPOSE] Главная и единственная Activity в приложении.
@@ -32,7 +33,7 @@ class MainActivity : ComponentActivity() {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
color = MaterialTheme.colorScheme.background,
) {
NavGraph()
}
@@ -43,10 +44,13 @@ class MainActivity : ComponentActivity() {
// [HELPER]
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
fun Greeting(
name: String,
modifier: Modifier = Modifier,
) {
Text(
text = "Hello $name!",
modifier = modifier
modifier = modifier,
)
}

View File

@@ -4,11 +4,11 @@
package com.homebox.lens
import android.app.Application
import com.homebox.lens.BuildConfig
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
// [CONTRACT]
/**
* [ENTITY: Application('MainApplication')]
* [PURPOSE] Точка входа в приложение. Инициализирует Hilt и Timber.

View File

@@ -24,6 +24,7 @@ import com.homebox.lens.ui.screen.search.SearchScreen
import com.homebox.lens.ui.screen.setup.SetupScreen
// [CORE-LOGIC]
/**
* [CONTRACT]
* Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation.
@@ -33,22 +34,21 @@ import com.homebox.lens.ui.screen.setup.SetupScreen
* @invariant Стартовый экран - `Screen.Setup`.
*/
@Composable
fun NavGraph(
navController: NavHostController = rememberNavController()
) {
fun NavGraph(navController: NavHostController = rememberNavController()) {
// [STATE]
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
// [HELPER]
val navigationActions = remember(navController) {
NavigationActions(navController)
}
val navigationActions =
remember(navController) {
NavigationActions(navController)
}
// [ACTION]
NavHost(
navController = navController,
startDestination = Screen.Setup.route
startDestination = Screen.Setup.route,
) {
// [COMPOSABLE_SETUP]
composable(route = Screen.Setup.route) {
@@ -62,28 +62,28 @@ fun NavGraph(
composable(route = Screen.Dashboard.route) {
DashboardScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
)
}
// [COMPOSABLE_INVENTORY_LIST]
composable(route = Screen.InventoryList.route) {
InventoryListScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
)
}
// [COMPOSABLE_ITEM_DETAILS]
composable(route = Screen.ItemDetails.route) {
ItemDetailsScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
)
}
// [COMPOSABLE_ITEM_EDIT]
composable(route = Screen.ItemEdit.route) {
ItemEditScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
)
}
// [COMPOSABLE_LABELS_LIST]
@@ -101,21 +101,21 @@ fun NavGraph(
},
onAddNewLocationClick = {
navController.navigate(Screen.LocationEdit.createRoute("new"))
}
},
)
}
// [COMPOSABLE_LOCATION_EDIT]
composable(route = Screen.LocationEdit.route) { backStackEntry ->
val locationId = backStackEntry.arguments?.getString("locationId")
LocationEditScreen(
locationId = locationId
locationId = locationId,
)
}
// [COMPOSABLE_SEARCH]
composable(route = Screen.Search.route) {
SearchScreen(
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
)
}
}

View File

@@ -4,6 +4,7 @@
package com.homebox.lens.navigation
import androidx.navigation.NavHostController
// [CORE-LOGIC]
/**
[CONTRACT]
@summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий.
@@ -13,9 +14,9 @@ import androidx.navigation.NavHostController
class NavigationActions(private val navController: NavHostController) {
// [ACTION]
/**
[CONTRACT]
@summary Навигация на главный экран.
@sideeffect Очищает back stack до главного экрана, чтобы избежать циклов.
[CONTRACT]
@summary Навигация на главный экран.
@sideeffect Очищает back stack до главного экрана, чтобы избежать циклов.
*/
fun navigateToDashboard() {
navController.navigate(Screen.Dashboard.route) {
@@ -25,47 +26,55 @@ class NavigationActions(private val navController: NavHostController) {
launchSingleTop = true
}
}
// [ACTION]
fun navigateToLocations() {
navController.navigate(Screen.LocationsList.route) {
launchSingleTop = true
}
}
// [ACTION]
fun navigateToLabels() {
navController.navigate(Screen.LabelsList.route) {
launchSingleTop = true
}
}
// [ACTION]
fun navigateToSearch() {
navController.navigate(Screen.Search.route) {
launchSingleTop = true
}
}
// [ACTION]
fun navigateToInventoryListWithLabel(labelId: String) {
val route = Screen.InventoryList.withFilter("label", labelId)
navController.navigate(route)
}
// [ACTION]
fun navigateToInventoryListWithLocation(locationId: String) {
val route = Screen.InventoryList.withFilter("location", locationId)
navController.navigate(route)
}
// [ACTION]
fun navigateToCreateItem() {
navController.navigate(Screen.ItemEdit.createRoute("new"))
}
// [ACTION]
fun navigateToLogout() {
navController.navigate(Screen.Setup.route) {
popUpTo(Screen.Dashboard.route) { inclusive = true }
}
}
// [ACTION]
fun navigateBack() {
navController.popBackStack()
}
}
// [END_FILE_NavigationActions.kt]
// [END_FILE_NavigationActions.kt]

View File

@@ -4,6 +4,7 @@
package com.homebox.lens.navigation
// [CORE-LOGIC]
/**
* [CONTRACT]
* Запечатанный класс для определения маршрутов навигации в приложении.
@@ -13,7 +14,9 @@ package com.homebox.lens.navigation
sealed class Screen(val route: String) {
// [STATE]
data object Setup : Screen("setup_screen")
data object Dashboard : Screen("dashboard_screen")
data object InventoryList : Screen("inventory_list_screen") {
/**
* [CONTRACT]
@@ -25,7 +28,10 @@ sealed class Screen(val route: String) {
* @sideeffect [ARCH-IMPLICATION] NavGraph должен быть настроен для приема этого опционального query-параметра (например, 'navArgument("label") { nullable = true }').
*/
// [HELPER]
fun withFilter(key: String, value: String): String {
fun withFilter(
key: String,
value: String,
): String {
// [PRECONDITION]
require(key.isNotBlank()) { "[PRECONDITION_FAILED] Filter key cannot be blank." }
require(value.isNotBlank()) { "[PRECONDITION_FAILED] Filter value cannot be blank." }
@@ -56,6 +62,7 @@ sealed class Screen(val route: String) {
return route
}
}
data object ItemEdit : Screen("item_edit_screen/{itemId}") {
/**
* [CONTRACT]
@@ -75,8 +82,11 @@ sealed class Screen(val route: String) {
return route
}
}
data object LabelsList : Screen("labels_list_screen")
data object LocationsList : Screen("locations_list_screen")
data object LocationEdit : Screen("location_edit_screen/{locationId}") {
/**
* [CONTRACT]
@@ -96,6 +106,7 @@ sealed class Screen(val route: String) {
return route
}
}
data object Search : Screen("search_screen")
}
// [END_FILE_Screen.kt]
// [END_FILE_Screen.kt]

View File

@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import com.homebox.lens.R
import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.navigation.Screen
/**
[CONTRACT]
@summary Контент для бокового навигационного меню (Drawer).
@@ -33,7 +34,7 @@ import com.homebox.lens.navigation.Screen
internal fun AppDrawerContent(
currentRoute: String?,
navigationActions: NavigationActions,
onCloseDrawer: () -> Unit
onCloseDrawer: () -> Unit,
) {
ModalDrawerSheet {
Spacer(Modifier.height(12.dp))
@@ -42,9 +43,10 @@ internal fun AppDrawerContent(
navigationActions.navigateToCreateItem()
onCloseDrawer()
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
@@ -58,7 +60,7 @@ internal fun AppDrawerContent(
onClick = {
navigationActions.navigateToDashboard()
onCloseDrawer()
}
},
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.nav_locations)) },
@@ -66,7 +68,7 @@ internal fun AppDrawerContent(
onClick = {
navigationActions.navigateToLocations()
onCloseDrawer()
}
},
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.nav_labels)) },
@@ -74,7 +76,7 @@ internal fun AppDrawerContent(
onClick = {
navigationActions.navigateToLabels()
onCloseDrawer()
}
},
)
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.search)) },
@@ -82,7 +84,7 @@ internal fun AppDrawerContent(
onClick = {
navigationActions.navigateToSearch()
onCloseDrawer()
}
},
)
// TODO: Add Profile and Tools items
Divider()
@@ -92,7 +94,7 @@ internal fun AppDrawerContent(
onClick = {
navigationActions.navigateToLogout()
onCloseDrawer()
}
},
)
}
}
}

View File

@@ -17,6 +17,7 @@ import com.homebox.lens.navigation.NavigationActions
import kotlinx.coroutines.launch
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Общая обертка для экранов, включающая Scaffold и Navigation Drawer.
@@ -35,7 +36,7 @@ fun MainScaffold(
currentRoute: String?,
navigationActions: NavigationActions,
topBarActions: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit
content: @Composable (PaddingValues) -> Unit,
) {
// [STATE]
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
@@ -48,9 +49,9 @@ fun MainScaffold(
AppDrawerContent(
currentRoute = currentRoute,
navigationActions = navigationActions,
onCloseDrawer = { scope.launch { drawerState.close() } }
onCloseDrawer = { scope.launch { drawerState.close() } },
)
}
},
) {
Scaffold(
topBar = {
@@ -60,13 +61,13 @@ fun MainScaffold(
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(
Icons.Default.Menu,
contentDescription = stringResource(id = R.string.cd_open_navigation_drawer)
contentDescription = stringResource(id = R.string.cd_open_navigation_drawer),
)
}
},
actions = { topBarActions() }
actions = { topBarActions() },
)
}
},
) { paddingValues ->
// [ACTION]
content(paddingValues)

View File

@@ -30,6 +30,7 @@ import com.homebox.lens.ui.common.MainScaffold
import com.homebox.lens.ui.theme.HomeboxLensTheme
import timber.log.Timber
// [ENTRYPOINT]
/**
[CONTRACT]
@summary Главная Composable-функция для экрана "Панель управления".
@@ -42,7 +43,7 @@ import timber.log.Timber
fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel(),
currentRoute: String?,
navigationActions: NavigationActions
navigationActions: NavigationActions,
) {
// [STATE]
val uiState by viewModel.uiState.collectAsState()
@@ -55,10 +56,10 @@ fun DashboardScreen(
IconButton(onClick = { navigationActions.navigateToSearch() }) {
Icon(
Icons.Default.Search,
contentDescription = stringResource(id = R.string.cd_scan_qr_code) // TODO: Rename string resource
contentDescription = stringResource(id = R.string.cd_scan_qr_code), // TODO: Rename string resource
)
}
}
},
) { paddingValues ->
DashboardContent(
modifier = Modifier.padding(paddingValues),
@@ -70,12 +71,13 @@ fun DashboardScreen(
onLabelClick = { label ->
Timber.i("[ACTION] Label chip clicked: ${label.id}. Navigating...")
navigationActions.navigateToInventoryListWithLabel(label.id)
}
},
)
}
// [END_FUNCTION_DashboardScreen]
}
// [HELPER]
/**
[CONTRACT]
@summary Отображает основной контент экрана в зависимости от uiState.
@@ -89,7 +91,7 @@ private fun DashboardContent(
modifier: Modifier = Modifier,
uiState: DashboardUiState,
onLocationClick: (LocationOutCount) -> Unit,
onLabelClick: (LabelOut) -> Unit
onLabelClick: (LabelOut) -> Unit,
) {
// [CORE-LOGIC]
when (uiState) {
@@ -103,16 +105,17 @@ private fun DashboardContent(
Text(
text = uiState.message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
}
is DashboardUiState.Success -> {
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
modifier =
modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item { Spacer(modifier = Modifier.height(8.dp)) }
item { StatisticsSection(statistics = uiState.statistics) }
@@ -126,6 +129,7 @@ private fun DashboardContent(
// [END_FUNCTION_DashboardContent]
}
// [UI_COMPONENT]
/**
[CONTRACT]
@summary Секция для отображения общей статистики.
@@ -136,27 +140,49 @@ private fun StatisticsSection(statistics: GroupStatistics) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(id = R.string.dashboard_section_quick_stats),
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
)
Card {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.height(120.dp)
.fillMaxWidth()
.padding(16.dp),
modifier =
Modifier
.height(120.dp)
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_items), value = statistics.items.toString()) }
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_value), value = statistics.totalValue.toString()) }
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_labels), value = statistics.labels.toString()) }
item { StatisticCard(title = stringResource(id = R.string.dashboard_stat_total_locations), value = statistics.locations.toString()) }
item {
StatisticCard(
title = stringResource(id = R.string.dashboard_stat_total_items),
value = statistics.items.toString(),
)
}
item {
StatisticCard(
title = stringResource(id = R.string.dashboard_stat_total_value),
value = statistics.totalValue.toString(),
)
}
item {
StatisticCard(
title = stringResource(id = R.string.dashboard_stat_total_labels),
value = statistics.labels.toString(),
)
}
item {
StatisticCard(
title = stringResource(id = R.string.dashboard_stat_total_locations),
value = statistics.locations.toString(),
)
}
}
}
}
}
// [UI_COMPONENT]
/**
[CONTRACT]
@summary Карточка для отображения одного статистического показателя.
@@ -164,13 +190,17 @@ private fun StatisticsSection(statistics: GroupStatistics) {
@param value Значение показателя.
*/
@Composable
private fun StatisticCard(title: String, value: String) {
private fun StatisticCard(
title: String,
value: String,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(text = title, style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center)
Text(text = value, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
}
}
// [UI_COMPONENT]
/**
[CONTRACT]
@summary Секция для отображения недавно добавленных элементов.
@@ -181,16 +211,17 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(id = R.string.dashboard_section_recently_added),
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
)
if (items.isEmpty()) {
Text(
text = stringResource(id = R.string.items_not_found),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
textAlign = TextAlign.Center
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
textAlign = TextAlign.Center,
)
} else {
LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
@@ -202,6 +233,7 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
}
}
// [UI_COMPONENT]
/**
[CONTRACT]
@summary Карточка для отображения краткой информации об элементе.
@@ -212,17 +244,25 @@ private fun ItemCard(item: ItemSummary) {
Card(modifier = Modifier.width(150.dp)) {
Column(modifier = Modifier.padding(8.dp)) {
// TODO: Add image here from item.image
Spacer(modifier = Modifier
.height(80.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer))
Spacer(
modifier =
Modifier
.height(80.dp)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer),
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = item.name, style = MaterialTheme.typography.titleSmall, maxLines = 1)
Text(text = item.location?.name ?: stringResource(id = R.string.no_location), style = MaterialTheme.typography.bodySmall, maxLines = 1)
Text(
text = item.location?.name ?: stringResource(id = R.string.no_location),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
}
}
}
// [UI_COMPONENT]
/**
[CONTRACT]
@summary Секция для отображения местоположений в виде чипсов.
@@ -231,11 +271,14 @@ private fun ItemCard(item: ItemSummary) {
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick: (LocationOutCount) -> Unit) {
private fun LocationsSection(
locations: List<LocationOutCount>,
onLocationClick: (LocationOutCount) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(id = R.string.dashboard_section_locations),
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -243,13 +286,14 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
locations.forEach { location ->
SuggestionChip(
onClick = { onLocationClick(location) },
label = { Text(stringResource(id = R.string.location_chip_label, location.name, location.itemCount)) }
label = { Text(stringResource(id = R.string.location_chip_label, location.name, location.itemCount)) },
)
}
}
}
}
// [UI_COMPONENT]
/**
[CONTRACT]
@summary Секция для отображения меток в виде чипсов.
@@ -258,11 +302,14 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Unit) {
private fun LabelsSection(
labels: List<LabelOut>,
onLabelClick: (LabelOut) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(id = R.string.dashboard_section_labels),
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -270,46 +317,92 @@ private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Un
labels.forEach { label ->
SuggestionChip(
onClick = { onLabelClick(label) },
label = { Text(label.name) }
label = { Text(label.name) },
)
}
}
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Success State")
@Composable
fun DashboardContentSuccessPreview() {
val previewState = DashboardUiState.Success(
statistics = GroupStatistics(
items = 123,
totalValue = 9999.99,
locations = 5,
labels = 8
),
locations = listOf(
LocationOutCount(id="1", name="Office", color = "#FF0000", isArchived = false, itemCount = 10, createdAt = "", updatedAt = ""),
LocationOutCount(id="2", name="Garage", color = "#00FF00", isArchived = false, itemCount = 5, createdAt = "", updatedAt = ""),
LocationOutCount(id="3",name="Living Room", color = "#0000FF", isArchived = false, itemCount = 15, createdAt = "", updatedAt = ""),
LocationOutCount(id="4",name="Kitchen", color = "#FFFF00", isArchived = false, itemCount = 20, createdAt = "", updatedAt = ""),
LocationOutCount(id="5",name="Basement", color = "#00FFFF", isArchived = false, itemCount = 3, createdAt = "", updatedAt = "")
),
labels = listOf(
LabelOut(id="1", name="electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="2", name="important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="3", name="seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id="4", name="hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = "")
),
recentlyAddedItems = emptyList()
)
val previewState =
DashboardUiState.Success(
statistics =
GroupStatistics(
items = 123,
totalValue = 9999.99,
locations = 5,
labels = 8,
),
locations =
listOf(
LocationOutCount(
id = "1",
name = "Office",
color = "#FF0000",
isArchived = false,
itemCount = 10,
createdAt = "",
updatedAt = "",
),
LocationOutCount(
id = "2",
name = "Garage",
color = "#00FF00",
isArchived = false,
itemCount = 5,
createdAt = "",
updatedAt = "",
),
LocationOutCount(
id = "3",
name = "Living Room",
color = "#0000FF",
isArchived = false,
itemCount = 15,
createdAt = "",
updatedAt = "",
),
LocationOutCount(
id = "4",
name = "Kitchen",
color = "#FFFF00",
isArchived = false,
itemCount = 20,
createdAt = "",
updatedAt = "",
),
LocationOutCount(
id = "5",
name = "Basement",
color = "#00FFFF",
isArchived = false,
itemCount = 3,
createdAt = "",
updatedAt = "",
),
),
labels =
listOf(
LabelOut(id = "1", name = "electronics", color = "#FF0000", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id = "2", name = "important", color = "#00FF00", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id = "3", name = "seasonal", color = "#0000FF", isArchived = false, createdAt = "", updatedAt = ""),
LabelOut(id = "4", name = "hobby", color = "#FFFF00", isArchived = false, createdAt = "", updatedAt = ""),
),
recentlyAddedItems = emptyList(),
)
HomeboxLensTheme {
DashboardContent(
uiState = previewState,
onLocationClick = {},
onLabelClick = {}
onLabelClick = {},
)
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Loading State")
@Composable
@@ -318,10 +411,11 @@ fun DashboardContentLoadingPreview() {
DashboardContent(
uiState = DashboardUiState.Loading,
onLocationClick = {},
onLabelClick = {}
onLabelClick = {},
)
}
}
// [PREVIEW]
@Preview(showBackground = true, name = "Dashboard Error State")
@Composable
@@ -330,8 +424,8 @@ fun DashboardContentErrorPreview() {
DashboardContent(
uiState = DashboardUiState.Error(stringResource(id = R.string.error_loading_failed)),
onLocationClick = {},
onLabelClick = {}
onLabelClick = {},
)
}
}
// [END_FILE_DashboardScreen.kt]
// [END_FILE_DashboardScreen.kt]

View File

@@ -11,6 +11,7 @@ import com.homebox.lens.domain.model.LocationOutCount
// [CORE-LOGIC]
// [ENTITY: SealedInterface('DashboardUiState')]
/**
* [CONTRACT]
* Определяет все возможные состояния для экрана "Дэшборд".
@@ -29,7 +30,7 @@ sealed interface DashboardUiState {
val statistics: GroupStatistics,
val locations: List<LocationOutCount>,
val labels: List<LabelOut>,
val recentlyAddedItems: List<com.homebox.lens.domain.model.ItemSummary>
val recentlyAddedItems: List<com.homebox.lens.domain.model.ItemSummary>,
) : DashboardUiState
/**
@@ -45,4 +46,4 @@ sealed interface DashboardUiState {
*/
data object Loading : DashboardUiState
}
// [END_FILE_DashboardUiState.kt]
// [END_FILE_DashboardUiState.kt]

View File

@@ -9,10 +9,7 @@ import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
import com.homebox.lens.domain.usecase.GetRecentlyAddedItemsUseCase
import com.homebox.lens.domain.usecase.GetStatisticsUseCase
import com.homebox.lens.ui.screen.dashboard.DashboardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -20,6 +17,7 @@ import javax.inject.Inject
// [VIEWMODEL]
// [ENTITY: ViewModel('DashboardViewModel')]
/**
* [CONTRACT]
* @summary ViewModel для главного экрана (Dashboard).
@@ -28,61 +26,64 @@ import javax.inject.Inject
* @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`.
*/
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val getStatisticsUseCase: GetStatisticsUseCase,
private val getAllLocationsUseCase: GetAllLocationsUseCase,
private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase
) : ViewModel() {
class DashboardViewModel
@Inject
constructor(
private val getStatisticsUseCase: GetStatisticsUseCase,
private val getAllLocationsUseCase: GetAllLocationsUseCase,
private val getAllLabelsUseCase: GetAllLabelsUseCase,
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase,
) : ViewModel() {
// [STATE]
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
// [STATE]
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
// [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow().
// [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и
// должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока.
val uiState = _uiState.asStateFlow()
// [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow().
// [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и
// должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока.
val uiState = _uiState.asStateFlow()
// [LIFECYCLE_HANDLER]
init {
loadDashboardData()
}
// [LIFECYCLE_HANDLER]
init {
loadDashboardData()
}
/**
* [CONTRACT]
* @summary Загружает все необходимые данные для экрана Dashboard.
* @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`.
* @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`.
*/
fun loadDashboardData() {
// [ENTRYPOINT]
viewModelScope.launch {
_uiState.value = DashboardUiState.Loading
Timber.i("[ACTION] Starting dashboard data collection.")
/**
* [CONTRACT]
* @summary Загружает все необходимые данные для экрана Dashboard.
* @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`.
* @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`.
*/
fun loadDashboardData() {
// [ENTRYPOINT]
viewModelScope.launch {
_uiState.value = DashboardUiState.Loading
Timber.i("[ACTION] Starting dashboard data collection.")
val statsFlow = flow { emit(getStatisticsUseCase()) }
val locationsFlow = flow { emit(getAllLocationsUseCase()) }
val labelsFlow = flow { emit(getAllLabelsUseCase()) }
val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10)
val statsFlow = flow { emit(getStatisticsUseCase()) }
val locationsFlow = flow { emit(getAllLocationsUseCase()) }
val labelsFlow = flow { emit(getAllLabelsUseCase()) }
val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10)
combine(statsFlow, locationsFlow, labelsFlow, recentItemsFlow) { stats, locations, labels, recentItems ->
DashboardUiState.Success(
statistics = stats,
locations = locations,
labels = labels,
recentlyAddedItems = recentItems
)
}.catch { exception ->
Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.")
_uiState.value = DashboardUiState.Error(
message = exception.message ?: "Could not load dashboard data."
)
}.collect { successState ->
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
_uiState.value = successState
combine(statsFlow, locationsFlow, labelsFlow, recentItemsFlow) { stats, locations, labels, recentItems ->
DashboardUiState.Success(
statistics = stats,
locations = locations,
labels = labels,
recentlyAddedItems = recentItems,
)
}.catch { exception ->
Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.")
_uiState.value =
DashboardUiState.Error(
message = exception.message ?: "Could not load dashboard data.",
)
}.collect { successState ->
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
_uiState.value = successState
}
}
}
// [END_CLASS_DashboardViewModel]
}
// [END_CLASS_DashboardViewModel]
}
// [END_FILE_DashboardViewModel.kt]
// [END_FILE_DashboardViewModel.kt]

View File

@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Список инвентаря".
@@ -22,16 +23,16 @@ import com.homebox.lens.ui.common.MainScaffold
@Composable
fun InventoryListScreen(
currentRoute: String?,
navigationActions: NavigationActions
navigationActions: NavigationActions,
) {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.inventory_list_title),
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
) {
// [CORE-LOGIC]
Text(text = "TODO: Inventory List Screen")
}
// [END_FUNCTION_InventoryListScreen]
}
}

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL]
@HiltViewModel
class InventoryListViewModel @Inject constructor() : ViewModel() {
// [STATE]
// TODO: Implement UI state
}
class InventoryListViewModel
@Inject
constructor() : ViewModel() {
// [STATE]
// TODO: Implement UI state
}
// [END_FILE_InventoryListViewModel.kt]

View File

@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Детали элемента".
@@ -22,16 +23,16 @@ import com.homebox.lens.ui.common.MainScaffold
@Composable
fun ItemDetailsScreen(
currentRoute: String?,
navigationActions: NavigationActions
navigationActions: NavigationActions,
) {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.item_details_title),
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
) {
// [CORE-LOGIC]
Text(text = "TODO: Item Details Screen")
}
// [END_FUNCTION_ItemDetailsScreen]
}
}

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL]
@HiltViewModel
class ItemDetailsViewModel @Inject constructor() : ViewModel() {
// [STATE]
// TODO: Implement UI state
}
class ItemDetailsViewModel
@Inject
constructor() : ViewModel() {
// [STATE]
// TODO: Implement UI state
}
// [END_FILE_ItemDetailsViewModel.kt]

View File

@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Редактирование элемента".
@@ -22,13 +23,13 @@ import com.homebox.lens.ui.common.MainScaffold
@Composable
fun ItemEditScreen(
currentRoute: String?,
navigationActions: NavigationActions
navigationActions: NavigationActions,
) {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.item_edit_title),
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
) {
// [CORE-LOGIC]
Text(text = "TODO: Item Edit Screen")

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL]
@HiltViewModel
class ItemEditViewModel @Inject constructor() : ViewModel() {
// [STATE]
// TODO: Implement UI state
}
class ItemEditViewModel
@Inject
constructor() : ViewModel() {
// [STATE]
// TODO: Implement UI state
}
// [END_FILE_ItemEditViewModel.kt]

View File

@@ -1,15 +1,11 @@
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
// [FILE] LabelsListScreen.kt
// [SEMANTICS] ui, labels_list, state_management, compose, dialog
// [SEMANTICS] ui, screen, jetpack_compose, labels_list, state_management
package com.homebox.lens.ui.screen.labelslist
// [SECTION] Imports
// [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
@@ -17,241 +13,193 @@ 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
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.homebox.lens.R
import com.homebox.lens.domain.model.Label
import com.homebox.lens.navigation.Screen
import com.homebox.lens.ui.theme.HomeboxLensTheme
import timber.log.Timber
// [SECTION] Main Screen Composable
// [ENTITY: Class('LabelsListScreen')]
// [RELATION: Class('LabelsListScreen')] -> [DEPENDS_ON] -> [Class('LabelsListViewModel')]
// [RELATION: Class('LabelsListScreen')] -> [READS_FROM] -> [DataStructure('LabelsListUiState')]
/**
* [CONTRACT]
* @summary Отображает экран со списком всех меток.
* @description Главная Composable-функция для экрана меток. Она использует Scaffold для структуры,
* получает состояние от `LabelsListViewModel`, обрабатывает навигацию и делегирует отображение
* списка и диалогов вспомогательным Composable-функциям.
* [MAIN-CONTRACT]
* Экран для отображения списка всех меток.
*
* @param navController Контроллер навигации для перемещения между экранами.
* @param viewModel ViewModel, предоставляющая состояние UI для экрана меток.
*
* @precondition `navController` должен быть корректно инициализирован и способен обрабатывать навигационные события.
* @precondition `viewModel` должен быть доступен через Hilt.
* @postcondition Экран исчерпывающе обрабатывает все состояния из `LabelsListUiState` (Loading, Success, Error).
* @sideeffect Пользовательские действия (клики) инициируют вызовы ViewModel и навигационные команды через `navController`.
* @param onLabelClick Функция обратного вызова при нажатии на метку. Передает ID метки.
* @param onAddNewLabelClick Функция обратного вызова для инициирования процесса создания новой метки.
* @param onNavigateBack Функция обратного вызова для навигации назад.
* @param viewModel ViewModel для этого экрана, предоставляемая Hilt.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelsListScreen(
navController: NavController,
viewModel: LabelsListViewModel = hiltViewModel()
) {
// [ENTRYPOINT]
val uiState by viewModel.uiState.collectAsState()
// [CORE-LOGIC]
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(id = R.string.screen_title_labels)) },
navigationIcon = {
// [ACTION] Handle back navigation
IconButton(onClick = {
Timber.i("[ACTION] Navigate up initiated.")
navController.navigateUp()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.content_desc_navigate_back)
)
}
}
)
},
floatingActionButton = {
// [ACTION] Handle create new label initiation
FloatingActionButton(onClick = {
Timber.i("[ACTION] FAB clicked: Initiate create new label flow.")
viewModel.onShowCreateDialog()
}) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(id = R.string.content_desc_create_label)
)
}
}
) { paddingValues ->
val currentState = uiState
if (currentState is LabelsListUiState.Success && currentState.isShowingCreateDialog) {
CreateLabelDialog(
onConfirm = { labelName ->
viewModel.createLabel(labelName)
},
onDismiss = {
viewModel.onDismissCreateDialog()
}
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
// [CORE-LOGIC] State-driven UI rendering
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.labels_list_empty))
} else {
LabelsList(
labels = currentState.labels,
onLabelClick = { label ->
// [ACTION] Handle label click
Timber.i("[ACTION] Label clicked: ${label.id}. Navigating to inventory list.")
// [DESIGN-DECISION] Использовать существующий экран списка инвентаря, передавая фильтр.
val route = Screen.InventoryList.withFilter("label", label.id)
navController.navigate(route)
}
)
}
}
}
}
}
// [COHERENCE_CHECK_PASSED]
}
// [END_FUNCTION] LabelsListScreen
// [SECTION] Helper Composables
/**
* [CONTRACT]
* @summary Composable-функция для отображения списка меток.
* @param labels Список объектов `Label` для отображения.
* @param onLabelClick Лямбда-функция, вызываемая при нажатии на элемент списка.
* @param modifier Модификатор для настройки внешнего вида.
*/
@Composable
private fun LabelsList(
labels: List<Label>,
onLabelClick: (Label) -> Unit,
modifier: Modifier = Modifier
) {
// [CORE-LOGIC]
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) }
)
}
}
}
// [END_FUNCTION] LabelsList
/**
* [CONTRACT]
* @summary Composable-функция для отображения одного элемента в списке меток.
* @param label Объект `Label`, который нужно отобразить.
* @param onClick Лямбда-функция, вызываемая при нажатии на элемент.
*/
@Composable
private fun LabelListItem(
label: Label,
onClick: () -> Unit
) {
// [CORE-LOGIC]
ListItem(
headlineContent = { Text(text = label.name) },
leadingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Label,
contentDescription = stringResource(id = R.string.content_desc_label_icon)
)
},
modifier = Modifier.clickable(onClick = onClick)
)
}
// [END_FUNCTION] LabelListItem
/**
* [CONTRACT]
* @summary Диалоговое окно для создания новой метки.
* @param onConfirm Лямбда-функция, вызываемая при подтверждении создания с именем метки.
* @param onDismiss Лямбда-функция, вызываемая при закрытии диалога.
*/
@Composable
private fun CreateLabelDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
onLabelClick: (String) -> Unit,
onAddNewLabelClick: () -> Unit,
onNavigateBack: () -> Unit,
viewModel: LabelsListViewModel = hiltViewModel(),
) {
// [STATE]
var text by remember { mutableStateOf("") }
val isConfirmEnabled = text.isNotBlank()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// [CORE-LOGIC]
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.dialog_title_create_label)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text(stringResource(R.string.dialog_field_label_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// [CONTRACT_VALIDATOR]
// В Compose UI контракты проверяются через состояние и события.
Scaffold(
topBar = {
// [ENTITY: Function('LabelsTopAppBar')]
LabelsTopAppBar(onNavigateBack = onNavigateBack)
},
confirmButton = {
TextButton(
onClick = { onConfirm(text) },
enabled = isConfirmEnabled
) {
Text(stringResource(R.string.dialog_button_create))
floatingActionButton = {
// [ENTITY: Function('LabelsFloatingActionButton')]
LabelsFloatingActionButton(onAddNewLabelClick = onAddNewLabelClick)
},
) { innerPadding ->
// [ENTITY: Function('LabelsListContent')]
// [RELATION: Function('LabelsListContent')] -> [CALLS] -> [Function('onLabelClick')]
LabelsListContent(
modifier = Modifier.padding(innerPadding),
labels = uiState.labels,
onLabelClick = onLabelClick,
)
}
}
/**
* [CONTRACT]
* Верхняя панель для экрана списка меток.
* @param onNavigateBack Функция для навигации назад.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LabelsTopAppBar(onNavigateBack: () -> Unit) {
// [PRECONDITION]
require(true) { "onNavigateBack must be a valid function." } // В Compose предусловия часто неявные
TopAppBar(
title = { Text(stringResource(id = R.string.screen_title_labels)) },
navigationIcon = {
IconButton(onClick = {
// [ACTION]
Timber.i("[INFO][ACTION][navigating_back] Navigate back from LabelsListScreen.")
onNavigateBack()
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.content_desc_navigate_back),
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_button_cancel))
}
}
)
}
// [END_FUNCTION] CreateLabelDialog
// [END_FILE] LabelsListScreen.kt
/**
* [CONTRACT]
* Плавающая кнопка действия для добавления новой метки.
* @param onAddNewLabelClick Функция для вызова экрана создания метки.
*/
@Composable
private fun LabelsFloatingActionButton(onAddNewLabelClick: () -> Unit) {
// [PRECONDITION]
require(true) { "onAddNewLabelClick must be a valid function." }
FloatingActionButton(
onClick = {
// [ACTION]
Timber.i("[INFO][ACTION][initiating_add_new_label] FAB clicked to add a new label.")
onAddNewLabelClick()
},
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(id = R.string.content_desc_add_label),
)
}
}
/**
* [CONTRACT]
* Основной контент экрана - список меток.
*
* @param modifier Модификатор для компоновки.
* @param labels Список меток для отображения.
* @param onLabelClick Обработчик нажатия на метку.
* @sideeffect Вызывает [onLabelClick] при взаимодействии пользователя.
*/
@Composable
private fun LabelsListContent(
modifier: Modifier = Modifier,
labels: List<Label>,
onLabelClick: (String) -> Unit,
) {
// [PRECONDITION]
requireNotNull(labels) { "Labels list cannot be null." }
LazyColumn(modifier = modifier) {
items(labels, key = { it.id }) { label ->
// [ENTITY: DataStructure('LabelListItem')]
ListItem(
headlineContent = { Text(label.name) },
leadingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Label,
contentDescription = null, // Декоративная иконка
)
},
modifier =
Modifier.clickable {
// [ACTION]
Timber.i("[INFO][ACTION][handling_label_click] Label clicked: id='${label.id}', name='${label.name}'")
onLabelClick(label.id)
},
)
}
}
// [POSTCONDITION]
// В декларативном UI постусловие - это корректное отображение предоставленного состояния.
check(true) { "LazyColumn rendering is managed by Compose runtime." }
}
// [SECTION] Previews
@Preview(showBackground = true)
@Composable
private fun LabelsListScreenPreview() {
HomeboxLensTheme {
val sampleLabels =
listOf(
Label(id = "1", name = "Electronics", color = "#FF0000"),
Label(id = "2", name = "Books", color = "#00FF00"),
Label(id = "3", name = "Documents", color = "#0000FF"),
)
// [HELPER]
// Для превью мы не можем использовать реальный ViewModel, поэтому создаем заглушки.
Scaffold(
topBar = { LabelsTopAppBar(onNavigateBack = {}) },
floatingActionButton = { LabelsFloatingActionButton(onAddNewLabelClick = {}) },
) { padding ->
LabelsListContent(
modifier = Modifier.padding(padding),
labels = sampleLabels,
onLabelClick = {},
)
}
}
}
// [COHERENCE_CHECK_PASSED]

View File

@@ -5,6 +5,7 @@ package com.homebox.lens.ui.screen.labelslist
// [IMPORTS]
import com.homebox.lens.domain.model.Label
// [CONTRACT]
/**
[CONTRACT]
@summary Определяет все возможные состояния для UI экрана со списком меток.
@@ -12,25 +13,27 @@ import com.homebox.lens.domain.model.Label
*/
sealed interface LabelsListUiState {
/**
@summary Состояние успеха, содержит список меток и состояние диалога.
@property labels Список меток для отображения.
@property isShowingCreateDialog Флаг, показывающий, должен ли быть отображен диалог создания метки.
@invariant labels не может быть null.
@summary Состояние успеха, содержит список меток и состояние диалога.
@property labels Список меток для отображения.
@property isShowingCreateDialog Флаг, показывающий, должен ли быть отображен диалог создания метки.
@invariant labels не может быть null.
*/
data class Success(
val labels: List<Label>,
val isShowingCreateDialog: Boolean = false
val isShowingCreateDialog: Boolean = false,
) : LabelsListUiState
/**
@summary Состояние ошибки.
@property message Текст ошибки для отображения пользователю.
@invariant message не может быть пустой.
@summary Состояние ошибки.
@property message Текст ошибки для отображения пользователю.
@invariant message не может быть пустой.
*/
data class Error(val message: String) : LabelsListUiState
/**
@summary Состояние загрузки данных.
@description Указывает, что идет процесс загрузки меток.
@summary Состояние загрузки данных.
@description Указывает, что идет процесс загрузки меток.
*/
data object Loading : LabelsListUiState
}
// [END_FILE_LabelsListUiState.kt]
// [END_FILE_LabelsListUiState.kt]

View File

@@ -18,6 +18,7 @@ import javax.inject.Inject
// [VIEWMODEL]
// [ENTITY: ViewModel('LabelsListViewModel')]
/**
* [CONTRACT]
* @summary ViewModel для экрана со списком меток.
@@ -25,116 +26,120 @@ import javax.inject.Inject
* @invariant `uiState` всегда является одним из состояний, определенных в `LabelsListUiState`.
*/
@HiltViewModel
class LabelsListViewModel @Inject constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase
) : ViewModel() {
class LabelsListViewModel
@Inject
constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase,
) : ViewModel() {
// [STATE]
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
val uiState = _uiState.asStateFlow()
// [STATE]
private val _uiState = MutableStateFlow<LabelsListUiState>(LabelsListUiState.Loading)
val uiState = _uiState.asStateFlow()
// [INIT]
init {
loadLabels()
}
// [INIT]
init {
loadLabels()
}
/**
* [CONTRACT]
* @summary Загружает список меток.
* @description Выполняет `GetAllLabelsUseCase` и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error`.
* @sideeffect Асинхронно обновляет `_uiState`.
*/
// [ACTION]
fun loadLabels() {
// [ENTRYPOINT]
viewModelScope.launch {
_uiState.value = LabelsListUiState.Loading
Timber.i("[ACTION] Starting labels list load. State -> Loading.")
/**
* [CONTRACT]
* @summary Загружает список меток.
* @description Выполняет `GetAllLabelsUseCase` и обновляет UI, переключая его
* между состояниями `Loading`, `Success` и `Error`.
* @sideeffect Асинхронно обновляет `_uiState`.
*/
// [ACTION]
fun loadLabels() {
// [ENTRYPOINT]
viewModelScope.launch {
_uiState.value = LabelsListUiState.Loading
Timber.i("[ACTION] Starting labels list load. State -> Loading.")
// [CORE-LOGIC]
val result = runCatching {
getAllLabelsUseCase()
}
// [RESULT_HANDLER]
result.fold(
onSuccess = { labelOuts ->
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
// [DATA-FLOW] Map List<LabelOut> to List<Label> for the UI state.
// The 'Label' model for the UI is simpler and only contains 'id' and 'name'.
val labels = labelOuts.map { labelOut ->
Label(
id = labelOut.id,
name = labelOut.name
)
// [CORE-LOGIC]
val result =
runCatching {
getAllLabelsUseCase()
}
_uiState.value = LabelsListUiState.Success(labels, isShowingCreateDialog = false)
},
onFailure = { exception ->
Timber.e(exception, "[ERROR] Failed to load labels. State -> Error.")
_uiState.value = LabelsListUiState.Error(
message = exception.message ?: "Could not load labels."
)
// [RESULT_HANDLER]
result.fold(
onSuccess = { labelOuts ->
Timber.i("[SUCCESS] Labels loaded successfully. Count: ${labelOuts.size}. State -> Success.")
// [DATA-FLOW] Map List<LabelOut> to List<Label> for the UI state.
// The 'Label' model for the UI is simpler and only contains 'id' and 'name'.
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] Failed to load labels. State -> Error.")
_uiState.value =
LabelsListUiState.Error(
message = exception.message ?: "Could not load labels.",
)
},
)
}
}
/**
* [CONTRACT]
* @summary Инициирует отображение диалога для создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `true`.
* @sideeffect Обновляет `_uiState`.
*/
// [ACTION]
fun onShowCreateDialog() {
Timber.i("[ACTION] Show create label dialog requested.")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = true)
}
)
}
}
/**
* [CONTRACT]
* @summary Инициирует отображение диалога для создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `true`.
* @sideeffect Обновляет `_uiState`.
*/
// [ACTION]
fun onShowCreateDialog() {
Timber.i("[ACTION] Show create label dialog requested.")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = true)
}
}
}
/**
* [CONTRACT]
* @summary Скрывает диалог создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `false`.
* @sideeffect Обновляет `_uiState`.
*/
// [ACTION]
fun onDismissCreateDialog() {
Timber.i("[ACTION] Dismiss create label dialog requested.")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = false)
/**
* [CONTRACT]
* @summary Скрывает диалог создания метки.
* @description Обновляет состояние `uiState`, устанавливая `isShowingCreateDialog` в `false`.
* @sideeffect Обновляет `_uiState`.
*/
// [ACTION]
fun onDismissCreateDialog() {
Timber.i("[ACTION] Dismiss create label dialog requested.")
if (_uiState.value is LabelsListUiState.Success) {
_uiState.update {
(it as LabelsListUiState.Success).copy(isShowingCreateDialog = false)
}
}
}
/**
* [CONTRACT]
* @summary Создает новую метку. [MVP_SCOPE] ЗАГЛУШКА.
* @description В текущей реализации (План Б, Этап 1), эта функция только логирует действие
* и скрывает диалог. Реальная логика сохранения будет добавлена на следующем этапе.
* @param name Название новой метки.
* @precondition `name` не должен быть пустым.
* @sideeffect Логирует действие, обновляет `_uiState`, чтобы скрыть диалог.
*/
// [ACTION]
fun createLabel(name: String) {
// [PRECONDITION]
require(name.isNotBlank()) { "[CONTRACT_VIOLATION] Label name cannot be blank." }
// [ENTRYPOINT]
Timber.i("[ACTION] Create label called with name: '$name'. [STUBBED]")
// [CORE-LOGIC] - Заглушка. Здесь будет вызов CreateLabelUseCase.
// [POSTCONDITION] Скрываем диалог после "создания".
onDismissCreateDialog()
// [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список.
}
}
/**
* [CONTRACT]
* @summary Создает новую метку. [MVP_SCOPE] ЗАГЛУШКА.
* @description В текущей реализации (План Б, Этап 1), эта функция только логирует действие
* и скрывает диалог. Реальная логика сохранения будет добавлена на следующем этапе.
* @param name Название новой метки.
* @precondition `name` не должен быть пустым.
* @sideeffect Логирует действие, обновляет `_uiState`, чтобы скрыть диалог.
*/
// [ACTION]
fun createLabel(name: String) {
// [PRECONDITION]
require(name.isNotBlank()) { "[CONTRACT_VIOLATION] Label name cannot be blank." }
// [ENTRYPOINT]
Timber.i("[ACTION] Create label called with name: '$name'. [STUBBED]")
// [CORE-LOGIC] - Заглушка. Здесь будет вызов CreateLabelUseCase.
// [POSTCONDITION] Скрываем диалог после "создания".
onDismissCreateDialog()
// [REFACTORING_NOTE] На следующем этапе нужно добавить вызов UseCase и обновить список.
}
}
// [END_CLASS_LabelsListViewModel]
// [END_CLASS_LabelsListViewModel]

View File

@@ -17,27 +17,28 @@ import androidx.compose.ui.res.stringResource
import com.homebox.lens.R
// [ENTRYPOINT]
/**
* [CONTRACT]
* @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)
}
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
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center,
) {
Text(text = "TODO: Location Edit Screen for ID: $locationId")
}

View File

@@ -51,6 +51,7 @@ import com.homebox.lens.ui.common.MainScaffold
import com.homebox.lens.ui.theme.HomeboxLensTheme
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Список местоположений".
@@ -66,7 +67,7 @@ fun LocationsListScreen(
navigationActions: NavigationActions,
onLocationClick: (String) -> Unit,
onAddNewLocationClick: () -> Unit,
viewModel: LocationsListViewModel = hiltViewModel()
viewModel: LocationsListViewModel = hiltViewModel(),
) {
// [STATE]
val uiState by viewModel.uiState.collectAsState()
@@ -75,7 +76,7 @@ fun LocationsListScreen(
MainScaffold(
topBarTitle = stringResource(id = R.string.locations_list_title),
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
) { paddingValues ->
Scaffold(
modifier = Modifier.padding(paddingValues),
@@ -83,23 +84,24 @@ fun LocationsListScreen(
FloatingActionButton(onClick = onAddNewLocationClick) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(id = R.string.cd_add_new_location)
contentDescription = stringResource(id = R.string.cd_add_new_location),
)
}
}
},
) { innerPadding ->
LocationsListContent(
modifier = Modifier.padding(innerPadding),
uiState = uiState,
onLocationClick = onLocationClick,
onEditLocation = { /* TODO */ },
onDeleteLocation = { /* TODO */ }
onDeleteLocation = { /* TODO */ },
)
}
}
}
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает основной контент экрана в зависимости от `uiState`.
@@ -115,7 +117,7 @@ private fun LocationsListContent(
uiState: LocationsListUiState,
onLocationClick: (String) -> Unit,
onEditLocation: (String) -> Unit,
onDeleteLocation: (String) -> Unit
onDeleteLocation: (String) -> Unit,
) {
Box(modifier = modifier.fillMaxSize()) {
when (uiState) {
@@ -127,9 +129,10 @@ private fun LocationsListContent(
text = uiState.message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
)
}
is LocationsListUiState.Success -> {
@@ -137,21 +140,22 @@ private fun LocationsListContent(
Text(
text = stringResource(id = R.string.locations_not_found),
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp)
modifier =
Modifier
.align(Alignment.Center)
.padding(16.dp),
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.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) }
onDeleteClick = { onDeleteLocation(location.id) },
)
}
}
@@ -162,6 +166,7 @@ private fun LocationsListContent(
}
// [UI_COMPONENT]
/**
* [CONTRACT]
* @summary Карточка для отображения одного местоположения.
@@ -175,25 +180,26 @@ private fun LocationCard(
location: LocationOutCount,
onClick: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit
onDeleteClick: () -> Unit,
) {
var menuExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
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
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
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(Modifier.width(16.dp))
@@ -203,21 +209,21 @@ private fun LocationCard(
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
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()
}
},
)
}
}
@@ -229,17 +235,18 @@ private fun LocationCard(
@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, "", "")
)
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 = {}
onDeleteLocation = {},
)
}
}
@@ -253,7 +260,7 @@ fun LocationsListEmptyPreview() {
uiState = LocationsListUiState.Success(emptyList()),
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
onDeleteLocation = {},
)
}
}
@@ -267,7 +274,7 @@ fun LocationsListLoadingPreview() {
uiState = LocationsListUiState.Loading,
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
onDeleteLocation = {},
)
}
}
@@ -281,7 +288,7 @@ fun LocationsListErrorPreview() {
uiState = LocationsListUiState.Error("Failed to load locations. Please try again."),
onLocationClick = {},
onEditLocation = {},
onDeleteLocation = {}
onDeleteLocation = {},
)
}
}

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
// [CORE-LOGIC]
/**
* [CONTRACT]
* @summary ViewModel для экрана списка местоположений.
@@ -23,36 +24,38 @@ import javax.inject.Inject
* @invariant `uiState` всегда отражает результат последней операции загрузки.
*/
@HiltViewModel
class LocationsListViewModel @Inject constructor(
private val getAllLocationsUseCase: GetAllLocationsUseCase
) : ViewModel() {
class LocationsListViewModel
@Inject
constructor(
private val getAllLocationsUseCase: GetAllLocationsUseCase,
) : ViewModel() {
// [STATE]
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow()
// [STATE]
private val _uiState = MutableStateFlow<LocationsListUiState>(LocationsListUiState.Loading)
val uiState: StateFlow<LocationsListUiState> = _uiState.asStateFlow()
// [INITIALIZER]
init {
loadLocations()
}
// [INITIALIZER]
init {
loadLocations()
}
// [ACTION]
// [ACTION]
/**
* [CONTRACT]
* @summary Загружает список местоположений из репозитория.
* @sideeffect Обновляет `_uiState` в зависимости от результата: Loading -> Success/Error.
*/
fun loadLocations() {
viewModelScope.launch {
_uiState.value = LocationsListUiState.Loading
try {
val locations = getAllLocationsUseCase()
_uiState.value = LocationsListUiState.Success(locations)
} catch (e: Exception) {
_uiState.value = LocationsListUiState.Error(e.message ?: "Unknown error")
/**
* [CONTRACT]
* @summary Загружает список местоположений из репозитория.
* @sideeffect Обновляет `_uiState` в зависимости от результата: Loading -> Success/Error.
*/
fun loadLocations() {
viewModelScope.launch {
_uiState.value = LocationsListUiState.Loading
try {
val locations = getAllLocationsUseCase()
_uiState.value = LocationsListUiState.Success(locations)
} catch (e: Exception) {
_uiState.value = LocationsListUiState.Error(e.message ?: "Unknown error")
}
}
}
// [END_CLASS_LocationsListViewModel]
}
// [END_CLASS_LocationsListViewModel]
}
// [END_FILE_LocationsListViewModel.kt]
// [END_FILE_LocationsListViewModel.kt]

View File

@@ -13,6 +13,7 @@ import com.homebox.lens.navigation.NavigationActions
import com.homebox.lens.ui.common.MainScaffold
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Composable-функция для экрана "Поиск".
@@ -22,16 +23,16 @@ import com.homebox.lens.ui.common.MainScaffold
@Composable
fun SearchScreen(
currentRoute: String?,
navigationActions: NavigationActions
navigationActions: NavigationActions,
) {
// [UI_COMPONENT]
MainScaffold(
topBarTitle = stringResource(id = R.string.search_title),
currentRoute = currentRoute,
navigationActions = navigationActions
navigationActions = navigationActions,
) {
// [CORE-LOGIC]
Text(text = "TODO: Search Screen")
}
// [END_FUNCTION_SearchScreen]
}
}

View File

@@ -9,8 +9,10 @@ import javax.inject.Inject
// [VIEWMODEL]
@HiltViewModel
class SearchViewModel @Inject constructor() : ViewModel() {
// [STATE]
// TODO: Implement UI state
}
class SearchViewModel
@Inject
constructor() : ViewModel() {
// [STATE]
// TODO: Implement UI state
}
// [END_FILE_SearchViewModel.kt]

View File

@@ -22,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.homebox.lens.R
// [ENTRYPOINT]
/**
* [CONTRACT]
* @summary Главная Composable-функция для экрана настройки соединения с сервером.
@@ -32,7 +33,7 @@ import com.homebox.lens.R
@Composable
fun SetupScreen(
viewModel: SetupViewModel = hiltViewModel(),
onSetupComplete: () -> Unit
onSetupComplete: () -> Unit,
) {
// [STATE]
val uiState by viewModel.uiState.collectAsState()
@@ -48,12 +49,13 @@ fun SetupScreen(
onServerUrlChange = viewModel::onServerUrlChange,
onUsernameChange = viewModel::onUsernameChange,
onPasswordChange = viewModel::onPasswordChange,
onConnectClick = viewModel::connect
onConnectClick = viewModel::connect,
)
// [END_FUNCTION_SetupScreen]
}
// [HELPER]
/**
* [CONTRACT]
* @summary Отображает контент экрана настройки: поля ввода и кнопку.
@@ -69,33 +71,34 @@ private fun SetupScreenContent(
onServerUrlChange: (String) -> Unit,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onConnectClick: () -> Unit
onConnectClick: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(id = R.string.setup_title)) })
}
},
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
) {
OutlinedTextField(
value = uiState.serverUrl,
onValueChange = onServerUrlChange,
label = { Text(stringResource(id = R.string.setup_server_url_label)) },
modifier = Modifier.fillMaxWidth()
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()
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
@@ -103,13 +106,13 @@ private fun SetupScreenContent(
onValueChange = onPasswordChange,
label = { Text(stringResource(id = R.string.setup_password_label)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onConnectClick,
enabled = !uiState.isLoading,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
@@ -135,7 +138,7 @@ fun SetupScreenPreview() {
onServerUrlChange = {},
onUsernameChange = {},
onPasswordChange = {},
onConnectClick = {}
onConnectClick = {},
)
}
// [END_FILE_SetupScreen.kt]

View File

@@ -22,6 +22,6 @@ data class SetupUiState(
val password: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val isSetupComplete: Boolean = false
val isSetupComplete: Boolean = false,
)
// [END_FILE_SetupUiState.kt]
// [END_FILE_SetupUiState.kt]

View File

@@ -8,7 +8,6 @@ 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 com.homebox.lens.ui.screen.setup.SetupUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -18,6 +17,7 @@ import javax.inject.Inject
// [VIEWMODEL]
// [ENTITY: ViewModel('SetupViewModel')]
/**
* [CONTRACT]
* ViewModel для экрана первоначальной настройки (Setup).
@@ -30,114 +30,116 @@ import javax.inject.Inject
* @invariant Состояние `uiState` всегда является единственным источником истины для UI.
*/
@HiltViewModel
class SetupViewModel @Inject constructor(
private val credentialsRepository: CredentialsRepository,
private val loginUseCase: LoginUseCase
) : ViewModel() {
class SetupViewModel
@Inject
constructor(
private val credentialsRepository: CredentialsRepository,
private val loginUseCase: LoginUseCase,
) : ViewModel() {
// [STATE]
private val _uiState = MutableStateFlow(SetupUiState())
val uiState = _uiState.asStateFlow()
// [STATE]
private val _uiState = MutableStateFlow(SetupUiState())
val uiState = _uiState.asStateFlow()
// [LIFECYCLE_HANDLER]
init {
// [ACTION] Загружаем учетные данные при создании ViewModel.
loadCredentials()
}
// [LIFECYCLE_HANDLER]
init {
// [ACTION] Загружаем учетные данные при создании ViewModel.
loadCredentials()
}
/**
* [CONTRACT]
* [HELPER] Загружает учетные данные из репозитория при инициализации.
* @sideeffect Асинхронно обновляет `_uiState` полученными учетными данными.
*/
private fun loadCredentials() {
// [ENTRYPOINT]
viewModelScope.launch {
// [CORE-LOGIC] Подписываемся на поток учетных данных.
credentialsRepository.getCredentials().collect { credentials ->
// [ACTION] Обновляем состояние, если учетные данные существуют.
if (credentials != null) {
_uiState.update {
it.copy(
serverUrl = credentials.serverUrl,
username = credentials.username,
password = credentials.password
)
/**
* [CONTRACT]
* [HELPER] Загружает учетные данные из репозитория при инициализации.
* @sideeffect Асинхронно обновляет `_uiState` полученными учетными данными.
*/
private fun loadCredentials() {
// [ENTRYPOINT]
viewModelScope.launch {
// [CORE-LOGIC] Подписываемся на поток учетных данных.
credentialsRepository.getCredentials().collect { credentials ->
// [ACTION] Обновляем состояние, если учетные данные существуют.
if (credentials != null) {
_uiState.update {
it.copy(
serverUrl = credentials.serverUrl,
username = credentials.username,
password = credentials.password,
)
}
}
}
}
}
}
/**
* [CONTRACT]
* [ACTION] Обновляет URL сервера в состоянии UI в ответ на ввод пользователя.
* @param newUrl Новое значение URL.
* @sideeffect Обновляет поле `serverUrl` в `_uiState`.
*/
fun onServerUrlChange(newUrl: String) {
_uiState.update { it.copy(serverUrl = newUrl) }
}
/**
* [CONTRACT]
* [ACTION] Обновляет имя пользователя в состоянии UI в ответ на ввод пользователя.
* @param newUsername Новое значение имени пользователя.
* @sideeffect Обновляет поле `username` в `_uiState`.
*/
fun onUsernameChange(newUsername: String) {
_uiState.update { it.copy(username = newUsername) }
}
/**
* [CONTRACT]
* [ACTION] Обновляет пароль в состоянии UI в ответ на ввод пользователя.
* @param newPassword Новое значение пароля.
* @sideeffect Обновляет поле `password` в `_uiState`.
*/
fun onPasswordChange(newPassword: String) {
_uiState.update { it.copy(password = newPassword) }
}
/**
* [CONTRACT]
* [ACTION] Запускает процесс подключения и входа в систему по действию пользователя.
* Выполняет две основные операции:
* 1. Сохраняет введенные учетные данные для последующих сессий.
* 2. Выполняет вход в систему с использованием этих данных.
* @sideeffect Обновляет `_uiState`, управляя флагами `isLoading`, `error` и `isSetupComplete`.
* @sideeffect Вызывает `credentialsRepository.saveCredentials`, изменяя сохраненные данные.
* @sideeffect Вызывает `loginUseCase`, который, в свою очередь, сохраняет токен.
*/
fun connect() {
// [ENTRYPOINT]
viewModelScope.launch {
// [ACTION] Устанавливаем состояние загрузки и сбрасываем предыдущую ошибку.
_uiState.update { it.copy(isLoading = true, error = null) }
// [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов.
val credentials = Credentials(
serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(),
password = _uiState.value.password
)
// [ACTION] Сохраняем учетные данные для будущего использования.
credentialsRepository.saveCredentials(credentials)
// [CORE-LOGIC] Выполняем UseCase и обрабатываем результат.
loginUseCase(credentials).fold(
onSuccess = {
// [ACTION] Обработка успеха: обновляем UI, отмечая завершение настройки.
_uiState.update { it.copy(isLoading = false, isSetupComplete = true) }
},
onFailure = { exception ->
// [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке.
_uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") }
}
)
/**
* [CONTRACT]
* [ACTION] Обновляет URL сервера в состоянии UI в ответ на ввод пользователя.
* @param newUrl Новое значение URL.
* @sideeffect Обновляет поле `serverUrl` в `_uiState`.
*/
fun onServerUrlChange(newUrl: String) {
_uiState.update { it.copy(serverUrl = newUrl) }
}
/**
* [CONTRACT]
* [ACTION] Обновляет имя пользователя в состоянии UI в ответ на ввод пользователя.
* @param newUsername Новое значение имени пользователя.
* @sideeffect Обновляет поле `username` в `_uiState`.
*/
fun onUsernameChange(newUsername: String) {
_uiState.update { it.copy(username = newUsername) }
}
/**
* [CONTRACT]
* [ACTION] Обновляет пароль в состоянии UI в ответ на ввод пользователя.
* @param newPassword Новое значение пароля.
* @sideeffect Обновляет поле `password` в `_uiState`.
*/
fun onPasswordChange(newPassword: String) {
_uiState.update { it.copy(password = newPassword) }
}
/**
* [CONTRACT]
* [ACTION] Запускает процесс подключения и входа в систему по действию пользователя.
* Выполняет две основные операции:
* 1. Сохраняет введенные учетные данные для последующих сессий.
* 2. Выполняет вход в систему с использованием этих данных.
* @sideeffect Обновляет `_uiState`, управляя флагами `isLoading`, `error` и `isSetupComplete`.
* @sideeffect Вызывает `credentialsRepository.saveCredentials`, изменяя сохраненные данные.
* @sideeffect Вызывает `loginUseCase`, который, в свою очередь, сохраняет токен.
*/
fun connect() {
// [ENTRYPOINT]
viewModelScope.launch {
// [ACTION] Устанавливаем состояние загрузки и сбрасываем предыдущую ошибку.
_uiState.update { it.copy(isLoading = true, error = null) }
// [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов.
val credentials =
Credentials(
serverUrl = _uiState.value.serverUrl.trim(),
username = _uiState.value.username.trim(),
password = _uiState.value.password,
)
// [ACTION] Сохраняем учетные данные для будущего использования.
credentialsRepository.saveCredentials(credentials)
// [CORE-LOGIC] Выполняем UseCase и обрабатываем результат.
loginUseCase(credentials).fold(
onSuccess = {
// [ACTION] Обработка успеха: обновляем UI, отмечая завершение настройки.
_uiState.update { it.copy(isLoading = false, isSetupComplete = true) }
},
onFailure = { exception ->
// [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке.
_uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") }
},
)
}
}
// [END_CLASS_SetupViewModel]
}
// [END_CLASS_SetupViewModel]
}
// [END_FILE_SetupViewModel.kt]
// [END_FILE_SetupViewModel.kt]

View File

@@ -18,34 +18,37 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val DarkColorScheme =
darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
)
private val LightColorScheme =
lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
)
@Composable
fun HomeboxLensTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
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)
}
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
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
@@ -58,7 +61,7 @@ fun HomeboxLensTheme(
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
content = content,
)
}
// [END_FILE_Theme.kt]

View File

@@ -10,14 +10,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
val Typography =
Typography(
bodyLarge =
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
)
)
// [END_FILE_Typography.kt]