* feat(chat): add animated GIF rendering with tap-to-play animation Closes #721 * fix(#721): add missing GifImageThumbnail component and format ktlint * fix(analytics): resolve ktlint indentation error in itemsIndexed * feat(chat): add interactive play/pause toggle for animated GIFs * fix(chat): control GIF playback via Animatable start and stop
This commit is contained in:
32 files changed
+370
-118
No files matched your search
@@ -39,7 +39,9 @@ data class AchievementsUiState(
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class AchievementsViewModel : ViewModel(), ToastHost {
|
||||
class AchievementsViewModel :
|
||||
ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(AchievementsUiState())
|
||||
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
||||
|
||||
|
||||
@@ -210,12 +210,12 @@ private fun AnalyticsContent(
|
||||
ModelRow(model = model)
|
||||
}
|
||||
} else if ((usage?.by_model ?: emptyList()).isNotEmpty()) {
|
||||
itemsIndexed(usage?.by_model ?: emptyList(), key = {
|
||||
index,
|
||||
it,
|
||||
->
|
||||
"usage_${it.model}_$index"
|
||||
}) { _, entry ->
|
||||
itemsIndexed(
|
||||
usage?.by_model ?: emptyList(),
|
||||
key = { index, it ->
|
||||
"usage_${it.model}_$index"
|
||||
},
|
||||
) { _, entry ->
|
||||
ModelEntryRow(entry = entry)
|
||||
}
|
||||
} else {
|
||||
@@ -232,12 +232,12 @@ private fun AnalyticsContent(
|
||||
|
||||
if (selectedSectionTab == 1) {
|
||||
if ((usage?.skills?.top_skills ?: emptyList()).isNotEmpty()) {
|
||||
itemsIndexed(usage?.skills?.top_skills ?: emptyList(), key = {
|
||||
index,
|
||||
it,
|
||||
->
|
||||
"skill_${it.skill}_$index"
|
||||
}) { _, skill ->
|
||||
itemsIndexed(
|
||||
usage?.skills?.top_skills ?: emptyList(),
|
||||
key = { index, it ->
|
||||
"skill_${it.skill}_$index"
|
||||
},
|
||||
) { _, skill ->
|
||||
SkillRow(skill = skill)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -162,11 +162,21 @@ class BillingViewModel : ViewModel() {
|
||||
}
|
||||
val msg =
|
||||
when {
|
||||
resp.requires_action == true && resp.recovery_url != null ->
|
||||
resp.requires_action == true && resp.recovery_url != null -> {
|
||||
"Action required — open: ${resp.recovery_url}"
|
||||
resp.payment_failed == true -> resp.message ?: "Payment failed"
|
||||
resp.ok == true -> "Upgrade successful"
|
||||
else -> resp.message ?: "Upgrade requested"
|
||||
}
|
||||
|
||||
resp.payment_failed == true -> {
|
||||
resp.message ?: "Payment failed"
|
||||
}
|
||||
|
||||
resp.ok == true -> {
|
||||
"Upgrade successful"
|
||||
}
|
||||
|
||||
else -> {
|
||||
resp.message ?: "Upgrade requested"
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(isActionInFlight = false, actionMessage = msg) }
|
||||
load()
|
||||
|
||||
@@ -59,7 +59,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
@@ -78,7 +77,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
@@ -2301,24 +2299,20 @@ private fun InlineAttachment(
|
||||
) {
|
||||
val clickable = Modifier.clickable { onOpen(attachment) }
|
||||
if (attachment.isImage) {
|
||||
// Image attachment — show as a rounded thumbnail; tap opens the viewer.
|
||||
AsyncImage(
|
||||
// Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation.
|
||||
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
|
||||
model = attachment.uri,
|
||||
contentDescription = attachment.name,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
onImageClick(
|
||||
ImageViewerModel(
|
||||
model = attachment.uri,
|
||||
name = attachment.name,
|
||||
mimeType = attachment.mimeType,
|
||||
),
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.FillWidth,
|
||||
isGif = attachment.isGif,
|
||||
onClick = {
|
||||
onImageClick(
|
||||
ImageViewerModel(
|
||||
model = attachment.uri,
|
||||
name = attachment.name,
|
||||
mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Non-image file — show a card with file icon and name. Tapping fetches
|
||||
|
||||
@@ -33,7 +33,9 @@ object ImageBytesResolver {
|
||||
) : Result
|
||||
|
||||
/** Could not load the image; [message] is safe to surface to the user. */
|
||||
data class Error(val message: String) : Result
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : Result
|
||||
}
|
||||
|
||||
suspend fun resolve(
|
||||
@@ -102,7 +104,8 @@ object ImageBytesResolver {
|
||||
if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})")
|
||||
val bytes = resp.body.bytes()
|
||||
val mime =
|
||||
resp.header("Content-Type")
|
||||
resp
|
||||
.header("Content-Type")
|
||||
?.substringBefore(";")
|
||||
?.ifBlank { fallbackMime }
|
||||
?: fallbackMime
|
||||
|
||||
@@ -96,8 +96,9 @@ fun ImageViewerDialog(
|
||||
}
|
||||
}
|
||||
|
||||
is ImageBytesResolver.Result.Error ->
|
||||
is ImageBytesResolver.Result.Error -> {
|
||||
String.format(loadFailedFmt, resolved.message)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
isBusy = false
|
||||
@@ -124,7 +125,9 @@ fun ImageViewerDialog(
|
||||
)
|
||||
}
|
||||
|
||||
is ImageBytesResolver.Result.Error -> null
|
||||
is ImageBytesResolver.Result.Error -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
isBusy = false
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -269,35 +268,31 @@ fun MarkdownText(
|
||||
}
|
||||
|
||||
is MdBlock.Image -> {
|
||||
// AsyncImage is a Compose runtime composable — load it lazily
|
||||
// behind remember so the parser stays pure and testable (the
|
||||
// parseBlocks/parseInline unit tests never touch Compose).
|
||||
val model: Any = remember(block.uri) { resolveImageUrl(block.uri) }
|
||||
androidx.compose.foundation.layout.Box(
|
||||
val isGif =
|
||||
remember(block.uri) {
|
||||
block.uri.contains(".gif", ignoreCase = true) ||
|
||||
block.uri.startsWith("data:image/gif", ignoreCase = true)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(
|
||||
onClick = {
|
||||
onImageClick(
|
||||
ImageViewerModel(
|
||||
model = block.uri,
|
||||
name = block.alt,
|
||||
mimeType = "image/*",
|
||||
),
|
||||
)
|
||||
},
|
||||
).padding(vertical = 4.dp),
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
coil.compose.AsyncImage(
|
||||
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
|
||||
model = model,
|
||||
contentDescription = block.alt.ifBlank { null },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.FillWidth,
|
||||
isGif = isGif,
|
||||
onClick = {
|
||||
onImageClick(
|
||||
ImageViewerModel(
|
||||
model = block.uri,
|
||||
name = block.alt,
|
||||
mimeType = if (isGif) "image/gif" else "image/*",
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +200,8 @@ fun ComposerToolbar(
|
||||
* "xhigh", "max", "ultra", or null for model default.
|
||||
* @return Display string such as "None", "Low", "XHigh", "Ultra", etc.
|
||||
*/
|
||||
private fun buildReasoningLabel(level: String?): String {
|
||||
return when (level) {
|
||||
private fun buildReasoningLabel(level: String?): String =
|
||||
when (level) {
|
||||
null -> "Med"
|
||||
"none" -> "None"
|
||||
"minimal" -> "Minimal"
|
||||
@@ -213,4 +213,3 @@ private fun buildReasoningLabel(level: String?): String {
|
||||
"ultra" -> "Ultra"
|
||||
else -> level
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.graphics.drawable.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
@Composable
|
||||
fun GifImageThumbnail(
|
||||
model: Any,
|
||||
contentDescription: String?,
|
||||
isGif: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isPlaying by remember { mutableStateOf(true) }
|
||||
var animatableDrawable by remember { mutableStateOf<Animatable?>(null) }
|
||||
|
||||
val togglePlayPause: () -> Unit = {
|
||||
val nextState = !isPlaying
|
||||
isPlaying = nextState
|
||||
animatableDrawable?.let { anim ->
|
||||
if (nextState) {
|
||||
anim.start()
|
||||
} else {
|
||||
anim.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onClick() },
|
||||
) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
onSuccess = { result ->
|
||||
val drawable = result.result.drawable
|
||||
if (drawable is Animatable) {
|
||||
animatableDrawable = drawable
|
||||
if (isPlaying) {
|
||||
drawable.start()
|
||||
} else {
|
||||
drawable.stop()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
|
||||
if (isGif) {
|
||||
// Play / Pause toggle badge
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(8.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
).clickable { togglePlayPause() }
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause GIF" else "Play GIF",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = " GIF",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 10.sp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Center overlay button when paused
|
||||
if (!isPlaying) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
|
||||
shape = CircleShape,
|
||||
).clickable { togglePlayPause() }
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "Play GIF",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,10 @@ internal fun highlightSyntax(code: String): AnnotatedString =
|
||||
}
|
||||
}
|
||||
|
||||
private data class TokenPattern(val regex: Regex, val color: Color)
|
||||
private data class TokenPattern(
|
||||
val regex: Regex,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
private fun copyToClipboard(
|
||||
context: Context,
|
||||
|
||||
@@ -207,7 +207,11 @@ private fun shimmerBrush(): Brush {
|
||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||
val highlight = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val widthPx = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.dp.toPx() }
|
||||
val widthPx =
|
||||
with(LocalDensity.current) {
|
||||
LocalConfiguration.current.screenWidthDp.dp
|
||||
.toPx()
|
||||
}
|
||||
val offset by transition.animateFloat(
|
||||
initialValue = -widthPx,
|
||||
targetValue = widthPx,
|
||||
|
||||
@@ -535,7 +535,9 @@ class ModelViewModel :
|
||||
return@launch
|
||||
}
|
||||
|
||||
is NetworkResult.Success -> activeProfileNameResResult.data.active
|
||||
is NetworkResult.Success -> {
|
||||
activeProfileNameResResult.data.active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ data class ProcessesUiState(
|
||||
val sessionId: String? = null,
|
||||
)
|
||||
|
||||
class ProcessesViewModel : ViewModel(), ToastHost {
|
||||
class ProcessesViewModel :
|
||||
ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(ProcessesUiState())
|
||||
val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow()
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ data class SessionsUiState(
|
||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||
}
|
||||
|
||||
class SessionsViewModel : ViewModel(), ToastHost {
|
||||
class SessionsViewModel :
|
||||
ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(SessionsUiState())
|
||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||
|
||||
|
||||
Reference in new issue
Block a user