Upload via GUI (67 Dateien)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user