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 @@
+
+
+
+
\ No newline at end of file
diff --git a/app-android/app/src/main/res/xml/backup_rules.xml b/app-android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/app-android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app-android/app/src/main/res/xml/data_extraction_rules.xml b/app-android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app-android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ExampleUnitTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ExampleUnitTest.kt
new file mode 100644
index 0000000..4833521
--- /dev/null
+++ b/app-android/app/src/test/java/com/huaga/life_echo/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.huaga.life_echo
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/app-android/build.gradle.kts b/app-android/build.gradle.kts
new file mode 100644
index 0000000..952b930
--- /dev/null
+++ b/app-android/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
\ No newline at end of file
diff --git a/app-android/gradle.properties b/app-android/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/app-android/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/app-android/gradle/libs.versions.toml b/app-android/gradle/libs.versions.toml
new file mode 100644
index 0000000..a2bd0fe
--- /dev/null
+++ b/app-android/gradle/libs.versions.toml
@@ -0,0 +1,70 @@
+[versions]
+agp = "8.13.2"
+kotlin = "2.0.21"
+coreKtx = "1.17.0"
+junit = "4.13.2"
+junitVersion = "1.3.0"
+espressoCore = "3.7.0"
+lifecycleRuntimeKtx = "2.6.1"
+lifecycleViewmodelCompose = "2.6.1"
+activityCompose = "1.12.2"
+composeBom = "2024.09.00"
+ktor = "3.0.0"
+room = "2.6.1"
+ksp = "2.0.21-1.0.28"
+navigationCompose = "2.8.4"
+permissions = "0.34.0"
+coroutines = "1.9.0"
+serialization = "1.7.3"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
+
+# 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-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" }
+ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
+
+# Room
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+
+# Navigation Compose
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+
+# Permissions
+accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "permissions" }
+
+# Coroutines
+kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+
+# Serialization
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+
diff --git a/app-android/gradle/wrapper/gradle-wrapper.jar b/app-android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/app-android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/app-android/gradle/wrapper/gradle-wrapper.properties b/app-android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..89599d0
--- /dev/null
+++ b/app-android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+#Wed Jan 07 09:55:15 CST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/app-android/gradlew b/app-android/gradlew
new file mode 100644
index 0000000..ef07e01
--- /dev/null
+++ b/app-android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/app-android/gradlew.bat b/app-android/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/app-android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/app-android/settings.gradle.kts b/app-android/settings.gradle.kts
new file mode 100644
index 0000000..e3b8360
--- /dev/null
+++ b/app-android/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "life-echo"
+include(":app")
+
\ No newline at end of file