diff --git a/app-android/.gitignore b/app-android/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/app-android/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app-android/.idea/.name b/app-android/.idea/.name new file mode 100644 index 0000000..78d1dc9 --- /dev/null +++ b/app-android/.idea/.name @@ -0,0 +1 @@ +life-echo \ No newline at end of file diff --git a/app-android/.idea/AndroidProjectSystem.xml b/app-android/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/app-android/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app-android/.idea/appInsightsSettings.xml b/app-android/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/app-android/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/app-android/.idea/compiler.xml b/app-android/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/app-android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-android/.idea/deploymentTargetSelector.xml b/app-android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..52ba993 --- /dev/null +++ b/app-android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/app-android/.idea/deviceManager.xml b/app-android/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/app-android/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/app-android/.idea/gradle.xml b/app-android/.idea/gradle.xml new file mode 100644 index 0000000..97f0a8e --- /dev/null +++ b/app-android/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app-android/.idea/inspectionProfiles/Project_Default.xml b/app-android/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/app-android/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/app-android/.idea/migrations.xml b/app-android/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/app-android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app-android/.idea/misc.xml b/app-android/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/app-android/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/app-android/.idea/runConfigurations.xml b/app-android/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/app-android/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/app-android/.idea/vcs.xml b/app-android/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/app-android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-android/app/.gitignore b/app-android/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app-android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app-android/app/build.gradle.kts b/app-android/app/build.gradle.kts new file mode 100644 index 0000000..f090ba3 --- /dev/null +++ b/app-android/app/build.gradle.kts @@ -0,0 +1,93 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.huaga.life_echo" + compileSdk = 36 + + defaultConfig { + applicationId = "com.huaga.life_echo" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + // AndroidX Core + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive.navigation.suite) + + // Navigation Compose + implementation(libs.androidx.navigation.compose) + + // Ktor Client + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.android) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + // Coroutines + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + // Serialization + implementation(libs.kotlinx.serialization.json) + + // Permissions + implementation(libs.accompanist.permissions) + + // Testing + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app-android/app/proguard-rules.pro b/app-android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app-android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app-android/app/src/androidTest/java/com/huaga/life_echo/ExampleInstrumentedTest.kt b/app-android/app/src/androidTest/java/com/huaga/life_echo/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..8e9b97e --- /dev/null +++ b/app-android/app/src/androidTest/java/com/huaga/life_echo/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.huaga.life_echo + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.huaga.life_echo", appContext.packageName) + } +} \ No newline at end of file diff --git a/app-android/app/src/main/AndroidManifest.xml b/app-android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f63d579 --- /dev/null +++ b/app-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000..b20540e --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/MainActivity.kt @@ -0,0 +1,104 @@ +package com.huaga.life_echo + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.navigation.compose.rememberNavController +import com.huaga.life_echo.navigation.AppNavigation +import com.huaga.life_echo.navigation.Screen +import com.huaga.life_echo.ui.theme.LifeechoTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + LifeechoTheme { + LifeechoApp() + } + } + } +} + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@PreviewScreenSizes +@Composable +fun LifeechoApp() { + val navController = rememberNavController() + var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) } + + NavigationSuiteScaffold( + navigationSuiteItems = { + AppDestinations.entries.forEach { + item( + icon = { + Icon( + it.icon, + contentDescription = it.label + ) + }, + label = { Text(it.label) }, + selected = it == currentDestination, + onClick = { + currentDestination = it + when (it) { + AppDestinations.HOME -> navController.navigate(Screen.CreateMemory.route) + AppDestinations.FAVORITES -> navController.navigate(Screen.MyMemoir.route) + AppDestinations.PROFILE -> navController.navigate(Screen.Profile.route) + } + } + ) + } + } + ) { + Scaffold(modifier = Modifier.fillMaxSize()) { + AppNavigation(navController = navController) + } + } +} + +enum class AppDestinations( + val label: String, + val icon: ImageVector, +) { + HOME("Home", Icons.Default.Home), + FAVORITES("Favorites", Icons.Default.Favorite), + PROFILE("Profile", Icons.Default.AccountBox), +} + +@Composable +fun Greeting(name: String, modifier: Modifier = Modifier) { + Text( + text = "Hello $name!", + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + LifeechoTheme { + Greeting("Android") + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..3873b31 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/config/AppConfig.kt @@ -0,0 +1,11 @@ +package com.huaga.life_echo.config + +object AppConfig { + // API 基础 URL(可以从 BuildConfig 或环境变量读取) + const val BASE_URL = "http://10.0.2.2:8000" // Android 模拟器使用 10.0.2.2 访问 localhost + const val WS_BASE_URL = "ws://10.0.2.2:8000" + + // 生产环境应该从配置文件或环境变量读取 + // const val BASE_URL = BuildConfig.API_BASE_URL +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/AppDatabase.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/AppDatabase.kt new file mode 100644 index 0000000..2519f62 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/AppDatabase.kt @@ -0,0 +1,43 @@ +package com.huaga.life_echo.data.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database( + entities = [ + User::class, + Conversation::class, + ConversationSegment::class, + Chapter::class, + Book::class + ], + version = 1, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + abstract fun conversationDao(): ConversationDao + abstract fun conversationSegmentDao(): ConversationSegmentDao + abstract fun chapterDao(): ChapterDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "life_echo_database" + ) + .fallbackToDestructiveMigration() + .build() + INSTANCE = instance + instance + } + } + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/Book.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/Book.kt new file mode 100644 index 0000000..9a40d08 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/Book.kt @@ -0,0 +1,15 @@ +package com.huaga.life_echo.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "books") +data class Book( + @PrimaryKey val id: String, + val userId: String, + val title: String, + val totalPages: Int, + val totalWords: Int, + val updatedAt: Long +) + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/Chapter.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/Chapter.kt new file mode 100644 index 0000000..2a2d6d5 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/Chapter.kt @@ -0,0 +1,16 @@ +package com.huaga.life_echo.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "chapters") +data class Chapter( + @PrimaryKey val id: String, + val title: String, + val content: String, + val orderIndex: Int, + val status: String, // draft, completed + val updatedAt: Long, + val category: String +) + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/ChapterDao.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/ChapterDao.kt new file mode 100644 index 0000000..4c440d1 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/ChapterDao.kt @@ -0,0 +1,29 @@ +package com.huaga.life_echo.data.database + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChapterDao { + @Query("SELECT * FROM chapters ORDER BY orderIndex ASC") + fun getAllChapters(): Flow> + + @Query("SELECT * FROM chapters WHERE id = :id") + suspend fun getChapterById(id: String): Chapter? + + @Query("SELECT * FROM chapters WHERE category = :category") + suspend fun getChaptersByCategory(category: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertChapter(chapter: Chapter) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertChapters(chapters: List) + + @Update + suspend fun updateChapter(chapter: Chapter) + + @Delete + suspend fun deleteChapter(chapter: Chapter) +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/Conversation.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/Conversation.kt new file mode 100644 index 0000000..d84e9e0 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/Conversation.kt @@ -0,0 +1,17 @@ +package com.huaga.life_echo.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "conversations") +data class Conversation( + @PrimaryKey val id: String, + val userId: String, + val startedAt: Long, + val endedAt: Long?, + val durationSeconds: Int, + val summary: String?, + val currentTopic: String?, + val conversationStage: String? // CHILDHOOD, EDUCATION, CAREER, FAMILY, BELIEFS, SUMMARY +) + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationDao.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationDao.kt new file mode 100644 index 0000000..e621e4c --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationDao.kt @@ -0,0 +1,23 @@ +package com.huaga.life_echo.data.database + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConversationDao { + @Query("SELECT * FROM conversations ORDER BY startedAt DESC") + fun getAllConversations(): Flow> + + @Query("SELECT * FROM conversations WHERE id = :id") + suspend fun getConversationById(id: String): Conversation? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertConversation(conversation: Conversation) + + @Update + suspend fun updateConversation(conversation: Conversation) + + @Delete + suspend fun deleteConversation(conversation: Conversation) +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationSegment.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationSegment.kt new file mode 100644 index 0000000..f39a07d --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationSegment.kt @@ -0,0 +1,16 @@ +package com.huaga.life_echo.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "conversation_segments") +data class ConversationSegment( + @PrimaryKey val id: String, + val conversationId: String, + val audioPath: String?, + val transcriptText: String, + val createdAt: Long, + val processed: Boolean, + val topicCategory: String? +) + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationSegmentDao.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationSegmentDao.kt new file mode 100644 index 0000000..db00b58 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/ConversationSegmentDao.kt @@ -0,0 +1,23 @@ +package com.huaga.life_echo.data.database + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConversationSegmentDao { + @Query("SELECT * FROM conversation_segments WHERE conversationId = :conversationId ORDER BY createdAt ASC") + fun getSegmentsByConversationId(conversationId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSegment(segment: ConversationSegment) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSegments(segments: List) + + @Update + suspend fun updateSegment(segment: ConversationSegment) + + @Delete + suspend fun deleteSegment(segment: ConversationSegment) +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/database/User.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/database/User.kt new file mode 100644 index 0000000..c54e6b6 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/database/User.kt @@ -0,0 +1,14 @@ +package com.huaga.life_echo.data.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "users") +data class User( + @PrimaryKey val id: String, + val nickname: String, + val avatarUrl: String?, + val subscriptionType: String, // free, premium + val createdAt: Long +) + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ChapterRepository.kt b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ChapterRepository.kt new file mode 100644 index 0000000..549cd40 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ChapterRepository.kt @@ -0,0 +1,34 @@ +package com.huaga.life_echo.data.repository + +import com.huaga.life_echo.data.database.Chapter +import com.huaga.life_echo.data.database.ChapterDao +import kotlinx.coroutines.flow.Flow + +class ChapterRepository( + private val chapterDao: ChapterDao +) { + fun getAllChapters(): Flow> { + return chapterDao.getAllChapters() + } + + suspend fun getChapterById(id: String): Chapter? { + return chapterDao.getChapterById(id) + } + + suspend fun getChaptersByCategory(category: String): List { + return chapterDao.getChaptersByCategory(category) + } + + suspend fun insertChapter(chapter: Chapter) { + chapterDao.insertChapter(chapter) + } + + suspend fun insertChapters(chapters: List) { + chapterDao.insertChapters(chapters) + } + + suspend fun updateChapter(chapter: Chapter) { + chapterDao.updateChapter(chapter) + } +} + 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 new file mode 100644 index 0000000..8d47dc9 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt @@ -0,0 +1,38 @@ +package com.huaga.life_echo.data.repository + +import com.huaga.life_echo.data.database.* +import kotlinx.coroutines.flow.Flow + +class ConversationRepository( + private val conversationDao: ConversationDao, + private val segmentDao: ConversationSegmentDao +) { + fun getAllConversations(): Flow> { + return conversationDao.getAllConversations() + } + + suspend fun getConversationById(id: String): Conversation? { + return conversationDao.getConversationById(id) + } + + suspend fun insertConversation(conversation: Conversation) { + conversationDao.insertConversation(conversation) + } + + suspend fun updateConversation(conversation: Conversation) { + conversationDao.updateConversation(conversation) + } + + fun getSegmentsByConversationId(conversationId: String): Flow> { + return segmentDao.getSegmentsByConversationId(conversationId) + } + + suspend fun insertSegment(segment: ConversationSegment) { + segmentDao.insertSegment(segment) + } + + suspend fun insertSegments(segments: List) { + segmentDao.insertSegments(segments) + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceRecorder.kt b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceRecorder.kt new file mode 100644 index 0000000..cd65534 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceRecorder.kt @@ -0,0 +1,69 @@ +package com.huaga.life_echo.feature.voice + +import android.Manifest +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import androidx.annotation.RequiresApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.File +import java.io.FileOutputStream +import java.util.* + +class VoiceRecorder(private val context: Context) { + private var mediaRecorder: MediaRecorder? = null + private var audioFile: File? = null + private val _isRecording = MutableStateFlow(false) + val isRecording: StateFlow = _isRecording + + @RequiresApi(Build.VERSION_CODES.S) + fun startRecording(): File? { + return try { + val outputDir = context.cacheDir + audioFile = File.createTempFile("audio_${UUID.randomUUID()}", ".m4a", outputDir) + + mediaRecorder = + MediaRecorder(context) + .apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(audioFile?.absolutePath) + prepare() + start() + } + + _isRecording.value = true + audioFile + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun stopRecording(): ByteArray? { + return try { + mediaRecorder?.apply { + stop() + release() + } + mediaRecorder = null + + _isRecording.value = false + + audioFile?.readBytes() + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun release() { + mediaRecorder?.release() + mediaRecorder = null + audioFile?.delete() + audioFile = null + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/navigation/AppNavigation.kt b/app-android/app/src/main/java/com/huaga/life_echo/navigation/AppNavigation.kt new file mode 100644 index 0000000..b24fb92 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/navigation/AppNavigation.kt @@ -0,0 +1,34 @@ +package com.huaga.life_echo.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.huaga.life_echo.ui.screens.CreateMemoryScreen +import com.huaga.life_echo.ui.screens.MyMemoirScreen +import com.huaga.life_echo.ui.screens.ProfileScreen + +sealed class Screen(val route: String) { + object CreateMemory : Screen("create_memory") + object MyMemoir : Screen("my_memoir") + object Profile : Screen("profile") +} + +@Composable +fun AppNavigation(navController: NavHostController) { + NavHost( + navController = navController, + startDestination = Screen.CreateMemory.route + ) { + composable(Screen.CreateMemory.route) { + CreateMemoryScreen() + } + composable(Screen.MyMemoir.route) { + MyMemoirScreen() + } + composable(Screen.Profile.route) { + ProfileScreen() + } + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt new file mode 100644 index 0000000..e4bb58d --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/ApiService.kt @@ -0,0 +1,68 @@ +package com.huaga.life_echo.network + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.android.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class ChapterDto( + val id: String, + val title: String, + val content: String, + val orderIndex: Int, + val status: String, + val category: String +) + +@Serializable +data class BookDto( + val id: String, + val userId: String, + val title: String, + val totalPages: Int, + val totalWords: Int +) + +class ApiService { + private val client = HttpClient(Android) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + }) + } + install(Logging) { + level = LogLevel.INFO + } + } + + companion object { + private const val BASE_URL = com.huaga.life_echo.config.AppConfig.BASE_URL + } + + suspend fun getChapters(): List { + return client.get("$BASE_URL/api/chapters") { + contentType(ContentType.Application.Json) + }.body() + } + + suspend fun getChapterById(id: String): ChapterDto { + return client.get("$BASE_URL/api/chapters/$id") { + contentType(ContentType.Application.Json) + }.body() + } + + suspend fun exportPdf(bookId: String): ByteArray { + return client.post("$BASE_URL/api/books/export-pdf") { + contentType(ContentType.Application.Json) + setBody(mapOf("book_id" to bookId)) + }.body() + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt new file mode 100644 index 0000000..54a294d --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketClient.kt @@ -0,0 +1,152 @@ +package com.huaga.life_echo.network + +import io.ktor.client.* +import io.ktor.client.engine.android.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.websocket.* +import io.ktor.http.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.* +import kotlinx.serialization.json.Json + +class WebSocketClient { + private val client = HttpClient(Android) { + install(WebSockets) + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + encodeDefaults = false + }) + } + install(Logging) { + level = LogLevel.INFO + } + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var session: DefaultWebSocketSession? = null + private val messageFlow = MutableSharedFlow() + private var reconnectJob: Job? = null + private var isConnected = false + private var currentConversationId: String? = null + + companion object { + private const val BASE_URL = com.huaga.life_echo.config.AppConfig.WS_BASE_URL + private const val RECONNECT_DELAY_MS = 3000L + private const val MAX_RECONNECT_ATTEMPTS = 5 + } + + suspend fun connect( + conversationId: String, + onMessage: (WebSocketMessage) -> Unit + ) { + val url = "$BASE_URL/ws/conversation/$conversationId" + + try { + session = client.webSocketSession { + url { + takeFrom(url) + } + } + + currentConversationId = conversationId + isConnected = true + + // 启动消息接收协程 + scope.launch { + receiveMessages(onMessage) + } + + // 发送连接消息 + sendMessage(WebSocketMessage( + type = MessageType.connect, + conversation_id = conversationId, + data = mapOf("status" to "connected") + )) + + } catch (e: Exception) { + isConnected = false + throw e + } + } + + private suspend fun receiveMessages(onMessage: (WebSocketMessage) -> Unit) { + try { + while (isConnected) { + val frame = session?.incoming?.receive() as? Frame.Text + ?: break + + val message = Json.decodeFromString(frame.readText()) + onMessage(message) + messageFlow.emit(message) + } + } catch (_: Exception) { + isConnected = false + // 触发重连 + reconnectJob?.cancel() + reconnectJob = scope.launch { + reconnectWithBackoff(onMessage) + } + } + } + + suspend fun sendMessage(message: WebSocketMessage) { + try { + val json = Json.encodeToString(WebSocketMessage.serializer(), message) + session?.send(Frame.Text(json)) + } catch (e: Exception) { + // 处理发送失败 + throw e + } + } + + suspend fun sendAudioChunk(chunk: ByteArray, conversationId: String) { + val base64Audio = android.util.Base64.encodeToString(chunk, android.util.Base64.NO_WRAP) + sendMessage(WebSocketMessage( + type = MessageType.audio_chunk, + conversation_id = conversationId, + data = mapOf("audio_base64" to base64Audio) + )) + } + + suspend fun sendEndConversation(conversationId: String) { + sendMessage(WebSocketMessage( + type = MessageType.end_conversation, + conversation_id = conversationId + )) + } + + suspend fun disconnect() { + isConnected = false + reconnectJob?.cancel() + session?.close() + session = null + currentConversationId = null + } + + private suspend fun reconnectWithBackoff( + onMessage: (WebSocketMessage) -> Unit, + attempt: Int = 1 + ) { + if (attempt > MAX_RECONNECT_ATTEMPTS) { + return + } + + delay(RECONNECT_DELAY_MS * attempt) + + try { + val conversationId = currentConversationId + if (conversationId != null) { + connect(conversationId, onMessage) + } + } catch (_: Exception) { + reconnectWithBackoff(onMessage, attempt + 1) + } + } + + fun isConnected(): Boolean = isConnected +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketMessage.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketMessage.kt new file mode 100644 index 0000000..760eae5 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/WebSocketMessage.kt @@ -0,0 +1,23 @@ +package com.huaga.life_echo.network + +import kotlinx.serialization.Serializable + +@Serializable +enum class MessageType { + connect, + audio_chunk, + transcript, + agent_response, + tts_audio, + end_conversation, + error +} + +@Serializable +data class WebSocketMessage( + val type: MessageType, + val conversation_id: String? = null, + val data: Map = emptyMap(), + val timestamp: String? = null +) + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt new file mode 100644 index 0000000..c3c6b00 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt @@ -0,0 +1,105 @@ +package com.huaga.life_echo.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModel +import com.huaga.life_echo.ui.viewmodel.ViewModelFactory + +@Composable +fun CreateMemoryScreen( + viewModel: CreateMemoryViewModel = viewModel( + factory = ViewModelFactory(LocalContext.current) + ) +) { + val isRecording by viewModel.isRecording.collectAsState() + val transcript by viewModel.transcript.collectAsState() + val agentResponse by viewModel.agentResponse.collectAsState() + val connectionStatus by viewModel.connectionStatus.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "创建回忆录", + style = MaterialTheme.typography.headlineLarge + ) + + Text( + text = "连接状态: $connectionStatus", + style = MaterialTheme.typography.bodyMedium + ) + + // 转写文本显示 + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "你说:", + style = MaterialTheme.typography.labelMedium + ) + Text( + text = transcript.ifEmpty { "等待语音输入..." }, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + // Agent 回应显示 + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "AI:", + style = MaterialTheme.typography.labelMedium + ) + Text( + text = agentResponse.ifEmpty { "等待 AI 回应..." }, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + // 开始/结束按钮 + Button( + onClick = { + if (!isRecording) { + viewModel.startConversation() + } else { + viewModel.endConversation() + } + }, + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + ) { + Text( + text = if (isRecording) "🟥 结束聊天" else "🎙️ 开始聊天", + style = MaterialTheme.typography.titleLarge + ) + } + + Text( + text = "你可以一直说,我会认真听", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt new file mode 100644 index 0000000..950f35a --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt @@ -0,0 +1,140 @@ +package com.huaga.life_echo.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.clickable +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.huaga.life_echo.data.database.Chapter +import com.huaga.life_echo.ui.viewmodel.MyMemoirViewModel +import com.huaga.life_echo.ui.viewmodel.ViewModelFactory + +@Composable +fun MyMemoirScreen( + viewModel: MyMemoirViewModel = viewModel( + factory = ViewModelFactory(LocalContext.current) + ) +) { + val chapters by viewModel.chapters.collectAsState(initial = emptyList()) + val selectedChapter by viewModel.selectedChapter.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + Column( + modifier = Modifier.fillMaxSize() + ) { + // 标题 + Text( + text = "我的回忆录", + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(16.dp) + ) + + if (selectedChapter == null) { + // 目录列表 + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(chapters) { chapter -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.selectChapter(chapter) + } + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = chapter.title, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "状态: ${chapter.status}", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + if (chapters.isEmpty()) { + item { + Text( + text = "暂无章节", + modifier = Modifier.padding(16.dp) + ) + } + } + } + } else { + // 章节阅读 + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = { + viewModel.clearSelection() + }) { + Text("返回目录") + } + TextButton( + onClick = { + viewModel.exportPdf( + bookId = "current", // TODO: 获取实际 bookId + onSuccess = { pdfBytes -> + // TODO: 保存或分享 PDF + }, + onError = { error -> + // TODO: 显示错误提示 + } + ) + }, + enabled = !isLoading + ) { + Text(if (isLoading) "导出中..." else "导出 PDF") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = selectedChapter!!.title, + style = MaterialTheme.typography.headlineMedium + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = selectedChapter!!.content, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt new file mode 100644 index 0000000..726f042 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt @@ -0,0 +1,118 @@ +package com.huaga.life_echo.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ProfileScreen() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "我的", + style = MaterialTheme.typography.headlineLarge + ) + + // 账户卡片 + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "账户信息", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "套餐状态: 免费体验", + style = MaterialTheme.typography.bodyMedium + ) + } + } + + // 套餐与付费 + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "套餐与付费", + style = MaterialTheme.typography.titleMedium + ) + TextButton(onClick = { /* TODO */ }) { + Text("升级套餐") + } + TextButton(onClick = { /* TODO */ }) { + Text("我的订单") + } + } + } + + // 数据与隐私 + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "数据与隐私", + style = MaterialTheme.typography.titleMedium + ) + TextButton(onClick = { /* TODO */ }) { + Text("导出所有数据") + } + } + } + + // 设置 + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "设置", + style = MaterialTheme.typography.titleMedium + ) + TextButton(onClick = { /* TODO */ }) { + Text("语速设置") + } + TextButton(onClick = { /* TODO */ }) { + Text("字体设置") + } + } + } + + // 帮助 + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "帮助", + style = MaterialTheme.typography.titleMedium + ) + TextButton(onClick = { /* TODO */ }) { + Text("常见问题") + } + TextButton(onClick = { /* TODO */ }) { + Text("反馈与客服") + } + } + } + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Color.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Color.kt new file mode 100644 index 0000000..5d45c9d --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Color.kt @@ -0,0 +1,22 @@ +package com.huaga.life_echo.ui.theme + +import androidx.compose.ui.graphics.Color + +// New color scheme based on design +val DeepPurple = Color(0xFF200028) +val SlatePurple = Color(0xFF8C8EA3) +val MediumPurple = Color(0xFFA177A6) +val Lavender = Color(0xFFCEB0DA) +val Blush = Color(0xFFDBBABA) +val Cream = Color(0xFFFAF7F8) +val White = Color(0xFFFFFFFF) + +// For dark theme +val Purple80 = Lavender +val PurpleGrey80 = SlatePurple +val Pink80 = Blush + +// For light theme +val Purple40 = MediumPurple +val PurpleGrey40 = SlatePurple +val Pink40 = Blush \ No newline at end of file diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Theme.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Theme.kt new file mode 100644 index 0000000..20fa39b --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Theme.kt @@ -0,0 +1,61 @@ +package com.huaga.life_echo.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Lavender, + secondary = SlatePurple, + tertiary = Blush, + background = DeepPurple, + surface = DeepPurple, + onPrimary = DeepPurple, + onSecondary = White, + onTertiary = DeepPurple, + onBackground = White, + onSurface = White +) + +private val LightColorScheme = lightColorScheme( + primary = MediumPurple, + secondary = SlatePurple, + tertiary = Blush, + background = Cream, + surface = White, + onPrimary = White, + onSecondary = DeepPurple, + onTertiary = DeepPurple, + onBackground = DeepPurple, + onSurface = DeepPurple +) + +@Composable +fun LifeechoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Type.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Type.kt new file mode 100644 index 0000000..b46ce91 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.huaga.life_echo.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt new file mode 100644 index 0000000..4c5c825 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt @@ -0,0 +1,93 @@ +package com.huaga.life_echo.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.huaga.life_echo.data.repository.ConversationRepository +import com.huaga.life_echo.data.repository.ChapterRepository +import com.huaga.life_echo.network.WebSocketClient +import com.huaga.life_echo.network.WebSocketMessage +import com.huaga.life_echo.network.MessageType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class CreateMemoryViewModel( + private val conversationRepository: ConversationRepository, + private val chapterRepository: ChapterRepository +) : ViewModel() { + + private val webSocketClient = WebSocketClient() + + val isRecording = MutableStateFlow(false) + val transcript = MutableStateFlow("") + val agentResponse = MutableStateFlow("") + val connectionStatus = MutableStateFlow("未连接") + val conversationId = MutableStateFlow(null) + + fun startConversation() { + viewModelScope.launch { + val convId = java.util.UUID.randomUUID().toString() + conversationId.value = convId + isRecording.value = true + connectionStatus.value = "连接中..." + + try { + webSocketClient.connect(convId) { message -> + handleWebSocketMessage(message) + } + } catch (e: Exception) { + connectionStatus.value = "连接失败: ${e.message}" + isRecording.value = false + } + } + } + + fun endConversation() { + viewModelScope.launch { + conversationId.value?.let { id -> + webSocketClient.sendEndConversation(id) + webSocketClient.disconnect() + connectionStatus.value = "已断开" + isRecording.value = false + } + } + } + + fun sendAudioChunk(chunk: ByteArray) { + viewModelScope.launch { + conversationId.value?.let { id -> + webSocketClient.sendAudioChunk(chunk, id) + } + } + } + + private fun handleWebSocketMessage(message: WebSocketMessage) { + when (message.type) { + MessageType.transcript -> { + transcript.value = message.data["text"] ?: "" + } + MessageType.agent_response -> { + agentResponse.value = message.data["text"] ?: "" + } + MessageType.connect -> { + connectionStatus.value = "已连接" + } + MessageType.end_conversation -> { + connectionStatus.value = "对话已结束" + isRecording.value = false + } + MessageType.error -> { + connectionStatus.value = "错误: ${message.data["message"]}" + } + else -> {} + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.launch { + webSocketClient.disconnect() + } + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt new file mode 100644 index 0000000..99a1bdb --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/MyMemoirViewModel.kt @@ -0,0 +1,60 @@ +package com.huaga.life_echo.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.huaga.life_echo.data.database.Chapter +import com.huaga.life_echo.data.repository.ChapterRepository +import com.huaga.life_echo.network.ApiService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class MyMemoirViewModel( + private val chapterRepository: ChapterRepository, + private val apiService: ApiService +) : ViewModel() { + + val chapters = chapterRepository.getAllChapters() + val selectedChapter = MutableStateFlow(null) + val isLoading = MutableStateFlow(false) + val error = MutableStateFlow(null) + + fun selectChapter(chapter: Chapter) { + selectedChapter.value = chapter + } + + fun clearSelection() { + selectedChapter.value = null + } + + fun exportPdf(bookId: String, onSuccess: (ByteArray) -> Unit, onError: (String) -> Unit) { + viewModelScope.launch { + isLoading.value = true + error.value = null + try { + val pdfBytes = apiService.exportPdf(bookId) + onSuccess(pdfBytes) + } catch (e: Exception) { + val errorMsg = "导出失败: ${e.message}" + error.value = errorMsg + onError(errorMsg) + } finally { + isLoading.value = false + } + } + } + + fun refreshChapters() { + viewModelScope.launch { + // 从服务器同步章节数据 + try { + val remoteChapters = apiService.getChapters() + // TODO: 更新本地数据库 + } catch (e: Exception) { + error.value = "同步失败: ${e.message}" + } + } + } +} + diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt new file mode 100644 index 0000000..f0a9fc8 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt @@ -0,0 +1,46 @@ +package com.huaga.life_echo.ui.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.huaga.life_echo.data.database.AppDatabase +import com.huaga.life_echo.data.repository.ChapterRepository +import com.huaga.life_echo.data.repository.ConversationRepository +import com.huaga.life_echo.network.ApiService + +class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory { + + private val database by lazy { AppDatabase.getDatabase(context) } + private val conversationRepository by lazy { + ConversationRepository( + conversationDao = database.conversationDao(), + segmentDao = database.conversationSegmentDao() + ) + } + private val chapterRepository by lazy { + ChapterRepository( + chapterDao = database.chapterDao() + ) + } + private val apiService by lazy { ApiService() } + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return when { + modelClass.isAssignableFrom(CreateMemoryViewModel::class.java) -> { + CreateMemoryViewModel( + conversationRepository = conversationRepository, + chapterRepository = chapterRepository + ) as T + } + modelClass.isAssignableFrom(MyMemoirViewModel::class.java) -> { + MyMemoirViewModel( + chapterRepository = chapterRepository, + apiService = apiService + ) as T + } + else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } + } +} + diff --git a/app-android/app/src/main/res/drawable/ic_launcher_background.xml b/app-android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app-android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-android/app/src/main/res/drawable/ic_launcher_foreground.xml b/app-android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app-android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app-android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app-android/app/src/main/res/values-en/strings.xml b/app-android/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..ef0a953 --- /dev/null +++ b/app-android/app/src/main/res/values-en/strings.xml @@ -0,0 +1,51 @@ + + Life Echo + + + Chat + Memoir + Profile + + + Create Memoir + Connection: %1$s + You said: + Waiting for voice input… + AI: + Waiting for AI response… + Start Chat + End Chat + Recording… + + + My Memoir + Chapters + Export PDF + Back to List + + + Profile + Account Info + Plan: %1$s + Free Trial + Subscription + Upgrade Plan + My Orders + Data & Privacy + Export All Data + Settings + Language + 中文 + English + Speech Rate + Standard + Large Font + Dark Mode + Daily Reminder + Remind to chat 5 min daily + Help + FAQ + Feedback & Support + About Us + + diff --git a/app-android/app/src/main/res/values/colors.xml b/app-android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..5d846a1 --- /dev/null +++ b/app-android/app/src/main/res/values/colors.xml @@ -0,0 +1,19 @@ + + + + #FF200028 + #FF8C8EA3 + #FFA177A6 + #FFCEB0DA + #FFDBBABA + #FFFAF7F8 + + + #FFCEB0DA + #FFA177A6 + #FF200028 + #FF8C8EA3 + #FF8C8EA3 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app-android/app/src/main/res/values/strings.xml b/app-android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1d9c238 --- /dev/null +++ b/app-android/app/src/main/res/values/strings.xml @@ -0,0 +1,50 @@ + + 往事拾遗 + + + 聊天 + 回忆录 + 我的 + + + 创建回忆录 + 连接状态: %1$s + 你说: + 等待语音输入… + AI: + 等待 AI 回应… + 开始聊天 + 结束聊天 + 录音中… + + + 我的回忆录 + 章节 + 导出 PDF + 返回列表 + + + 我的 + 账户信息 + 套餐状态: %1$s + 免费体验 + 套餐与付费 + 升级套餐 + 我的订单 + 数据与隐私 + 导出所有数据 + 设置 + 语言 + 中文 + English + 语速 + 标准 + 大字模式 + 夜间模式 + 每日提醒 + 每天提醒聊5分钟 + 帮助 + 常见问题 + 反馈与客服 + 关于我们 + \ No newline at end of file diff --git a/app-android/app/src/main/res/values/themes.xml b/app-android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..dc29ce0 --- /dev/null +++ b/app-android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +