feat: 新增功能组件和应用配置

- 新增OrganizeConversationDialog组织对话对话框
- 新增debug调试组件
- 新增network_security_config网络安全配置
- 更新MainActivity主活动
- 更新AppConfig应用配置
- 更新ConversationRepository数据仓库
- 更新build.gradle和AndroidManifest配置
This commit is contained in:
iammm0
2026-01-23 14:02:57 +08:00
parent b0c9f3dc15
commit 8c219ab1b1
9 changed files with 513 additions and 23 deletions

View File

@@ -63,7 +63,8 @@ dependencies {
// Ktor Client // Ktor Client
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.android) implementation(libs.ktor.client.android) // 用于普通HTTP请求
implementation(libs.ktor.client.okhttp) // 用于WebSocketOkHttp引擎支持WebSocket
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
@@ -81,6 +82,9 @@ dependencies {
// Serialization // Serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Image Loading
implementation(libs.coil.compose)
// Permissions // Permissions
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)

View File

@@ -11,6 +11,7 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Lifeecho" > android:theme="@style/Theme.Lifeecho" >

View File

@@ -46,8 +46,11 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.WindowInsetsCompat
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.compose.ui.platform.LocalContext
import com.huaga.life_echo.data.auth.TokenManager import com.huaga.life_echo.data.auth.TokenManager
import com.huaga.life_echo.navigation.AppNavigation import com.huaga.life_echo.navigation.AppNavigation
import com.huaga.life_echo.navigation.Screen import com.huaga.life_echo.navigation.Screen
@@ -81,12 +84,61 @@ class MainActivity : ComponentActivity() {
windowInsetsController.isAppearanceLightStatusBars = !darkMode windowInsetsController.isAppearanceLightStatusBars = !darkMode
windowInsetsController.isAppearanceLightNavigationBars = !darkMode windowInsetsController.isAppearanceLightNavigationBars = !darkMode
} }
// 隐藏系统状态栏和导航栏
SystemUiController(
isStatusBarVisible = false,
isNavigationBarVisible = false
)
LifeechoApp(TokenManager.isLoggedIn) LifeechoApp(TokenManager.isLoggedIn)
} }
} }
} }
} }
/**
* 系统UI控制器 Composable 函数
* 用于控制状态栏和导航栏的显示/隐藏
*
* @param isStatusBarVisible 状态栏是否可见false 表示隐藏
* @param isNavigationBarVisible 导航栏是否可见false 表示隐藏
*/
@Composable
fun SystemUiController(
isStatusBarVisible: Boolean = true,
isNavigationBarVisible: Boolean = true
) {
val context = LocalContext.current
val activity = context as? ComponentActivity
androidx.compose.runtime.LaunchedEffect(isStatusBarVisible, isNavigationBarVisible) {
activity?.window?.let { window ->
// 让内容延伸到系统栏下方
WindowCompat.setDecorFitsSystemWindows(window, false)
// 获取 WindowInsetsController
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
// 控制状态栏的显示/隐藏
if (isStatusBarVisible) {
windowInsetsController.show(WindowInsetsCompat.Type.statusBars())
} else {
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
}
// 控制导航栏的显示/隐藏
if (isNavigationBarVisible) {
windowInsetsController.show(WindowInsetsCompat.Type.navigationBars())
} else {
windowInsetsController.hide(WindowInsetsCompat.Type.navigationBars())
}
// 设置系统栏行为:当隐藏时,内容可以延伸到系统栏区域
windowInsetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@PreviewScreenSizes @PreviewScreenSizes
@Composable @Composable

View File

@@ -1,12 +1,23 @@
package com.huaga.life_echo.config package com.huaga.life_echo.config
object AppConfig { object AppConfig {
// API 基础 URL - 内网地址,用于同一局域网下的物理机测试 // API 基础 URL 配置
// 当前主机IP: 192.168.10.9,端口: 8000 //
// 如果IP地址变化请修改此处 // 物理机测试使用实际的内网IP地址如 192.168.10.9
// Android模拟器使用 10.0.2.2 来访问主机(开发机器)
//
// 注意从Android 9 (API 28)开始默认禁止明文HTTP流量
// 已在 network_security_config.xml 中配置允许明文流量(仅用于开发环境)
// 生产环境应该使用HTTPS并移除明文流量配置
// 当前配置:物理机测试
const val BASE_URL = "http://192.168.10.9:8000" const val BASE_URL = "http://192.168.10.9:8000"
const val WS_BASE_URL = "ws://192.168.10.9:8000" const val WS_BASE_URL = "ws://192.168.10.9:8000"
// 如果需要在Android模拟器上测试请使用以下配置
// const val BASE_URL = "http://10.0.2.2:8000"
// const val WS_BASE_URL = "ws://10.0.2.2:8000"
// 生产环境应该从配置文件或环境变量读取 // 生产环境应该从配置文件或环境变量读取
// const val BASE_URL = BuildConfig.API_BASE_URL // const val BASE_URL = BuildConfig.API_BASE_URL
} }

View File

@@ -26,6 +26,22 @@ class ConversationRepository(
conversationDao.updateConversation(conversation) conversationDao.updateConversation(conversation)
} }
suspend fun deleteConversation(id: String): Result<Unit> {
val result = apiService.deleteConversation(id)
return result.fold(
onSuccess = {
// 从本地数据库删除
conversationDao.getConversationById(id)?.let { conversation ->
conversationDao.deleteConversation(conversation)
}
Result.success(Unit)
},
onFailure = { exception ->
Result.failure(exception)
}
)
}
fun getSegmentsByConversationId(conversationId: String): Flow<List<ConversationSegment>> { fun getSegmentsByConversationId(conversationId: String): Flow<List<ConversationSegment>> {
return segmentDao.getSegmentsByConversationId(conversationId) return segmentDao.getSegmentsByConversationId(conversationId)
} }
@@ -38,12 +54,48 @@ class ConversationRepository(
segmentDao.insertSegments(segments) segmentDao.insertSegments(segments)
} }
/**
* 创建新对话
*/
suspend fun createConversation(): Result<String> {
val result = apiService.createConversation()
return result.fold(
onSuccess = { response ->
// 将新对话保存到本地数据库
val conversation = Conversation(
id = response.id,
userId = response.user_id,
startedAt = try {
java.time.Instant.parse(response.started_at).toEpochMilli()
} catch (e: Exception) {
System.currentTimeMillis()
},
endedAt = null,
durationSeconds = 0,
summary = null,
currentTopic = null,
conversationStage = null,
avatarUrl = null,
title = null,
latestMessagePreview = null,
latestMessageTime = System.currentTimeMillis()
)
conversationDao.insertConversation(conversation)
Result.success(response.id)
},
onFailure = { exception ->
Result.failure(exception)
}
)
}
/** /**
* 从API同步对话列表 * 从API同步对话列表
*/ */
suspend fun syncConversations() { suspend fun syncConversations() {
val result = apiService.getConversationList() val result = apiService.getConversationList()
result.getOrNull()?.let { conversations -> result.fold(
onSuccess = { conversations ->
// 将DTO转换为Entity并保存到数据库 // 将DTO转换为Entity并保存到数据库
conversations.forEach { dto -> conversations.forEach { dto ->
val conversation = Conversation( val conversation = Conversation(
@@ -62,7 +114,12 @@ class ConversationRepository(
) )
conversationDao.insertConversation(conversation) conversationDao.insertConversation(conversation)
} }
},
onFailure = { exception ->
// 失败时抛出异常,让调用方处理
throw exception
} }
)
} }
} }

View File

@@ -0,0 +1,184 @@
package com.huaga.life_echo.ui.components.debug
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.huaga.life_echo.ui.icons.AppIcons
/**
* WebSocket调试面板 - 开发测试用
* 实时显示WebSocket连接状态、消息收发情况等调试信息
*/
@Composable
fun WebSocketDebugPanel(
connectionStatus: String,
conversationId: String?,
isConnected: Boolean,
isStreaming: Boolean,
isTyping: Boolean,
lastMessageType: String?,
lastMessageTime: String?,
errorMessages: List<String>,
messageCount: Int,
modifier: Modifier = Modifier
) {
var isExpanded by remember { mutableStateOf(false) }
// 根据连接状态决定颜色
val statusColor = when {
connectionStatus.contains("已连接") -> Color(0xFF4CAF50)
connectionStatus.contains("连接中") -> Color(0xFFFF9800)
connectionStatus.contains("错误") || connectionStatus.contains("失败") -> Color(0xFFF44336)
else -> Color(0xFF9E9E9E)
}
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
// 标题栏(可点击展开/收起)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 状态指示器
Box(
modifier = Modifier
.size(12.dp)
.background(
color = statusColor,
shape = RoundedCornerShape(50)
)
)
Text(
text = "WS调试",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = connectionStatus,
fontSize = 12.sp,
color = statusColor
)
}
Icon(
imageVector = if (isExpanded) AppIcons.ExpandLess else AppIcons.ExpandMore,
contentDescription = if (isExpanded) "收起" else "展开",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 详细信息(展开时显示)
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// 基本信息
DebugInfoRow("连接状态", connectionStatus, statusColor)
DebugInfoRow("是否已连接", if (isConnected) "" else "", if (isConnected) Color(0xFF4CAF50) else Color(0xFFF44336))
DebugInfoRow("对话ID", conversationId ?: "未设置", MaterialTheme.colorScheme.onSurfaceVariant)
Divider(modifier = Modifier.padding(vertical = 4.dp))
// 消息统计
DebugInfoRow("消息总数", "$messageCount", MaterialTheme.colorScheme.primary)
DebugInfoRow("流式状态", if (isStreaming) "正在接收" else "空闲", if (isStreaming) Color(0xFFFF9800) else Color(0xFF4CAF50))
DebugInfoRow("输入状态", if (isTyping) "AI正在输入" else "空闲", if (isTyping) Color(0xFFFF9800) else Color(0xFF4CAF50))
// 最后一条消息信息
if (lastMessageType != null) {
Divider(modifier = Modifier.padding(vertical = 4.dp))
DebugInfoRow("最后消息类型", lastMessageType, MaterialTheme.colorScheme.primary)
if (lastMessageTime != null) {
DebugInfoRow("最后消息时间", lastMessageTime, MaterialTheme.colorScheme.onSurfaceVariant)
}
}
// 错误信息
if (errorMessages.isNotEmpty()) {
Divider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = "错误日志",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFF44336),
modifier = Modifier.padding(bottom = 4.dp)
)
errorMessages.takeLast(5).forEach { error ->
Text(
text = "$error",
fontSize = 10.sp,
color = Color(0xFFF44336),
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(start = 8.dp, bottom = 2.dp)
)
}
}
}
}
}
}
}
@Composable
private fun DebugInfoRow(
label: String,
value: String,
valueColor: Color
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$label:",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium
)
Text(
text = value,
fontSize = 11.sp,
color = valueColor,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp)
)
}
}

View File

@@ -0,0 +1,156 @@
package com.huaga.life_echo.ui.components.memoir
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.huaga.life_echo.network.models.ConversationListItemDto
import com.huaga.life_echo.ui.theme.LightPurple
import com.huaga.life_echo.utils.TimeUtils
/**
* 整理对话对话框
* 用于选择要整理的对话
*/
@Composable
fun OrganizeConversationDialog(
conversations: List<ConversationListItemDto>,
isLoading: Boolean = false,
onDismiss: () -> Unit,
onSelectConversation: (String) -> Unit,
modifier: Modifier = Modifier
) {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(0.8f),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// 标题栏
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "选择要整理的对话",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
IconButton(onClick = onDismiss) {
Text("", fontSize = 20.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
Divider()
// 对话列表
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = LightPurple)
}
} else if (conversations.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "还没有对话",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(conversations, key = { it.id }) { conversation ->
ConversationItem(
conversation = conversation,
onClick = {
onSelectConversation(conversation.id)
onDismiss()
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
}
}
}
}
}
}
@Composable
private fun ConversationItem(
conversation: ConversationListItemDto,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.clickable { onClick() },
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = conversation.title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
if (conversation.latestMessagePreview != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = conversation.latestMessagePreview,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = TimeUtils.formatDateTime(conversation.latestMessageTime),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- 开发环境允许明文HTTP流量 -->
<!-- 注意生产环境应该移除此配置强制使用HTTPS -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- 可选:仅允许特定域名的明文流量(更安全的方式) -->
<!--
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.10.9</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
-->
</network-security-config>

View File

@@ -16,6 +16,7 @@ navigationCompose = "2.8.4"
permissions = "0.34.0" permissions = "0.34.0"
coroutines = "1.9.0" coroutines = "1.9.0"
serialization = "1.7.3" serialization = "1.7.3"
coil = "2.5.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -38,6 +39,7 @@ androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compo
# Ktor # Ktor
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
@@ -61,6 +63,9 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
# Serialization # Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
# Image Loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }