diff --git a/app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/9.json b/app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/9.json new file mode 100644 index 0000000..9e77d10 --- /dev/null +++ b/app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/9.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/de/bollwerk/app/ui/navigation/BollwerkNavGraph.kt b/app/src/main/java/de/bollwerk/app/ui/navigation/BollwerkNavGraph.kt index 6eaecc7..a05c79f 100644 --- a/app/src/main/java/de/bollwerk/app/ui/navigation/BollwerkNavGraph.kt +++ b/app/src/main/java/de/bollwerk/app/ui/navigation/BollwerkNavGraph.kt @@ -13,6 +13,7 @@ import de.bollwerk.app.ui.item.ItemListScreen import de.bollwerk.app.ui.location.LocationListScreen import de.bollwerk.app.ui.messaging.ChatScreen 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.warnings.WarningsScreen @@ -99,6 +100,10 @@ internal fun BollwerkNavGraph( SettingsScreen() } + composable { + ResourceListScreen() + } + composable { UserListScreen( onUserClick = { id, username -> diff --git a/app/src/main/java/de/bollwerk/app/ui/navigation/Screen.kt b/app/src/main/java/de/bollwerk/app/ui/navigation/Screen.kt index c397858..637f3d1 100644 --- a/app/src/main/java/de/bollwerk/app/ui/navigation/Screen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/navigation/Screen.kt @@ -34,4 +34,7 @@ internal sealed interface Screen { @Serializable data class Chat(val recipientId: String, val recipientUsername: String) : Screen + + @Serializable + data object ResourceList : Screen } diff --git a/app/src/main/java/de/bollwerk/app/ui/navigation/TopLevelDestination.kt b/app/src/main/java/de/bollwerk/app/ui/navigation/TopLevelDestination.kt index 677669f..c839658 100644 --- a/app/src/main/java/de/bollwerk/app/ui/navigation/TopLevelDestination.kt +++ b/app/src/main/java/de/bollwerk/app/ui/navigation/TopLevelDestination.kt @@ -4,6 +4,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Message import androidx.compose.material.icons.automirrored.outlined.Message 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.Warning import androidx.compose.material.icons.outlined.Home @@ -43,6 +45,12 @@ internal enum class TopLevelDestination( unselectedIcon = Icons.AutoMirrored.Outlined.Message, label = "Chat" ), + RESOURCES( + route = Screen.ResourceList, + selectedIcon = Icons.AutoMirrored.Filled.LibraryBooks, + unselectedIcon = Icons.AutoMirrored.Outlined.LibraryBooks, + label = "Ressourcen" + ), SETTINGS( route = Screen.Settings, selectedIcon = Icons.Filled.Settings, diff --git a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt new file mode 100644 index 0000000..b64f846 --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt new file mode 100644 index 0000000..6a45427 --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/ui/resources/ResourceListViewModel.kt @@ -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 A–Z"), + DATE_DESC("Datum neu→alt"), + SIZE_DESC("Größe groß→klein") +} + +internal data class ResourceListUiState( + val resources: List = emptyList(), + val searchQuery: String = "", + val selectedTags: Set = emptySet(), + val sortMode: SortMode = SortMode.TITLE_ASC, + val isLoading: Boolean = false, + val allTags: List = emptyList() +) + +@HiltViewModel +internal class ResourceListViewModel @Inject constructor( + private val resourceRepository: ResourceRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(ResourceListUiState()) + val uiState: StateFlow = _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) } + } +}