parent
542fbb0941
commit
44476b21e6
6 changed files with 797 additions and 0 deletions
419
app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/9.json
Normal file
419
app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/9.json
Normal 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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue