Upload via GUI (56 Dateien)
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Popups bei neuen Tickets (ab Android 13 fragt die App einmal nach) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".DeskHubApplication"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -14,13 +17,41 @@
|
||||
<meta-data
|
||||
android:name="de.deskhub.staff.creator"
|
||||
android:value="M_Viper" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_notification" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="tickets" />
|
||||
<service
|
||||
android:name=".push.DeskHubMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name=".push.PushReplyReceiver"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name=".DeskHubWidget"
|
||||
android:exported="true"
|
||||
android:label="DeskHub Tickets">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/deskhub_widget_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.deskhub.staff
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import de.deskhub.staff.data.remote.DeskHubApi
|
||||
import de.deskhub.staff.data.remote.dto.CrashReportRequest
|
||||
import de.deskhub.staff.data.security.SecureSessionStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Merkt sich einen Absturz (nur Fehlerart und die ersten Stack-Zeilen, keine Ticketinhalte) und meldet ihn beim nächsten Start nach
|
||||
* der Anmeldung an den eigenen DeskHub-Server. Dort steht er im Aktivitätsprotokoll des Adminportals. Nichts geht an Dritte.
|
||||
*/
|
||||
object CrashReporter {
|
||||
private const val PREFS = "deskhub_crash"
|
||||
private const val KEY = "last_trace"
|
||||
|
||||
fun install(app: Application) {
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
|
||||
runCatching {
|
||||
val trace = buildString {
|
||||
append(error.javaClass.name).append(": ").append(error.message.orEmpty().take(120))
|
||||
error.stackTrace.take(6).forEach { append(" | ").append(it.toString()) }
|
||||
}
|
||||
app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, trace).commit()
|
||||
}
|
||||
previous?.uncaughtException(thread, error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sendet einen gemerkten Absturz (falls vorhanden) und löscht ihn danach. Fehler werden still ignoriert. */
|
||||
suspend fun sendPending(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val trace = prefs.getString(KEY, null)?.takeIf { it.isNotBlank() } ?: return
|
||||
try {
|
||||
val session = SecureSessionStore(context.applicationContext).load() ?: return
|
||||
if (session.token.isBlank() || session.serverUrl.isBlank()) return
|
||||
withContext(Dispatchers.IO) {
|
||||
DeskHubApi(session.serverUrl) { session.token }.crashReport(
|
||||
CrashReportRequest(BuildConfig.VERSION_NAME, "${Build.MANUFACTURER} ${Build.MODEL}".trim(), trace),
|
||||
)
|
||||
}
|
||||
prefs.edit().remove(KEY).apply()
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// beim nächsten Start erneut versuchen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DeskHubApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashReporter.install(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.deskhub.staff
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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 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.
|
||||
*/
|
||||
class DeskHubWidget : AppWidgetProvider() {
|
||||
override fun onUpdate(context: Context, manager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
refresh(context, manager, appWidgetIds)
|
||||
}
|
||||
|
||||
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)))
|
||||
} 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 …")) }
|
||||
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()
|
||||
}.start()
|
||||
}
|
||||
|
||||
private data class WidgetState(val counts: Triple<Int, Int, Int>? = null, val message: String? = null)
|
||||
|
||||
private fun load(context: Context): WidgetState {
|
||||
val session = SecureSessionStore(context.applicationContext).load()
|
||||
if (session == null || session.token.isBlank() || session.serverUrl.isBlank()) {
|
||||
return WidgetState(message = "Bitte in der App anmelden.")
|
||||
}
|
||||
return try {
|
||||
val api = DeskHubApi(session.serverUrl) { session.token }
|
||||
val d = runBlocking { api.dashboard() }
|
||||
WidgetState(counts = Triple(d.myOpen, d.unassigned, d.overdue))
|
||||
} 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}).")
|
||||
}
|
||||
}
|
||||
|
||||
private fun views(context: Context, counts: Triple<Int, Int, Int>? = null, message: String? = null): RemoteViews {
|
||||
val v = RemoteViews(context.packageName, R.layout.widget_deskhub)
|
||||
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())
|
||||
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()))
|
||||
} else {
|
||||
v.setTextViewText(R.id.widget_message, message ?: "")
|
||||
v.setViewVisibility(R.id.widget_message, View.VISIBLE)
|
||||
v.setViewVisibility(R.id.widget_tiles, View.INVISIBLE)
|
||||
v.setTextViewText(R.id.widget_time, "")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_REFRESH = "de.deskhub.staff.WIDGET_REFRESH"
|
||||
|
||||
/** Stößt eine Aktualisierung aller Widgets an (zum Beispiel beim Öffnen der App). */
|
||||
fun requestUpdate(context: Context) {
|
||||
val intent = Intent(context, DeskHubWidget::class.java).setAction(ACTION_REFRESH)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,82 @@
|
||||
package de.deskhub.staff
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.deskhub.staff.data.TicketRepository
|
||||
import de.deskhub.staff.data.security.SecureSessionStore
|
||||
import de.deskhub.staff.ui.DeskHubApp
|
||||
import de.deskhub.staff.ui.DeskHubViewModel
|
||||
import de.deskhub.staff.ui.DeskHubViewModelFactory
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val repository = TicketRepository(SecureSessionStore(applicationContext))
|
||||
val factory = DeskHubViewModelFactory(repository)
|
||||
setContent {
|
||||
val viewModel: DeskHubViewModel = viewModel(factory = factory)
|
||||
DeskHubApp(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
package de.deskhub.staff
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.deskhub.staff.data.TicketRepository
|
||||
import de.deskhub.staff.data.security.SecureSessionStore
|
||||
import de.deskhub.staff.push.DeskHubPush
|
||||
import de.deskhub.staff.push.PushRegistration
|
||||
import de.deskhub.staff.ui.DeskHubApp
|
||||
import de.deskhub.staff.ui.DeskHubViewModel
|
||||
import de.deskhub.staff.ui.DeskHubViewModelFactory
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: DeskHubViewModel by viewModels {
|
||||
DeskHubViewModelFactory(TicketRepository(SecureSessionStore(applicationContext)))
|
||||
}
|
||||
private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
/** Jede Berührung meldet (höchstens einmal pro Minute) dem Server, dass die App benutzt wird. */
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
PresencePing.onUserInteraction(applicationContext)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
DeskHubWidget.requestUpdate(applicationContext)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleNotificationIntent(intent)
|
||||
setContent {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val signedIn = state.staff != null
|
||||
// Nach der Anmeldung: um Erlaubnis für Benachrichtigungen bitten (einmal) und das Gerät für Push anmelden
|
||||
LaunchedEffect(signedIn) {
|
||||
if (signedIn) {
|
||||
requestNotificationPermissionOnce()
|
||||
PushRegistration.registerCurrent(applicationContext)
|
||||
CrashReporter.sendPending(applicationContext)
|
||||
}
|
||||
}
|
||||
DeskHubApp(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleNotificationIntent(intent)
|
||||
}
|
||||
|
||||
/** Tipp auf eine Benachrichtigung: das Ticket aus dem Intent öffnen. */
|
||||
private fun handleNotificationIntent(intent: Intent?) {
|
||||
val number = intent?.getStringExtra(DeskHubPush.EXTRA_TICKET)?.trim().orEmpty()
|
||||
if (number.isNotEmpty()) {
|
||||
intent?.removeExtra(DeskHubPush.EXTRA_TICKET)
|
||||
viewModel.openTicketFromNotification(number)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermissionOnce() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return
|
||||
val prefs = getSharedPreferences("deskhub_push", MODE_PRIVATE)
|
||||
if (prefs.getBoolean("asked_notifications", false)) return
|
||||
prefs.edit().putBoolean("asked_notifications", true).apply()
|
||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.deskhub.staff
|
||||
|
||||
import android.content.Context
|
||||
import de.deskhub.staff.data.remote.DeskHubApi
|
||||
import de.deskhub.staff.data.security.SecureSessionStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Meldet dem Server höchstens einmal pro Minute, dass die App gerade bedient wird. Der Server setzt Mitarbeiter nach längerer
|
||||
* Inaktivität (Standard 30 Minuten) automatisch auf "Offline" und holt sie bei dieser Meldung wieder zurück. Fehler werden still
|
||||
* ignoriert. Wird nur bei echter Bedienung aufgerufen (Berührung), nicht bei Hintergrund-Abfragen.
|
||||
*/
|
||||
object PresencePing {
|
||||
private const val MIN_INTERVAL_MS = 60_000L
|
||||
@Volatile private var lastPing = 0L
|
||||
|
||||
fun onUserInteraction(context: Context) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastPing < MIN_INTERVAL_MS) return
|
||||
lastPing = now
|
||||
val app = context.applicationContext
|
||||
thread(name = "presence-ping") {
|
||||
try {
|
||||
val session = SecureSessionStore(app).load() ?: return@thread
|
||||
if (session.token.isBlank() || session.serverUrl.isBlank()) return@thread
|
||||
runBlocking { DeskHubApi(session.serverUrl) { session.token }.presence() }
|
||||
} catch (e: Exception) {
|
||||
lastPing = 0L // beim nächsten Tipp erneut versuchen
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,8 +240,8 @@ class TicketRepository(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun addMessage(number: String, type: String, message: String, setStatus: String?) {
|
||||
requireApi().addMessage(MessageRequest(number, type, message, setStatus))
|
||||
suspend fun addMessage(number: String, type: String, message: String, setStatus: String?, signature: String? = null) {
|
||||
requireApi().addMessage(MessageRequest(number, type, message, setStatus, signature))
|
||||
}
|
||||
|
||||
suspend fun cannedResponses(): List<CannedResponse> =
|
||||
@@ -301,7 +301,12 @@ class TicketRepository(
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
requireApi().logout()
|
||||
val api = requireApi()
|
||||
// Dieses Gerät für Push abmelden, damit nach dem Ausloggen keine Popups mehr kommen (Fehler dabei verhindern das Ausloggen nicht)
|
||||
sessionStore.loadPushToken()?.let { token ->
|
||||
runCatching { api.pushDevice(de.deskhub.staff.data.remote.dto.PushDeviceRequest(action = "unregister", token = token)) }
|
||||
}
|
||||
api.logout()
|
||||
clearSession()
|
||||
}
|
||||
|
||||
@@ -705,7 +710,7 @@ class TicketRepository(
|
||||
return AvailabilityData(myStatus = r.myStatus, myNote = r.myNote, team = r.team.map(TeamMemberDto::toDomain))
|
||||
}
|
||||
|
||||
suspend fun setAvailability(status: String, note: String) { requireApi().setAvailability(status, note) }
|
||||
suspend fun setAvailability(status: String, note: String, staffId: Int? = null) { requireApi().setAvailability(status, note, staffId) }
|
||||
|
||||
// ─── Ticket-Regeln (Automatisierung) ───────────────────────────────────────
|
||||
|
||||
@@ -811,7 +816,7 @@ private fun ChatMessageDto.toDomain() = ChatMessage(id, fromStaffId, fromName, m
|
||||
private fun LiveChatListItemDto.toDomain() = LiveChatSummary(number, name, preview, needsReply, updatedAt)
|
||||
private fun LiveChatAttachmentDto.toDomain() = LiveChatAttachment(id, name, size)
|
||||
private fun LiveChatMessageDto.toDomain() = LiveChatMessage(id, authorType, authorName, message, createdAt, attachments.map(LiveChatAttachmentDto::toDomain))
|
||||
private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt)
|
||||
private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt, autoOffline)
|
||||
private fun KeyLabelDto.toDomain() = KeyLabel(value, label)
|
||||
private fun TicketRuleConditionDto.toDomain() = TicketRuleCondition(field, operator, value)
|
||||
private fun TicketRuleActionDto.toDomain() = TicketRuleAction(type, value, valueLabel)
|
||||
@@ -898,4 +903,12 @@ private fun TicketDetailDto.toDomain() = TicketDetail(
|
||||
)
|
||||
},
|
||||
permissions = permissions,
|
||||
customFields = customFields.map { de.deskhub.staff.domain.model.CustomFieldValue(it.label, it.value) },
|
||||
replySignatures = replySignatures?.let { sig ->
|
||||
de.deskhub.staff.domain.model.ReplySignatures(
|
||||
default = sig.default,
|
||||
own = de.deskhub.staff.domain.model.ReplySignature(sig.own.available, sig.own.text, sig.own.hasImage, sig.own.name, sig.own.imageUrl),
|
||||
department = de.deskhub.staff.domain.model.ReplySignature(sig.department.available, sig.department.text, sig.department.hasImage, sig.department.name, sig.department.imageUrl),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -207,6 +207,19 @@ class DeskHubApi(
|
||||
execute("logout.php", "POST", emptyMap<String, String>(), Map::class.java)
|
||||
}
|
||||
|
||||
suspend fun pushPrefs(): de.deskhub.staff.data.remote.dto.PushPrefsDto =
|
||||
execute("push_prefs.php", "GET", null, de.deskhub.staff.data.remote.dto.PushPrefsDto::class.java)
|
||||
|
||||
suspend fun savePushPrefs(request: de.deskhub.staff.data.remote.dto.PushPrefsRequest): de.deskhub.staff.data.remote.dto.PushPrefsDto =
|
||||
execute("push_prefs.php", "POST", request, de.deskhub.staff.data.remote.dto.PushPrefsDto::class.java)
|
||||
|
||||
suspend fun crashReport(request: de.deskhub.staff.data.remote.dto.CrashReportRequest) {
|
||||
execute("crash_report.php", "POST", request, Map::class.java)
|
||||
}
|
||||
|
||||
suspend fun pushDevice(request: de.deskhub.staff.data.remote.dto.PushDeviceRequest): de.deskhub.staff.data.remote.dto.PushDeviceResponse =
|
||||
execute("push_device.php", "POST", request, de.deskhub.staff.data.remote.dto.PushDeviceResponse::class.java)
|
||||
|
||||
// ─── Admin: Wissensdatenbank ─────────────────────────────────────────────
|
||||
|
||||
suspend fun kb(): KbResponse =
|
||||
@@ -484,8 +497,13 @@ class DeskHubApi(
|
||||
suspend fun availability(): AvailabilityResponse =
|
||||
execute("availability.php", "GET", null, AvailabilityResponse::class.java)
|
||||
|
||||
suspend fun setAvailability(status: String, note: String): AvailabilitySetResponse =
|
||||
execute("availability.php", "POST", AvailabilitySetRequest(status, note), AvailabilitySetResponse::class.java)
|
||||
suspend fun setAvailability(status: String, note: String, staffId: Int? = null): AvailabilitySetResponse =
|
||||
execute("availability.php", "POST", AvailabilitySetRequest(status, note, staffId), AvailabilitySetResponse::class.java)
|
||||
|
||||
/** Berührungs-Ping: die Person nutzt die App gerade (verhindert den automatischen Offline-Status). */
|
||||
suspend fun presence() {
|
||||
execute("presence.php", "POST", emptyMap<String, String>(), Map::class.java)
|
||||
}
|
||||
|
||||
// ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────
|
||||
|
||||
@@ -526,6 +544,7 @@ class DeskHubApi(
|
||||
val token = tokenProvider()
|
||||
?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.")
|
||||
requestBuilder.header("Authorization", "Bearer $token")
|
||||
requestBuilder.header("X-DeskHub-Authorization", "Bearer $token") // Ersatz-Header: viele Hoster entfernen "Authorization"
|
||||
}
|
||||
val body = payload?.let { gson.toJson(it).toRequestBody(jsonType) }
|
||||
when (method) {
|
||||
@@ -574,6 +593,7 @@ class DeskHubApi(
|
||||
.url("$baseUrl/api/staff/v1/upload_image.php".toHttpUrl())
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("X-DeskHub-Authorization", "Bearer $token")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
|
||||
@@ -86,6 +86,16 @@ data class TicketDetailDto(
|
||||
val lockedByOtherName: String? = null,
|
||||
val threads: List<TicketThreadDto> = emptyList(),
|
||||
val permissions: Map<String, Boolean>? = null,
|
||||
// Signaturen zur Auswahl beim Antworten; null = ältere Serverversion
|
||||
val replySignatures: ReplySignaturesDto? = null,
|
||||
// Vom Kunden im Formular ausgefüllte Zusatzfelder; leer bei älteren Servern
|
||||
val customFields: List<CustomFieldValueDto> = emptyList(),
|
||||
)
|
||||
|
||||
data class CustomFieldValueDto(
|
||||
val label: String = "",
|
||||
val type: String = "text",
|
||||
val value: String = "",
|
||||
)
|
||||
|
||||
data class TicketSpamRequest(
|
||||
@@ -149,10 +159,54 @@ data class MessageRequest(
|
||||
val type: String,
|
||||
val message: String,
|
||||
val setStatus: String? = null,
|
||||
// Welche Signatur angehängt wird: "own" (eigene), "dept" (Abteilung), "none"; null = Standard des Servers
|
||||
val signature: String? = null,
|
||||
)
|
||||
|
||||
data class ReplySignatureDto(
|
||||
val available: Boolean = false,
|
||||
val text: String = "",
|
||||
val hasImage: Boolean = false,
|
||||
val imageUrl: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
data class ReplySignaturesDto(
|
||||
val default: String = "none",
|
||||
val own: ReplySignatureDto = ReplySignatureDto(),
|
||||
val department: ReplySignatureDto = ReplySignatureDto(),
|
||||
)
|
||||
|
||||
data class MessageResponse(val threadId: Int = 0)
|
||||
|
||||
/** Gerät für Push-Benachrichtigungen beim Server an- oder abmelden (action: "register" oder "unregister"). */
|
||||
data class PushDeviceRequest(
|
||||
val action: String,
|
||||
val token: String,
|
||||
val deviceName: String = "",
|
||||
val appVersion: String = "",
|
||||
)
|
||||
|
||||
/** Push-Einstellungen: welche Popups, stille Zeiten (Server: push_prefs.php). */
|
||||
data class PushEventTypeDto(val key: String = "", val label: String = "")
|
||||
|
||||
data class PushPrefsDto(
|
||||
val events: List<String> = emptyList(),
|
||||
val eventTypes: List<PushEventTypeDto> = emptyList(),
|
||||
val quietFrom: String = "",
|
||||
val quietTo: String = "",
|
||||
)
|
||||
|
||||
data class PushPrefsRequest(val events: List<String>, val quietFrom: String, val quietTo: String)
|
||||
|
||||
/** Absturzbericht (Server: crash_report.php). */
|
||||
data class CrashReportRequest(val appVersion: String, val device: String, val trace: String)
|
||||
|
||||
data class PushDeviceResponse(
|
||||
val registered: Boolean = false,
|
||||
val serverConfigured: Boolean = false,
|
||||
)
|
||||
|
||||
data class CannedResponseDto(
|
||||
val id: Int = 0,
|
||||
val title: String = "",
|
||||
@@ -975,6 +1029,8 @@ data class TeamMemberDto(
|
||||
val note: String = "",
|
||||
val isOnVacation: Boolean = false,
|
||||
val updatedAt: String? = null,
|
||||
val lastActiveAt: String? = null,
|
||||
val autoOffline: Boolean = false,
|
||||
)
|
||||
|
||||
data class AvailabilityResponse(
|
||||
@@ -983,7 +1039,8 @@ data class AvailabilityResponse(
|
||||
val team: List<TeamMemberDto> = emptyList(),
|
||||
)
|
||||
|
||||
data class AvailabilitySetRequest(val status: String, val note: String)
|
||||
// staffId nur für Administratoren: Status eines anderen Mitarbeiters ändern (leer = eigener Status)
|
||||
data class AvailabilitySetRequest(val status: String, val note: String, val staffId: Int? = null)
|
||||
data class AvailabilitySetResponse(val ok: Boolean = false, val status: String = "")
|
||||
|
||||
// ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────────
|
||||
|
||||
@@ -24,6 +24,7 @@ class SessionStorageException(message: String, cause: Throwable) : Exception(mes
|
||||
|
||||
class SecureSessionStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences("deskhub_secure_session", Context.MODE_PRIVATE)
|
||||
private val pushPreferences = context.getSharedPreferences("deskhub_push", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
fun save(session: StoredSession) {
|
||||
@@ -62,6 +63,9 @@ class SecureSessionStore(context: Context) {
|
||||
|
||||
fun loadLastServerUrl(): String? = preferences.getString(LAST_SERVER_URL_KEY, null)
|
||||
|
||||
/** Firebase-Token dieses Geräts (für das Abmelden beim Ausloggen); steht getrennt von der geschützten Sitzung. */
|
||||
fun loadPushToken(): String? = pushPreferences.getString("fcm_token", null)?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun encrypt(value: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
|
||||
|
||||
@@ -81,6 +81,26 @@ data class TicketDetail(
|
||||
val threads: List<TicketThread>,
|
||||
// Rechte für dieses Ticket (gelten pro Abteilung); null = ältere Serverversion -> globale Rechte
|
||||
val permissions: Map<String, Boolean>? = null,
|
||||
// Signaturen zur Auswahl beim Antworten; null = ältere Serverversion (keine Auswahl)
|
||||
val replySignatures: ReplySignatures? = null,
|
||||
// Vom Kunden im Formular ausgefüllte Zusatzfelder (Bezeichnung, Wert)
|
||||
val customFields: List<CustomFieldValue> = emptyList(),
|
||||
)
|
||||
|
||||
data class CustomFieldValue(val label: String, val value: String)
|
||||
|
||||
data class ReplySignature(
|
||||
val available: Boolean,
|
||||
val text: String,
|
||||
val hasImage: Boolean,
|
||||
val name: String,
|
||||
val imageUrl: String = "",
|
||||
)
|
||||
|
||||
data class ReplySignatures(
|
||||
val default: String,
|
||||
val own: ReplySignature,
|
||||
val department: ReplySignature,
|
||||
)
|
||||
|
||||
data class SpamActionResult(
|
||||
@@ -667,6 +687,7 @@ data class TeamMember(
|
||||
val note: String,
|
||||
val isOnVacation: Boolean,
|
||||
val updatedAt: String?,
|
||||
val autoOffline: Boolean = false,
|
||||
)
|
||||
|
||||
data class AvailabilityData(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.deskhub.staff.push
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
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)
|
||||
}
|
||||
|
||||
/** Firebase vergibt ein neues Geräte-Token (zum Beispiel nach Neuinstallation): beim Server melden. */
|
||||
override fun onNewToken(token: String) {
|
||||
PushRegistration.onNewToken(applicationContext, token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package de.deskhub.staff.push
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.deskhub.staff.MainActivity
|
||||
import de.deskhub.staff.R
|
||||
|
||||
/**
|
||||
* Benachrichtigungen (Popups) für neue Tickets, Kundenantworten und Zuweisungen.
|
||||
*
|
||||
* Der Server schickt über Firebase Cloud Messaging nur eine kurze Daten-Nachricht (Titel, Text, Ticketnummer). Daraus baut die App
|
||||
* hier die Benachrichtigung selbst. Ein Tipp darauf öffnet das Ticket in der App.
|
||||
*/
|
||||
object DeskHubPush {
|
||||
const val CHANNEL_TICKETS = "tickets"
|
||||
const val EXTRA_TICKET = "de.deskhub.staff.extra.TICKET"
|
||||
|
||||
/** Nachrichten, die der Server schickt (Feld "type"). */
|
||||
private val KNOWN_TYPES = setOf("ticket_new", "ticket_reply", "ticket_assigned", "chat_new", "ticket_sla", "ticket_mention", "test")
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_TICKETS) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_TICKETS, "Tickets", NotificationManager.IMPORTANCE_HIGH).apply {
|
||||
description = "Neue Tickets, Kundenantworten und Zuweisungen"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Ob die App Benachrichtigungen anzeigen darf (ab Android 13 muss der Nutzer zustimmen). */
|
||||
fun canNotify(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
/** Zeigt die Benachrichtigung zu einer Nachricht des Servers. Gibt zurück, ob etwas angezeigt wurde. */
|
||||
fun show(context: Context, data: Map<String, String>): Boolean {
|
||||
val type = data["type"].orEmpty()
|
||||
val title = data["title"].orEmpty().trim()
|
||||
if (type !in KNOWN_TYPES || title.isEmpty()) return false
|
||||
if (!canNotify(context)) return false
|
||||
ensureChannel(context)
|
||||
|
||||
val ticket = data["ticket"].orEmpty().trim().take(60)
|
||||
val open = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
if (ticket.isNotEmpty()) putExtra(EXTRA_TICKET, ticket)
|
||||
}
|
||||
val id = notificationId(type, ticket)
|
||||
val pending = PendingIntent.getActivity(
|
||||
context, id, open,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_TICKETS)
|
||||
.setSmallIcon(R.drawable.ic_stat_notification)
|
||||
.setContentTitle(title.take(120))
|
||||
.setContentText(data["body"].orEmpty().take(240))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
// Frei wählbarer Name des Servers (Einstellung des Admins) neben dem App-Namen
|
||||
data["brand"].orEmpty().trim().take(40).takeIf { it.isNotEmpty() }?.let { builder.setSubText(it) }
|
||||
// Kundenantwort: direkt aus der Benachrichtigung antworten
|
||||
if (type == "ticket_reply" && ticket.isNotEmpty()) builder.addAction(replyAction(context, id, ticket))
|
||||
val notification = builder.build()
|
||||
// Dieselbe Ticketnummer und Art ersetzt die frühere Benachrichtigung, statt den Bildschirm zu füllen
|
||||
NotificationManagerCompat.from(context).notify(id, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun replyAction(context: Context, id: Int, ticket: String): NotificationCompat.Action {
|
||||
val intent = Intent(context, PushReplyReceiver::class.java).apply {
|
||||
putExtra(EXTRA_TICKET, ticket)
|
||||
putExtra(PushReplyReceiver.EXTRA_NOTIFICATION_ID, id)
|
||||
}
|
||||
// Muss veränderbar sein, damit Android den eingegebenen Text einfügen kann
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
|
||||
val pending = PendingIntent.getBroadcast(context, id, intent, flags)
|
||||
val input = androidx.core.app.RemoteInput.Builder(PushReplyReceiver.KEY_REPLY).setLabel("Antwort schreiben").build()
|
||||
return NotificationCompat.Action.Builder(R.drawable.ic_stat_notification, "Antworten", pending)
|
||||
.addRemoteInput(input)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun notificationId(type: String, ticket: String): Int = (type + ":" + ticket).hashCode() and 0x7fffffff
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.deskhub.staff.push
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import de.deskhub.staff.BuildConfig
|
||||
import de.deskhub.staff.data.remote.DeskHubApi
|
||||
import de.deskhub.staff.data.remote.dto.PushDeviceRequest
|
||||
import de.deskhub.staff.data.security.SecureSessionStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Meldet das Gerät für Push-Benachrichtigungen beim DeskHub-Server an und ab.
|
||||
* Das Firebase-Token liegt in eigenen Einstellungen (nicht in den geschützten Sitzungsdaten, die bei einem Fehler gelöscht werden).
|
||||
*/
|
||||
object PushRegistration {
|
||||
private const val PREFS = "deskhub_push"
|
||||
private const val KEY_TOKEN = "fcm_token"
|
||||
|
||||
fun savedToken(context: Context): String? =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_TOKEN, null)?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun saveToken(context: Context, token: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
/** Holt das aktuelle Firebase-Token (null, wenn Firebase nicht erreichbar oder nicht eingerichtet ist). */
|
||||
suspend fun fetchToken(): String? = suspendCancellableCoroutine { cont ->
|
||||
runCatching {
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (cont.isActive) cont.resume(if (task.isSuccessful) task.result else null)
|
||||
}
|
||||
}.onFailure { if (cont.isActive) cont.resume(null) }
|
||||
}
|
||||
|
||||
/** Nach der Anmeldung: Berechtigungskanal anlegen, Token holen und beim Server anmelden. Fehler werden still ignoriert (kein Push, App läuft normal). */
|
||||
suspend fun registerCurrent(context: Context) {
|
||||
// Push darf die App nie zum Absturz bringen (Server ohne Push-Schnittstelle, kein Netz, Firebase fehlt ...)
|
||||
try {
|
||||
DeskHubPush.ensureChannel(context)
|
||||
val token = fetchToken() ?: return
|
||||
saveToken(context, token)
|
||||
withContext(Dispatchers.IO) { registerWithServer(context, token) }
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// still ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
/** Firebase hat ein neues Token vergeben. */
|
||||
fun onNewToken(context: Context, token: String) {
|
||||
saveToken(context, token)
|
||||
runCatching { registerWithServer(context, token) }
|
||||
}
|
||||
|
||||
private fun registerWithServer(context: Context, token: String): Boolean {
|
||||
val session = SecureSessionStore(context.applicationContext).load() ?: return false
|
||||
if (session.token.isBlank() || session.serverUrl.isBlank()) return false
|
||||
val api = DeskHubApi(session.serverUrl) { session.token }
|
||||
val response = kotlinx.coroutines.runBlocking {
|
||||
api.pushDevice(
|
||||
PushDeviceRequest(
|
||||
action = "register",
|
||||
token = token,
|
||||
deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(),
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
),
|
||||
)
|
||||
}
|
||||
return response.registered
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.deskhub.staff.push
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import de.deskhub.staff.R
|
||||
import de.deskhub.staff.data.remote.DeskHubApi
|
||||
import de.deskhub.staff.data.remote.dto.MessageRequest
|
||||
import de.deskhub.staff.data.security.SecureSessionStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/** Schnellantwort direkt aus der Benachrichtigung: sendet den eingegebenen Text als Antwort auf das Ticket. */
|
||||
class PushReplyReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val ticket = intent.getStringExtra(DeskHubPush.EXTRA_TICKET).orEmpty()
|
||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
|
||||
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_REPLY)?.toString()?.trim().orEmpty()
|
||||
if (ticket.isEmpty() || text.isEmpty()) return
|
||||
val pending = goAsync()
|
||||
thread(name = "push-reply") {
|
||||
val ok = try {
|
||||
send(context.applicationContext, ticket, text)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
try {
|
||||
confirm(context, notificationId, ticket, ok)
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(context: Context, ticket: String, text: String): Boolean {
|
||||
val session = SecureSessionStore(context).load() ?: return false
|
||||
if (session.token.isBlank() || session.serverUrl.isBlank()) return false
|
||||
val api = DeskHubApi(session.serverUrl) { session.token }
|
||||
runBlocking { api.addMessage(MessageRequest(number = ticket, type = "reply", message = toHtml(text))) }
|
||||
return true
|
||||
}
|
||||
|
||||
/** Ersetzt die Benachrichtigung durch eine Bestätigung (sonst bleibt die Eingabe-Anzeige hängen). */
|
||||
private fun confirm(context: Context, id: Int, ticket: String, ok: Boolean) {
|
||||
if (!DeskHubPush.canNotify(context)) return
|
||||
val n = NotificationCompat.Builder(context, DeskHubPush.CHANNEL_TICKETS)
|
||||
.setSmallIcon(R.drawable.ic_stat_notification)
|
||||
.setContentTitle(if (ok) "Antwort gesendet" else "Antwort nicht gesendet")
|
||||
.setContentText("#$ticket" + if (ok) "" else ": bitte in der App erneut versuchen")
|
||||
.setTimeoutAfter(if (ok) 4000 else 0)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).notify(id, n)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_REPLY = "reply_text"
|
||||
const val EXTRA_NOTIFICATION_ID = "de.deskhub.staff.extra.NOTIFICATION_ID"
|
||||
|
||||
/** Reiner Text wird für die Nachricht als HTML maskiert, Zeilenumbrüche bleiben erhalten. */
|
||||
fun toHtml(text: String): String =
|
||||
text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\r\n", "\n").replace("\n", "<br>")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -261,6 +261,8 @@ class DeskHubViewModel(
|
||||
private var liveChatListPollingJob: Job? = null
|
||||
private var liveChatDetailPollingJob: Job? = null
|
||||
private var lastLiveChatTypingPingAt = 0L
|
||||
// Ticket, das über eine Benachrichtigung geöffnet werden soll, bevor die Anmeldung fertig ist
|
||||
private var pendingTicket: String? = null
|
||||
|
||||
init {
|
||||
restoreSession()
|
||||
@@ -313,6 +315,7 @@ class DeskHubViewModel(
|
||||
loadChatContacts()
|
||||
startActivityPolling()
|
||||
startTicketsPolling()
|
||||
applyPendingTicket()
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
@@ -411,6 +414,16 @@ class DeskHubViewModel(
|
||||
loadTickets(reset = false)
|
||||
}
|
||||
|
||||
/** Tipp auf eine Push-Benachrichtigung: das Ticket öffnen (sobald die Anmeldung steht). */
|
||||
fun openTicketFromNotification(number: String) {
|
||||
if (number.isBlank()) return
|
||||
if (state.value.staff != null) openTicket(number) else pendingTicket = number
|
||||
}
|
||||
|
||||
private fun applyPendingTicket() {
|
||||
pendingTicket?.let { pendingTicket = null; openTicket(it) }
|
||||
}
|
||||
|
||||
fun openTicket(number: String) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update {
|
||||
@@ -479,11 +492,11 @@ class DeskHubViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun addMessage(type: String, message: String, setStatus: String?, onSuccess: () -> Unit) {
|
||||
fun addMessage(type: String, message: String, setStatus: String?, signature: String?, onSuccess: () -> Unit) {
|
||||
val number = state.value.selectedTicket?.summary?.number ?: return
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(mutationLoading = true, mutationError = null) }
|
||||
runCatching { repository.addMessage(number, type, message, setStatus) }
|
||||
runCatching { repository.addMessage(number, type, message, setStatus, signature) }
|
||||
.onSuccess {
|
||||
mutableState.update { it.copy(mutationLoading = false) }
|
||||
onSuccess()
|
||||
@@ -1419,6 +1432,19 @@ class DeskHubViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Administrator: Status eines anderen Mitarbeiters ändern. */
|
||||
fun setMemberAvailability(staffId: Int, status: String, note: String) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) }
|
||||
runCatching { repository.setAvailability(status, note, staffId) }
|
||||
.onSuccess {
|
||||
mutableState.update { it.copy(availabilitySaving = false) }
|
||||
loadAvailability()
|
||||
}
|
||||
.onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun setMyAvailability(status: String, note: String) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) }
|
||||
@@ -1520,6 +1546,7 @@ class DeskHubViewModel(
|
||||
loadChatContacts()
|
||||
startActivityPolling()
|
||||
startTicketsPolling()
|
||||
applyPendingTicket()
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error is ApiException && error.status == 401) repository.clearSession()
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
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.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.deskhub.staff.data.remote.DeskHubApi
|
||||
import de.deskhub.staff.data.remote.dto.PushPrefsRequest
|
||||
import de.deskhub.staff.data.security.SecureSessionStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Wählen, welche Popups die App zeigt, und stille Zeiten festlegen (die Einstellungen liegen auf dem Server). */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun PushPrefsScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val types = remember { mutableStateListOf<Pair<String, String>>() }
|
||||
val selected = remember { mutableStateListOf<String>() }
|
||||
var quietFrom by remember { mutableStateOf("") }
|
||||
var quietTo by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun api(): DeskHubApi? {
|
||||
val session = SecureSessionStore(context.applicationContext).load() ?: return null
|
||||
return DeskHubApi(session.serverUrl) { session.token }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching {
|
||||
val prefs = withContext(Dispatchers.IO) { api()?.pushPrefs() }
|
||||
if (prefs != null) {
|
||||
types.clear(); types.addAll(prefs.eventTypes.map { it.key to it.label })
|
||||
selected.clear(); selected.addAll(prefs.events)
|
||||
quietFrom = prefs.quietFrom; quietTo = prefs.quietTo
|
||||
} else {
|
||||
message = "Nicht angemeldet."
|
||||
}
|
||||
}.onFailure { message = "Einstellungen konnten nicht geladen werden (hat der Server die neueste Version?)." }
|
||||
loading = false
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
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,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Wofür soll die App ein Popup zeigen?", fontWeight = FontWeight.Bold)
|
||||
if (loading) Text("Lade …")
|
||||
types.forEach { (key, label) ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = key in selected, onCheckedChange = { on -> if (on) selected.add(key) else selected.remove(key) })
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
if (types.isNotEmpty()) {
|
||||
Text("Stille Zeiten (keine Popups)", fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = quietFrom, onValueChange = { quietFrom = it.take(5) }, label = { Text("von (HH:MM)") }, singleLine = true, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(value = quietTo, onValueChange = { quietTo = it.take(5) }, label = { Text("bis (HH:MM)") }, singleLine = true, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Text("Beide leer = immer Popups. Es gilt die Zeit des Servers, auch über Mitternacht (z. B. 22:00 bis 06:00).", style = MaterialTheme.typography.bodySmall)
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
message = runCatching {
|
||||
withContext(Dispatchers.IO) { api()?.savePushPrefs(PushPrefsRequest(selected.toList(), quietFrom.trim(), quietTo.trim())) }
|
||||
"Gespeichert."
|
||||
}.getOrElse { "Nicht gespeichert: bitte Uhrzeiten prüfen (z. B. 22:00) oder später erneut versuchen." }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Speichern") }
|
||||
}
|
||||
message?.let { Text(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Kleines Glockensymbol für die Statusleiste (einfarbig, Android färbt es ein) -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
|
||||
</vector>
|
||||
Reference in New Issue
Block a user