feat(app): add ResourceListScreen + BottomBar navigation

Closes #122
This commit is contained in:
Jens Reinemann 2026-05-18 22:15:40 +02:00
parent 542fbb0941
commit 44476b21e6
6 changed files with 797 additions and 0 deletions

View file

@ -0,0 +1,419 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "282459c07065326c1a4001e826b8e4a3",
"entities": [
{
"tableName": "categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "locations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category_id` INTEGER NOT NULL, `quantity` REAL NOT NULL, `unit` TEXT NOT NULL, `unit_price` REAL NOT NULL, `kcal_per_unit` INTEGER, `expiry_date` TEXT, `location_id` INTEGER NOT NULL, `notes` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "quantity",
"columnName": "quantity",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "unit",
"columnName": "unit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unitPrice",
"columnName": "unit_price",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "kcalPerUnit",
"columnName": "kcal_per_unit",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expiryDate",
"columnName": "expiry_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "locationId",
"columnName": "location_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "last_updated",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_items_category_id",
"unique": false,
"columnNames": [
"category_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_category_id` ON `${TABLE_NAME}` (`category_id`)"
},
{
"name": "index_items_location_id",
"unique": false,
"columnNames": [
"location_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_location_id` ON `${TABLE_NAME}` (`location_id`)"
}
],
"foreignKeys": [
{
"table": "categories",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"category_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"location_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "pending_sync_ops",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `operation` TEXT NOT NULL, `payload` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "item_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "operation",
"columnName": "operation",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sender_id` TEXT NOT NULL, `sender_username` TEXT NOT NULL, `receiver_id` TEXT NOT NULL, `body` TEXT NOT NULL, `sent_at` INTEGER NOT NULL, `is_pending` INTEGER NOT NULL, `is_read` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderId",
"columnName": "sender_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderUsername",
"columnName": "sender_username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "receiverId",
"columnName": "receiver_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sentAt",
"columnName": "sent_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPending",
"columnName": "is_pending",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "is_read",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "resources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `file_format` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `file_size` INTEGER NOT NULL, `release_date` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `author` TEXT, `language` TEXT, `edition` TEXT, `download_url` TEXT NOT NULL, PRIMARY KEY(`guid`))",
"fields": [
{
"fieldPath": "guid",
"columnName": "guid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileFormat",
"columnName": "file_format",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mime_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileSize",
"columnName": "file_size",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "releaseDate",
"columnName": "release_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "edition",
"columnName": "edition",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "downloadUrl",
"columnName": "download_url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"guid"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '282459c07065326c1a4001e826b8e4a3')"
]
}
}

View file

@ -13,6 +13,7 @@ import de.bollwerk.app.ui.item.ItemListScreen
import de.bollwerk.app.ui.location.LocationListScreen import de.bollwerk.app.ui.location.LocationListScreen
import de.bollwerk.app.ui.messaging.ChatScreen import de.bollwerk.app.ui.messaging.ChatScreen
import de.bollwerk.app.ui.messaging.UserListScreen import de.bollwerk.app.ui.messaging.UserListScreen
import de.bollwerk.app.ui.resources.ResourceListScreen
import de.bollwerk.app.ui.settings.SettingsScreen import de.bollwerk.app.ui.settings.SettingsScreen
import de.bollwerk.app.ui.warnings.WarningsScreen import de.bollwerk.app.ui.warnings.WarningsScreen
@ -99,6 +100,10 @@ internal fun BollwerkNavGraph(
SettingsScreen() SettingsScreen()
} }
composable<Screen.ResourceList> {
ResourceListScreen()
}
composable<Screen.UserList> { composable<Screen.UserList> {
UserListScreen( UserListScreen(
onUserClick = { id, username -> onUserClick = { id, username ->

View file

@ -34,4 +34,7 @@ internal sealed interface Screen {
@Serializable @Serializable
data class Chat(val recipientId: String, val recipientUsername: String) : Screen data class Chat(val recipientId: String, val recipientUsername: String) : Screen
@Serializable
data object ResourceList : Screen
} }

View file

@ -4,6 +4,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material.icons.automirrored.outlined.Message
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Home
@ -43,6 +45,12 @@ internal enum class TopLevelDestination(
unselectedIcon = Icons.AutoMirrored.Outlined.Message, unselectedIcon = Icons.AutoMirrored.Outlined.Message,
label = "Chat" label = "Chat"
), ),
RESOURCES(
route = Screen.ResourceList,
selectedIcon = Icons.AutoMirrored.Filled.LibraryBooks,
unselectedIcon = Icons.AutoMirrored.Outlined.LibraryBooks,
label = "Ressourcen"
),
SETTINGS( SETTINGS(
route = Screen.Settings, route = Screen.Settings,
selectedIcon = Icons.Filled.Settings, selectedIcon = Icons.Filled.Settings,

View file

@ -0,0 +1,285 @@
package de.bollwerk.app.ui.resources
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.bollwerk.shared.model.ResourceDto
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
internal fun ResourceListScreen(
viewModel: ResourceListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var isSortMenuExpanded by remember { mutableStateOf(false) }
val filteredResources = remember(
uiState.resources,
uiState.searchQuery,
uiState.selectedTags,
uiState.sortMode
) {
uiState.resources
.filter { resource ->
(uiState.searchQuery.isBlank() ||
resource.title.contains(uiState.searchQuery, ignoreCase = true)) &&
(uiState.selectedTags.isEmpty() ||
resource.tags.any { it in uiState.selectedTags })
}
.let { list ->
when (uiState.sortMode) {
SortMode.TITLE_ASC -> list.sortedBy { it.title.lowercase() }
SortMode.DATE_DESC -> list.sortedByDescending { it.createdAt }
SortMode.SIZE_DESC -> list.sortedByDescending { it.fileSize }
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Ressourcen") },
actions = {
IconButton(onClick = { viewModel.refresh() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "Aktualisieren"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
OutlinedTextField(
value = uiState.searchQuery,
onValueChange = { viewModel.setSearchQuery(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Suche nach Titel…") },
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
if (uiState.allTags.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
uiState.allTags.forEach { tag ->
FilterChip(
selected = tag in uiState.selectedTags,
onClick = { viewModel.toggleTag(tag) },
label = { Text(tag) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Sortierung:",
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.width(4.dp))
Box {
TextButton(onClick = { isSortMenuExpanded = true }) {
Text(uiState.sortMode.label)
}
DropdownMenu(
expanded = isSortMenuExpanded,
onDismissRequest = { isSortMenuExpanded = false }
) {
SortMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.label) },
onClick = {
viewModel.setSortMode(mode)
isSortMenuExpanded = false
}
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
when {
uiState.isLoading && filteredResources.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
filteredResources.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Keine Ressourcen verfügbar",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredResources, key = { it.guid }) { resource ->
ResourceCard(resource = resource)
}
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ResourceCard(resource: ResourceDto) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = resource.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { /* download handled externally */ }) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Herunterladen"
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FormatBadge(format = resource.fileFormat)
Text(
text = formatFileSize(resource.fileSize),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (resource.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
resource.tags.forEach { tag ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer
) {
Text(
text = tag,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
}
}
}
@Composable
private fun FormatBadge(format: String) {
val color = when (format.lowercase()) {
"epub" -> MaterialTheme.colorScheme.primary
"pdf" -> Color(0xFFE53935)
"zip" -> Color.Gray
"7z" -> Color(0xFFFF8F00)
else -> MaterialTheme.colorScheme.tertiary
}
Surface(
shape = MaterialTheme.shapes.small,
color = color.copy(alpha = 0.15f)
) {
Text(
text = format.uppercase(),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = color
)
}
}
private fun formatFileSize(bytes: Long): String {
return if (bytes >= 1024 * 1024) {
String.format("%.1f MB", bytes / (1024.0 * 1024.0))
} else {
String.format("%.0f KB", bytes / 1024.0)
}
}

View file

@ -0,0 +1,77 @@
package de.bollwerk.app.ui.resources
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.bollwerk.app.domain.repository.ResourceRepository
import de.bollwerk.shared.model.ResourceDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
internal enum class SortMode(val label: String) {
TITLE_ASC("Titel AZ"),
DATE_DESC("Datum neu→alt"),
SIZE_DESC("Größe groß→klein")
}
internal data class ResourceListUiState(
val resources: List<ResourceDto> = emptyList(),
val searchQuery: String = "",
val selectedTags: Set<String> = emptySet(),
val sortMode: SortMode = SortMode.TITLE_ASC,
val isLoading: Boolean = false,
val allTags: List<String> = emptyList()
)
@HiltViewModel
internal class ResourceListViewModel @Inject constructor(
private val resourceRepository: ResourceRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ResourceListUiState())
val uiState: StateFlow<ResourceListUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
resourceRepository.getAll().collect { resources ->
val allTags = resources.flatMap { it.tags }.distinct().sorted()
_uiState.update { it.copy(resources = resources, allTags = allTags) }
}
}
refresh()
}
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
resourceRepository.refreshFromServer()
} finally {
_uiState.update { it.copy(isLoading = false) }
}
}
}
fun setSearchQuery(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
fun toggleTag(tag: String) {
_uiState.update { state ->
val updated = if (tag in state.selectedTags) {
state.selectedTags - tag
} else {
state.selectedTags + tag
}
state.copy(selectedTags = updated)
}
}
fun setSortMode(mode: SortMode) {
_uiState.update { it.copy(sortMode = mode) }
}
}