diff --git a/app-android/app/build.gradle.kts b/app-android/app/build.gradle.kts index 5aab33e..b7f6f78 100644 --- a/app-android/app/build.gradle.kts +++ b/app-android/app/build.gradle.kts @@ -63,7 +63,8 @@ dependencies { // Ktor Client implementation(libs.ktor.client.core) - implementation(libs.ktor.client.android) + implementation(libs.ktor.client.android) // 用于普通HTTP请求 + implementation(libs.ktor.client.okhttp) // 用于WebSocket(OkHttp引擎支持WebSocket) implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) @@ -81,6 +82,9 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) + // Image Loading + implementation(libs.coil.compose) + // Permissions implementation(libs.accompanist.permissions) diff --git a/app-android/app/src/main/AndroidManifest.xml b/app-android/app/src/main/AndroidManifest.xml index 3e44214..74e4180 100644 --- a/app-android/app/src/main/AndroidManifest.xml +++ b/app-android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Lifeecho" > diff --git a/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt b/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt index a9ea4fc..a6a8072 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt @@ -46,8 +46,11 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.WindowInsetsCompat import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.compose.ui.platform.LocalContext import com.huaga.life_echo.data.auth.TokenManager import com.huaga.life_echo.navigation.AppNavigation import com.huaga.life_echo.navigation.Screen @@ -81,12 +84,61 @@ class MainActivity : ComponentActivity() { windowInsetsController.isAppearanceLightStatusBars = !darkMode windowInsetsController.isAppearanceLightNavigationBars = !darkMode } + // 隐藏系统状态栏和导航栏 + SystemUiController( + isStatusBarVisible = false, + isNavigationBarVisible = false + ) 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") @PreviewScreenSizes @Composable diff --git a/app-android/app/src/main/java/com/huaga/life_echo/config/AppConfig.kt b/app-android/app/src/main/java/com/huaga/life_echo/config/AppConfig.kt index b493e7e..d5d58dd 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/config/AppConfig.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/config/AppConfig.kt @@ -1,12 +1,23 @@ package com.huaga.life_echo.config object AppConfig { - // API 基础 URL - 内网地址,用于同一局域网下的物理机测试 - // 当前主机IP: 192.168.10.9,端口: 8000 - // 如果IP地址变化,请修改此处 + // API 基础 URL 配置 + // + // 物理机测试:使用实际的内网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 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 } \ No newline at end of file diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt index a1542cb..0921810 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt @@ -26,6 +26,22 @@ class ConversationRepository( conversationDao.updateConversation(conversation) } + suspend fun deleteConversation(id: String): Result { + 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> { return segmentDao.getSegmentsByConversationId(conversationId) } @@ -38,31 +54,72 @@ class ConversationRepository( segmentDao.insertSegments(segments) } + /** + * 创建新对话 + */ + suspend fun createConversation(): Result { + 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同步对话列表 */ suspend fun syncConversations() { val result = apiService.getConversationList() - result.getOrNull()?.let { conversations -> - // 将DTO转换为Entity并保存到数据库 - conversations.forEach { dto -> - val conversation = Conversation( - id = dto.id, - userId = "", // 需要从TokenManager获取 - startedAt = dto.latestMessageTime, - endedAt = null, - durationSeconds = 0, - summary = dto.latestMessagePreview, - currentTopic = null, - conversationStage = null, - avatarUrl = dto.avatarUrl, - title = dto.title, - latestMessagePreview = dto.latestMessagePreview, - latestMessageTime = dto.latestMessageTime - ) - conversationDao.insertConversation(conversation) + result.fold( + onSuccess = { conversations -> + // 将DTO转换为Entity并保存到数据库 + conversations.forEach { dto -> + val conversation = Conversation( + id = dto.id, + userId = "", // 需要从TokenManager获取 + startedAt = dto.latestMessageTime, + endedAt = null, + durationSeconds = 0, + summary = dto.latestMessagePreview, + currentTopic = null, + conversationStage = null, + avatarUrl = dto.avatarUrl, + title = dto.title, + latestMessagePreview = dto.latestMessagePreview, + latestMessageTime = dto.latestMessageTime + ) + conversationDao.insertConversation(conversation) + } + }, + onFailure = { exception -> + // 失败时抛出异常,让调用方处理 + throw exception } - } + ) } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/debug/WebSocketDebugPanel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/debug/WebSocketDebugPanel.kt new file mode 100644 index 0000000..85c0856 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/debug/WebSocketDebugPanel.kt @@ -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, + 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) + ) + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/OrganizeConversationDialog.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/OrganizeConversationDialog.kt new file mode 100644 index 0000000..4db04a0 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/OrganizeConversationDialog.kt @@ -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, + 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 + ) + } + } +} diff --git a/app-android/app/src/main/res/xml/network_security_config.xml b/app-android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..8f8a956 --- /dev/null +++ b/app-android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/app-android/gradle/libs.versions.toml b/app-android/gradle/libs.versions.toml index a2bd0fe..31ca3fc 100644 --- a/app-android/gradle/libs.versions.toml +++ b/app-android/gradle/libs.versions.toml @@ -16,6 +16,7 @@ navigationCompose = "2.8.4" permissions = "0.34.0" coroutines = "1.9.0" serialization = "1.7.3" +coil = "2.5.0" [libraries] 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-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-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-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" } @@ -61,6 +63,9 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- # 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] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }