* 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
@@ -1,8 +1,11 @@
|
||||
package com.m57.hermescontrol
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
@@ -28,6 +31,12 @@ class HermesControlApp :
|
||||
ImageLoader
|
||||
.Builder(this)
|
||||
.okHttpClient(OkHttpProvider.base)
|
||||
.crossfade(true)
|
||||
.components {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}.crossfade(true)
|
||||
.build()
|
||||
}
|
||||
@@ -105,10 +105,12 @@ class ServerStoreMigration(
|
||||
profile.copy(
|
||||
baseUrl =
|
||||
profile.baseUrl
|
||||
?: ServerEndpoint.fromLegacy(
|
||||
profile.host,
|
||||
profile.port,
|
||||
).baseUrl.toString(),
|
||||
?: ServerEndpoint
|
||||
.fromLegacy(
|
||||
profile.host,
|
||||
profile.port,
|
||||
).baseUrl
|
||||
.toString(),
|
||||
)
|
||||
},
|
||||
selectedProfileId = selectedProfileId,
|
||||
|
||||
@@ -19,19 +19,23 @@ class ServerUrlMigration : DataMigration<ServerStoreState> {
|
||||
state.copy(
|
||||
baseUrl =
|
||||
normalizeExisting(state.baseUrl)
|
||||
?: ServerEndpoint.fromLegacy(
|
||||
state.host,
|
||||
state.port,
|
||||
).baseUrl.toString(),
|
||||
?: ServerEndpoint
|
||||
.fromLegacy(
|
||||
state.host,
|
||||
state.port,
|
||||
).baseUrl
|
||||
.toString(),
|
||||
connectionProfiles =
|
||||
state.connectionProfiles.map { profile ->
|
||||
profile.copy(
|
||||
baseUrl =
|
||||
normalizeExisting(profile.baseUrl)
|
||||
?: ServerEndpoint.fromLegacy(
|
||||
profile.host,
|
||||
profile.port,
|
||||
).baseUrl.toString(),
|
||||
?: ServerEndpoint
|
||||
.fromLegacy(
|
||||
profile.host,
|
||||
profile.port,
|
||||
).baseUrl
|
||||
.toString(),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -427,10 +427,12 @@ object AuthManager {
|
||||
|
||||
fun setBaseUrl(baseUrl: String) {
|
||||
val normalized =
|
||||
ServerEndpoint.parse(
|
||||
baseUrl,
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
).baseUrl.toString()
|
||||
ServerEndpoint
|
||||
.parse(
|
||||
baseUrl,
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
).baseUrl
|
||||
.toString()
|
||||
val selectedId =
|
||||
getSelectedProfileId() ?: run {
|
||||
ensureDefaultSelected()
|
||||
|
||||
@@ -28,6 +28,13 @@ data class Attachment(
|
||||
val isImage: Boolean
|
||||
get() = mimeType.startsWith("image/")
|
||||
|
||||
val isGif: Boolean
|
||||
get() =
|
||||
mimeType.equals("image/gif", ignoreCase = true) ||
|
||||
fileExtension == "gif" ||
|
||||
uri.contains(".gif", ignoreCase = true) ||
|
||||
uri.startsWith("data:image/gif", ignoreCase = true)
|
||||
|
||||
val isGateway: Boolean
|
||||
get() = source == AttachmentSource.GATEWAY
|
||||
|
||||
|
||||
@@ -81,8 +81,7 @@ object CookieManager {
|
||||
.expiresAt(
|
||||
System.currentTimeMillis() +
|
||||
10L * 365 * 24 * 60 * 60 * 1000,
|
||||
)
|
||||
.hostOnlyDomain(endpoint.baseUrl.host)
|
||||
).hostOnlyDomain(endpoint.baseUrl.host)
|
||||
.path(endpoint.baseUrl.encodedPath)
|
||||
.httpOnly()
|
||||
if (endpoint.baseUrl.isHttps) builder.secure()
|
||||
|
||||
@@ -149,7 +149,12 @@ class ServerEndpoint private constructor(
|
||||
val converted =
|
||||
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
|
||||
?: return "<invalid-websocket-url>"
|
||||
val redacted = converted.newBuilder().query(null).build().toString()
|
||||
val redacted =
|
||||
converted
|
||||
.newBuilder()
|
||||
.query(null)
|
||||
.build()
|
||||
.toString()
|
||||
return replaceScheme(redacted, socketScheme)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,17 +54,39 @@ object BillingRepository {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun anyToJsonElement(value: Any?): JsonElement =
|
||||
when (value) {
|
||||
null -> JsonNull
|
||||
is JsonElement -> value
|
||||
is Map<*, *> ->
|
||||
null -> {
|
||||
JsonNull
|
||||
}
|
||||
|
||||
is JsonElement -> {
|
||||
value
|
||||
}
|
||||
|
||||
is Map<*, *> -> {
|
||||
JsonObject(
|
||||
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
|
||||
)
|
||||
is List<*> -> JsonArray(value.map { anyToJsonElement(it) })
|
||||
is String -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is Number -> JsonPrimitive(value)
|
||||
else -> JsonPrimitive(value.toString())
|
||||
}
|
||||
|
||||
is List<*> -> {
|
||||
JsonArray(value.map { anyToJsonElement(it) })
|
||||
}
|
||||
|
||||
is String -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Boolean -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
is Number -> {
|
||||
JsonPrimitive(value)
|
||||
}
|
||||
|
||||
else -> {
|
||||
JsonPrimitive(value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSubscriptionState(): SubscriptionStateResponse? {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -25,20 +25,28 @@ object LocaleContextWrapper {
|
||||
*/
|
||||
fun localeForCode(code: String): Locale =
|
||||
when {
|
||||
code.isEmpty() || code == SYSTEM_LANGUAGE -> Locale.getDefault()
|
||||
code.isEmpty() || code == SYSTEM_LANGUAGE -> {
|
||||
Locale.getDefault()
|
||||
}
|
||||
|
||||
code.contains("-r", ignoreCase = true) -> {
|
||||
val parts = code.split("-r", ignoreCase = true)
|
||||
Locale(parts[0], parts.getOrElse(1) { "" })
|
||||
}
|
||||
|
||||
code.contains("-") -> {
|
||||
val parts = code.split("-")
|
||||
Locale(parts[0], parts.getOrElse(1) { "" })
|
||||
}
|
||||
|
||||
code.contains("_") -> {
|
||||
val parts = code.split("_")
|
||||
Locale(parts[0], parts.getOrElse(1) { "" })
|
||||
}
|
||||
else -> Locale(code)
|
||||
|
||||
else -> {
|
||||
Locale(code)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user