Upload via GUI (67 Dateien)

This commit is contained in:
2026-10-23 17:24:21 +00:00
parent 0c5967aa89
commit 1beee34c01
26 changed files with 3009 additions and 1678 deletions
+3
View File
@@ -53,5 +53,8 @@
android:name="android.appwidget.provider"
android:resource="@xml/deskhub_widget_info" />
</receiver>
<receiver
android:name=".WidgetActionReceiver"
android:exported="false" />
</application>
</manifest>
@@ -3,99 +3,195 @@ package de.deskhub.staff
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import de.deskhub.staff.data.remote.ApiException
import de.deskhub.staff.data.remote.DeskHubApi
import de.deskhub.staff.data.security.SecureSessionStore
import de.deskhub.staff.push.DeskHubPush
import kotlinx.coroutines.runBlocking
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Startbildschirm-Widget: Anzahl der mir zugewiesenen offenen, der nicht zugewiesenen und der überfälligen Tickets.
* Es zeigt bewusst nur Zahlen (keine Betreffzeilen oder Kundennamen), damit nichts Vertrauliches auf dem Startbildschirm steht.
* Aktualisiert sich alle 30 Minuten, beim Tippen auf ⟳ und beim Öffnen der App.
* Startbildschirm-Widget in drei Größen (klein, mittel, groß), hell und dunkel passend zum System.
* - klein: Meine offenen und Überfällige
* - mittel: vier Kennzahlen und die drei wichtigsten Tickets (nur Nummern, Tipp öffnet das Ticket)
* - groß: zusätzlich „Neues Ticket“, „Alle Tickets“ und der eigene Status zum direkten Umschalten
* Es zeigt bewusst keine Betreffzeilen oder Kundennamen, damit nichts Vertrauliches auf dem Startbildschirm steht.
* Aktualisiert sich nach Android-Vorgabe etwa alle 30 Minuten, beim Tippen auf ⟳, beim Öffnen der App und bei jeder Push-Nachricht.
*/
class DeskHubWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, manager: AppWidgetManager, appWidgetIds: IntArray) {
refresh(context, manager, appWidgetIds)
refresh(context, appWidgetIds, goAsync())
}
override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle) {
// Größe geändert: mit den gespeicherten Daten passend neu zeichnen (ohne Netz)
manager.updateAppWidget(appWidgetId, views(context, Cache.read(context), layoutFor(newOptions)))
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_REFRESH) {
val manager = AppWidgetManager.getInstance(context)
refresh(context, manager, manager.getAppWidgetIds(ComponentName(context, DeskHubWidget::class.java)))
refresh(context, null, goAsync())
} else {
super.onReceive(context, intent)
}
}
private fun refresh(context: Context, manager: AppWidgetManager, ids: IntArray) {
if (ids.isEmpty()) return
val pending = goAsync()
ids.forEach { manager.updateAppWidget(it, views(context, message = "Aktualisiere …")) }
/** Gespeicherter Stand (damit eine Größenänderung nicht jedes Mal das Netz braucht). */
data class Snapshot(
val my: Int = 0, val unassigned: Int = 0, val overdue: Int = 0, val unanswered: Int = 0,
val recent: List<String> = emptyList(), val status: String? = null, val at: Long = 0L, val message: String? = null,
)
private object Cache {
private const val FILE = "deskhub_widget"
fun read(context: Context): Snapshot {
val p = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
return Snapshot(
my = p.getInt("my", 0), unassigned = p.getInt("un", 0), overdue = p.getInt("od", 0), unanswered = p.getInt("ua", 0),
recent = p.getString("recent", "").orEmpty().split('|').filter { it.isNotBlank() },
status = p.getString("status", null), at = p.getLong("at", 0L), message = p.getString("message", if (p.contains("at")) null else "Lade …"),
)
}
fun write(context: Context, s: Snapshot) {
context.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit()
.putInt("my", s.my).putInt("un", s.unassigned).putInt("od", s.overdue).putInt("ua", s.unanswered)
.putString("recent", s.recent.joinToString("|")).putString("status", s.status).putLong("at", s.at).putString("message", s.message).apply()
}
}
private fun layoutFor(options: Bundle?): Int {
val w = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 250) ?: 250
val h = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 110) ?: 110
return when {
w < 200 -> R.layout.widget_deskhub_small
h < 170 -> R.layout.widget_deskhub
else -> R.layout.widget_deskhub_large
}
}
private fun refresh(context: Context, ids: IntArray?, pending: BroadcastReceiver.PendingResult?, setStatus: String? = null) {
val manager = AppWidgetManager.getInstance(context)
val all = ids ?: manager.getAppWidgetIds(ComponentName(context, DeskHubWidget::class.java))
if (all.isEmpty()) { pending?.finish(); return }
paint(context, manager, all, Cache.read(context).copy(message = if (Cache.read(context).at == 0L) "Aktualisiere …" else null))
Thread {
val result = try {
load(context)
} catch (_: Exception) {
WidgetState(message = "Keine Verbindung zum Server.")
}
ids.forEach { manager.updateAppWidget(it, views(context, result.counts, result.message)) }
pending.finish()
val result = try { load(context, setStatus) } catch (_: Exception) { Cache.read(context).copy(message = "Keine Verbindung zum Server.") }
if (result.message == null) Cache.write(context, result)
paint(context, manager, all, result)
pending?.finish()
}.start()
}
private data class WidgetState(val counts: Triple<Int, Int, Int>? = null, val message: String? = null)
private fun paint(context: Context, manager: AppWidgetManager, ids: IntArray, s: Snapshot) {
ids.forEach { manager.updateAppWidget(it, views(context, s, layoutFor(manager.getAppWidgetOptions(it)))) }
}
private fun load(context: Context): WidgetState {
private fun load(context: Context, setStatus: String?): Snapshot {
val session = SecureSessionStore(context.applicationContext).load()
if (session == null || session.token.isBlank() || session.serverUrl.isBlank()) {
return WidgetState(message = "Bitte in der App anmelden.")
}
if (session == null || session.token.isBlank() || session.serverUrl.isBlank()) return Snapshot(message = "Bitte in der App anmelden.")
return try {
val api = DeskHubApi(session.serverUrl) { session.token }
if (setStatus != null) runBlocking { api.setAvailability(setStatus, "") }
val d = runBlocking { api.dashboard() }
WidgetState(counts = Triple(d.myOpen, d.unassigned, d.overdue))
val status = runCatching { runBlocking { api.availability() }.myStatus }.getOrNull()
Snapshot(d.myOpen, d.unassigned, d.overdue, d.unanswered, d.recent.map { it.number }.take(3), status, System.currentTimeMillis())
} catch (e: ApiException) {
if (e.status == 401 || e.status == 403) WidgetState(message = "Bitte in der App neu anmelden.") else WidgetState(message = "Server-Fehler (${e.status}).")
if (e.status == 401 || e.status == 403) Snapshot(message = "Bitte in der App neu anmelden.") else Snapshot(message = "Server-Fehler (${e.status}).")
}
}
private fun views(context: Context, counts: Triple<Int, Int, Int>? = null, message: String? = null): RemoteViews {
val v = RemoteViews(context.packageName, R.layout.widget_deskhub)
private fun views(context: Context, s: Snapshot, layout: Int): RemoteViews {
val v = RemoteViews(context.packageName, layout)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val open = PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), flags)
v.setOnClickPendingIntent(R.id.widget_root, open)
val refresh = Intent(context, DeskHubWidget::class.java).setAction(ACTION_REFRESH)
v.setOnClickPendingIntent(R.id.widget_refresh, PendingIntent.getBroadcast(context, 1, refresh, flags))
if (counts != null) {
v.setTextViewText(R.id.widget_my_open, counts.first.toString())
v.setTextViewText(R.id.widget_unassigned, counts.second.toString())
v.setTextViewText(R.id.widget_overdue, counts.third.toString())
fun open(code: Int, data: String, extra: (Intent) -> Unit = {}): PendingIntent {
val i = Intent(context, MainActivity::class.java).setData(Uri.parse("deskhub://widget/$data")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
extra(i)
return PendingIntent.getActivity(context, code, i, flags)
}
fun action(code: Int, action: String, status: String? = null): PendingIntent {
val i = Intent(context, WidgetActionReceiver::class.java).setAction(action).setData(Uri.parse("deskhub://widget-action/$action/${status.orEmpty()}"))
status?.let { i.putExtra(WidgetActionReceiver.EXTRA_STATUS, it) }
return PendingIntent.getBroadcast(context, code, i, flags)
}
v.setOnClickPendingIntent(R.id.widget_root, open(0, "home"))
v.setOnClickPendingIntent(R.id.widget_refresh, action(1, ACTION_REFRESH))
v.setOnClickPendingIntent(R.id.widget_btn_new, open(2, "new") { it.putExtra(EXTRA_ROUTE, "newTicket") })
v.setOnClickPendingIntent(R.id.widget_btn_tickets, open(3, "tickets") { it.putExtra(EXTRA_ROUTE, "tickets") })
listOf("available" to R.id.widget_st_available, "busy" to R.id.widget_st_busy, "break" to R.id.widget_st_break, "offline" to R.id.widget_st_offline)
.forEachIndexed { index, (status, id) ->
v.setOnClickPendingIntent(id, action(10 + index, WidgetActionReceiver.ACTION_SET_STATUS, status))
v.setInt(id, "setBackgroundResource", if (s.status == status) R.drawable.widget_chip_on else R.drawable.widget_chip)
}
if (s.message == null && s.at > 0) {
v.setTextViewText(R.id.widget_my_open, s.my.toString())
v.setTextViewText(R.id.widget_unassigned, s.unassigned.toString())
v.setTextViewText(R.id.widget_overdue, s.overdue.toString())
v.setTextViewText(R.id.widget_unanswered, s.unanswered.toString())
v.setInt(R.id.widget_tile_overdue, "setBackgroundResource", if (s.overdue > 0) R.drawable.widget_tile_alert else R.drawable.widget_tile)
v.setTextViewText(R.id.widget_time, "Stand " + SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(s.at)))
v.setViewVisibility(R.id.widget_message, View.GONE)
v.setViewVisibility(R.id.widget_tiles, View.VISIBLE)
v.setTextViewText(R.id.widget_time, "Stand " + SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date()))
listOf(R.id.widget_t1, R.id.widget_t2, R.id.widget_t3).forEachIndexed { index, id ->
val number = s.recent.getOrNull(index)
if (number == null) {
v.setViewVisibility(id, View.GONE)
} else {
v.setTextViewText(id, "#" + number.substringAfterLast('-'))
v.setViewVisibility(id, View.VISIBLE)
v.setOnClickPendingIntent(id, open(20 + index, "ticket/$number") { it.putExtra(DeskHubPush.EXTRA_TICKET, number) })
}
}
} else {
v.setTextViewText(R.id.widget_message, message ?: "")
v.setTextViewText(R.id.widget_message, s.message ?: "Lade …")
v.setViewVisibility(R.id.widget_message, View.VISIBLE)
v.setViewVisibility(R.id.widget_tiles, View.INVISIBLE)
v.setTextViewText(R.id.widget_time, "")
listOf(R.id.widget_t1, R.id.widget_t2, R.id.widget_t3).forEach { v.setViewVisibility(it, View.GONE) }
}
return v
}
/** Für den nicht exportierten Empfänger der Widget-Knöpfe. */
internal fun refreshFromAction(context: Context, pending: BroadcastReceiver.PendingResult?, setStatus: String?) = refresh(context, null, pending, setStatus)
companion object {
const val ACTION_REFRESH = "de.deskhub.staff.WIDGET_REFRESH"
const val EXTRA_ROUTE = "de.deskhub.staff.extra.ROUTE"
/** Stößt eine Aktualisierung aller Widgets an (zum Beispiel beim Öffnen der App). */
/** Stößt eine Aktualisierung aller Widgets an (zum Beispiel beim Öffnen der App oder bei einer Push-Nachricht). */
fun requestUpdate(context: Context) {
val intent = Intent(context, DeskHubWidget::class.java).setAction(ACTION_REFRESH)
context.sendBroadcast(intent)
}
}
}
/**
* Empfänger für die Knöpfe im Widget (Aktualisieren, Status setzen). Nicht exportiert: Nur das Widget selbst kann ihn auslösen,
* keine andere App kann den Status des Mitarbeiters ändern.
*/
class WidgetActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
val status = if (intent.action == ACTION_SET_STATUS) intent.getStringExtra(EXTRA_STATUS)?.takeIf { it in ALLOWED } else null
if (intent.action == ACTION_SET_STATUS && status == null) { pending.finish(); return }
DeskHubWidget().refreshFromAction(context, pending, status)
}
companion object {
const val ACTION_SET_STATUS = "de.deskhub.staff.WIDGET_SET_STATUS"
const val EXTRA_STATUS = "de.deskhub.staff.extra.STATUS"
private val ALLOWED = setOf("available", "busy", "break", "offline")
}
}
@@ -69,6 +69,12 @@ class MainActivity : ComponentActivity() {
intent?.removeExtra(DeskHubPush.EXTRA_TICKET)
viewModel.openTicketFromNotification(number)
}
// Knöpfe im Widget: „Neues Ticket“ und „Alle Tickets"
val route = intent?.getStringExtra(DeskHubWidget.EXTRA_ROUTE)?.trim().orEmpty()
if (route == "newTicket" || route == "tickets") {
intent?.removeExtra(DeskHubWidget.EXTRA_ROUTE)
viewModel.requestRoute(route)
}
}
private fun requestNotificationPermissionOnce() {
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,12 @@ package de.deskhub.staff.data.remote
import com.google.gson.GsonBuilder
import de.deskhub.staff.data.remote.dto.ActivityResponseDto
import de.deskhub.staff.data.remote.dto.AssistantAnswerResponseDto
import de.deskhub.staff.data.remote.dto.AssistantAnswersDto
import de.deskhub.staff.data.remote.dto.AssistantLogDto
import de.deskhub.staff.data.remote.dto.AssistantOkDto
import de.deskhub.staff.data.remote.dto.AssistantOverviewDto
import de.deskhub.staff.data.remote.dto.AssistantTestDto
import de.deskhub.staff.data.remote.dto.CannedResponseListResponse
import de.deskhub.staff.data.remote.dto.CreateTicketRequest
import de.deskhub.staff.data.remote.dto.CreateTicketResponse
@@ -166,6 +172,24 @@ class DeskHubApi(
query = mapOf("number" to number),
)
suspend fun assistantOverview(): AssistantOverviewDto =
execute("assistant.php", "GET", null, AssistantOverviewDto::class.java, query = mapOf("section" to "overview"))
suspend fun assistantAnswers(): AssistantAnswersDto =
execute("assistant.php", "GET", null, AssistantAnswersDto::class.java, query = mapOf("section" to "answers"))
suspend fun assistantLog(query: Map<String, String>): AssistantLogDto =
execute("assistant.php", "GET", null, AssistantLogDto::class.java, query = query + ("section" to "log"))
suspend fun assistantAction(request: Map<String, Any?>): AssistantOkDto =
execute("assistant.php", "POST", request, AssistantOkDto::class.java)
suspend fun assistantSaveAnswer(request: Map<String, Any?>): AssistantAnswerResponseDto =
execute("assistant.php", "POST", request, AssistantAnswerResponseDto::class.java)
suspend fun assistantTest(question: String): AssistantTestDto =
execute("assistant.php", "POST", mapOf("action" to "test", "question" to question), AssistantTestDto::class.java)
suspend fun updateTicket(request: Map<String, Any?>): TicketSummaryResponse =
execute("ticket_update.php", "PATCH", request, TicketSummaryResponse::class.java)
@@ -1,5 +1,84 @@
package de.deskhub.staff.data.remote.dto
// ── Digitaler Assistent (Verwaltung) ─────────────────────────────────────────
data class AssistantSettingsDto(
val enabled: Boolean = false,
val mode: String = "offline_only",
val name: String = "",
val greeting: String = "",
val noAnswer: String = "",
val departmentId: Int = 0,
val priorityId: Int = 0,
val logDays: Int = 90,
val synonyms: String = "",
)
data class AssistantDefaultsDto(val name: String = "", val greeting: String = "", val noAnswer: String = "")
data class AssistantStateDto(
val activeNow: Boolean = false,
val liveChatEnabled: Boolean = false,
val kbEnabled: Boolean = true,
val helpdeskOffline: Boolean = false,
)
data class AssistantOverviewDto(
val dbReady: Boolean = true,
val settings: AssistantSettingsDto = AssistantSettingsDto(),
val defaults: AssistantDefaultsDto = AssistantDefaultsDto(),
val state: AssistantStateDto = AssistantStateDto(),
val departments: List<NamedOptionDto> = emptyList(),
val priorities: List<NamedOptionDto> = emptyList(),
)
data class AssistantAnswerDto(
val id: Int = 0,
val title: String = "",
val keywords: String = "",
val answer: String = "",
val isActive: Boolean = true,
val hits: Int = 0,
val updatedAt: String = "",
)
data class AssistantAnswersDto(val items: List<AssistantAnswerDto> = emptyList())
data class AssistantAnswerResponseDto(val item: AssistantAnswerDto = AssistantAnswerDto())
data class AssistantLogItemDto(
val id: Int = 0,
val question: String = "",
val result: String = "",
val matchedTitle: String? = null,
val ticketNumber: String? = null,
val helpful: Boolean? = null,
val createdAt: String = "",
)
data class AssistantTopDto(val question: String = "", val n: Int = 0)
data class AssistantLogDto(
val filters: Map<String, String> = emptyMap(),
val stats: Map<String, Int> = emptyMap(),
val top: List<AssistantTopDto> = emptyList(),
val total: Int = 0,
val pages: Int = 1,
val page: Int = 1,
val items: List<AssistantLogItemDto> = emptyList(),
)
data class AssistantTestResultDto(
val source: String = "",
val title: String = "",
val text: String = "",
val others: List<String> = emptyList(),
)
data class AssistantTestDto(val found: Boolean = false, val result: AssistantTestResultDto? = null)
data class AssistantOkDto(val ok: Boolean = true, val groups: Int = 0, val deleted: Int = 0)
data class StaffDto(
val id: Int = 0,
val name: String = "",
@@ -40,6 +119,9 @@ data class TicketSummaryDto(
val isSpam: Boolean = false,
val createdAt: String = "",
val updatedAt: String = "",
// Wiedervorlage (Zeitpunkt, bis zu dem das Ticket ausgeblendet ist) und ob der Mitarbeiter das Ticket beobachtet
val snoozedUntil: String? = null,
val following: Boolean = false,
)
data class AttachmentDto(
@@ -93,6 +175,19 @@ data class TicketDetailDto(
val replySignatures: ReplySignaturesDto? = null,
// Vom Kunden im Formular ausgefüllte Zusatzfelder; leer bei älteren Servern
val customFields: List<CustomFieldValueDto> = emptyList(),
val snoozedUntil: String? = null,
val following: Boolean = false,
// Passende Artikel und ähnliche gelöste Tickets; null bei älteren Servern
val similar: SimilarDto? = null,
)
data class SimilarArticleDto(val title: String = "", val url: String = "")
data class SimilarTicketDto(val number: String = "", val subject: String = "")
data class SimilarDto(
val articles: List<SimilarArticleDto> = emptyList(),
val tickets: List<SimilarTicketDto> = emptyList(),
)
data class CustomFieldValueDto(
@@ -43,6 +43,10 @@ data class TicketSummary(
val isSpam: Boolean,
val createdAt: String,
val updatedAt: String,
/** Wiedervorlage: Das Ticket ist bis zu diesem Zeitpunkt ausgeblendet. */
val snoozedUntil: String? = null,
/** Der angemeldete Mitarbeiter beobachtet dieses Ticket. */
val following: Boolean = false,
)
data class ThreadAttachment(
@@ -88,6 +92,13 @@ data class TicketDetail(
// Administrator: Name des Mitarbeiters, der dieses Ticket gerade bearbeitet (Sperre), und ob die Sperre aufgehoben werden darf
val lockedBy: String? = null,
val canUnlock: Boolean = false,
/** Passende Wissensdatenbank-Artikel und ähnliche gelöste Tickets (null bei älteren Servern). */
val similar: SimilarTickets? = null,
)
data class SimilarTickets(
val articles: List<Pair<String, String>> = emptyList(), // Titel, Adresse
val tickets: List<Pair<String, String>> = emptyList(), // Nummer, Betreff
)
data class CustomFieldValue(val label: String, val value: String)
@@ -198,6 +209,9 @@ data class TicketFilters(
val tagId: Int? = null,
// "" (alle) | "overdue" | "at_risk" | "on_time" – siehe staff/tickets.php für dieselbe Logik.
val sla: String = "",
/** Nur Tickets in der Wiedervorlage / nur beobachtete Tickets. */
val snoozed: Boolean = false,
val following: Boolean = false,
)
/** Gespeicherte Ticket-Ansicht (Filterkombination), eigene oder von Kollegen geteilte. */
@@ -1,12 +1,14 @@
package de.deskhub.staff.push
import com.google.firebase.messaging.FirebaseMessagingService
import de.deskhub.staff.DeskHubWidget
import com.google.firebase.messaging.RemoteMessage
/** Empfängt Nachrichten von Firebase Cloud Messaging (auch wenn die App geschlossen ist). */
class DeskHubMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
DeskHubPush.show(applicationContext, message.data)
DeskHubWidget.requestUpdate(applicationContext) // Widget zeigt sofort den neuen Stand
}
/** Firebase vergibt ein neues Geräte-Token (zum Beispiel nach Neuinstallation): beim Server melden. */
@@ -0,0 +1,290 @@
package de.deskhub.staff.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.deskhub.staff.data.remote.dto.AssistantAnswerDto
import de.deskhub.staff.data.remote.dto.AssistantSettingsDto
private val ASSISTANT_TABS = listOf("Einstellungen", "Antworten", "Fragen-Log", "Wörter", "Ausprobieren")
/** Verwaltung des digitalen Assistenten: Einstellungen, zusätzliche Antworten, Fragen-Log, Gleichbedeutende Wörter, Ausprobieren. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminAssistantScreen(state: AssistantUiState, viewModel: DeskHubViewModel, onBack: () -> Unit) {
var tab by rememberSaveable { mutableStateOf(0) }
// Aus dem Fragen-Log: eine neue Antwort zu dieser Frage anlegen
var prefill by rememberSaveable { mutableStateOf<String?>(null) }
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) { viewModel.assistantLoadOverview() }
LaunchedEffect(tab) {
when (tab) {
1 -> viewModel.assistantLoadAnswers()
2 -> viewModel.assistantLoadLog()
3 -> viewModel.assistantLoadOverview()
}
}
LaunchedEffect(state.message) {
state.message?.let { snackbar.showSnackbar(it); viewModel.assistantClearMessage() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Digitaler Assistent", fontWeight = FontWeight.Bold) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } },
colors = deskTopBarColors(),
)
},
snackbarHost = { SnackbarHost(snackbar) },
) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
ScrollableTabRow(selectedTabIndex = tab, edgePadding = 8.dp) {
ASSISTANT_TABS.forEachIndexed { i, label -> Tab(selected = tab == i, onClick = { tab = i }, text = { Text(label) }) }
}
if (state.error != null) Text(state.error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
when (tab) {
0 -> AssistantSettingsTab(state, viewModel)
1 -> AssistantAnswersTab(state, viewModel, prefill) { prefill = null }
2 -> AssistantLogTab(state, viewModel) { q -> prefill = q; tab = 1 }
3 -> AssistantSynonymsTab(state, viewModel)
else -> AssistantTestTab(state, viewModel)
}
}
}
}
@Composable
private fun PickField(label: String, options: List<Pair<String, String>>, value: String, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) }
Column {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) { Text(options.firstOrNull { it.first == value }?.second ?: "—", maxLines = 2) }
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
options.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { onPick(v); open = false }) }
}
}
}
@Composable
private fun AssistantSettingsTab(state: AssistantUiState, vm: DeskHubViewModel) {
val o = state.overview
if (o == null) { Text("Wird geladen …", modifier = Modifier.padding(16.dp)); return }
var s by remember(o) { mutableStateOf(o.settings) }
var days by remember(o) { mutableStateOf(o.settings.logDays.toString()) }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (!o.dbReady) Text("Die Datenbank ist noch nicht aktuell. Bitte im Adminportal unter System-Update „Datenbank aktualisieren“ klicken.", color = MaterialTheme.colorScheme.error)
Text("Der Assistent beantwortet Fragen von Kunden aus der Wissensdatenbank und den zusätzlichen Antworten. Findet er nichts, bietet er ein Ticket an. Es wird kein externer KI-Dienst verwendet.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
val zustand = if (!o.settings.enabled) "ausgeschaltet" else if (o.state.activeNow) "aktiv (Kunden sehen den Assistenten)" else "bereit, aber gerade nicht sichtbar"
Text("Zustand jetzt: $zustand", fontWeight = FontWeight.SemiBold)
Text("Live-Chat ${if (o.state.liveChatEnabled) "an" else "aus"} · Wissensdatenbank im Portal ${if (o.state.kbEnabled) "an" else "aus"}", style = MaterialTheme.typography.bodySmall)
Row(verticalAlignment = Alignment.CenterVertically) { Switch(checked = s.enabled, onCheckedChange = { s = s.copy(enabled = it) }); Text(" Digitalen Assistenten einschalten") }
PickField("Wann ist er aktiv?", listOf("offline_only" to "Nur wenn der Live-Chat aus oder niemand erreichbar ist (Standard)", "always" to "Immer (zum Testen)"), s.mode) { s = s.copy(mode = it) }
OutlinedTextField(s.name, { s = s.copy(name = it.take(60)) }, label = { Text("Name des Assistenten") }, placeholder = { Text(o.defaults.name) }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(s.greeting, { s = s.copy(greeting = it.take(500)) }, label = { Text("Begrüßung") }, placeholder = { Text(o.defaults.greeting) }, modifier = Modifier.fillMaxWidth(), minLines = 3)
OutlinedTextField(s.noAnswer, { s = s.copy(noAnswer = it.take(500)) }, label = { Text("Text, wenn er keine Antwort findet") }, placeholder = { Text(o.defaults.noAnswer) }, modifier = Modifier.fillMaxWidth(), minLines = 2)
PickField("Abteilung für Tickets des Assistenten", listOf("0" to "Standardabteilung (bzw. Abteilung des Themas)") + o.departments.map { it.id.toString() to it.name }, s.departmentId.toString()) { s = s.copy(departmentId = it.toInt()) }
PickField("Priorität für Tickets des Assistenten", listOf("0" to "Standard") + o.priorities.map { it.id.toString() to it.name }, s.priorityId.toString()) { s = s.copy(priorityId = it.toInt()) }
OutlinedTextField(days, { days = it.filter(Char::isDigit).take(4) }, label = { Text("Fragen-Log: Aufbewahrung in Tagen (0 = nichts speichern)") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
Button(onClick = { vm.assistantSaveSettings(s.copy(logDays = days.toIntOrNull() ?: 90)) }, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { Text("Speichern") }
}
}
@Composable
private fun AssistantAnswersTab(state: AssistantUiState, vm: DeskHubViewModel, prefill: String?, onPrefillUsed: () -> Unit) {
var editing by remember { mutableStateOf<AssistantAnswerDto?>(null) }
var creating by remember { mutableStateOf(false) }
LaunchedEffect(prefill) { if (prefill != null) creating = true }
val answers = state.answers
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Diese Antworten haben Vorrang vor der Wissensdatenbank. Der Assistent findet sie über die Wörter in der Frage und in den Stichwörtern.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = { creating = true }, modifier = Modifier.fillMaxWidth()) { Text("+ Antwort hinzufügen") }
if (answers == null) Text("Wird geladen …")
else if (answers.isEmpty()) Text("Noch keine zusätzlichen Antworten.")
else LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 24.dp)) {
items(answers) { a ->
Card(onClick = { editing = a }, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(a.title, fontWeight = FontWeight.SemiBold)
if (a.keywords.isNotBlank()) Text("Stichwörter: ${a.keywords}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(a.answer.take(120), style = MaterialTheme.typography.bodySmall)
Text("${a.hits}× genutzt · ${if (a.isActive) "Aktiv" else "Aus"}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
if (creating || editing != null) {
val a = editing
var title by remember(a, prefill) { mutableStateOf(a?.title ?: prefill.orEmpty()) }
var kw by remember(a) { mutableStateOf(a?.keywords.orEmpty()) }
var text by remember(a) { mutableStateOf(a?.answer.orEmpty()) }
var active by remember(a) { mutableStateOf(a?.isActive ?: true) }
val close = { creating = false; editing = null; onPrefillUsed() }
AlertDialog(
onDismissRequest = close,
title = { Text(if (a == null) "Neue Antwort" else "Antwort bearbeiten") },
text = {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(title, { title = it.take(255) }, label = { Text("Frage / Thema *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(kw, { kw = it.take(1000) }, label = { Text("Stichwörter (durch Komma)") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(text, { text = it.take(4000) }, label = { Text("Antwort *") }, modifier = Modifier.fillMaxWidth(), minLines = 4)
Row(verticalAlignment = Alignment.CenterVertically) { Switch(active, { active = it }); Text(" Aktiv") }
if (a != null) TextButton(onClick = { vm.assistantDeleteAnswer(a.id) { close() } }) { Text("Antwort löschen", color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = { TextButton(onClick = { vm.assistantSaveAnswer(a?.id ?: 0, title, kw, text, active) { close() } }, enabled = title.isNotBlank() && text.isNotBlank() && !state.loading) { Text("Speichern") } },
dismissButton = { TextButton(onClick = close) { Text("Abbrechen") } },
)
}
}
@Composable
private fun AssistantLogTab(state: AssistantUiState, vm: DeskHubViewModel, onCreateAnswer: (String) -> Unit) {
val log = state.log
var q by remember(state.logQuery) { mutableStateOf(state.logQuery) }
var confirmClear by remember { mutableStateOf(false) }
if (log == null) { Text("Wird geladen …", modifier = Modifier.padding(16.dp)); return }
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
item {
Text("Zeigt, was Kunden gefragt haben und ob der Assistent eine Antwort fand. Gespeichert werden nur Frage und Ergebnis.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
item {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
log.filters.entries.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { (key, label) ->
FilterChip(selected = state.logResult == key, onClick = { vm.assistantLoadLog(result = if (state.logResult == key) "" else key) }, label = { Text("${log.stats[key] ?: 0} · $label") })
}
}
}
}
}
item {
PickField("Zeitraum", listOf("today" to "Heute", "7" to "Letzte 7 Tage", "30" to "Letzte 30 Tage", "all" to "Gesamter Zeitraum"), state.logPeriod) { vm.assistantLoadLog(period = it) }
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(q, { q = it.take(100) }, label = { Text("Suchen …") }, singleLine = true, modifier = Modifier.weight(1f))
OutlinedButton(onClick = { vm.assistantLoadLog(query = q) }) { Text("Filtern") }
}
}
if (log.top.isNotEmpty()) item {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Häufige Fragen ohne Antwort", fontWeight = FontWeight.SemiBold)
log.top.forEach { t ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${t.question} (${t.n}×)", Modifier.weight(1f), style = MaterialTheme.typography.bodySmall)
TextButton(onClick = { onCreateAnswer(t.question) }) { Text("Antwort anlegen") }
}
}
}
}
}
items(log.items) { x ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(x.question, fontWeight = FontWeight.Medium)
val res = (log.filters[x.result] ?: x.result) + (when (x.helpful) { true -> " 👍"; false -> " 👎"; null -> "" })
Text(res + (x.ticketNumber?.let { " · #$it" } ?: x.matchedTitle?.let { " · $it" } ?: ""), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(x.createdAt.take(16), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (x.result == "none") TextButton(onClick = { onCreateAnswer(x.question) }) { Text("Antwort anlegen") }
}
}
}
item {
Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { vm.assistantLoadLog(page = log.page - 1) }, enabled = log.page > 1) { Text("‹") }
Text("${log.total} Einträge · Seite ${log.page}/${log.pages}", style = MaterialTheme.typography.bodySmall)
OutlinedButton(onClick = { vm.assistantLoadLog(page = log.page + 1) }, enabled = log.page < log.pages) { Text("›") }
}
}
item { TextButton(onClick = { confirmClear = true }) { Text("Log leeren", color = MaterialTheme.colorScheme.error) } }
}
if (confirmClear) AlertDialog(
onDismissRequest = { confirmClear = false },
title = { Text("Fragen-Log leeren?") }, text = { Text("Das gesamte Fragen-Log wird gelöscht.") },
confirmButton = { TextButton(onClick = { confirmClear = false; vm.assistantClearLog() }) { Text("Löschen", color = MaterialTheme.colorScheme.error) } },
dismissButton = { TextButton(onClick = { confirmClear = false }) { Text("Abbrechen") } },
)
}
@Composable
private fun AssistantSynonymsTab(state: AssistantUiState, vm: DeskHubViewModel) {
val o = state.overview
if (o == null) { Text("Wird geladen …", modifier = Modifier.padding(16.dp)); return }
var text by remember(o) { mutableStateOf(o.settings.synonyms) }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Wörter, die Kunden und Artikel unterschiedlich nennen, aber dasselbe meinen. Eine Gruppe je Zeile, durch Komma getrennt, zum Beispiel „Zugang, Einladung, beitreten“. Einige Gruppen sind eingebaut, deine ergänzen oder überschreiben sie.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(text, { text = it.take(5000) }, modifier = Modifier.fillMaxWidth(), minLines = 8, placeholder = { Text("Zugang, Einladung, beitreten\nGeld zurück, Erstattung, Rückzahlung") })
Button(onClick = { vm.assistantSaveSynonyms(text) }, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { Text("Speichern") }
}
}
@Composable
private fun AssistantTestTab(state: AssistantUiState, vm: DeskHubViewModel) {
var q by rememberSaveable { mutableStateOf("") }
val r = state.testResult
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Zeigt, was der Assistent auf eine Kundenfrage antworten würde (ohne Zähler und ohne Ticket).", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(q, { q = it.take(300) }, label = { Text("z. B. Wie setze ich mein Passwort zurück?") }, modifier = Modifier.fillMaxWidth())
Button(onClick = { vm.assistantTest(q) }, enabled = q.isNotBlank() && !state.loading, modifier = Modifier.fillMaxWidth()) { Text("Testen") }
if (r != null) {
if (r.found && r.result != null) Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text((if (r.result.source == "custom") "Zusätzliche Antwort" else "Wissensdatenbank") + ": " + r.result.title, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(r.result.text)
r.result.others.forEach { Text("Weiterer Treffer: $it", style = MaterialTheme.typography.bodySmall) }
}
} else Text("Keine passende Antwort: Der Assistent würde anbieten, ein Ticket zu erstellen. Lege dafür eine zusätzliche Antwort an oder ergänze die Wissensdatenbank.", style = MaterialTheme.typography.bodySmall)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -58,6 +58,21 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** Zustand der Verwaltung des digitalen Assistenten (Einstellungen, Antworten, Fragen-Log, Ausprobieren). */
data class AssistantUiState(
val overview: de.deskhub.staff.data.remote.dto.AssistantOverviewDto? = null,
val answers: List<de.deskhub.staff.data.remote.dto.AssistantAnswerDto>? = null,
val log: de.deskhub.staff.data.remote.dto.AssistantLogDto? = null,
val logResult: String = "",
val logPeriod: String = "30",
val logQuery: String = "",
val logPage: Int = 1,
val testResult: de.deskhub.staff.data.remote.dto.AssistantTestDto? = null,
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
data class DeskHubState(
val initializing: Boolean = true,
val staff: Staff? = null,
@@ -67,6 +82,9 @@ data class DeskHubState(
/** Zuletzt verwendete Server-Adresse – bleibt auch nach dem Ausloggen erhalten, um erneutes Eintippen zu ersparen. */
val savedServerUrl: String? = null,
val metadata: Metadata? = null,
val assistant: AssistantUiState = AssistantUiState(),
/** Von außen angeforderter Bildschirm (zum Beispiel aus dem Widget): "newTicket" oder "tickets". */
val requestedRoute: String? = null,
val cannedResponses: List<CannedResponse> = emptyList(),
val filters: TicketFilters = TicketFilters(),
val tickets: List<TicketSummary> = emptyList(),
@@ -476,6 +494,91 @@ class DeskHubViewModel(
}
}
// ── Digitaler Assistent (Verwaltung) ─────────────────────────────────────
private fun updateAssistant(block: (AssistantUiState) -> AssistantUiState) = mutableState.update { it.copy(assistant = block(it.assistant)) }
private fun assistantRun(success: String? = null, onDone: () -> Unit = {}, block: suspend () -> Unit) {
viewModelScope.launch {
updateAssistant { it.copy(loading = true, error = null) }
runCatching { block() }
.onSuccess { updateAssistant { s -> s.copy(loading = false, message = success ?: s.message) }; onDone() }
.onFailure { e -> updateAssistant { it.copy(loading = false, error = e.userMessage()) } }
}
}
fun assistantLoadOverview() = assistantRun { val o = repository.assistantOverview(); updateAssistant { it.copy(overview = o) } }
fun assistantLoadAnswers() = assistantRun { val a = repository.assistantAnswers(); updateAssistant { it.copy(answers = a) } }
fun assistantLoadLog(result: String? = null, period: String? = null, query: String? = null, page: Int? = null) {
updateAssistant { it.copy(logResult = result ?: it.logResult, logPeriod = period ?: it.logPeriod, logQuery = query ?: it.logQuery, logPage = page ?: 1) }
assistantRun {
val s = state.value.assistant
val l = repository.assistantLog(mapOf("result" to s.logResult, "period" to s.logPeriod, "q" to s.logQuery, "page" to s.logPage.toString()))
updateAssistant { it.copy(log = l) }
}
}
fun assistantSaveSettings(s: de.deskhub.staff.data.remote.dto.AssistantSettingsDto) = assistantRun(success = "Gespeichert", onDone = { assistantLoadOverview() }) {
repository.assistantAction(mapOf("action" to "save_settings", "enabled" to s.enabled, "mode" to s.mode, "name" to s.name, "greeting" to s.greeting,
"noAnswer" to s.noAnswer, "departmentId" to s.departmentId, "priorityId" to s.priorityId, "logDays" to s.logDays))
}
fun assistantSaveAnswer(id: Int, title: String, keywords: String, answer: String, isActive: Boolean, onDone: () -> Unit) =
assistantRun(success = "Antwort gespeichert", onDone = { onDone(); assistantLoadAnswers() }) {
repository.assistantSaveAnswer(mapOf("action" to "save_answer", "id" to id, "title" to title, "keywords" to keywords, "answer" to answer, "isActive" to isActive))
}
fun assistantDeleteAnswer(id: Int, onDone: () -> Unit) = assistantRun(success = "Antwort gelöscht", onDone = { onDone(); assistantLoadAnswers() }) {
repository.assistantAction(mapOf("action" to "delete_answer", "id" to id))
}
fun assistantSaveSynonyms(text: String) = assistantRun(success = "Gleichbedeutende Wörter gespeichert", onDone = { assistantLoadOverview() }) {
repository.assistantAction(mapOf("action" to "save_synonyms", "text" to text))
}
fun assistantClearLog() = assistantRun(success = "Fragen-Log geleert", onDone = { assistantLoadLog(page = 1) }) {
repository.assistantAction(mapOf("action" to "clear_log"))
}
fun assistantTest(question: String) = assistantRun { val r = repository.assistantTest(question); updateAssistant { it.copy(testResult = r) } }
fun requestRoute(route: String) = mutableState.update { it.copy(requestedRoute = route) }
fun routeHandled() = mutableState.update { it.copy(requestedRoute = null) }
fun assistantClearMessage() = updateAssistant { it.copy(message = null) }
/** Legt das geöffnete Ticket auf Wiedervorlage (ISO-Zeit) oder hebt sie auf (null). */
fun snoozeTicket(untilIso: String?) {
val ticket = state.value.selectedTicket ?: return
viewModelScope.launch {
mutableState.update { it.copy(mutationLoading = true, mutationError = null) }
runCatching { repository.setSnooze(ticket.summary.number, untilIso) }
.onSuccess {
mutableState.update { it.copy(mutationLoading = false) }
openTicket(ticket.summary.number)
loadTickets(reset = true)
}
.onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } }
}
}
/** Beobachten ein- oder ausschalten. */
fun setFollowing(following: Boolean) {
val ticket = state.value.selectedTicket ?: return
viewModelScope.launch {
mutableState.update { it.copy(mutationLoading = true, mutationError = null) }
runCatching { repository.setFollowing(ticket.summary.number, following) }
.onSuccess {
mutableState.update { it.copy(mutationLoading = false) }
openTicket(ticket.summary.number)
}
.onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } }
}
}
/** Markiert das aktuell geöffnete Ticket als Spam, oder macht das (restore = true) rückgängig. */
fun setTicketSpam(restore: Boolean) {
val ticket = state.value.selectedTicket ?: return
@@ -0,0 +1,203 @@
package de.deskhub.staff.ui
import androidx.compose.foundation.background
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.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.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Assignment
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.automirrored.filled.Rule
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Apartment
import androidx.compose.material.icons.filled.Assessment
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.deskhub.staff.domain.model.Staff
/** Ein Eintrag der schwebenden Navigationsleiste. */
data class NavEntry(val route: String, val label: String, val icon: ImageVector, val badge: Int = 0)
/** Schwebende Navigationsleiste: der gewählte Eintrag zeigt Symbol und Text in einer farbigen Pille, die anderen nur das Symbol. */
@Composable
fun DeskBottomBar(entries: List<NavEntry>, current: String?, onSelect: (String) -> Unit) {
Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 10.dp), contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.fillMaxWidth().shadow(10.dp, CircleShape, ambientColor = Color(0x33000000), spotColor = Color(0x33000000)),
shape = CircleShape,
color = MaterialTheme.colorScheme.surface,
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
entries.forEach { e ->
val selected = current == e.route
Row(
Modifier.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
.clickable { onSelect(e.route) }
.padding(horizontal = if (selected) 16.dp else 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BadgedBox(badge = { if (e.badge > 0) Badge { Text(if (e.badge > 99) "99+" else e.badge.toString()) } }) {
Icon(e.icon, contentDescription = e.label, tint = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
}
if (selected) {
Spacer(Modifier.width(8.dp))
Text(e.label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimaryContainer, maxLines = 1)
}
}
}
}
}
}
}
private data class MoreEntry(val icon: ImageVector, val title: String, val subtitle: String, val route: String)
/** „Mehr“: Profil, persönliche Bereiche, Verwaltung nach Rechten, Design, Abmelden. */
@Composable
fun MoreScreen(staff: Staff?, onNavigate: (String) -> Unit, onLogout: () -> Unit) {
val isFullAdmin = staff?.role == "admin"
fun allowed(permission: String) = isFullAdmin || staff?.permissions?.get(permission) == true
val context = LocalContext.current
val version = remember(context) { runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull().orEmpty() }
val personal = listOf(
MoreEntry(Icons.AutoMirrored.Filled.Assignment, "Aufgaben", "To-dos für dich und dein Team", "adminTasks"),
MoreEntry(Icons.Filled.Circle, if (isFullAdmin) "Status der Mitarbeiter" else "Mein Status", "Verfügbarkeit und Team-Übersicht", "adminAvailability"),
MoreEntry(Icons.Filled.Notifications, "Benachrichtigungen", "Popups und stille Zeiten", "pushPrefs"),
)
val manage = buildList {
if (allowed("manage_departments")) add(MoreEntry(Icons.Filled.Business, "Abteilungen", "Leiter, Mitglieder, E-Mail", "adminDepartments"))
if (allowed("manage_priorities")) add(MoreEntry(Icons.Filled.Flag, "Prioritäten / SLA", "Reaktions- und Lösungszeiten", "adminPriorities"))
if (allowed("manage_priorities")) add(MoreEntry(Icons.Filled.Schedule, "SLA-Pläne", "Zeiten je Abteilung oder Thema", "adminSlaPlans"))
if (allowed("manage_help_topics")) add(MoreEntry(Icons.AutoMirrored.Filled.Help, "Hilfethemen", "Thema, Abteilung, Priorität", "adminHelpTopics"))
if (allowed("manage_kb")) add(MoreEntry(Icons.AutoMirrored.Filled.MenuBook, "Wissensdatenbank", "Artikel und Kategorien", "adminKb"))
if (allowed("manage_email_addresses")) add(MoreEntry(Icons.Filled.Email, "E-Mail-Adressen", "Adressen und Postfach-Abruf", "adminEmailAddresses"))
if (allowed("manage_email_templates")) add(MoreEntry(Icons.Filled.Description, "E-Mail-Vorlagen", "Texte der automatischen Mails", "adminEmailTemplates"))
if (allowed("manage_assistant")) add(MoreEntry(Icons.Filled.Bolt, "Digitaler Assistent", "Antworten, Fragen-Log, Einstellungen", "adminAssistant"))
if (allowed("manage_organizations")) add(MoreEntry(Icons.Filled.Apartment, "Organisationen", "Kunden zu Firmen bündeln", "adminOrganizations"))
}
val people = buildList {
if (allowed("manage_staff")) add(MoreEntry(Icons.Filled.Person, "Mitarbeiter", "Konten und Zugriff", "adminStaff"))
if (allowed("manage_teams")) add(MoreEntry(Icons.Filled.Groups, "Teams", "Mitarbeiter-Gruppen", "adminTeams"))
if (allowed("manage_roles")) add(MoreEntry(Icons.Filled.Lock, "Rollen", "Berechtigungssets", "adminRoles"))
}
val system = buildList {
if (allowed("view_reports")) add(MoreEntry(Icons.Filled.Assessment, "Berichte", "Auswertungen", "adminReports"))
if (allowed("view_time_entries")) add(MoreEntry(Icons.Filled.AccessTime, "Zeiterfassung", "Zeiten je Mitarbeiter", "adminTimeEntries"))
if (allowed("manage_escalations")) add(MoreEntry(Icons.AutoMirrored.Filled.Rule, "Eskalationsregeln", "Regeln für überfällige Tickets", "adminEscalation"))
if (allowed("manage_business_hours")) add(MoreEntry(Icons.Filled.Schedule, "Geschäftszeiten", "SLA-Zeitfenster, Feiertage", "adminBusinessHours"))
if (isFullAdmin) add(MoreEntry(Icons.Filled.Bolt, "Automatisierung", "Ticket-Regeln, Auto-Antworten", "adminTicketRules"))
if (isFullAdmin) add(MoreEntry(Icons.Filled.Block, "Sperrliste", "Absender sperren", "adminBanlist"))
if (allowed("view_audit_log")) add(MoreEntry(Icons.Filled.History, "Admin-Protokoll", "Änderungen und Mailfehler", "adminAuditLog"))
}
LazyColumn(Modifier.fillMaxSize().statusBarsPadding(), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
item {
GradientHeader {
Row(verticalAlignment = Alignment.CenterVertically) {
DeskAvatar(staff?.name.orEmpty(), size = 56, color = Color.White)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(staff?.name.orEmpty(), style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(staff?.email.orEmpty(), style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.85f))
Text(if (isFullAdmin) "Administrator" else "Mitarbeiter", style = MaterialTheme.typography.labelMedium, color = Color.White.copy(alpha = 0.85f))
}
}
}
}
item { SectionTitle("Für dich") }
item { MoreGrid(personal, onNavigate) }
if (manage.isNotEmpty()) { item { SectionTitle("Verwalten") }; item { MoreGrid(manage, onNavigate) } }
if (people.isNotEmpty()) { item { SectionTitle("Agenten") }; item { MoreGrid(people, onNavigate) } }
if (system.isNotEmpty()) { item { SectionTitle("System") }; item { MoreGrid(system, onNavigate) } }
item { SectionTitle("Design") }
item {
val controller = LocalThemeController.current
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeMode.values().forEach { m -> FilterChip(selected = controller.mode == m, onClick = { controller.setMode(m) }, label = { Text(m.label) }) }
}
}
item {
OutlinedButton(onClick = onLogout, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Abmelden")
}
}
item { Text("DeskHub $version", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 4.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center) }
}
}
@Composable
private fun MoreGrid(entries: List<MoreEntry>, onNavigate: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
entries.chunked(2).forEach { row ->
Row(Modifier.height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
row.forEach { e ->
DeskCard(Modifier.weight(1f).fillMaxHeight(), onClick = { onNavigate(e.route) }) {
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.size(40.dp).clip(MaterialTheme.shapes.small).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
Icon(e.icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer)
}
Column {
Text(e.title, style = MaterialTheme.typography.titleSmall, maxLines = 1)
Text(e.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2)
}
}
}
}
if (row.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
@@ -0,0 +1,276 @@
package de.deskhub.staff.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
/*
* Neues Design (Version 3): helle, luftige Oberfläche mit großen runden Karten, Farbverlauf nur in Kopfbereichen und frischem Blau/Türkis.
* Keine dunklen Kopfleisten mehr: Titel sitzen direkt auf dem Hintergrund, die Navigation schwebt als Pille am unteren Rand.
*/
/** Design-Modus der App: folgt dem System oder ist fest Hell/Dunkel (wird lokal gespeichert). */
enum class ThemeMode(val label: String) { System("Wie System"), Light("Hell"), Dark("Dunkel") }
class ThemeController(val mode: ThemeMode, val setMode: (ThemeMode) -> Unit)
val LocalThemeController = staticCompositionLocalOf { ThemeController(ThemeMode.System) { } }
/** Zusatzfarben, die Material nicht kennt: Verlauf für Kopfbereiche, Erfolg, Warnung. */
class DeskExtras(val gradient: Brush, val success: Color, val onSuccess: Color, val warning: Color, val onWarning: Color, val dark: Boolean)
val LocalDeskExtras = staticCompositionLocalOf {
DeskExtras(Brush.linearGradient(listOf(Color(0xFF0E8AC9), Color(0xFF12A594))), Color(0xFF1E9E6A), Color.White, Color(0xFFE8920B), Color.White, false)
}
/** Kompatibilität für ältere Bildschirme: früher die dunkle Kopfleistenfarbe, jetzt die Hintergrundfarbe. */
val LocalChrome = staticCompositionLocalOf { Color(0xFFF4F8FB) }
private fun loadThemeMode(context: android.content.Context): ThemeMode =
runCatching { ThemeMode.valueOf(context.getSharedPreferences("deskhub_ui", android.content.Context.MODE_PRIVATE).getString("theme", null) ?: "System") }
.getOrDefault(ThemeMode.System)
private fun saveThemeMode(context: android.content.Context, mode: ThemeMode) {
context.getSharedPreferences("deskhub_ui", android.content.Context.MODE_PRIVATE).edit().putString("theme", mode.name).apply()
}
private val Azure = Color(0xFF0E8AC9)
private val Teal = Color(0xFF12A594)
private val Ink = Color(0xFF10212B)
private val DeskLight = lightColorScheme(
primary = Azure,
onPrimary = Color.White,
primaryContainer = Color(0xFFD5EEFA),
onPrimaryContainer = Color(0xFF053B57),
secondary = Teal,
onSecondary = Color.White,
secondaryContainer = Color(0xFFD5EEFA),
onSecondaryContainer = Color(0xFF053B57),
tertiary = Color(0xFFE8920B),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFFFE8C7),
onTertiaryContainer = Color(0xFF5B3500),
background = Color(0xFFF3F8FB),
onBackground = Ink,
surface = Color(0xFFFFFFFF),
onSurface = Ink,
surfaceVariant = Color(0xFFE6EEF3),
onSurfaceVariant = Color(0xFF5B6E7B),
surfaceTint = Azure,
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF8FBFD),
surfaceContainer = Color(0xFFF0F5F8),
surfaceContainerHigh = Color(0xFFE9F0F4),
surfaceContainerHighest = Color.White,
outline = Color(0xFFC9D6DE),
outlineVariant = Color(0xFFE1EAF0),
error = Color(0xFFD64545),
onError = Color.White,
errorContainer = Color(0xFFFDE3E3),
onErrorContainer = Color(0xFF7A1F1F),
)
private val DeskDark = darkColorScheme(
primary = Color(0xFF52C2F2),
onPrimary = Color(0xFF032A3D),
primaryContainer = Color(0xFF0C4560),
onPrimaryContainer = Color(0xFFD5EEFA),
secondary = Color(0xFF4FD6C3),
onSecondary = Color(0xFF032E28),
secondaryContainer = Color(0xFF0C4560),
onSecondaryContainer = Color(0xFFD5EEFA),
tertiary = Color(0xFFF2AE4D),
onTertiary = Color(0xFF3A2400),
tertiaryContainer = Color(0xFF5B3A0A),
onTertiaryContainer = Color(0xFFFFE8C7),
background = Color(0xFF0B1319),
onBackground = Color(0xFFE5EEF3),
surface = Color(0xFF131E26),
onSurface = Color(0xFFE5EEF3),
surfaceVariant = Color(0xFF1B2A34),
onSurfaceVariant = Color(0xFF93A7B4),
surfaceTint = Color(0xFF52C2F2),
surfaceContainerLowest = Color(0xFF080F14),
surfaceContainerLow = Color(0xFF0F1920),
surfaceContainer = Color(0xFF16222B),
surfaceContainerHigh = Color(0xFF1B2A34),
surfaceContainerHighest = Color(0xFF1A2832),
outline = Color(0xFF2E4350),
outlineVariant = Color(0xFF22333E),
error = Color(0xFFF27A7A),
onError = Color(0xFF3A0A0A),
errorContainer = Color(0xFF5E1713),
onErrorContainer = Color(0xFFFFDAD6),
)
private val DeskShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(18.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)
private val DeskTypography = Typography(
displaySmall = TextStyle(fontSize = 34.sp, lineHeight = 40.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.5).sp),
headlineMedium = TextStyle(fontSize = 28.sp, lineHeight = 34.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.4).sp),
headlineSmall = TextStyle(fontSize = 24.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp),
titleLarge = TextStyle(fontSize = 21.sp, lineHeight = 27.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp),
titleMedium = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold),
titleSmall = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold),
bodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp),
bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 21.sp),
bodySmall = TextStyle(fontSize = 12.sp, lineHeight = 17.sp),
labelLarge = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold),
labelMedium = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
labelSmall = TextStyle(fontSize = 11.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.3.sp),
)
/** Das Design der App: Farben, Formen, Schrift, Statusleiste. */
@Composable
fun DeskTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
var mode by remember { mutableStateOf(loadThemeMode(context)) }
val dark = when (mode) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Light -> false
ThemeMode.Dark -> true
}
val scheme = if (dark) DeskDark else DeskLight
val controller = remember(mode) { ThemeController(mode) { mode = it; saveThemeMode(context, it) } }
val extras = remember(dark) {
DeskExtras(
gradient = Brush.linearGradient(if (dark) listOf(Color(0xFF0C4560), Color(0xFF0C4A42)) else listOf(Color(0xFF0E8AC9), Color(0xFF12A594))),
success = if (dark) Color(0xFF58D49B) else Color(0xFF1E9E6A), onSuccess = if (dark) Color(0xFF03281A) else Color.White,
warning = if (dark) Color(0xFFF2AE4D) else Color(0xFFE8920B), onWarning = if (dark) Color(0xFF3A2400) else Color.White,
dark = dark,
)
}
val view = LocalView.current
SideEffect {
(view.context as? android.app.Activity)?.window?.let { window ->
window.statusBarColor = scheme.background.toArgb()
window.navigationBarColor = scheme.background.toArgb()
val insets = WindowCompat.getInsetsController(window, view)
insets.isAppearanceLightStatusBars = !dark
insets.isAppearanceLightNavigationBars = !dark
}
}
CompositionLocalProvider(LocalThemeController provides controller, LocalChrome provides scheme.background, LocalDeskExtras provides extras) {
MaterialTheme(colorScheme = scheme, shapes = DeskShapes, typography = DeskTypography, content = content)
}
}
/** Kopfleiste ohne eigene Farbe: Titel liegt auf dem Hintergrund. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun deskTopBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
scrolledContainerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground,
navigationIconContentColor = MaterialTheme.colorScheme.onBackground,
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
// ─── Gemeinsame Bausteine ─────────────────────────────────────────────────────
/** Weiße, runde Karte mit feiner Linie (statt Schatten): der Grundbaustein der Oberfläche. */
@Composable
fun DeskCard(modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
val shape = MaterialTheme.shapes.large
val base = modifier
.fillMaxWidth()
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
Box(if (onClick != null) base.clickable(onClick = onClick) else base) { content() }
}
/** Abschnittsüberschrift über Karten. */
@Composable
fun SectionTitle(text: String, modifier: Modifier = Modifier) {
Text(text.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = modifier.padding(start = 6.dp, top = 8.dp, bottom = 2.dp))
}
/** Farbige Kapsel (Status, Priorität, Tags). */
@Composable
fun DeskChip(text: String, color: Color, modifier: Modifier = Modifier, filled: Boolean = false) {
val fg = if (filled) Color.White else color
Box(
modifier
.clip(CircleShape)
.background(if (filled) color else color.copy(alpha = 0.14f))
.padding(horizontal = 10.dp, vertical = 4.dp),
) { Text(text, style = MaterialTheme.typography.labelMedium, color = fg, maxLines = 1) }
}
/** Runder Kreis mit Initialen. */
@Composable
fun DeskAvatar(name: String, modifier: Modifier = Modifier, size: Int = 40, color: Color = MaterialTheme.colorScheme.primary) {
val initials = name.trim().split(' ', '.', '-', '@').filter { it.isNotBlank() }.take(2).joinToString("") { it.first().uppercase() }.ifBlank { "?" }
Box(modifier.size(size.dp).clip(CircleShape).background(color.copy(alpha = 0.16f)), contentAlignment = Alignment.Center) {
Text(initials, color = color, fontWeight = FontWeight.Bold, fontSize = (size * 0.38f).sp)
}
}
/** Kopfbereich mit Farbverlauf (Übersicht, Mehr, Anmeldung). */
@Composable
fun GradientHeader(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(modifier.fillMaxWidth().clip(MaterialTheme.shapes.extraLarge).background(LocalDeskExtras.current.gradient).padding(20.dp)) { content() }
}
/** Zeile mit Beschriftung links und Wert rechts (Eigenschaften). */
@Composable
fun KeyValueRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
/** Standard-Spalte für Bildschirminhalte. */
@Composable
fun ScreenColumn(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) {
Column(modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), content = content)
}
@@ -79,11 +79,7 @@ internal fun PushPrefsScreen(onBack: () -> Unit) {
TopAppBar(
title = { Text("Benachrichtigungen", fontWeight = FontWeight.Bold) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = LocalChrome.current,
titleContentColor = Color.White,
navigationIconContentColor = Color.White,
),
colors = deskTopBarColors(),
)
},
) { padding ->
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="315"
android:endColor="#0C4A42"
android:startColor="#0C3B55" />
<corners android:radius="26dp" />
</shape>
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#28405A" />
<corners android:radius="18dp" />
<gradient
android:angle="315"
android:endColor="#12A594"
android:startColor="#0E7FBF" />
<corners android:radius="26dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#26FFFFFF" />
<corners android:radius="20dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#66FFFFFF" />
<corners android:radius="20dp" />
</shape>
+2 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#33FFFFFF" />
<corners android:radius="12dp" />
<solid android:color="#30FFFFFF" />
<corners android:radius="16dp" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E0D64545" />
<corners android:radius="16dp" />
</shape>
+113 -28
View File
@@ -5,8 +5,9 @@
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
android:padding="14dp">
<!-- Kopf: Name, Stand, Aktualisieren -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -14,40 +15,50 @@
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="DeskHub"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_refresh"
android:layout_width="wrap_content"
android:id="@+id/widget_time"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:textColor="#CCFFFFFF"
android:textSize="11sp" />
<TextView
android:id="@+id/widget_refresh"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="@drawable/widget_chip"
android:contentDescription="Aktualisieren"
android:padding="4dp"
android:gravity="center"
android:text="⟳"
android:textColor="#FFFFFF"
android:textSize="20sp" />
android:textSize="18sp" />
</LinearLayout>
<TextView
android:id="@+id/widget_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginTop="10dp"
android:text="Lade …"
android:textColor="#D6DEE8"
android:textSize="13sp"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
<!-- Kennzahlen -->
<LinearLayout
android:id="@+id/widget_tiles"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginTop="10dp"
android:layout_weight="1"
android:orientation="horizontal">
@@ -65,21 +76,21 @@
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Meine"
android:textColor="#D6DEE8"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="6dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
@@ -91,21 +102,22 @@
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Offen"
android:textColor="#D6DEE8"
android:text="Nicht zugew."
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_tile_overdue"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="6dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
@@ -116,25 +128,98 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFB4A8"
android:textSize="24sp"
android:textColor="#FFFFFF"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Überfällig"
android:textColor="#D6DEE8"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/widget_unanswered"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unbeantw."
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/widget_time"
<!-- Die wichtigsten Tickets: nur Nummern (nichts Vertrauliches auf dem Startbildschirm), Tipp öffnet das Ticket -->
<LinearLayout
android:id="@+id/widget_recent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="end"
android:textColor="#A9B6C6"
android:textSize="10sp" />
android:layout_marginTop="10dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_t1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<TextView
android:id="@+id/widget_t2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<TextView
android:id="@+id/widget_t3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,325 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="14dp">
<!-- Kopf: Name, Stand, Aktualisieren -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DeskHub"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_time"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:textColor="#CCFFFFFF"
android:textSize="11sp" />
<TextView
android:id="@+id/widget_refresh"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="@drawable/widget_chip"
android:contentDescription="Aktualisieren"
android:gravity="center"
android:text="⟳"
android:textColor="#FFFFFF"
android:textSize="18sp" />
</LinearLayout>
<TextView
android:id="@+id/widget_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Lade …"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
<!-- Kennzahlen -->
<LinearLayout
android:id="@+id/widget_tiles"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="10dp"
android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/widget_my_open"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Meine"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/widget_unassigned"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Nicht zugew."
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_tile_overdue"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/widget_overdue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Überfällig"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/widget_unanswered"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unbeantw."
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
<!-- Die wichtigsten Tickets: nur Nummern (nichts Vertrauliches auf dem Startbildschirm), Tipp öffnet das Ticket -->
<LinearLayout
android:id="@+id/widget_recent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_t1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<TextView
android:id="@+id/widget_t2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<TextView
android:id="@+id/widget_t3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:textColor="#FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_btn_new"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:gravity="center"
android:paddingTop="9dp"
android:paddingBottom="9dp"
android:text="+ Neues Ticket"
android:textColor="#FFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/widget_btn_tickets"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:gravity="center"
android:paddingTop="9dp"
android:paddingBottom="9dp"
android:text="Alle Tickets"
android:textColor="#FFFFFF"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/widget_st_available"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:text="Verfügbar"
android:textColor="#FFFFFF"
android:textSize="11sp" />
<TextView
android:id="@+id/widget_st_busy"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:text="Beschäftigt"
android:textColor="#FFFFFF"
android:textSize="11sp" />
<TextView
android:id="@+id/widget_st_break"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:text="Pause"
android:textColor="#FFFFFF"
android:textSize="11sp" />
<TextView
android:id="@+id/widget_st_offline"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:background="@drawable/widget_chip"
android:gravity="center"
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:text="Offline"
android:textColor="#FFFFFF"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_background"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="DeskHub"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_refresh"
android:layout_width="28dp"
android:layout_height="28dp"
android:background="@drawable/widget_chip"
android:contentDescription="Aktualisieren"
android:gravity="center"
android:text="⟳"
android:textColor="#FFFFFF"
android:textSize="16sp" />
</LinearLayout>
<TextView
android:id="@+id/widget_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Lade …"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/widget_tiles"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingEnd="4dp">
<TextView
android:id="@+id/widget_my_open"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Meine"
android:textColor="#E6FFFFFF"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_tile_overdue"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="6dp"
android:layout_weight="1"
android:background="@drawable/widget_tile"
android:gravity="center"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingEnd="4dp">
<TextView
android:id="@+id/widget_overdue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="–"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Überfällig"
android:textColor="#E6FFFFFF"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
+1 -1
View File
@@ -1,5 +1,5 @@
<resources>
<string name="app_name">deskhub</string>
<string name="server_label">deskhub-Server</string>
<string name="widget_description">Zeigt deine offenen, nicht zugewiesenen und überfälligen Tickets.</string>
<string name="widget_description">Offene, nicht zugewiesene und überfällige Tickets, die wichtigsten Tickets zum Antippen und dein Status. In drei Größen.</string>
</resources>
@@ -2,9 +2,11 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/widget_deskhub"
android:minWidth="250dp"
android:minWidth="110dp"
android:minHeight="110dp"
android:previewLayout="@layout/widget_deskhub"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:previewLayout="@layout/widget_deskhub_large"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />
@@ -105,4 +105,31 @@ class ApiDtoParsingTest {
assertEquals("FORBIDDEN", e.error.code)
assertEquals("UNKNOWN", gson.fromJson("""{"error":{}}""", ErrorEnvelope::class.java).error.code)
}
@Test
fun `assistant log with stats filters and feedback parses`() {
val json = """{"filters":{"custom":"Meine Antwort gefunden","none":"Keine Antwort","helpful":"Hat geholfen"},"stats":{"custom":4,"none":1,"helpful":3},
"top":[{"question":"Wie kündige ich?","n":2,"last":"2026-10-22 09:00:00"}],"total":2,"pages":1,"page":1,
"items":[{"id":1,"question":"Wo ist mein Paket?","result":"custom","matchedTitle":"Versand","ticketNumber":null,"helpful":true,"createdAt":"2026-10-22 09:00:00"},
{"id":2,"question":"Wie kündige ich?","result":"none","matchedTitle":null,"ticketNumber":null,"helpful":null,"createdAt":"2026-10-22 09:01:00"}]}"""
val l = gson.fromJson(json, AssistantLogDto::class.java)
assertEquals(listOf("custom", "none", "helpful"), l.filters.keys.toList())
assertEquals(3, l.stats["helpful"])
assertEquals(2, l.top[0].n)
assertEquals(true, l.items[0].helpful)
assertNull(l.items[1].helpful)
}
@Test
fun `assistant overview with settings and options parses`() {
val json = """{"dbReady":true,"settings":{"enabled":true,"mode":"always","name":"Bot","greeting":"Moin","noAnswer":"","departmentId":2,"priorityId":0,"logDays":30,"synonyms":"a, b"},
"defaults":{"name":"Digitaler Assistent","greeting":"Hallo","noAnswer":"Nix"},"state":{"activeNow":true,"liveChatEnabled":false,"kbEnabled":true,"helpdeskOffline":false},
"departments":[{"id":2,"name":"Technik"}],"priorities":[{"id":1,"name":"Normal"}]}"""
val o = gson.fromJson(json, AssistantOverviewDto::class.java)
assertTrue(o.settings.enabled)
assertEquals("always", o.settings.mode)
assertEquals(30, o.settings.logDays)
assertEquals("Technik", o.departments[0].name)
assertTrue(o.state.activeNow)
}
}