feat: Messaging-System mit Offline-First und WebSocket-Push (#58)
## Server
- Messages-Tabelle (id, sender_id, receiver_id, body, sent_at, delivered_at)
- MessageRepository: save/getUndelivered/getConversation/markDelivered (JOIN statt N+1)
- POST /api/messages, GET /api/messages/{userId}: Nachrichten senden/abrufen
- GET /api/users: User-Liste fuer authentifizierte User (ohne eigenen Account)
- WebSocketManager: notifyNewMessage() + isOnline()
- WebSocketRoutes: unzugestellte Nachrichten bei Reconnect pushen
- LoginResponse: userId + username ergaenzt
- Server-Dependency: kotlinx.serialization fuer shared
## App
- MessageEntity + MessageDao (Room, Migration 3->4)
- KrisenvorratDatabase v4, Migrations.MIGRATION_3_4
- MessageRepositoryImpl: Offline-First (isPending), drain bei WebSocket-Connect
- WebSocketEvent.NewMessage -> MessageDto aus shared
- WebSocketClientImpl: new_message-Event parsen
- AUTH_USER_ID in SettingsKeys, SyncServiceImpl speichert userId bei Login
- UserListScreen + UserListViewModel: User-Liste anzeigen
- ChatScreen + ChatViewModel: WhatsApp-Style Chat (links/rechts, Zeitstempel)
- Navigation: Screen.UserList, Screen.Chat, MESSAGES in Bottom-Nav
- RepositoryModule: MessageRepository gebunden
## Tests
- 234 Tests, 0 Fehler
This commit is contained in:
parent
1d7a62448a
commit
56ac9b1425
39 changed files with 1814 additions and 22 deletions
44
.github/skills/vps-deploy/SKILL.md
vendored
44
.github/skills/vps-deploy/SKILL.md
vendored
|
|
@ -112,24 +112,52 @@ services:
|
|||
ports:
|
||||
- '8080:8080'
|
||||
environment:
|
||||
- KRISENVORRAT_API_KEY=<key>
|
||||
- KRISENVORRAT_JWT_SECRET=<secret>
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
```
|
||||
|
||||
**Hinweis:** Den echten API-Key NICHT in Skill-Dateien oder Git speichern. Er liegt nur in der `docker-compose.yml` auf dem VPS.
|
||||
**Hinweis:** Das JWT-Secret NICHT in Skill-Dateien oder Git speichern. Es liegt nur in der `docker-compose.yml` auf dem VPS.
|
||||
|
||||
---
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Der Server nutzt JWT-basierte Authentifizierung (kein API-Key mehr).
|
||||
|
||||
### Admin-Zugang
|
||||
|
||||
- **Admin-UI:** `http://195.246.231.210:8080/admin/`
|
||||
- **Admin-User:** `admin`
|
||||
- **Admin-Passwort:** Der User muss das Passwort selbst eingeben. Es ist NICHT gespeichert – bei Bedarf den User fragen.
|
||||
- Beim ersten Start ohne `KRISENVORRAT_ADMIN_PASSWORD` ENV wird ein zufälliges Passwort generiert und in die Logs geschrieben.
|
||||
|
||||
### Environment-Variablen
|
||||
|
||||
| Variable | Pflicht | Beschreibung |
|
||||
| ------------------------------- | ------- | --------------------------------------------------- |
|
||||
| `KRISENVORRAT_JWT_SECRET` | ja | Secret für JWT-Token-Signierung (mind. 32 Zeichen) |
|
||||
| `KRISENVORRAT_ADMIN_PASSWORD` | nein | Admin-Passwort beim ersten Start (sonst auto-gen.) |
|
||||
|
||||
---
|
||||
|
||||
## Server-Endpunkte
|
||||
|
||||
| Endpunkt | Auth | Beschreibung |
|
||||
| ----------------------- | ------- | ------------------ |
|
||||
| `GET /api/health` | nein | Health-Check → "OK"|
|
||||
| `GET /api/inventory` | API-Key | Inventar abrufen |
|
||||
| `PUT /api/inventory` | API-Key | Inventar hochladen |
|
||||
| Endpunkt | Auth | Beschreibung |
|
||||
| ------------------------------ | ----- | ------------------------------------- |
|
||||
| `GET /api/health` | nein | Health-Check → "OK" |
|
||||
| `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) |
|
||||
| `POST /api/auth/refresh` | nein | Access-Token erneuern |
|
||||
| `GET /api/inventory` | JWT | Inventar des Users abrufen |
|
||||
| `PUT /api/inventory` | JWT | Inventar des Users hochladen |
|
||||
| `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten |
|
||||
| `GET /api/admin/users` | Admin | Alle User auflisten |
|
||||
| `POST /api/admin/users` | Admin | Neuen User anlegen |
|
||||
| `PUT /api/admin/users/{id}` | Admin | Passwort ändern |
|
||||
| `DELETE /api/admin/users/{id}` | Admin | User löschen |
|
||||
| `WS /ws/sync` | JWT | WebSocket für Push-Benachrichtigungen |
|
||||
|
||||
API-Key wird als `Authorization: Bearer <key>` oder `X-API-Key: <key>` Header mitgeschickt.
|
||||
JWT wird als `Authorization: Bearer <accessToken>` Header mitgeschickt.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 4,
|
||||
"identityHash": "1008ae2dd73e9444de995dcb89dcfa43",
|
||||
"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_kg` 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": "kcalPerKg",
|
||||
"columnName": "kcal_per_kg",
|
||||
"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, 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
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"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, '1008ae2dd73e9444de995dcb89dcfa43')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,19 @@ import androidx.room.TypeConverters
|
|||
import de.krisenvorrat.app.data.db.dao.CategoryDao
|
||||
import de.krisenvorrat.app.data.db.dao.ItemDao
|
||||
import de.krisenvorrat.app.data.db.dao.LocationDao
|
||||
import de.krisenvorrat.app.data.db.dao.MessageDao
|
||||
import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao
|
||||
import de.krisenvorrat.app.data.db.dao.SettingsDao
|
||||
import de.krisenvorrat.app.data.db.entity.CategoryEntity
|
||||
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
||||
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity
|
||||
import de.krisenvorrat.app.data.db.entity.SettingsEntity
|
||||
|
||||
@Database(
|
||||
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class],
|
||||
version = 3,
|
||||
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
|
||||
version = 4,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(LocalDateConverter::class)
|
||||
|
|
@ -26,4 +28,5 @@ internal abstract class KrisenvorratDatabase : RoomDatabase() {
|
|||
abstract fun itemDao(): ItemDao
|
||||
abstract fun settingsDao(): SettingsDao
|
||||
abstract fun pendingSyncOpDao(): PendingSyncOpDao
|
||||
abstract fun messageDao(): MessageDao
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,4 +85,23 @@ internal object Migrations {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `messages` (
|
||||
`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,
|
||||
PRIMARY KEY(`id`)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package de.krisenvorrat.app.data.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
internal interface MessageDao {
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertIfNotExists(message: MessageEntity)
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM messages
|
||||
WHERE (sender_id = :myId AND receiver_id = :otherId)
|
||||
OR (sender_id = :otherId AND receiver_id = :myId)
|
||||
ORDER BY sent_at ASC
|
||||
""")
|
||||
fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE is_pending = 1 ORDER BY sent_at ASC")
|
||||
suspend fun getPendingMessages(): List<MessageEntity>
|
||||
|
||||
@Query("UPDATE messages SET is_pending = 0 WHERE id = :id")
|
||||
suspend fun markDelivered(id: String)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package de.krisenvorrat.app.data.db.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "messages")
|
||||
internal data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
@ColumnInfo(name = "sender_id") val senderId: String,
|
||||
@ColumnInfo(name = "sender_username") val senderUsername: String,
|
||||
@ColumnInfo(name = "receiver_id") val receiverId: String,
|
||||
@ColumnInfo(name = "body") val body: String,
|
||||
@ColumnInfo(name = "sent_at") val sentAt: Long,
|
||||
@ColumnInfo(name = "is_pending") val isPending: Boolean
|
||||
)
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package de.krisenvorrat.app.data.repository
|
||||
|
||||
import de.krisenvorrat.app.data.db.dao.MessageDao
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import de.krisenvorrat.app.data.sync.WebSocketClient
|
||||
import de.krisenvorrat.app.data.sync.WebSocketEvent
|
||||
import de.krisenvorrat.app.di.ApplicationScope
|
||||
import de.krisenvorrat.app.domain.model.SettingsKeys
|
||||
import de.krisenvorrat.app.domain.model.SyncError
|
||||
import de.krisenvorrat.app.domain.repository.MessageRepository
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import de.krisenvorrat.shared.model.UserListItemDto
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
private data class SendMessageRequest(
|
||||
val id: String,
|
||||
val receiverId: String,
|
||||
val body: String,
|
||||
val sentAt: Long
|
||||
)
|
||||
|
||||
@Singleton
|
||||
internal class MessageRepositoryImpl @Inject constructor(
|
||||
private val dao: MessageDao,
|
||||
private val httpClient: HttpClient,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val webSocketClient: WebSocketClient,
|
||||
@ApplicationScope private val scope: CoroutineScope
|
||||
) : MessageRepository {
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
webSocketClient.events.collect { event ->
|
||||
when (event) {
|
||||
is WebSocketEvent.NewMessage -> {
|
||||
val msg = event.message
|
||||
dao.upsert(
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
senderId = msg.senderId,
|
||||
senderUsername = msg.senderUsername,
|
||||
receiverId = msg.receiverId,
|
||||
body = msg.body,
|
||||
sentAt = msg.sentAt,
|
||||
isPending = false
|
||||
)
|
||||
)
|
||||
}
|
||||
is WebSocketEvent.Connected -> drainPendingMessages()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
|
||||
dao.getConversation(myId, otherId)
|
||||
|
||||
override suspend fun sendMessage(recipientId: String, body: String) {
|
||||
val myId = settingsRepository.getValue(SettingsKeys.AUTH_USER_ID) ?: return
|
||||
val myUsername = settingsRepository.getValue(SettingsKeys.AUTH_USERNAME) ?: ""
|
||||
val localId = UUID.randomUUID().toString()
|
||||
val sentAt = System.currentTimeMillis()
|
||||
|
||||
dao.upsert(
|
||||
MessageEntity(
|
||||
id = localId,
|
||||
senderId = myId,
|
||||
senderUsername = myUsername,
|
||||
receiverId = recipientId,
|
||||
body = body,
|
||||
sentAt = sentAt,
|
||||
isPending = true
|
||||
)
|
||||
)
|
||||
|
||||
val result = attemptSendToServer(localId, recipientId, body, sentAt)
|
||||
if (result.isSuccess) {
|
||||
dao.markDelivered(localId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun fetchUsers(): Result<List<UserListItemDto>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val serverUrl = settingsRepository.getValue(SettingsKeys.SERVER_URL)
|
||||
?: return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
|
||||
val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)
|
||||
?: return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
|
||||
try {
|
||||
val response = httpClient.get("${serverUrl.trimEnd('/')}/api/users") {
|
||||
header("Authorization", "Bearer $token")
|
||||
}
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
Result.success(response.body<List<UserListItemDto>>())
|
||||
} else {
|
||||
Result.failure(SyncError.ServerError(response.status.value, response.status.description))
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(SyncError.Timeout(e))
|
||||
} catch (e: ConnectException) {
|
||||
Result.failure(SyncError.ConnectionError(e))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(SyncError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMyUserId(): String? =
|
||||
settingsRepository.getValue(SettingsKeys.AUTH_USER_ID)
|
||||
|
||||
override suspend fun drainPendingMessages() {
|
||||
val pending = withContext(Dispatchers.IO) { dao.getPendingMessages() }
|
||||
for (msg in pending) {
|
||||
val result = attemptSendToServer(msg.id, msg.receiverId, msg.body, msg.sentAt)
|
||||
if (result.isSuccess) {
|
||||
withContext(Dispatchers.IO) { dao.markDelivered(msg.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun attemptSendToServer(
|
||||
id: String,
|
||||
recipientId: String,
|
||||
body: String,
|
||||
sentAt: Long
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
val serverUrl = settingsRepository.getValue(SettingsKeys.SERVER_URL)
|
||||
?: return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
|
||||
val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)
|
||||
?: return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
|
||||
try {
|
||||
val response = httpClient.post("${serverUrl.trimEnd('/')}/api/messages") {
|
||||
header("Authorization", "Bearer $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(SendMessageRequest(id = id, receiverId = recipientId, body = body, sentAt = sentAt))
|
||||
}
|
||||
if (response.status == HttpStatusCode.Created || response.status == HttpStatusCode.OK) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(SyncError.ServerError(response.status.value, response.status.description))
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(SyncError.Timeout(e))
|
||||
} catch (e: ConnectException) {
|
||||
Result.failure(SyncError.ConnectionError(e))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(SyncError.Unknown(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,12 @@ import kotlinx.serialization.Serializable
|
|||
internal data class LoginRequest(val username: String, val password: String)
|
||||
|
||||
@Serializable
|
||||
internal data class LoginResponse(val accessToken: String, val refreshToken: String)
|
||||
internal data class LoginResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val userId: String,
|
||||
val username: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class RefreshRequest(val refreshToken: String)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ internal class SyncServiceImpl @Inject constructor(
|
|||
settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken)
|
||||
settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken)
|
||||
settingsRepository.setValue(KEY_AUTH_USERNAME, username)
|
||||
settingsRepository.setValue(KEY_AUTH_USER_ID, loginResponse.userId)
|
||||
Result.success(Unit)
|
||||
}
|
||||
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
|
||||
|
|
@ -212,5 +213,6 @@ internal class SyncServiceImpl @Inject constructor(
|
|||
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
|
||||
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
|
||||
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
|
||||
val KEY_AUTH_USER_ID = SettingsKeys.AUTH_USER_ID
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package de.krisenvorrat.app.data.sync
|
||||
|
||||
import de.krisenvorrat.shared.model.MessageDto
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
internal interface WebSocketClient {
|
||||
|
|
@ -13,4 +14,5 @@ internal sealed interface WebSocketEvent {
|
|||
data object FullSyncRequired : WebSocketEvent
|
||||
data object Connected : WebSocketEvent
|
||||
data object Disconnected : WebSocketEvent
|
||||
data class NewMessage(val message: MessageDto) : WebSocketEvent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,18 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
|
|||
when (event.type) {
|
||||
"inventoryUpdated" -> _events.emit(WebSocketEvent.InventoryUpdated(event.itemId ?: ""))
|
||||
"fullSyncRequired" -> _events.emit(WebSocketEvent.FullSyncRequired)
|
||||
"new_message" -> {
|
||||
val msg = de.krisenvorrat.shared.model.MessageDto(
|
||||
id = event.id ?: return,
|
||||
senderId = event.senderId ?: return,
|
||||
senderUsername = event.senderUsername ?: return,
|
||||
receiverId = event.receiverId ?: return,
|
||||
body = event.body ?: return,
|
||||
sentAt = event.sentAt ?: return,
|
||||
deliveredAt = null
|
||||
)
|
||||
_events.emit(WebSocketEvent.NewMessage(msg))
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// ignore malformed events
|
||||
|
|
@ -95,5 +107,11 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
|
|||
@Serializable
|
||||
private data class WsServerEvent(
|
||||
val type: String,
|
||||
val itemId: String? = null
|
||||
val itemId: String? = null,
|
||||
val id: String? = null,
|
||||
val senderId: String? = null,
|
||||
val senderUsername: String? = null,
|
||||
val receiverId: String? = null,
|
||||
val body: String? = null,
|
||||
val sentAt: Long? = null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import de.krisenvorrat.app.data.db.Migrations
|
|||
import de.krisenvorrat.app.data.db.dao.CategoryDao
|
||||
import de.krisenvorrat.app.data.db.dao.ItemDao
|
||||
import de.krisenvorrat.app.data.db.dao.LocationDao
|
||||
import de.krisenvorrat.app.data.db.dao.MessageDao
|
||||
import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao
|
||||
import de.krisenvorrat.app.data.db.dao.SettingsDao
|
||||
import de.krisenvorrat.app.data.export.DatabaseTransaction
|
||||
|
|
@ -29,7 +30,7 @@ internal object DatabaseModule {
|
|||
fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase =
|
||||
Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db")
|
||||
.addCallback(DefaultDataCallback)
|
||||
.addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3)
|
||||
.addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4)
|
||||
.build()
|
||||
|
||||
private object DefaultDataCallback : RoomDatabase.Callback() {
|
||||
|
|
@ -65,6 +66,9 @@ internal object DatabaseModule {
|
|||
@Provides
|
||||
fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao()
|
||||
|
||||
@Provides
|
||||
fun provideMessageDao(db: KrisenvorratDatabase): MessageDao = db.messageDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabaseTransaction(db: KrisenvorratDatabase): DatabaseTransaction =
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import de.krisenvorrat.app.data.export.ImportExportRepositoryImpl
|
|||
import de.krisenvorrat.app.data.repository.CategoryRepositoryImpl
|
||||
import de.krisenvorrat.app.data.repository.ItemRepositoryImpl
|
||||
import de.krisenvorrat.app.data.repository.LocationRepositoryImpl
|
||||
import de.krisenvorrat.app.data.repository.MessageRepositoryImpl
|
||||
import de.krisenvorrat.app.data.repository.SettingsRepositoryImpl
|
||||
import de.krisenvorrat.app.domain.repository.CategoryRepository
|
||||
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
||||
import de.krisenvorrat.app.domain.repository.ItemRepository
|
||||
import de.krisenvorrat.app.domain.repository.LocationRepository
|
||||
import de.krisenvorrat.app.domain.repository.MessageRepository
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -39,4 +41,8 @@ internal abstract class RepositoryModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindImportExportRepository(impl: ImportExportRepositoryImpl): ImportExportRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMessageRepository(impl: MessageRepositoryImpl): MessageRepository
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ internal object SettingsKeys {
|
|||
const val AUTH_ACCESS_TOKEN = "auth_access_token"
|
||||
const val AUTH_REFRESH_TOKEN = "auth_refresh_token"
|
||||
const val AUTH_USERNAME = "auth_username"
|
||||
const val AUTH_USER_ID = "auth_user_id"
|
||||
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
||||
const val OPENAI_API_KEY = "openai_api_key"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package de.krisenvorrat.app.domain.repository
|
||||
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import de.krisenvorrat.shared.model.UserListItemDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
internal interface MessageRepository {
|
||||
fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>>
|
||||
suspend fun sendMessage(recipientId: String, body: String)
|
||||
suspend fun fetchUsers(): Result<List<UserListItemDto>>
|
||||
suspend fun getMyUserId(): String?
|
||||
suspend fun drainPendingMessages()
|
||||
}
|
||||
181
app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt
Normal file
181
app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package de.krisenvorrat.app.ui.messaging
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun ChatScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ChatViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(uiState.messages.size) {
|
||||
if (uiState.messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(uiState.messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(uiState.recipientUsername) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Zurück"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(uiState.messages, key = { it.id }) { message ->
|
||||
MessageBubble(
|
||||
message = message,
|
||||
isMine = message.senderId == uiState.myUserId
|
||||
)
|
||||
}
|
||||
}
|
||||
MessageInputBar(
|
||||
text = uiState.inputText,
|
||||
onTextChange = { viewModel.onInputChanged(it) },
|
||||
onSend = { viewModel.sendMessage() },
|
||||
isSending = uiState.isSending
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubble(message: MessageEntity, isMine: Boolean) {
|
||||
val horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
val bubbleColor = if (isMine) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = horizontalAlignment
|
||||
) {
|
||||
if (!isMine) {
|
||||
Text(
|
||||
text = message.senderUsername,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(
|
||||
topStart = if (isMine) 12.dp else 2.dp,
|
||||
topEnd = if (isMine) 2.dp else 12.dp,
|
||||
bottomStart = 12.dp,
|
||||
bottomEnd = 12.dp
|
||||
),
|
||||
color = bubbleColor,
|
||||
modifier = Modifier.widthIn(max = 280.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = message.body,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.sentAt)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageInputBar(
|
||||
text: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
isSending: Boolean
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Nachricht...") },
|
||||
maxLines = 4,
|
||||
enabled = !isSending
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
enabled = text.isNotBlank() && !isSending
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Senden"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package de.krisenvorrat.app.ui.messaging
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import de.krisenvorrat.app.domain.repository.MessageRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
internal data class ChatUiState(
|
||||
val messages: List<MessageEntity> = emptyList(),
|
||||
val myUserId: String = "",
|
||||
val recipientUsername: String = "",
|
||||
val inputText: String = "",
|
||||
val isSending: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
internal class ChatViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val messageRepository: MessageRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val recipientId: String = savedStateHandle.get<String>("recipientId") ?: ""
|
||||
private val recipientUsername: String = savedStateHandle.get<String>("recipientUsername") ?: ""
|
||||
|
||||
private val _uiState = MutableStateFlow(ChatUiState(recipientUsername = recipientUsername))
|
||||
val uiState: StateFlow<ChatUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val myId = messageRepository.getMyUserId() ?: ""
|
||||
_uiState.update { it.copy(myUserId = myId) }
|
||||
if (myId.isNotEmpty()) {
|
||||
messageRepository.getConversation(myId, recipientId).collect { messages ->
|
||||
_uiState.update { it.copy(messages = messages) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onInputChanged(text: String) {
|
||||
_uiState.update { it.copy(inputText = text) }
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
val text = _uiState.value.inputText.trim()
|
||||
if (text.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isSending = true, inputText = "") }
|
||||
messageRepository.sendMessage(recipientId = recipientId, body = text)
|
||||
_uiState.update { it.copy(isSending = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package de.krisenvorrat.app.ui.messaging
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.outlined.Person
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.krisenvorrat.shared.model.UserListItemDto
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun UserListScreen(
|
||||
onUserClick: (id: String, username: String) -> Unit,
|
||||
viewModel: UserListViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(title = { Text("Nachrichten") })
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
uiState.errorMessage != null -> Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = uiState.errorMessage ?: "Fehler",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Button(onClick = { viewModel.retry() }) { Text("Erneut versuchen") }
|
||||
}
|
||||
uiState.users.isEmpty() -> Text(
|
||||
text = "Keine Benutzer gefunden",
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
else -> LazyColumn {
|
||||
items(uiState.users) { user ->
|
||||
UserListItem(user = user, onClick = { onUserClick(user.id, user.username) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserListItem(user: UserListItemDto, onClick: () -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = { Text(user.username) },
|
||||
leadingContent = {
|
||||
Icon(imageVector = Icons.Outlined.Person, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onClick)
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package de.krisenvorrat.app.ui.messaging
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.krisenvorrat.app.domain.repository.MessageRepository
|
||||
import de.krisenvorrat.shared.model.UserListItemDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
internal data class UserListUiState(
|
||||
val users: List<UserListItemDto> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
internal class UserListViewModel @Inject constructor(
|
||||
private val messageRepository: MessageRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(UserListUiState(isLoading = true))
|
||||
val uiState: StateFlow<UserListUiState> = _uiState
|
||||
|
||||
init {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
private fun loadUsers() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
val result = messageRepository.fetchUsers()
|
||||
_uiState.value = if (result.isSuccess) {
|
||||
UserListUiState(users = result.getOrDefault(emptyList()), isLoading = false)
|
||||
} else {
|
||||
UserListUiState(
|
||||
isLoading = false,
|
||||
errorMessage = result.exceptionOrNull()?.message ?: "Fehler beim Laden"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() = loadUsers()
|
||||
}
|
||||
|
|
@ -5,12 +5,14 @@ import androidx.compose.ui.Modifier
|
|||
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.camera.CameraScreen
|
||||
import de.krisenvorrat.app.ui.category.CategoryListScreen
|
||||
import de.krisenvorrat.app.ui.dashboard.DashboardScreen
|
||||
import de.krisenvorrat.app.ui.item.ItemFormScreen
|
||||
import de.krisenvorrat.app.ui.item.ItemListScreen
|
||||
import de.krisenvorrat.app.ui.location.LocationListScreen
|
||||
import de.krisenvorrat.app.ui.messaging.ChatScreen
|
||||
import de.krisenvorrat.app.ui.messaging.UserListScreen
|
||||
import de.krisenvorrat.app.ui.settings.SettingsScreen
|
||||
import de.krisenvorrat.app.ui.warnings.WarningsScreen
|
||||
|
||||
|
|
@ -92,5 +94,19 @@ internal fun KrisenvorratNavGraph(
|
|||
composable<Screen.Settings> {
|
||||
SettingsScreen()
|
||||
}
|
||||
|
||||
composable<Screen.UserList> {
|
||||
UserListScreen(
|
||||
onUserClick = { id, username ->
|
||||
navController.navigate(Screen.Chat(recipientId = id, recipientUsername = username))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.Chat> {
|
||||
ChatScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,4 +28,10 @@ internal sealed interface Screen {
|
|||
|
||||
@Serializable
|
||||
data object Settings : Screen
|
||||
|
||||
@Serializable
|
||||
data object UserList : Screen
|
||||
|
||||
@Serializable
|
||||
data class Chat(val recipientId: String, val recipientUsername: String) : Screen
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package de.krisenvorrat.app.ui.navigation
|
||||
|
||||
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.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
|
|
@ -34,6 +36,12 @@ internal enum class TopLevelDestination(
|
|||
unselectedIcon = Icons.Outlined.Warning,
|
||||
label = "Warnungen"
|
||||
),
|
||||
MESSAGES(
|
||||
route = Screen.UserList,
|
||||
selectedIcon = Icons.AutoMirrored.Filled.Message,
|
||||
unselectedIcon = Icons.AutoMirrored.Outlined.Message,
|
||||
label = "Nachrichten"
|
||||
),
|
||||
SETTINGS(
|
||||
route = Screen.Settings,
|
||||
selectedIcon = Icons.Filled.Settings,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
package de.krisenvorrat.app.data.repository
|
||||
|
||||
import de.krisenvorrat.app.data.db.dao.MessageDao
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import de.krisenvorrat.app.data.sync.WebSocketClient
|
||||
import de.krisenvorrat.app.data.sync.WebSocketEvent
|
||||
import de.krisenvorrat.app.domain.model.SettingsKeys
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import de.krisenvorrat.shared.model.MessageDto
|
||||
import de.krisenvorrat.shared.model.UserListItemDto
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.engine.mock.respondError
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.headersOf
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private class FakeMessageDao : MessageDao {
|
||||
val upserted = mutableListOf<MessageEntity>()
|
||||
val delivered = mutableListOf<String>()
|
||||
|
||||
override suspend fun upsert(message: MessageEntity) { upserted.add(message) }
|
||||
override suspend fun insertIfNotExists(message: MessageEntity) { upserted.add(message) }
|
||||
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
|
||||
MutableStateFlow(upserted.filter {
|
||||
(it.senderId == myId && it.receiverId == otherId) ||
|
||||
(it.senderId == otherId && it.receiverId == myId)
|
||||
})
|
||||
override suspend fun getPendingMessages(): List<MessageEntity> =
|
||||
upserted.filter { it.isPending }
|
||||
override suspend fun markDelivered(id: String) {
|
||||
delivered.add(id)
|
||||
val idx = upserted.indexOfFirst { it.id == id }
|
||||
if (idx >= 0) upserted[idx] = upserted[idx].copy(isPending = false)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeMessageSettingsRepository : SettingsRepository {
|
||||
private val store = mutableMapOf<String, String>()
|
||||
fun set(key: String, value: String) { store[key] = value }
|
||||
|
||||
override suspend fun getValue(key: String): String? = store[key]
|
||||
override suspend fun setValue(key: String, value: String) { store[key] = value }
|
||||
override fun observeValue(key: String): Flow<String?> = MutableStateFlow(store[key])
|
||||
override fun getAll(): Flow<List<de.krisenvorrat.app.data.db.entity.SettingsEntity>> =
|
||||
MutableStateFlow(emptyList())
|
||||
}
|
||||
|
||||
private class FakeMessageWsClient : WebSocketClient {
|
||||
val events2 = MutableSharedFlow<WebSocketEvent>(extraBufferCapacity = 16)
|
||||
override val events: SharedFlow<WebSocketEvent> = events2.asSharedFlow()
|
||||
override fun connect(serverUrl: String, accessToken: String) = Unit
|
||||
override fun disconnect() = Unit
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MessageRepositoryImplTest {
|
||||
|
||||
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
private val testScope = TestScope()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
testScope.cancel()
|
||||
}
|
||||
|
||||
private fun createClient(engine: MockEngine): HttpClient = HttpClient(engine) {
|
||||
install(ContentNegotiation) { json(jsonSerializer) }
|
||||
}
|
||||
|
||||
private fun buildRepository(
|
||||
dao: FakeMessageDao,
|
||||
httpClient: HttpClient,
|
||||
settings: FakeMessageSettingsRepository,
|
||||
wsClient: FakeMessageWsClient = FakeMessageWsClient()
|
||||
) = MessageRepositoryImpl(
|
||||
dao = dao,
|
||||
httpClient = httpClient,
|
||||
settingsRepository = settings,
|
||||
webSocketClient = wsClient,
|
||||
scope = testScope
|
||||
)
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_whenServerReachable_marksDelivered() = runTest {
|
||||
// Given
|
||||
val dao = FakeMessageDao()
|
||||
val settings = FakeMessageSettingsRepository().apply {
|
||||
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
|
||||
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
|
||||
set(SettingsKeys.AUTH_USER_ID, "user1")
|
||||
set(SettingsKeys.AUTH_USERNAME, "Alice")
|
||||
}
|
||||
val engine = MockEngine {
|
||||
respond(
|
||||
content = """{"id":"x","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hi","sentAt":1}""",
|
||||
status = HttpStatusCode.Created,
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
}
|
||||
val repo = buildRepository(dao, createClient(engine), settings)
|
||||
|
||||
// When
|
||||
repo.sendMessage("user2", "hi")
|
||||
|
||||
// Then
|
||||
assertTrue(dao.delivered.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_whenServerUnreachable_remainsPending() = runTest {
|
||||
// Given
|
||||
val dao = FakeMessageDao()
|
||||
val settings = FakeMessageSettingsRepository().apply {
|
||||
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
|
||||
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
|
||||
set(SettingsKeys.AUTH_USER_ID, "user1")
|
||||
set(SettingsKeys.AUTH_USERNAME, "Alice")
|
||||
}
|
||||
val engine = MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }
|
||||
val repo = buildRepository(dao, createClient(engine), settings)
|
||||
|
||||
// When
|
||||
repo.sendMessage("user2", "hi")
|
||||
|
||||
// Then
|
||||
assertTrue(dao.delivered.isEmpty())
|
||||
assertTrue(dao.upserted.any { it.isPending })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_drainPendingMessages_sendsAllPending() = runTest {
|
||||
// Given
|
||||
val dao = FakeMessageDao()
|
||||
dao.upserted.add(
|
||||
MessageEntity("m1", "user1", "Alice", "user2", "hello", 1000L, isPending = true)
|
||||
)
|
||||
val settings = FakeMessageSettingsRepository().apply {
|
||||
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
|
||||
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
|
||||
}
|
||||
val engine = MockEngine {
|
||||
respond(
|
||||
content = """{"id":"m1","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hello","sentAt":1000}""",
|
||||
status = HttpStatusCode.Created,
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
}
|
||||
val repo = buildRepository(dao, createClient(engine), settings)
|
||||
|
||||
// When
|
||||
repo.drainPendingMessages()
|
||||
|
||||
// Then
|
||||
assertTrue(dao.delivered.contains("m1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_fetchUsers_returnsUserList() = runTest {
|
||||
// Given
|
||||
val settings = FakeMessageSettingsRepository().apply {
|
||||
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
|
||||
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
|
||||
}
|
||||
val users = listOf(UserListItemDto("u2", "Bob"), UserListItemDto("u3", "Carol"))
|
||||
val engine = MockEngine {
|
||||
respond(
|
||||
content = jsonSerializer.encodeToString(ListSerializer(UserListItemDto.serializer()), users),
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
}
|
||||
val repo = buildRepository(FakeMessageDao(), createClient(engine), settings)
|
||||
|
||||
// When
|
||||
val result = repo.fetchUsers()
|
||||
|
||||
// Then
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(2, result.getOrNull()?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_newMessageEvent_upsertedToDatabase() = runTest {
|
||||
// Given
|
||||
val dao = FakeMessageDao()
|
||||
val wsClient = FakeMessageWsClient()
|
||||
buildRepository(
|
||||
dao = dao,
|
||||
httpClient = createClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }),
|
||||
settings = FakeMessageSettingsRepository(),
|
||||
wsClient = wsClient
|
||||
)
|
||||
// Let init block start and subscribe before emitting
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
val msg = MessageDto("m1", "u2", "Bob", "u1", "hey", 1000L)
|
||||
|
||||
// When
|
||||
wsClient.events2.emit(WebSocketEvent.NewMessage(msg))
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(dao.upserted.any { it.id == "m1" && !it.isPending })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package de.krisenvorrat.app.ui.messaging
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import de.krisenvorrat.app.domain.repository.MessageRepository
|
||||
import de.krisenvorrat.shared.model.UserListItemDto
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
private class FakeChatMessageRepository(
|
||||
private val myUserId: String = "user1",
|
||||
private val conversation: MutableStateFlow<List<MessageEntity>> = MutableStateFlow(emptyList())
|
||||
) : MessageRepository {
|
||||
var sendMessageCalled = false
|
||||
var lastSentBody: String? = null
|
||||
|
||||
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> = conversation
|
||||
override suspend fun sendMessage(recipientId: String, body: String) {
|
||||
sendMessageCalled = true
|
||||
lastSentBody = body
|
||||
}
|
||||
override suspend fun fetchUsers(): Result<List<UserListItemDto>> = Result.success(emptyList())
|
||||
override suspend fun getMyUserId(): String = myUserId
|
||||
override suspend fun drainPendingMessages() = Unit
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
recipientId: String = "user2",
|
||||
recipientUsername: String = "Bob",
|
||||
repo: MessageRepository = FakeChatMessageRepository()
|
||||
): ChatViewModel {
|
||||
val savedStateHandle = SavedStateHandle(mapOf("recipientId" to recipientId, "recipientUsername" to recipientUsername))
|
||||
return ChatViewModel(savedStateHandle = savedStateHandle, messageRepository = repo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_uiState_showsCorrectRecipientUsername() = runTest(testDispatcher) {
|
||||
// Given / When
|
||||
val viewModel = createViewModel(recipientUsername = "Bob")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals("Bob", viewModel.uiState.value.recipientUsername)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_clearsInput() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.onInputChanged("Hello")
|
||||
|
||||
// When
|
||||
viewModel.sendMessage()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals("", viewModel.uiState.value.inputText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_whenEmptyBody_doesNotSend() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val fakeRepo = FakeChatMessageRepository()
|
||||
val viewModel = createViewModel(repo = fakeRepo)
|
||||
advanceUntilIdle()
|
||||
viewModel.onInputChanged(" ")
|
||||
|
||||
// When
|
||||
viewModel.sendMessage()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertFalse(fakeRepo.sendMessageCalled)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package de.krisenvorrat.app.ui.messaging
|
||||
|
||||
import de.krisenvorrat.app.data.db.entity.MessageEntity
|
||||
import de.krisenvorrat.app.domain.model.SyncError
|
||||
import de.krisenvorrat.app.domain.repository.MessageRepository
|
||||
import de.krisenvorrat.shared.model.UserListItemDto
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
private class FakeUserListMessageRepository(
|
||||
private val usersResult: Result<List<UserListItemDto>>
|
||||
) : MessageRepository {
|
||||
var fetchCount = 0
|
||||
|
||||
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
|
||||
MutableStateFlow(emptyList())
|
||||
override suspend fun sendMessage(recipientId: String, body: String) = Unit
|
||||
override suspend fun fetchUsers(): Result<List<UserListItemDto>> {
|
||||
fetchCount++
|
||||
return usersResult
|
||||
}
|
||||
override suspend fun getMyUserId(): String = ""
|
||||
override suspend fun drainPendingMessages() = Unit
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class UserListViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_loadUsers_success_showsUsers() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val users = listOf(UserListItemDto("u1", "Alice"), UserListItemDto("u2", "Bob"))
|
||||
val repo = FakeUserListMessageRepository(Result.success(users))
|
||||
val viewModel = UserListViewModel(repo)
|
||||
|
||||
// When
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(2, viewModel.uiState.value.users.size)
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
assertNull(viewModel.uiState.value.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_loadUsers_failure_showsError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val repo = FakeUserListMessageRepository(
|
||||
Result.failure(SyncError.ConnectionError())
|
||||
)
|
||||
val viewModel = UserListViewModel(repo)
|
||||
|
||||
// When
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
assertNotNull(viewModel.uiState.value.errorMessage)
|
||||
assertTrue(viewModel.uiState.value.users.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_retry_reloadsUsers() = runTest(testDispatcher) {
|
||||
// Given
|
||||
val repo = FakeUserListMessageRepository(Result.success(emptyList()))
|
||||
val viewModel = UserListViewModel(repo)
|
||||
advanceUntilIdle()
|
||||
val countBefore = repo.fetchCount
|
||||
|
||||
// When
|
||||
viewModel.retry()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertEquals(countBefore + 1, repo.fetchCount)
|
||||
}
|
||||
}
|
||||
|
|
@ -48,3 +48,9 @@ dependencies {
|
|||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
tasks {
|
||||
named("distZip") { dependsOn("shadowJar") }
|
||||
named("distTar") { dependsOn("shadowJar") }
|
||||
named("startScripts") { dependsOn("shadowJar") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ internal object DatabaseFactory {
|
|||
) {
|
||||
Database.connect(jdbcUrl, driver)
|
||||
transaction {
|
||||
SchemaUtils.create(Users, Categories, Locations, Items, Settings)
|
||||
SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings)
|
||||
SchemaUtils.create(Users, Categories, Locations, Items, Settings, Messages)
|
||||
SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings, Messages)
|
||||
}
|
||||
seedAdmin(adminPassword)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,3 +55,13 @@ internal object Settings : Table("settings") {
|
|||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
internal object Messages : Table("messages") {
|
||||
val id = varchar("id", 36)
|
||||
val senderId = varchar("sender_id", 36)
|
||||
val receiverId = varchar("receiver_id", 36)
|
||||
val body = text("body")
|
||||
val sentAt = long("sent_at")
|
||||
val deliveredAt = long("delivered_at").nullable()
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import kotlinx.serialization.Serializable
|
|||
internal data class LoginRequest(val username: String, val password: String)
|
||||
|
||||
@Serializable
|
||||
internal data class LoginResponse(val accessToken: String, val refreshToken: String)
|
||||
internal data class LoginResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val userId: String,
|
||||
val username: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class RefreshRequest(val refreshToken: String)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package de.krisenvorrat.server.plugins
|
||||
|
||||
import de.krisenvorrat.server.repository.InventoryRepository
|
||||
import de.krisenvorrat.server.repository.MessageRepository
|
||||
import de.krisenvorrat.server.repository.UserRepository
|
||||
import de.krisenvorrat.server.routes.adminRoutes
|
||||
import de.krisenvorrat.server.routes.authRoutes
|
||||
import de.krisenvorrat.server.routes.inventoryRoutes
|
||||
import de.krisenvorrat.server.routes.messageRoutes
|
||||
import de.krisenvorrat.server.routes.userRoutes
|
||||
import de.krisenvorrat.server.routes.webSocketRoutes
|
||||
import de.krisenvorrat.server.security.JwtService
|
||||
import de.krisenvorrat.server.websocket.WebSocketManager
|
||||
|
|
@ -20,6 +23,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||
internal fun Application.configureRouting(
|
||||
inventoryRepository: InventoryRepository = InventoryRepository(),
|
||||
userRepository: UserRepository = UserRepository(),
|
||||
messageRepository: MessageRepository = MessageRepository(),
|
||||
jwtService: JwtService = JwtService(environment.config),
|
||||
wsManager: WebSocketManager = WebSocketManager()
|
||||
) {
|
||||
|
|
@ -36,14 +40,16 @@ internal fun Application.configureRouting(
|
|||
// Public auth endpoints
|
||||
authRoutes(userRepository, jwtService)
|
||||
|
||||
// Protected inventory + admin endpoints
|
||||
// Protected endpoints
|
||||
authenticate("auth-jwt") {
|
||||
inventoryRoutes(inventoryRepository, wsManager)
|
||||
adminRoutes(userRepository)
|
||||
messageRoutes(messageRepository, userRepository, wsManager)
|
||||
userRoutes(userRepository)
|
||||
}
|
||||
|
||||
// WebSocket – auth via query param ?token=
|
||||
webSocketRoutes(wsManager, jwtService)
|
||||
webSocketRoutes(wsManager, jwtService, messageRepository)
|
||||
|
||||
// Admin web UI (static)
|
||||
staticResources("/admin", "static/admin")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
package de.krisenvorrat.server.repository
|
||||
|
||||
import de.krisenvorrat.server.db.Messages
|
||||
import de.krisenvorrat.server.db.Users
|
||||
import de.krisenvorrat.shared.model.MessageDto
|
||||
import org.jetbrains.exposed.sql.JoinType
|
||||
import org.jetbrains.exposed.sql.SortOrder
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.or
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
|
||||
internal class MessageRepository {
|
||||
|
||||
fun save(
|
||||
id: String,
|
||||
senderId: String,
|
||||
senderUsername: String,
|
||||
receiverId: String,
|
||||
body: String,
|
||||
sentAt: Long
|
||||
): MessageDto {
|
||||
transaction {
|
||||
Messages.insert {
|
||||
it[Messages.id] = id
|
||||
it[Messages.senderId] = senderId
|
||||
it[Messages.receiverId] = receiverId
|
||||
it[Messages.body] = body
|
||||
it[Messages.sentAt] = sentAt
|
||||
it[Messages.deliveredAt] = null
|
||||
}
|
||||
}
|
||||
return MessageDto(
|
||||
id = id,
|
||||
senderId = senderId,
|
||||
senderUsername = senderUsername,
|
||||
receiverId = receiverId,
|
||||
body = body,
|
||||
sentAt = sentAt,
|
||||
deliveredAt = null
|
||||
)
|
||||
}
|
||||
|
||||
fun getUndelivered(receiverId: String): List<MessageDto> = transaction {
|
||||
Messages.join(Users, JoinType.LEFT, Messages.senderId, Users.id)
|
||||
.selectAll()
|
||||
.where { (Messages.receiverId eq receiverId) and Messages.deliveredAt.isNull() }
|
||||
.map { row ->
|
||||
MessageDto(
|
||||
id = row[Messages.id],
|
||||
senderId = row[Messages.senderId],
|
||||
senderUsername = row.getOrNull(Users.username) ?: "",
|
||||
receiverId = row[Messages.receiverId],
|
||||
body = row[Messages.body],
|
||||
sentAt = row[Messages.sentAt],
|
||||
deliveredAt = row[Messages.deliveredAt]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markDelivered(messageId: String) {
|
||||
transaction {
|
||||
Messages.update({ Messages.id eq messageId }) {
|
||||
it[deliveredAt] = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllDeliveredForReceiver(receiverId: String) {
|
||||
transaction {
|
||||
Messages.update({
|
||||
(Messages.receiverId eq receiverId) and Messages.deliveredAt.isNull()
|
||||
}) {
|
||||
it[deliveredAt] = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getConversation(userId1: String, userId2: String): List<MessageDto> = transaction {
|
||||
Messages.join(Users, JoinType.LEFT, Messages.senderId, Users.id)
|
||||
.selectAll()
|
||||
.where {
|
||||
((Messages.senderId eq userId1) and (Messages.receiverId eq userId2)) or
|
||||
((Messages.senderId eq userId2) and (Messages.receiverId eq userId1))
|
||||
}
|
||||
.orderBy(Messages.sentAt to SortOrder.ASC)
|
||||
.map { row ->
|
||||
MessageDto(
|
||||
id = row[Messages.id],
|
||||
senderId = row[Messages.senderId],
|
||||
senderUsername = row.getOrNull(Users.username) ?: "",
|
||||
receiverId = row[Messages.receiverId],
|
||||
body = row[Messages.body],
|
||||
sentAt = row[Messages.sentAt],
|
||||
deliveredAt = row[Messages.deliveredAt]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,15 @@ internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtSer
|
|||
}
|
||||
val accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin)
|
||||
val refreshToken = jwtService.generateRefreshToken(user.id)
|
||||
call.respond(HttpStatusCode.OK, LoginResponse(accessToken = accessToken, refreshToken = refreshToken))
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
LoginResponse(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
userId = user.id,
|
||||
username = user.username
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
post("/refresh") {
|
||||
|
|
@ -49,7 +57,15 @@ internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtSer
|
|||
}
|
||||
val accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin)
|
||||
val refreshToken = jwtService.generateRefreshToken(user.id)
|
||||
call.respond(HttpStatusCode.OK, LoginResponse(accessToken = accessToken, refreshToken = refreshToken))
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
LoginResponse(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
userId = user.id,
|
||||
username = user.username
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
package de.krisenvorrat.server.routes
|
||||
|
||||
import de.krisenvorrat.server.model.ErrorResponse
|
||||
import de.krisenvorrat.server.repository.MessageRepository
|
||||
import de.krisenvorrat.server.repository.UserRepository
|
||||
import de.krisenvorrat.server.security.UserPrincipal
|
||||
import de.krisenvorrat.server.websocket.WebSocketManager
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
internal data class SendMessageRequest(
|
||||
val id: String? = null,
|
||||
val receiverId: String,
|
||||
val body: String,
|
||||
val sentAt: Long
|
||||
)
|
||||
|
||||
internal fun Route.messageRoutes(
|
||||
messageRepository: MessageRepository,
|
||||
userRepository: UserRepository,
|
||||
wsManager: WebSocketManager
|
||||
) {
|
||||
route("/api/messages") {
|
||||
post {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
?: return@post call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ErrorResponse(status = 401, message = "Unauthorized")
|
||||
)
|
||||
val request = call.receive<SendMessageRequest>()
|
||||
if (request.body.isBlank()) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorResponse(status = 400, message = "Body must not be empty")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
if (userRepository.findById(request.receiverId) == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.NotFound,
|
||||
ErrorResponse(status = 404, message = "Receiver not found")
|
||||
)
|
||||
return@post
|
||||
}
|
||||
val msgId = request.id ?: UUID.randomUUID().toString()
|
||||
val message = messageRepository.save(
|
||||
id = msgId,
|
||||
senderId = principal.userId,
|
||||
senderUsername = principal.username,
|
||||
receiverId = request.receiverId,
|
||||
body = request.body,
|
||||
sentAt = request.sentAt
|
||||
)
|
||||
wsManager.notifyNewMessage(request.receiverId, message)
|
||||
if (wsManager.isOnline(request.receiverId)) {
|
||||
messageRepository.markDelivered(msgId)
|
||||
}
|
||||
call.respond(HttpStatusCode.Created, message)
|
||||
}
|
||||
|
||||
get("/{userId}") {
|
||||
val myId = call.principal<UserPrincipal>()?.userId
|
||||
?: return@get call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ErrorResponse(status = 401, message = "Unauthorized")
|
||||
)
|
||||
val otherId = call.parameters["userId"]
|
||||
?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorResponse(status = 400, message = "Missing userId")
|
||||
)
|
||||
val messages = messageRepository.getConversation(myId, otherId)
|
||||
call.respond(HttpStatusCode.OK, messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package de.krisenvorrat.server.routes
|
||||
|
||||
import de.krisenvorrat.server.model.ErrorResponse
|
||||
import de.krisenvorrat.server.repository.UserRepository
|
||||
import de.krisenvorrat.server.security.UserPrincipal
|
||||
import de.krisenvorrat.shared.model.UserListItemDto
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
internal fun Route.userRoutes(userRepository: UserRepository) {
|
||||
get("/api/users") {
|
||||
val principal = call.principal<UserPrincipal>()
|
||||
?: return@get call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ErrorResponse(status = 401, message = "Unauthorized")
|
||||
)
|
||||
val users = userRepository.listAll()
|
||||
.filter { it.id != principal.userId }
|
||||
.map { UserListItemDto(id = it.id, username = it.username) }
|
||||
call.respond(HttpStatusCode.OK, users)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,21 @@
|
|||
package de.krisenvorrat.server.routes
|
||||
|
||||
import de.krisenvorrat.server.repository.MessageRepository
|
||||
import de.krisenvorrat.server.security.JwtService
|
||||
import de.krisenvorrat.server.websocket.WebSocketManager
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.websocket.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal fun Route.webSocketRoutes(wsManager: WebSocketManager, jwtService: JwtService) {
|
||||
internal fun Route.webSocketRoutes(
|
||||
wsManager: WebSocketManager,
|
||||
jwtService: JwtService,
|
||||
messageRepository: MessageRepository
|
||||
) {
|
||||
webSocket("/ws/sync") {
|
||||
val token = call.request.queryParameters["token"]
|
||||
if (token == null) {
|
||||
|
|
@ -21,6 +30,22 @@ internal fun Route.webSocketRoutes(wsManager: WebSocketManager, jwtService: JwtS
|
|||
return@webSocket
|
||||
}
|
||||
wsManager.addSession(userId, this)
|
||||
val pending = messageRepository.getUndelivered(userId)
|
||||
for (msg in pending) {
|
||||
val payload = Json.encodeToString(buildJsonObject {
|
||||
put("type", "new_message")
|
||||
put("id", msg.id)
|
||||
put("senderId", msg.senderId)
|
||||
put("senderUsername", msg.senderUsername)
|
||||
put("receiverId", msg.receiverId)
|
||||
put("body", msg.body)
|
||||
put("sentAt", msg.sentAt)
|
||||
})
|
||||
send(Frame.Text(payload))
|
||||
}
|
||||
if (pending.isNotEmpty()) {
|
||||
messageRepository.markAllDeliveredForReceiver(userId)
|
||||
}
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
// Client frames are accepted but not processed (server push only)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package de.krisenvorrat.server.websocket
|
||||
|
||||
import de.krisenvorrat.shared.model.MessageDto
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -21,6 +22,8 @@ internal class WebSocketManager {
|
|||
sessions[userId]?.remove(session)
|
||||
}
|
||||
|
||||
fun isOnline(userId: String): Boolean = sessions[userId]?.isNotEmpty() == true
|
||||
|
||||
suspend fun notifyInventoryUpdated(userId: String, itemId: String) {
|
||||
val payload = buildJsonObject {
|
||||
put("type", "inventory_updated")
|
||||
|
|
@ -38,6 +41,19 @@ internal class WebSocketManager {
|
|||
broadcast(userId, Json.encodeToString(payload))
|
||||
}
|
||||
|
||||
suspend fun notifyNewMessage(receiverId: String, message: MessageDto) {
|
||||
val payload = buildJsonObject {
|
||||
put("type", "new_message")
|
||||
put("id", message.id)
|
||||
put("senderId", message.senderId)
|
||||
put("senderUsername", message.senderUsername)
|
||||
put("receiverId", message.receiverId)
|
||||
put("body", message.body)
|
||||
put("sentAt", message.sentAt)
|
||||
}
|
||||
broadcast(receiverId, Json.encodeToString(payload))
|
||||
}
|
||||
|
||||
private suspend fun broadcast(userId: String, message: String) {
|
||||
val userSessions = sessions[userId] ?: return
|
||||
val toRemove = mutableListOf<WebSocketSession>()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
.modal { background: #fff; border-radius: 8px; padding: 28px; width: 360px; }
|
||||
.modal h3 { margin-bottom: 16px; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
|
||||
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -253,6 +254,8 @@
|
|||
function logout() {
|
||||
accessToken = '';
|
||||
sessionStorage.removeItem('accessToken');
|
||||
document.getElementById('login-username').value = '';
|
||||
document.getElementById('login-password').value = '';
|
||||
document.getElementById('login-section').style.display = 'block';
|
||||
document.getElementById('admin-section').style.display = 'none';
|
||||
document.getElementById('logout-btn').style.display = 'none';
|
||||
|
|
@ -260,5 +263,6 @@
|
|||
|
||||
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
|
||||
</script>
|
||||
<footer>Krisenvorrat Server v0.2 · © 2026 faenocasul</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package de.krisenvorrat.shared.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MessageDto(
|
||||
val id: String,
|
||||
val senderId: String,
|
||||
val senderUsername: String,
|
||||
val receiverId: String,
|
||||
val body: String,
|
||||
val sentAt: Long,
|
||||
val deliveredAt: Long? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package de.krisenvorrat.shared.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserListItemDto(
|
||||
val id: String,
|
||||
val username: String
|
||||
)
|
||||
Loading…
Reference in a new issue