From a8dd0554262aca0441347cc627dad1baf0517f4b Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:45:06 +0300 Subject: [PATCH] feat(ui): copy button on error states and chat error snackbar (#1420) --- .../chat/components/ChatLifecycleEffects.kt | 24 ++++++++++++++++++- .../m57/hermescontrol/ui/common/StateViews.kt | 23 ++++++++++++++++++ app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 50 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index e8cfd3bc..b6161f6d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -1,17 +1,23 @@ package com.m57.hermescontrol.ui.chat.components import android.Manifest +import android.content.ClipData import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle @@ -19,6 +25,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.m57.hermescontrol.NavigationController +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.notification.ReplyNotificationTracker import com.m57.hermescontrol.notification.correlationScopeId @@ -28,6 +35,7 @@ import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.SecretPromptUi import com.m57.hermescontrol.ui.chat.SudoPromptUi import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch @Composable fun ChatLifecycleEffects( @@ -137,10 +145,24 @@ fun ChatLifecycleEffects( } // Show error as snackbar + val clipboard = LocalClipboard.current + val copyLabel = context.getString(R.string.action_copy_error) + // Own scope + immediate clear: a later `errorMessage = null` elsewhere must not cancel a visible popup. + val snackbarScope = rememberCoroutineScope() LaunchedEffect(errorMessage) { errorMessage?.let { error -> - snackbarHostState.showSnackbar(error) viewModel.clearError() + snackbarScope.launch { + val result = + snackbarHostState.showSnackbar( + error, + actionLabel = copyLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, error))) + } + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt index 4f9bd413..027bce0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/StateViews.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.common +import android.content.ClipData import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -22,6 +23,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button @@ -30,8 +32,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -39,6 +43,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -48,6 +54,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalSpacing +import kotlinx.coroutines.launch // Unified loading / error / empty / skeleton placeholders so every screen // shares the same visual language. @@ -96,6 +103,8 @@ fun ErrorState( modifier: Modifier = Modifier, ) { val spacing = LocalSpacing.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() Column( modifier = modifier @@ -117,6 +126,20 @@ fun ErrorState( textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) + TextButton( + onClick = { + scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message))) } + }, + modifier = Modifier.testTag("error_copy_button"), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.size(spacing.xs)) + Text(stringResource(R.string.action_copy_error)) + } if (onRetry != null) { Spacer(modifier = Modifier.height(spacing.md)) Button( diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6a328570..8e923ad0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -19,6 +19,7 @@ النتيجة التالية إغلاق البحث إعادة المحاولة +نسخ الخطأ إلغاء تأكيد مسح diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fabaaf82..ee02ab7a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -22,6 +22,7 @@ 재시도 + 오류 복사 취소 지우기 확인 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 67868a7b..6944c427 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -19,6 +19,7 @@ 下一个匹配 关闭搜索 重试 + 复制错误 取消 确认 清除 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2bca4211..eb91aa7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,7 @@ Retry + Copy error Cancel Confirm Clear