feat(navigation): implement CRUD navigation and MainActivity integration
ui/navigation/Screen.kt: - Sealed interface with @Serializable routes: ItemList, ItemForm, CategoryManagement, LocationManagement - ItemForm accepts optional itemId for edit mode (type-safe navigation) ui/navigation/KrisenvorratNavGraph.kt: - NavHost with ItemList as start destination - Screen wiring: ItemList -> ItemForm (create/edit), CategoryManagement, LocationManagement with back navigation via popBackStack() ui/item/ItemListScreen.kt: - Added onItemClick, onCategoriesClick, onLocationsClick callbacks - TopAppBar MoreVert dropdown menu for category/location management - ItemCard now clickable to navigate to edit mode MainActivity.kt: - Replaced placeholder with NavHost via KrisenvorratNavGraph - rememberNavController() as root navigation controller ui/navigation/ScreenTest.kt: - 7 tests covering route instantiation, nullable itemId, equality Closes #28
This commit is contained in:
parent
f0ad946140
commit
10d19f0321
5 changed files with 206 additions and 28 deletions
|
|
@ -4,18 +4,13 @@ import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.krisenvorrat.app.ui.navigation.KrisenvorratNavGraph
|
||||||
import de.krisenvorrat.app.ui.theme.KrisenvorratTheme
|
import de.krisenvorrat.app.ui.theme.KrisenvorratTheme
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
|
@ -25,25 +20,8 @@ class MainActivity : ComponentActivity() {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
KrisenvorratTheme {
|
KrisenvorratTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
val navController = rememberNavController()
|
||||||
Column(
|
KrisenvorratNavGraph(navController = navController)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Krisenvorrat",
|
|
||||||
style = MaterialTheme.typography.headlineLarge
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "v${BuildConfig.VERSION_NAME}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +31,9 @@ class MainActivity : ComponentActivity() {
|
||||||
@Composable
|
@Composable
|
||||||
fun DefaultPreview() {
|
fun DefaultPreview() {
|
||||||
KrisenvorratTheme {
|
KrisenvorratTheme {
|
||||||
Text("Krisenvorrat")
|
Text(
|
||||||
|
text = "Krisenvorrat",
|
||||||
|
style = MaterialTheme.typography.headlineLarge
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.krisenvorrat.app.ui.item
|
package de.krisenvorrat.app.ui.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -14,8 +15,11 @@ import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -27,6 +31,9 @@ import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -39,14 +46,45 @@ import java.time.format.DateTimeFormatter
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ItemListScreen(
|
internal fun ItemListScreen(
|
||||||
onAddItem: () -> Unit,
|
onAddItem: () -> Unit,
|
||||||
|
onItemClick: (String) -> Unit,
|
||||||
|
onCategoriesClick: () -> Unit,
|
||||||
|
onLocationsClick: () -> Unit,
|
||||||
viewModel: ItemListViewModel = hiltViewModel()
|
viewModel: ItemListViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Artikel") }
|
title = { Text("Artikel") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { isMenuExpanded = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = "Menü"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = isMenuExpanded,
|
||||||
|
onDismissRequest = { isMenuExpanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Kategorien") },
|
||||||
|
onClick = {
|
||||||
|
isMenuExpanded = false
|
||||||
|
onCategoriesClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Lagerorte") },
|
||||||
|
onClick = {
|
||||||
|
isMenuExpanded = false
|
||||||
|
onLocationsClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
|
|
@ -67,6 +105,7 @@ internal fun ItemListScreen(
|
||||||
} else {
|
} else {
|
||||||
ItemList(
|
ItemList(
|
||||||
groupedItems = uiState.groupedItems,
|
groupedItems = uiState.groupedItems,
|
||||||
|
onItemClick = onItemClick,
|
||||||
onDeleteClick = viewModel::showDeleteDialog,
|
onDeleteClick = viewModel::showDeleteDialog,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -109,6 +148,7 @@ private fun EmptyState(modifier: Modifier = Modifier) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun ItemList(
|
private fun ItemList(
|
||||||
groupedItems: Map<String, List<ItemUiModel>>,
|
groupedItems: Map<String, List<ItemUiModel>>,
|
||||||
|
onItemClick: (String) -> Unit,
|
||||||
onDeleteClick: (ItemUiModel) -> Unit,
|
onDeleteClick: (ItemUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -123,6 +163,7 @@ private fun ItemList(
|
||||||
items(items, key = { it.id }) { item ->
|
items(items, key = { it.id }) { item ->
|
||||||
ItemCard(
|
ItemCard(
|
||||||
item = item,
|
item = item,
|
||||||
|
onClick = { onItemClick(item.id) },
|
||||||
onDeleteClick = { onDeleteClick(item) }
|
onDeleteClick = { onDeleteClick(item) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -145,12 +186,14 @@ private fun CategoryHeader(categoryName: String) {
|
||||||
@Composable
|
@Composable
|
||||||
private fun ItemCard(
|
private fun ItemCard(
|
||||||
item: ItemUiModel,
|
item: ItemUiModel,
|
||||||
|
onClick: () -> Unit,
|
||||||
onDeleteClick: () -> Unit
|
onDeleteClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 2.dp)
|
.padding(horizontal = 16.dp, vertical = 2.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package de.krisenvorrat.app.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import de.krisenvorrat.app.ui.category.CategoryListScreen
|
||||||
|
import de.krisenvorrat.app.ui.item.ItemFormScreen
|
||||||
|
import de.krisenvorrat.app.ui.item.ItemListScreen
|
||||||
|
import de.krisenvorrat.app.ui.location.LocationListScreen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun KrisenvorratNavGraph(navController: NavHostController) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Screen.ItemList
|
||||||
|
) {
|
||||||
|
composable<Screen.ItemList> {
|
||||||
|
ItemListScreen(
|
||||||
|
onAddItem = {
|
||||||
|
navController.navigate(Screen.ItemForm())
|
||||||
|
},
|
||||||
|
onItemClick = { itemId ->
|
||||||
|
navController.navigate(Screen.ItemForm(itemId = itemId))
|
||||||
|
},
|
||||||
|
onCategoriesClick = {
|
||||||
|
navController.navigate(Screen.CategoryManagement)
|
||||||
|
},
|
||||||
|
onLocationsClick = {
|
||||||
|
navController.navigate(Screen.LocationManagement)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable<Screen.ItemForm> {
|
||||||
|
ItemFormScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable<Screen.CategoryManagement> {
|
||||||
|
CategoryListScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable<Screen.LocationManagement> {
|
||||||
|
LocationListScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package de.krisenvorrat.app.ui.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal sealed interface Screen {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object ItemList : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ItemForm(val itemId: String? = null) : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object CategoryManagement : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object LocationManagement : Screen
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
package de.krisenvorrat.app.ui.navigation
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ScreenTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_itemListRoute_isSingleton() {
|
||||||
|
// Given & When
|
||||||
|
val route = Screen.ItemList
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(Screen.ItemList, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_itemFormRoute_withoutItemId_hasNullItemId() {
|
||||||
|
// Given & When
|
||||||
|
val route = Screen.ItemForm()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertNull(route.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_itemFormRoute_withItemId_hasCorrectItemId() {
|
||||||
|
// Given
|
||||||
|
val expectedId = "test-item-123"
|
||||||
|
|
||||||
|
// When
|
||||||
|
val route = Screen.ItemForm(itemId = expectedId)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(expectedId, route.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_categoryManagementRoute_isSingleton() {
|
||||||
|
// Given & When
|
||||||
|
val route = Screen.CategoryManagement
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(Screen.CategoryManagement, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_locationManagementRoute_isSingleton() {
|
||||||
|
// Given & When
|
||||||
|
val route = Screen.LocationManagement
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(Screen.LocationManagement, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_itemFormRoute_equality_sameItemId_areEqual() {
|
||||||
|
// Given
|
||||||
|
val route1 = Screen.ItemForm(itemId = "abc")
|
||||||
|
val route2 = Screen.ItemForm(itemId = "abc")
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(route1, route2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_itemFormRoute_equality_differentItemId_areNotEqual() {
|
||||||
|
// Given
|
||||||
|
val route1 = Screen.ItemForm(itemId = "abc")
|
||||||
|
val route2 = Screen.ItemForm(itemId = "xyz")
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assert(route1 != route2)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue