Upload via GUI (7854 Dateien)

This commit is contained in:
2026-10-19 18:12:49 +00:00
parent 54fc07c9db
commit 9daab8d383
7 changed files with 256 additions and 0 deletions
@@ -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<Bitmap?>(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),
)
}
}
@@ -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="#28405A" />
<corners android:radius="18dp" />
</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="#33FFFFFF" />
<corners android:radius="12dp" />
</shape>
@@ -0,0 +1,140 @@
<?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="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Aktualisieren"
android:padding="4dp"
android:text="⟳"
android:textColor="#FFFFFF"
android:textSize="20sp" />
</LinearLayout>
<TextView
android:id="@+id/widget_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Lade …"
android:textColor="#D6DEE8"
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="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="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Meine"
android:textColor="#D6DEE8"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="6dp"
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="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Offen"
android:textColor="#D6DEE8"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="6dp"
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="#FFB4A8"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Überfällig"
android:textColor="#D6DEE8"
android:textSize="11sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/widget_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="end"
android:textColor="#A9B6C6"
android:textSize="10sp" />
</LinearLayout>
+1
View File
@@ -1,4 +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>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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:minHeight="110dp"
android:previewLayout="@layout/widget_deskhub"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen" />
@@ -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)