+linter
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user