diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/FriendlyError.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/FriendlyError.kt new file mode 100644 index 0000000..ef80dc5 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/FriendlyError.kt @@ -0,0 +1,475 @@ +package com.huaga.life_echo.ui.components.common + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.huaga.life_echo.config.AppConfig +import com.huaga.life_echo.ui.icons.AppIcons +import com.huaga.life_echo.ui.theme.LightPurple + +/** + * 错误类型枚举 + * 用于分类不同的错误场景,展示对应的友好提示 + */ +enum class ErrorType { + NETWORK, // 网络连接错误 + SERVER, // 服务器错误 + TIMEOUT, // 请求超时 + AUTH, // 认证/授权错误 + NOT_FOUND, // 资源不存在 + VALIDATION, // 数据验证错误 + UNKNOWN // 未知错误 +} + +/** + * 友好错误信息数据类 + */ +data class FriendlyErrorInfo( + val icon: ImageVector, + val title: String, + val message: String, + val actionText: String = "重试", + val iconTint: Color = LightPurple +) + +/** + * 错误信息处理工具 + * 根据运行模式决定显示原始错误还是友好提示 + */ +object ErrorHandler { + + /** + * 获取友好错误信息 + * 生产模式下返回友好提示,开发模式下可选择显示原始错误 + */ + fun getFriendlyError( + errorType: ErrorType, + originalMessage: String? = null + ): FriendlyErrorInfo { + return when (errorType) { + ErrorType.NETWORK -> FriendlyErrorInfo( + icon = AppIcons.WifiOff, + title = "网络连接失败", + message = "请检查您的网络连接后重试", + iconTint = Color(0xFFFF9800) + ) + ErrorType.SERVER -> FriendlyErrorInfo( + icon = AppIcons.CloudOff, + title = "服务暂时不可用", + message = "服务器正在维护中,请稍后再试", + iconTint = Color(0xFFF44336) + ) + ErrorType.TIMEOUT -> FriendlyErrorInfo( + icon = AppIcons.AccessTime, + title = "请求超时", + message = "网络响应较慢,请稍后重试", + iconTint = Color(0xFFFF9800) + ) + ErrorType.AUTH -> FriendlyErrorInfo( + icon = AppIcons.Lock, + title = "登录已过期", + message = "请重新登录后继续操作", + actionText = "去登录", + iconTint = Color(0xFF9C27B0) + ) + ErrorType.NOT_FOUND -> FriendlyErrorInfo( + icon = AppIcons.SearchOff, + title = "内容不存在", + message = "您访问的内容可能已被删除或移动", + iconTint = Color(0xFF607D8B) + ) + ErrorType.VALIDATION -> FriendlyErrorInfo( + icon = AppIcons.Warning, + title = "信息有误", + message = if (AppConfig.isDebugMode && originalMessage != null) { + originalMessage + } else { + "请检查输入的信息是否正确" + }, + iconTint = Color(0xFFFF9800) + ) + ErrorType.UNKNOWN -> FriendlyErrorInfo( + icon = AppIcons.Error, + title = "出了点小问题", + message = "请稍后重试,如问题持续请联系客服", + iconTint = Color(0xFFF44336) + ) + } + } + + /** + * 根据异常自动判断错误类型 + */ + fun detectErrorType(exception: Throwable): ErrorType { + val message = exception.message?.lowercase() ?: "" + return when { + message.contains("timeout") || message.contains("timed out") -> ErrorType.TIMEOUT + message.contains("network") || message.contains("connect") || + message.contains("socket") || message.contains("unreachable") -> ErrorType.NETWORK + message.contains("401") || message.contains("unauthorized") || + message.contains("token") || message.contains("expired") -> ErrorType.AUTH + message.contains("404") || message.contains("not found") -> ErrorType.NOT_FOUND + message.contains("500") || message.contains("502") || + message.contains("503") || message.contains("server") -> ErrorType.SERVER + message.contains("validation") || message.contains("invalid") -> ErrorType.VALIDATION + else -> ErrorType.UNKNOWN + } + } + + /** + * 根据HTTP状态码判断错误类型 + */ + fun detectErrorTypeByStatusCode(statusCode: Int): ErrorType { + return when (statusCode) { + 401, 403 -> ErrorType.AUTH + 404 -> ErrorType.NOT_FOUND + 408, 504 -> ErrorType.TIMEOUT + in 500..599 -> ErrorType.SERVER + else -> ErrorType.UNKNOWN + } + } + + /** + * 获取用于显示的错误消息 + * 生产模式下返回友好提示,开发模式下返回原始错误 + */ + fun getDisplayMessage( + errorType: ErrorType, + originalMessage: String? + ): String { + return if (AppConfig.isDebugMode && originalMessage != null) { + originalMessage + } else { + getFriendlyError(errorType, originalMessage).message + } + } + + /** + * 处理异常并返回显示消息 + */ + fun handleException(exception: Throwable): String { + val errorType = detectErrorType(exception) + return getDisplayMessage(errorType, exception.message) + } +} + +/** + * 友好错误视图组件(带动画) + */ +@Composable +fun FriendlyErrorView( + errorType: ErrorType, + originalMessage: String? = null, + onRetry: (() -> Unit)? = null, + onAction: (() -> Unit)? = null, + modifier: Modifier = Modifier, + showAnimation: Boolean = true +) { + val errorInfo = ErrorHandler.getFriendlyError(errorType, originalMessage) + + // 动画状态 + var visible by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + visible = true + } + + // 图标呼吸动画 + val infiniteTransition = rememberInfiniteTransition(label = "error_animation") + val iconScale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = EaseInOutCubic), + repeatMode = RepeatMode.Reverse + ), + label = "icon_scale" + ) + + val iconAlpha by infiniteTransition.animateFloat( + initialValue = 0.7f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = EaseInOutCubic), + repeatMode = RepeatMode.Reverse + ), + label = "icon_alpha" + ) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut() + ) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // 图标容器(带动画) + Box( + modifier = Modifier + .size(100.dp) + .scale(if (showAnimation) iconScale else 1f) + .alpha(if (showAnimation) iconAlpha else 1f) + .clip(CircleShape) + .background(errorInfo.iconTint.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = errorInfo.icon, + contentDescription = null, + tint = errorInfo.iconTint, + modifier = Modifier.size(48.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 标题 + Text( + text = errorInfo.title, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 描述信息 + Text( + text = errorInfo.message, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = 22.sp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 开发模式下显示原始错误信息 + if (AppConfig.isDebugMode && originalMessage != null && errorType != ErrorType.VALIDATION) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) + ) { + Text( + text = "[DEBUG] $originalMessage", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(12.dp) + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // 操作按钮 + val actionHandler = onAction ?: onRetry + if (actionHandler != null) { + Button( + onClick = actionHandler, + colors = ButtonDefaults.buttonColors( + containerColor = errorInfo.iconTint + ), + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 32.dp), + shape = RoundedCornerShape(24.dp) + ) { + Text( + text = errorInfo.actionText, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.White + ) + } + } + } + } +} + +/** + * 友好错误对话框组件 + */ +@Composable +fun FriendlyErrorDialog( + errorType: ErrorType, + originalMessage: String? = null, + onDismiss: () -> Unit, + onRetry: (() -> Unit)? = null +) { + val errorInfo = ErrorHandler.getFriendlyError(errorType, originalMessage) + + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(errorInfo.iconTint.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = errorInfo.icon, + contentDescription = null, + tint = errorInfo.iconTint, + modifier = Modifier.size(28.dp) + ) + } + }, + title = { + Text( + text = errorInfo.title, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = errorInfo.message, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // 开发模式下显示原始错误 + if (AppConfig.isDebugMode && originalMessage != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "[DEBUG] $originalMessage", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + }, + confirmButton = { + if (onRetry != null) { + Button( + onClick = { + onDismiss() + onRetry() + }, + colors = ButtonDefaults.buttonColors( + containerColor = errorInfo.iconTint + ) + ) { + Text(errorInfo.actionText, color = Color.White) + } + } else { + TextButton(onClick = onDismiss) { + Text("我知道了", color = errorInfo.iconTint) + } + } + }, + dismissButton = if (onRetry != null) { + { + TextButton(onClick = onDismiss) { + Text("取消", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } else null + ) +} + +/** + * 友好错误 Snackbar 消息 + */ +@Composable +fun rememberFriendlyErrorSnackbarHostState(): SnackbarHostState { + return remember { SnackbarHostState() } +} + +/** + * 显示友好错误 Snackbar + */ +suspend fun SnackbarHostState.showFriendlyError( + errorType: ErrorType, + originalMessage: String? = null, + actionLabel: String? = "重试" +): SnackbarResult { + val errorInfo = ErrorHandler.getFriendlyError(errorType, originalMessage) + return showSnackbar( + message = errorInfo.message, + actionLabel = actionLabel, + duration = SnackbarDuration.Long + ) +} + +/** + * 内联错误提示组件(用于表单等场景) + */ +@Composable +fun InlineErrorMessage( + message: String, + modifier: Modifier = Modifier +) { + // 生产模式下使用通用提示 + val displayMessage = if (AppConfig.isDebugMode) { + message + } else { + // 检查是否是常见的技术性错误信息,如果是则替换为友好提示 + when { + message.contains("timeout", ignoreCase = true) -> "请求超时,请重试" + message.contains("network", ignoreCase = true) -> "网络连接失败" + message.contains("server", ignoreCase = true) -> "服务暂时不可用" + message.contains("unauthorized", ignoreCase = true) -> "请重新登录" + message.contains("exception", ignoreCase = true) -> "操作失败,请重试" + message.contains("error", ignoreCase = true) && message.length > 50 -> "操作失败,请重试" + else -> message // 保留业务相关的错误信息 + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = AppIcons.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = displayMessage, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.error, + lineHeight = 18.sp + ) + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/MarkdownText.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/MarkdownText.kt new file mode 100644 index 0000000..e449579 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/common/MarkdownText.kt @@ -0,0 +1,98 @@ +package com.huaga.life_echo.ui.components.common + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography + +/** + * Markdown 文本渲染组件 + * 支持常见的 Markdown 语法:标题、粗体、斜体、列表、引用、代码块等 + */ +@Composable +fun MarkdownText( + content: String, + modifier: Modifier = Modifier, + textColor: Color = MaterialTheme.colorScheme.onSurface, + linkColor: Color = MaterialTheme.colorScheme.primary, + fontSize: Int = 16, + lineHeight: Int = 28 +) { + val typography = markdownTypography( + h1 = MaterialTheme.typography.headlineLarge.copy( + color = textColor, + fontSize = (fontSize + 12).sp + ), + h2 = MaterialTheme.typography.headlineMedium.copy( + color = textColor, + fontSize = (fontSize + 8).sp + ), + h3 = MaterialTheme.typography.headlineSmall.copy( + color = textColor, + fontSize = (fontSize + 4).sp + ), + h4 = MaterialTheme.typography.titleLarge.copy( + color = textColor, + fontSize = (fontSize + 2).sp + ), + h5 = MaterialTheme.typography.titleMedium.copy( + color = textColor, + fontSize = (fontSize + 1).sp + ), + h6 = MaterialTheme.typography.titleSmall.copy( + color = textColor, + fontSize = fontSize.sp + ), + text = TextStyle( + color = textColor, + fontSize = fontSize.sp, + lineHeight = lineHeight.sp + ), + paragraph = TextStyle( + color = textColor, + fontSize = fontSize.sp, + lineHeight = lineHeight.sp + ), + ordered = TextStyle( + color = textColor, + fontSize = fontSize.sp, + lineHeight = lineHeight.sp + ), + bullet = TextStyle( + color = textColor, + fontSize = fontSize.sp, + lineHeight = lineHeight.sp + ), + quote = TextStyle( + color = textColor.copy(alpha = 0.8f), + fontSize = fontSize.sp, + lineHeight = lineHeight.sp + ), + code = TextStyle( + color = textColor, + fontSize = (fontSize - 2).sp, + lineHeight = (lineHeight - 4).sp + ) + ) + + val colors = markdownColor( + text = textColor, + codeText = textColor, + linkText = linkColor, + codeBackground = MaterialTheme.colorScheme.surfaceVariant, + inlineCodeBackground = MaterialTheme.colorScheme.surfaceVariant, + dividerColor = MaterialTheme.colorScheme.outlineVariant + ) + + Markdown( + content = content, + modifier = modifier, + colors = colors, + typography = typography + ) +}