diff --git a/mobile/src/main/java/de/deskhub/staff/ui/SignatureImage.kt b/mobile/src/main/java/de/deskhub/staff/ui/SignatureImage.kt new file mode 100644 index 00000000..36b60001 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/SignatureImage.kt @@ -0,0 +1,71 @@ +package de.deskhub.staff.ui + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +private const val MAX_IMAGE_BYTES = 2 * 1024 * 1024 + +/** Lädt ein Bild nur über https, höchstens 2 MB und verkleinert auf ca. 600 px; bei jedem Fehler null (dann bleibt nur der Text). */ +internal fun loadSignatureBitmap(url: String): Bitmap? { + if (!url.startsWith("https://", ignoreCase = true)) return null + return try { + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + conn.instanceFollowRedirects = false + try { + if (conn.responseCode != 200) return null + val bytes = conn.inputStream.use { input -> + val out = java.io.ByteArrayOutputStream() + val buf = ByteArray(8192) + while (true) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + if (out.size() > MAX_IMAGE_BYTES) return null + } + out.toByteArray() + } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0) return null + var sample = 1 + while (bounds.outWidth / sample > 1200) sample *= 2 + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } finally { + conn.disconnect() + } + } catch (_: Exception) { + null + } +} + +@Composable +fun SignatureImage(url: String, modifier: Modifier = Modifier) { + if (url.isBlank()) return + val bitmap by produceState(initialValue = null, url) { + value = withContext(Dispatchers.IO) { loadSignatureBitmap(url) } + } + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "Bild aus der Signatur", + contentScale = ContentScale.Fit, + modifier = modifier.widthIn(max = 220.dp).heightIn(max = 120.dp), + ) + } +} diff --git a/mobile/src/main/res/drawable/widget_background.xml b/mobile/src/main/res/drawable/widget_background.xml new file mode 100644 index 00000000..a8c355fb --- /dev/null +++ b/mobile/src/main/res/drawable/widget_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/drawable/widget_tile.xml b/mobile/src/main/res/drawable/widget_tile.xml new file mode 100644 index 00000000..acbb5dfd --- /dev/null +++ b/mobile/src/main/res/drawable/widget_tile.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/layout/widget_deskhub.xml b/mobile/src/main/res/layout/widget_deskhub.xml new file mode 100644 index 00000000..851c259c --- /dev/null +++ b/mobile/src/main/res/layout/widget_deskhub.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index acb900ce..e94b54f8 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ deskhub deskhub-Server + Zeigt deine offenen, nicht zugewiesenen und überfälligen Tickets. diff --git a/mobile/src/main/res/xml/deskhub_widget_info.xml b/mobile/src/main/res/xml/deskhub_widget_info.xml new file mode 100644 index 00000000..dff0c3ec --- /dev/null +++ b/mobile/src/main/res/xml/deskhub_widget_info.xml @@ -0,0 +1,10 @@ + + diff --git a/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt b/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt index 4c8bfbaa..fd659aa2 100644 --- a/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt +++ b/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt @@ -42,6 +42,30 @@ class ApiDtoParsingTest { assertEquals(false, t.permissions?.get("close_ticket")) } + @Test + fun `ticket detail carries reply signatures and message request sends the choice`() { + val t = gson.fromJson( + """{"id":5,"number":"T-1","subject":"S","department":{"id":2,"name":"Technik"}, + "customFields":[{"label":"Titel","type":"text","value":"Bär"}],"replySignatures":{"default":"own","own":{"available":true,"text":"Grüße\nMax","hasImage":false}, + "department":{"available":true,"name":"Technik","text":"Team","hasImage":true,"imageUrl":"https://x.de/l.png"}}}""", + TicketDetailDto::class.java, + ) + val sigs = t.replySignatures!! + assertEquals("own", sigs.default) + assertTrue(sigs.own.available) + assertEquals("Grüße\nMax", sigs.own.text) + assertEquals("Technik", sigs.department.name) + assertTrue(sigs.department.hasImage) + assertEquals("https://x.de/l.png", sigs.department.imageUrl) + assertEquals("Bär", t.customFields.single().value) + // ältere Server liefern keine Signaturen: keine Auswahl + assertNull(gson.fromJson("""{"id":5,"number":"T-1"}""", TicketDetailDto::class.java).replySignatures) + // die Wahl geht als Feld "signature" an den Server + val body = gson.toJson(MessageRequest("T-1", "reply", "Hallo", "answered", "dept")) + assertTrue(body.contains("\"signature\":\"dept\"")) + assertFalse(gson.toJson(MessageRequest("T-1", "note", "Notiz")).contains("signature")) + } + @Test fun `activity response reports health`() { val a = gson.fromJson("""{"newReplies":2,"newUnassigned":1,"serverTime":"2026-10-15 10:00:00","health":[{"level":"warning","code":"fetch_stale","message":"Abruf veraltet"}]}""", ActivityResponseDto::class.java)