Navigation refactor
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
// [PACKAGE] com.homebox.lens.navigation
|
||||
// [FILE] NavGraph.kt
|
||||
// [SEMANTICS] navigation, compose, nav_host
|
||||
|
||||
package com.homebox.lens.navigation
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.homebox.lens.ui.screen.dashboard.DashboardScreen
|
||||
import com.homebox.lens.ui.screen.inventorylist.InventoryListScreen
|
||||
@@ -19,15 +25,31 @@ import com.homebox.lens.ui.screen.setup.SetupScreen
|
||||
// [CORE-LOGIC]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Определяет граф навигации для приложения.
|
||||
* Определяет граф навигации для всего приложения с использованием Jetpack Compose Navigation.
|
||||
* @param navController Контроллер навигации.
|
||||
* @see Screen
|
||||
* @sideeffect Регистрирует все экраны и управляет состоянием навигации.
|
||||
* @invariant Стартовый экран - `Screen.Setup`.
|
||||
*/
|
||||
@Composable
|
||||
fun NavGraph() {
|
||||
val navController = rememberNavController()
|
||||
fun NavGraph(
|
||||
navController: NavHostController = rememberNavController()
|
||||
) {
|
||||
// [STATE]
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
// [HELPER]
|
||||
val navigationActions = remember(navController) {
|
||||
NavigationActions(navController)
|
||||
}
|
||||
|
||||
// [ACTION]
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Setup.route
|
||||
) {
|
||||
// [COMPOSABLE_SETUP]
|
||||
composable(route = Screen.Setup.route) {
|
||||
SetupScreen(onSetupComplete = {
|
||||
navController.navigate(Screen.Dashboard.route) {
|
||||
@@ -35,30 +57,39 @@ fun NavGraph() {
|
||||
}
|
||||
})
|
||||
}
|
||||
// [COMPOSABLE_DASHBOARD]
|
||||
composable(route = Screen.Dashboard.route) {
|
||||
DashboardScreen(
|
||||
onNavigateToLocations = { navController.navigate(Screen.LocationsList.route) },
|
||||
onNavigateToSearch = { navController.navigate(Screen.Search.route) },
|
||||
onNavigateToCreateItem = { navController.navigate(Screen.ItemEdit.createRoute("new")) },
|
||||
onLogout = {
|
||||
navController.navigate(Screen.Setup.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
)
|
||||
}
|
||||
// [COMPOSABLE_INVENTORY_LIST]
|
||||
composable(route = Screen.InventoryList.route) {
|
||||
InventoryListScreen()
|
||||
InventoryListScreen(
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
)
|
||||
}
|
||||
// [COMPOSABLE_ITEM_DETAILS]
|
||||
composable(route = Screen.ItemDetails.route) {
|
||||
ItemDetailsScreen()
|
||||
ItemDetailsScreen(
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
)
|
||||
}
|
||||
// [COMPOSABLE_ITEM_EDIT]
|
||||
composable(route = Screen.ItemEdit.route) {
|
||||
ItemEditScreen()
|
||||
ItemEditScreen(
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
)
|
||||
}
|
||||
// [COMPOSABLE_LABELS_LIST]
|
||||
composable(route = Screen.LabelsList.route) {
|
||||
LabelsListScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions,
|
||||
onLabelClick = { labelId ->
|
||||
// TODO: Navigate to a pre-filtered inventory list screen
|
||||
navController.navigate(Screen.InventoryList.route)
|
||||
@@ -68,9 +99,11 @@ fun NavGraph() {
|
||||
}
|
||||
)
|
||||
}
|
||||
// [COMPOSABLE_LOCATIONS_LIST]
|
||||
composable(route = Screen.LocationsList.route) {
|
||||
LocationsListScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions,
|
||||
onLocationClick = { locationId ->
|
||||
// TODO: Navigate to a pre-filtered inventory list screen
|
||||
navController.navigate(Screen.InventoryList.route)
|
||||
@@ -80,9 +113,14 @@ fun NavGraph() {
|
||||
}
|
||||
)
|
||||
}
|
||||
// [COMPOSABLE_SEARCH]
|
||||
composable(route = Screen.Search.route) {
|
||||
SearchScreen()
|
||||
SearchScreen(
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
)
|
||||
}
|
||||
}
|
||||
// [END_FUNCTION_NavGraph]
|
||||
}
|
||||
// [END_FILE_NavGraph.kt]
|
||||
// [END_FILE_NavGraph.kt]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// [PACKAGE] com.homebox.lens.navigation
|
||||
// [FILE] NavigationActions.kt
|
||||
// [SEMANTICS] navigation, controller, actions
|
||||
|
||||
package com.homebox.lens.navigation
|
||||
|
||||
import androidx.navigation.NavHostController
|
||||
|
||||
// [CORE-LOGIC]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Класс-обертка над NavHostController для предоставления типизированных навигационных действий.
|
||||
* @param navController Контроллер Jetpack Navigation.
|
||||
* @invariant Все навигационные действия должны использовать предоставленный navController.
|
||||
*/
|
||||
class NavigationActions(private val navController: NavHostController) {
|
||||
|
||||
// [ACTION]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Навигация на главный экран.
|
||||
* @sideeffect Очищает back stack до главного экрана, чтобы избежать циклов.
|
||||
*/
|
||||
fun navigateToDashboard() {
|
||||
navController.navigate(Screen.Dashboard.route) {
|
||||
// Используем popUpTo для удаления всех экранов до dashboard из back stack
|
||||
// Это предотвращает создание большой стопки экранов при навигации через drawer
|
||||
popUpTo(navController.graph.startDestinationId)
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
// [ACTION]
|
||||
fun navigateToLocations() {
|
||||
navController.navigate(Screen.LocationsList.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
// [ACTION]
|
||||
fun navigateToSearch() {
|
||||
navController.navigate(Screen.Search.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
|
||||
// [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]
|
||||
@@ -1,9 +1,10 @@
|
||||
// [PACKAGE] com.homebox.lens.navigation
|
||||
// [FILE] app/src/main/java/com/homebox/lens/navigation/Screen.kt
|
||||
// [FILE] Screen.kt
|
||||
// [SEMANTICS] navigation, routes, sealed_class
|
||||
|
||||
package com.homebox.lens.navigation
|
||||
|
||||
// [CORE-LOGIC]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Запечатанный класс для определения маршрутов навигации в приложении.
|
||||
@@ -11,17 +12,50 @@ package com.homebox.lens.navigation
|
||||
* @property route Строковый идентификатор маршрута.
|
||||
*/
|
||||
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")
|
||||
data object ItemDetails : Screen("item_details_screen/{itemId}") {
|
||||
fun createRoute(itemId: String) = "item_details_screen/$itemId"
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Создает маршрут для экрана деталей элемента с указанным ID.
|
||||
* @param itemId ID элемента для отображения.
|
||||
* @return Строку полного маршрута.
|
||||
* @throws IllegalArgumentException если itemId пустой.
|
||||
*/
|
||||
// [HELPER]
|
||||
fun createRoute(itemId: String): String {
|
||||
// [PRECONDITION]
|
||||
require(itemId.isNotBlank()) { "[PRECONDITION_FAILED] itemId не может быть пустым." }
|
||||
// [ACTION]
|
||||
val route = "item_details_screen/$itemId"
|
||||
// [POSTCONDITION]
|
||||
check(route.endsWith(itemId)) { "[POSTCONDITION_FAILED] Маршрут должен заканчиваться на itemId." }
|
||||
return route
|
||||
}
|
||||
}
|
||||
data object ItemEdit : Screen("item_edit_screen/{itemId}") {
|
||||
fun createRoute(itemId: String) = "item_edit_screen/$itemId"
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* Создает маршрут для экрана редактирования элемента с указанным ID.
|
||||
* @param itemId ID элемента для редактирования.
|
||||
* @return Строку полного маршрута.
|
||||
* @throws IllegalArgumentException если itemId пустой.
|
||||
*/
|
||||
// [HELPER]
|
||||
fun createRoute(itemId: String): String {
|
||||
// [PRECONDITION]
|
||||
require(itemId.isNotBlank()) { "[PRECONDITION_FAILED] itemId не может быть пустым." }
|
||||
// [ACTION]
|
||||
val route = "item_edit_screen/$itemId"
|
||||
// [POSTCONDITION]
|
||||
check(route.endsWith(itemId)) { "[POSTCONDITION_FAILED] Маршрут должен заканчиваться на itemId." }
|
||||
return route
|
||||
}
|
||||
}
|
||||
data object LabelsList : Screen("labels_list_screen")
|
||||
data object LocationsList : Screen("locations_list_screen")
|
||||
data object Search : Screen("search_screen")
|
||||
}
|
||||
// [END_FILE_Screen.kt]
|
||||
// [END_FILE_Screen.kt]
|
||||
|
||||
92
app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt
Normal file
92
app/src/main/java/com/homebox/lens/ui/common/AppDrawer.kt
Normal file
@@ -0,0 +1,92 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.common
|
||||
// [FILE] AppDrawer.kt
|
||||
package com.homebox.lens.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.navigation.Screen
|
||||
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Контент для бокового навигационного меню (Drawer).
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @param onCloseDrawer Лямбда для закрытия бокового меню.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AppDrawerContent(
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions,
|
||||
onCloseDrawer: () -> Unit
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
navigationActions.navigateToCreateItem()
|
||||
onCloseDrawer()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(id = R.string.create))
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Divider()
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.dashboard_title)) },
|
||||
selected = currentRoute == Screen.Dashboard.route,
|
||||
onClick = {
|
||||
navigationActions.navigateToDashboard()
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.nav_locations)) },
|
||||
selected = currentRoute == Screen.LocationsList.route,
|
||||
onClick = {
|
||||
navigationActions.navigateToLocations()
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.search)) },
|
||||
selected = currentRoute == Screen.Search.route,
|
||||
onClick = {
|
||||
navigationActions.navigateToSearch()
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
// TODO: Add Profile and Tools items
|
||||
Divider()
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.logout)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
navigationActions.navigateToLogout()
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
77
app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt
Normal file
77
app/src/main/java/com/homebox/lens/ui/common/MainScaffold.kt
Normal file
@@ -0,0 +1,77 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.common
|
||||
// [FILE] MainScaffold.kt
|
||||
// [SEMANTICS] ui, common, scaffold, navigation_drawer
|
||||
|
||||
package com.homebox.lens.ui.common
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Общая обертка для экранов, включающая Scaffold и Navigation Drawer.
|
||||
* @param topBarTitle Заголовок для TopAppBar.
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @param topBarActions Composable-функция для отображения действий (иконок) в TopAppBar.
|
||||
* @param content Основное содержимое экрана, которое будет отображено внутри Scaffold.
|
||||
* @sideeffect Управляет состоянием (открыто/закрыто) бокового меню (ModalNavigationDrawer).
|
||||
* @invariant TopAppBar всегда отображается с иконкой меню.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScaffold(
|
||||
topBarTitle: String,
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions,
|
||||
topBarActions: @Composable () -> Unit = {},
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
// [STATE]
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// [CORE-LOGIC]
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
AppDrawerContent(
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions,
|
||||
onCloseDrawer = { scope.launch { drawerState.close() } }
|
||||
)
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(topBarTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(
|
||||
Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.cd_open_navigation_drawer)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = { topBarActions() }
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
// [ACTION]
|
||||
content(paddingValues)
|
||||
}
|
||||
}
|
||||
// [END_FUNCTION_MainScaffold]
|
||||
}
|
||||
// [END_FILE_MainScaffold.kt]
|
||||
@@ -1,8 +1,10 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.dashboard
|
||||
// [FILE] DashboardScreen.kt
|
||||
// [SEMANTICS] ui, screen, dashboard, compose
|
||||
|
||||
package com.homebox.lens.ui.screen.dashboard
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -11,14 +13,11 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -28,67 +27,61 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.domain.model.*
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// [ANCHOR] Главная точка входа для экрана Dashboard
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Главная Composable-функция для экрана "Панель управления".
|
||||
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @sideeffect Вызывает навигационные лямбды при взаимодействии с UI.
|
||||
*/
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
viewModel: DashboardViewModel = hiltViewModel(),
|
||||
onNavigateToLocations: () -> Unit,
|
||||
onNavigateToSearch: () -> Unit,
|
||||
onNavigateToCreateItem: () -> Unit,
|
||||
onLogout: () -> Unit
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions
|
||||
) {
|
||||
// [ACTION] Собираем состояние из ViewModel
|
||||
// [STATE]
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// [ANCHOR] Определяем навигационное меню
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
DrawerContent(
|
||||
onNavigateToLocations = onNavigateToLocations,
|
||||
onNavigateToSearch = onNavigateToSearch,
|
||||
onNavigateToCreateItem = onNavigateToCreateItem,
|
||||
onLogout = onLogout,
|
||||
onCloseDrawer = { scope.launch { drawerState.close() } }
|
||||
)
|
||||
}
|
||||
) {
|
||||
// [ANCHOR] Основной Scaffold экрана
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(id = R.string.dashboard_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Default.Menu, contentDescription = stringResource(id = R.string.cd_open_navigation_drawer))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /* TODO: Handle scanner click */ }) {
|
||||
Icon(Icons.Default.Search, contentDescription = stringResource(id = R.string.cd_scan_qr_code))
|
||||
}
|
||||
}
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.dashboard_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions,
|
||||
topBarActions = {
|
||||
IconButton(onClick = { /* TODO: Handle scanner click */ }) {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = stringResource(id = R.string.cd_scan_qr_code)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
// [ANCHOR] Основной контент экрана
|
||||
DashboardContent(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
uiState = uiState,
|
||||
onLocationClick = { /* TODO */ },
|
||||
onLabelClick = { /* TODO */ }
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
DashboardContent(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
uiState = uiState,
|
||||
onLocationClick = { /* TODO */ },
|
||||
onLabelClick = { /* TODO */ }
|
||||
)
|
||||
}
|
||||
// [END_FUNCTION_DashboardScreen]
|
||||
}
|
||||
|
||||
// [ANCHOR] Компонент основного контента
|
||||
// [HELPER]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Отображает основной контент экрана в зависимости от `uiState`.
|
||||
* @param modifier Модификатор для стилизации.
|
||||
* @param uiState Текущее состояние UI экрана.
|
||||
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
|
||||
* @param onLabelClick Лямбда-обработчик нажатия на метку.
|
||||
*/
|
||||
@Composable
|
||||
private fun DashboardContent(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -96,7 +89,7 @@ private fun DashboardContent(
|
||||
onLocationClick: (LocationOutCount) -> Unit,
|
||||
onLabelClick: (LabelOut) -> Unit
|
||||
) {
|
||||
// [FIX] Based on the UiState, we decide what to show
|
||||
// [CORE-LOGIC]
|
||||
when (uiState) {
|
||||
is DashboardUiState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -120,35 +113,23 @@ private fun DashboardContent(
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
|
||||
// [ANCHOR] Секция "Быстрая статистика"
|
||||
item {
|
||||
StatisticsSection(statistics = uiState.statistics)
|
||||
}
|
||||
|
||||
// [ANCHOR] Секция "Недавно добавлено"
|
||||
item {
|
||||
// TODO: Add recently added items to UiState and display them here
|
||||
// RecentlyAddedSection(items = uiState.recentlyAddedItems)
|
||||
}
|
||||
|
||||
// [ANCHOR] Секция "Места хранения"
|
||||
item {
|
||||
LocationsSection(locations = uiState.locations, onLocationClick = onLocationClick)
|
||||
}
|
||||
|
||||
// [ANCHOR] Секция "Метки"
|
||||
item {
|
||||
LabelsSection(labels = uiState.labels, onLabelClick = onLabelClick)
|
||||
}
|
||||
|
||||
item { StatisticsSection(statistics = uiState.statistics) }
|
||||
item { RecentlyAddedSection(items = uiState.recentlyAddedItems) }
|
||||
item { LocationsSection(locations = uiState.locations, onLocationClick = onLocationClick) }
|
||||
item { LabelsSection(labels = uiState.labels, onLabelClick = onLabelClick) }
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_FUNCTION_DashboardContent]
|
||||
}
|
||||
|
||||
// [ANCHOR] Секция статистики
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Секция для отображения общей статистики.
|
||||
* @param statistics Объект со статистическими данными.
|
||||
*/
|
||||
@Composable
|
||||
private fun StatisticsSection(statistics: GroupStatistics) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -159,7 +140,10 @@ private fun StatisticsSection(statistics: GroupStatistics) {
|
||||
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)
|
||||
) {
|
||||
@@ -172,6 +156,13 @@ private fun StatisticsSection(statistics: GroupStatistics) {
|
||||
}
|
||||
}
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Карточка для отображения одного статистического показателя.
|
||||
* @param title Название показателя.
|
||||
* @param value Значение показателя.
|
||||
*/
|
||||
@Composable
|
||||
private fun StatisticCard(title: String, value: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
@@ -180,7 +171,12 @@ private fun StatisticCard(title: String, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
// [ANCHOR] Секция недавно добавленных
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Секция для отображения недавно добавленных элементов.
|
||||
* @param items Список элементов для отображения.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecentlyAddedSection(items: List<ItemSummary>) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -207,12 +203,21 @@ private fun RecentlyAddedSection(items: List<ItemSummary>) {
|
||||
}
|
||||
}
|
||||
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Карточка для отображения краткой информации об элементе.
|
||||
* @param item Элемент для отображения.
|
||||
*/
|
||||
@Composable
|
||||
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)
|
||||
@@ -221,7 +226,13 @@ private fun ItemCard(item: ItemSummary) {
|
||||
}
|
||||
|
||||
|
||||
// [ANCHOR] Секция местоположений
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Секция для отображения местоположений в виде чипсов.
|
||||
* @param locations Список местоположений.
|
||||
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick: (LocationOutCount) -> Unit) {
|
||||
@@ -243,7 +254,13 @@ private fun LocationsSection(locations: List<LocationOutCount>, onLocationClick:
|
||||
}
|
||||
}
|
||||
|
||||
// [ANCHOR] Секция меток
|
||||
// [UI_COMPONENT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Секция для отображения меток в виде чипсов.
|
||||
* @param labels Список меток.
|
||||
* @param onLabelClick Лямбда-обработчик нажатия на метку.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Unit) {
|
||||
@@ -266,67 +283,7 @@ private fun LabelsSection(labels: List<LabelOut>, onLabelClick: (LabelOut) -> Un
|
||||
}
|
||||
|
||||
|
||||
// [ANCHOR] Контент бокового меню
|
||||
@Composable
|
||||
private fun DrawerContent(
|
||||
onNavigateToLocations: () -> Unit,
|
||||
onNavigateToSearch: () -> Unit,
|
||||
onNavigateToCreateItem: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onCloseDrawer: () -> Unit
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
onNavigateToCreateItem()
|
||||
onCloseDrawer()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(id = R.string.create))
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Divider()
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.dashboard_title)) },
|
||||
selected = true,
|
||||
onClick = { onCloseDrawer() }
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.nav_locations)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
onNavigateToLocations()
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.search)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
onNavigateToSearch()
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
// TODO: Add Profile and Tools items
|
||||
Divider()
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(id = R.string.logout)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
onLogout()
|
||||
onCloseDrawer()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// [ANCHOR] Preview для DashboardContent
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Dashboard Success State")
|
||||
@Composable
|
||||
fun DashboardContentSuccessPreview() {
|
||||
@@ -349,7 +306,8 @@ fun DashboardContentSuccessPreview() {
|
||||
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(
|
||||
@@ -360,6 +318,7 @@ fun DashboardContentSuccessPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Dashboard Loading State")
|
||||
@Composable
|
||||
fun DashboardContentLoadingPreview() {
|
||||
@@ -372,6 +331,7 @@ fun DashboardContentLoadingPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Dashboard Error State")
|
||||
@Composable
|
||||
fun DashboardContentErrorPreview() {
|
||||
@@ -383,3 +343,4 @@ fun DashboardContentErrorPreview() {
|
||||
)
|
||||
}
|
||||
}
|
||||
// [END_FILE_DashboardScreen.kt]
|
||||
|
||||
@@ -23,11 +23,13 @@ sealed interface DashboardUiState {
|
||||
* @property statistics Статистика по инвентарю.
|
||||
* @property locations Список локаций со счетчиками.
|
||||
* @property labels Список всех меток.
|
||||
* @property recentlyAddedItems Список недавно добавленных товаров.
|
||||
*/
|
||||
data class Success(
|
||||
val statistics: GroupStatistics,
|
||||
val locations: List<LocationOutCount>,
|
||||
val labels: List<LabelOut>
|
||||
val labels: List<LabelOut>,
|
||||
val recentlyAddedItems: List<com.homebox.lens.domain.model.ItemSummary>
|
||||
) : DashboardUiState
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,13 +7,13 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.homebox.lens.domain.usecase.GetAllLabelsUseCase
|
||||
import com.homebox.lens.domain.usecase.GetAllLocationsUseCase
|
||||
import com.homebox.lens.domain.usecase.GetRecentlyAddedItemsUseCase
|
||||
import com.homebox.lens.domain.usecase.GetStatisticsUseCase
|
||||
import 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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -31,7 +31,8 @@ import javax.inject.Inject
|
||||
class DashboardViewModel @Inject constructor(
|
||||
private val getStatisticsUseCase: GetStatisticsUseCase,
|
||||
private val getAllLocationsUseCase: GetAllLocationsUseCase,
|
||||
private val getAllLabelsUseCase: GetAllLabelsUseCase
|
||||
private val getAllLabelsUseCase: GetAllLabelsUseCase,
|
||||
private val getRecentlyAddedItemsUseCase: GetRecentlyAddedItemsUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
// [STATE]
|
||||
@@ -57,47 +58,29 @@ class DashboardViewModel @Inject constructor(
|
||||
// [ENTRYPOINT]
|
||||
viewModelScope.launch {
|
||||
_uiState.value = DashboardUiState.Loading
|
||||
// [FIX] Используем Timber для логирования.
|
||||
Timber.i("[ACTION] Starting parallel dashboard data load. State -> Loading.")
|
||||
Timber.i("[ACTION] Starting dashboard data collection.")
|
||||
|
||||
// [CORE-LOGIC: PARALLEL_FETCH]
|
||||
val result = runCatching {
|
||||
coroutineScope {
|
||||
val statsDeferred = async { getStatisticsUseCase() }
|
||||
val locationsDeferred = async { getAllLocationsUseCase() }
|
||||
val labelsDeferred = async { getAllLabelsUseCase() }
|
||||
val statsFlow = flow { emit(getStatisticsUseCase()) }
|
||||
val locationsFlow = flow { emit(getAllLocationsUseCase()) }
|
||||
val labelsFlow = flow { emit(getAllLabelsUseCase()) }
|
||||
val recentItemsFlow = getRecentlyAddedItemsUseCase(limit = 10)
|
||||
|
||||
val stats = statsDeferred.await()
|
||||
val locations = locationsDeferred.await()
|
||||
val labels = labelsDeferred.await()
|
||||
|
||||
// [POSTCONDITION_CHECK]
|
||||
check(stats != null && locations != null && labels != null) {
|
||||
"[POSTCONDITION_FAILED] One or more dashboard data sources returned null."
|
||||
}
|
||||
Triple(stats, locations, labels)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// [RESULT_HANDLER]
|
||||
result.fold(
|
||||
onSuccess = { (stats, locations, labels) ->
|
||||
// [FIX] Используем Timber для логирования.
|
||||
Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.")
|
||||
_uiState.value = DashboardUiState.Success(
|
||||
statistics = stats,
|
||||
locations = locations,
|
||||
labels = labels
|
||||
)
|
||||
},
|
||||
onFailure = { exception ->
|
||||
// [FIX] Используем Timber для логирования ошибок с передачей исключения.
|
||||
Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.")
|
||||
_uiState.value = DashboardUiState.Error(
|
||||
message = exception.message ?: "Could not load dashboard data."
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// [END_CLASS_DashboardViewModel]
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.inventorylist
|
||||
// [FILE] InventoryListScreen.kt
|
||||
// [SEMANTICS] ui, screen, inventory, list
|
||||
|
||||
package com.homebox.lens.ui.screen.inventorylist
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Composable-функция для экрана "Список инвентаря".
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
*/
|
||||
@Composable
|
||||
fun InventoryListScreen() {
|
||||
// [ACTION]
|
||||
Text(text = "Inventory List Screen")
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun InventoryListScreenPreview() {
|
||||
InventoryListScreen()
|
||||
}
|
||||
// [END_FILE_InventoryListScreen.kt]
|
||||
fun InventoryListScreen(
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions
|
||||
) {
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.inventory_list_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
) {
|
||||
// [CORE-LOGIC]
|
||||
Text(text = "TODO: Inventory List Screen")
|
||||
}
|
||||
// [END_FUNCTION_InventoryListScreen]
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.itemdetails
|
||||
// [FILE] ItemDetailsScreen.kt
|
||||
// [SEMANTICS] ui, screen, item, details
|
||||
|
||||
package com.homebox.lens.ui.screen.itemdetails
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Composable-функция для экрана "Детали элемента".
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
*/
|
||||
@Composable
|
||||
fun ItemDetailsScreen() {
|
||||
// [ACTION]
|
||||
Text(text = "Item Details Screen")
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ItemDetailsScreenPreview() {
|
||||
ItemDetailsScreen()
|
||||
}
|
||||
// [END_FILE_ItemDetailsScreen.kt]
|
||||
fun ItemDetailsScreen(
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions
|
||||
) {
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.item_details_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
) {
|
||||
// [CORE-LOGIC]
|
||||
Text(text = "TODO: Item Details Screen")
|
||||
}
|
||||
// [END_FUNCTION_ItemDetailsScreen]
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.itemedit
|
||||
// [FILE] ItemEditScreen.kt
|
||||
// [SEMANTICS] ui, screen, item, edit
|
||||
|
||||
package com.homebox.lens.ui.screen.itemedit
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Composable-функция для экрана "Редактирование элемента".
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
*/
|
||||
@Composable
|
||||
fun ItemEditScreen() {
|
||||
// [ACTION]
|
||||
Text(text = "Item Edit Screen")
|
||||
fun ItemEditScreen(
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions
|
||||
) {
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.item_edit_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
) {
|
||||
// [CORE-LOGIC]
|
||||
Text(text = "TODO: Item Edit Screen")
|
||||
}
|
||||
// [END_FUNCTION_ItemEditScreen]
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ItemEditScreenPreview() {
|
||||
ItemEditScreen()
|
||||
}
|
||||
// [END_FILE_ItemEditScreen.kt]
|
||||
|
||||
@@ -1,172 +1,41 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.labelslist
|
||||
// [FILE] LabelsListScreen.kt
|
||||
// [SEMANTICS] ui, screen, labels_list, compose
|
||||
// [SEMANTICS] ui, screen, labels, list
|
||||
|
||||
package com.homebox.lens.ui.screen.labelslist
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
// [IMPORTS]
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.domain.model.LabelOut
|
||||
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Главная Composable-функция для экрана списка меток.
|
||||
* @param onNavigateBack Функция для навигации на предыдущий экран.
|
||||
* @param onLabelClick Функция, вызываемая при нажатии на метку.
|
||||
* @param onAddNewLabelClick Функция, вызываемая при нажатии на FAB для добавления новой метки.
|
||||
* @summary Composable-функция для экрана "Список меток".
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @param onLabelClick Лямбда-обработчик нажатия на метку.
|
||||
* @param onAddNewLabelClick Лямбда-обработчик нажатия на кнопку добавления новой метки.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelsListScreen(
|
||||
viewModel: LabelsListViewModel = hiltViewModel(),
|
||||
onNavigateBack: () -> Unit,
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions,
|
||||
onLabelClick: (String) -> Unit,
|
||||
onAddNewLabelClick: () -> Unit
|
||||
) {
|
||||
// [STATE]
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// [UI]
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(id = R.string.nav_labels)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.cd_navigate_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onAddNewLabelClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(id = R.string.cd_add_new_label)
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
// [ANCHOR] Основной контент в зависимости от состояния
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
when (val state = uiState) {
|
||||
is LabelsListUiState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
is LabelsListUiState.Error -> {
|
||||
Text(
|
||||
text = state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.align(Alignment.Center).padding(16.dp)
|
||||
)
|
||||
}
|
||||
is LabelsListUiState.Success -> {
|
||||
LabelsList(
|
||||
labels = state.labels,
|
||||
onLabelClick = onLabelClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [HELPER]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Отображает список меток.
|
||||
*/
|
||||
@Composable
|
||||
private fun LabelsList(
|
||||
labels: List<LabelOut>,
|
||||
onLabelClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.labels_list_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
) {
|
||||
items(labels) { label ->
|
||||
LabelListItem(
|
||||
label = label,
|
||||
onClick = { onLabelClick(label.id) }
|
||||
)
|
||||
}
|
||||
// [CORE-LOGIC]
|
||||
Text(text = "TODO: Labels List Screen")
|
||||
}
|
||||
}
|
||||
|
||||
// [HELPER]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Отображает один элемент списка меток.
|
||||
*/
|
||||
@Composable
|
||||
private fun LabelListItem(
|
||||
label: LabelOut,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label.name) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Labels List Success")
|
||||
@Composable
|
||||
private fun LabelsListScreenSuccessPreview() {
|
||||
val labels = listOf(
|
||||
LabelOut("1", "Electronics", "#FF0000", false, "", ""),
|
||||
LabelOut("2", "Books", "#00FF00", false, "", ""),
|
||||
LabelOut("3", "Winter Clothes", "#0000FF", false, "", "")
|
||||
)
|
||||
HomeboxLensTheme {
|
||||
LabelsList(labels = labels, onLabelClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Labels List Empty")
|
||||
@Composable
|
||||
private fun LabelsListScreenEmptyPreview() {
|
||||
HomeboxLensTheme {
|
||||
LabelsList(labels = emptyList(), onLabelClick = {})
|
||||
}
|
||||
}
|
||||
// [END_FILE_LabelsListScreen.kt]
|
||||
// [END_FUNCTION_LabelsListScreen]
|
||||
}
|
||||
@@ -1,175 +1,41 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.locationslist
|
||||
// [FILE] LocationsListScreen.kt
|
||||
// [SEMANTICS] ui, screen, locations_list, compose
|
||||
// [SEMANTICS] ui, screen, locations, list
|
||||
|
||||
package com.homebox.lens.ui.screen.locationslist
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material3.*
|
||||
// [IMPORTS]
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.domain.model.LocationOutCount
|
||||
import com.homebox.lens.ui.theme.HomeboxLensTheme
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Главная Composable-функция для экрана списка локаций.
|
||||
* @param onNavigateBack Функция для навигации на предыдущий экран.
|
||||
* @param onLocationClick Функция, вызываемая при нажатии на локацию.
|
||||
* @param onAddNewLocationClick Функция, вызываемая при нажатии на FAB для добавления новой локации.
|
||||
* @summary Composable-функция для экрана "Список местоположений".
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
* @param onLocationClick Лямбда-обработчик нажатия на местоположение.
|
||||
* @param onAddNewLocationClick Лямбда-обработчик нажатия на кнопку добавления нового местоположения.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LocationsListScreen(
|
||||
viewModel: LocationsListViewModel = hiltViewModel(),
|
||||
onNavigateBack: () -> Unit,
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions,
|
||||
onLocationClick: (String) -> Unit,
|
||||
onAddNewLocationClick: () -> Unit
|
||||
) {
|
||||
// [STATE]
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// [UI]
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(id = R.string.nav_locations)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.cd_navigate_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = onAddNewLocationClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(id = R.string.cd_add_new_location)
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
// [ANCHOR] Основной контент в зависимости от состояния
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
when (val state = uiState) {
|
||||
is LocationsListUiState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
is LocationsListUiState.Error -> {
|
||||
Text(
|
||||
text = state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.align(Alignment.Center).padding(16.dp)
|
||||
)
|
||||
}
|
||||
is LocationsListUiState.Success -> {
|
||||
LocationsList(
|
||||
locations = state.locations,
|
||||
onLocationClick = onLocationClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [HELPER]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Отображает список локаций.
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationsList(
|
||||
locations: List<LocationOutCount>,
|
||||
onLocationClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.locations_list_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
) {
|
||||
items(locations) { location ->
|
||||
LocationListItem(
|
||||
location = location,
|
||||
onClick = { onLocationClick(location.id) }
|
||||
)
|
||||
}
|
||||
// [CORE-LOGIC]
|
||||
Text(text = "TODO: Locations List Screen")
|
||||
}
|
||||
}
|
||||
|
||||
// [HELPER]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Отображает один элемент списка локаций.
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationListItem(
|
||||
location: LocationOutCount,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(location.name) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Place,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Text(text = location.itemCount.toString())
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Locations List Success")
|
||||
@Composable
|
||||
private fun LocationsListScreenSuccessPreview() {
|
||||
val locations = listOf(
|
||||
LocationOutCount("1", "Garage", "#FF0000", false, 12, "", ""),
|
||||
LocationOutCount("2", "Kitchen", "#00FF00", false, 3, "", ""),
|
||||
LocationOutCount("3", "Office", "#0000FF", false, 25, "", "")
|
||||
)
|
||||
HomeboxLensTheme {
|
||||
LocationsList(locations = locations, onLocationClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true, name = "Locations List Empty")
|
||||
@Composable
|
||||
private fun LocationsListScreenEmptyPreview() {
|
||||
HomeboxLensTheme {
|
||||
LocationsList(locations = emptyList(), onLocationClick = {})
|
||||
}
|
||||
}
|
||||
// [END_FILE_LocationsListScreen.kt]
|
||||
// [END_FUNCTION_LocationsListScreen]
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.search
|
||||
// [FILE] SearchScreen.kt
|
||||
// [SEMANTICS] ui, screen, search
|
||||
|
||||
package com.homebox.lens.ui.screen.search
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.homebox.lens.R
|
||||
import com.homebox.lens.navigation.NavigationActions
|
||||
import com.homebox.lens.ui.common.MainScaffold
|
||||
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Composable-функция для экрана "Поиск".
|
||||
* @param currentRoute Текущий маршрут для подсветки активного элемента в Drawer.
|
||||
* @param navigationActions Объект с навигационными действиями.
|
||||
*/
|
||||
@Composable
|
||||
fun SearchScreen() {
|
||||
// [ACTION]
|
||||
Text(text = "Search Screen")
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun SearchScreenPreview() {
|
||||
SearchScreen()
|
||||
}
|
||||
// [END_FILE_SearchScreen.kt]
|
||||
fun SearchScreen(
|
||||
currentRoute: String?,
|
||||
navigationActions: NavigationActions
|
||||
) {
|
||||
// [UI_COMPONENT]
|
||||
MainScaffold(
|
||||
topBarTitle = stringResource(id = R.string.search_title),
|
||||
currentRoute = currentRoute,
|
||||
navigationActions = navigationActions
|
||||
) {
|
||||
// [CORE-LOGIC]
|
||||
Text(text = "TODO: Search Screen")
|
||||
}
|
||||
// [END_FUNCTION_SearchScreen]
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
// [PACKAGE] com.homebox.lens.ui.screen.setup
|
||||
// [FILE] SetupScreen.kt
|
||||
// [SEMANTICS] ui, screen, setup, compose
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package com.homebox.lens.ui.screen.setup
|
||||
|
||||
|
||||
// [IMPORTS]
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -11,25 +14,35 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.homebox.lens.R
|
||||
|
||||
// [FIX] Opt-in for experimental Material 3 APIs
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
// [ENTRYPOINT]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Главная Composable-функция для экрана настройки соединения с сервером.
|
||||
* @param viewModel ViewModel для этого экрана, предоставляется через Hilt.
|
||||
* @param onSetupComplete Лямбда, вызываемая после успешной настройки и входа.
|
||||
* @sideeffect Вызывает `onSetupComplete` при изменении `uiState.isSetupComplete`.
|
||||
*/
|
||||
@Composable
|
||||
fun SetupScreen(
|
||||
viewModel: SetupViewModel = hiltViewModel(),
|
||||
onSetupComplete: () -> Unit
|
||||
) {
|
||||
// [STATE]
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// [CORE-LOGIC]
|
||||
if (uiState.isSetupComplete) {
|
||||
onSetupComplete()
|
||||
}
|
||||
|
||||
// [UI_COMPONENT]
|
||||
SetupScreenContent(
|
||||
uiState = uiState,
|
||||
onServerUrlChange = viewModel::onServerUrlChange,
|
||||
@@ -37,11 +50,19 @@ fun SetupScreen(
|
||||
onPasswordChange = viewModel::onPasswordChange,
|
||||
onConnectClick = viewModel::connect
|
||||
)
|
||||
// [END_FUNCTION_SetupScreen]
|
||||
}
|
||||
|
||||
// [FIX] Opt-in for experimental Material 3 APIs
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
// [CONTENT]
|
||||
// [HELPER]
|
||||
/**
|
||||
* [CONTRACT]
|
||||
* @summary Отображает контент экрана настройки: поля ввода и кнопку.
|
||||
* @param uiState Текущее состояние UI.
|
||||
* @param onServerUrlChange Лямбда-обработчик изменения URL сервера.
|
||||
* @param onUsernameChange Лямбда-обработчик изменения имени пользователя.
|
||||
* @param onPasswordChange Лямбда-обработчик изменения пароля.
|
||||
* @param onConnectClick Лямбда-обработчик нажатия на кнопку "Подключиться".
|
||||
*/
|
||||
@Composable
|
||||
private fun SetupScreenContent(
|
||||
uiState: SetupUiState,
|
||||
@@ -102,10 +123,10 @@ private fun SetupScreenContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
// [END_FUNCTION_SetupScreenContent]
|
||||
}
|
||||
|
||||
// [FIX] Opt-in for experimental Material 3 APIs
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
// [PREVIEW]
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun SetupScreenPreview() {
|
||||
@@ -117,4 +138,4 @@ fun SetupScreenPreview() {
|
||||
onConnectClick = {}
|
||||
)
|
||||
}
|
||||
// [END_FILE_SetupScreen.kt]
|
||||
// [END_FILE_SetupScreen.kt]
|
||||
|
||||
@@ -34,6 +34,14 @@
|
||||
<string name="nav_locations">Локации</string>
|
||||
<string name="nav_labels">Метки</string>
|
||||
|
||||
<!-- Screen Titles -->
|
||||
<string name="inventory_list_title">Инвентарь</string>
|
||||
<string name="item_details_title">Детали</string>
|
||||
<string name="item_edit_title">Редактирование</string>
|
||||
<string name="labels_list_title">Метки</string>
|
||||
<string name="locations_list_title">Места хранения</string>
|
||||
<string name="search_title">Поиск</string>
|
||||
|
||||
<!-- Setup Screen -->
|
||||
<string name="setup_title">Настройка сервера</string>
|
||||
<string name="setup_server_url_label">URL сервера</string>
|
||||
|
||||
Reference in New Issue
Block a user