From a7830b39c0d62f8443769e5c472076b5492ceada Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 12 Mar 2026 11:12:22 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=B4=E6=8A=A4:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=AE=A1=E5=88=92=E5=92=8C=E5=8F=82=E8=80=83?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android-kotlin-docs-bestpractice.md | 1 + ...conversation-architecture-modernization.md | 665 ++++++++++++++++++ 2 files changed, 666 insertions(+) create mode 100644 docs/3rd-party-api-best-practice/android-kotlin-docs-bestpractice.md create mode 100644 docs/plans/2026-03-12-conversation-architecture-modernization.md diff --git a/docs/3rd-party-api-best-practice/android-kotlin-docs-bestpractice.md b/docs/3rd-party-api-best-practice/android-kotlin-docs-bestpractice.md new file mode 100644 index 0000000..dd67608 --- /dev/null +++ b/docs/3rd-party-api-best-practice/android-kotlin-docs-bestpractice.md @@ -0,0 +1 @@ +参考文档:https://developer.android.com/topic/architecture/recommendations https://developer.android.com/topic/architecture https://developer.android.com/guide/components/fundamentals https://developer.android.com/topic/architecture/ui-layer https://developer.android.com/topic/architecture/ui-layer/events https://developer.android.com/topic/architecture/ui-layer/stateholders https://developer.android.com/topic/architecture/ui-layer/state-production https://developer.android.com/topic/libraries/view-binding https://developer.android.com/topic/libraries/view-binding/migration https://developer.android.com/topic/libraries/data-binding https://developer.android.com/topic/libraries/data-binding/start https://developer.android.com/topic/libraries/data-binding/expressions https://developer.android.com/topic/libraries/data-binding/observability https://developer.android.com/topic/libraries/data-binding/generated-binding https://developer.android.com/topic/libraries/data-binding/binding-adapters https://developer.android.com/topic/libraries/data-binding/architecture https://developer.android.com/topic/libraries/data-binding/two-way https://developer.android.com/topic/libraries/architecture/lifecycle https://developer.android.com/topic/libraries/architecture/compose https://developer.android.com/topic/libraries/architecture/viewmodel https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-factories https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-apis https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-savedstate https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-cheatsheet https://developer.android.com/topic/libraries/architecture/livedata https://developer.android.com/topic/libraries/architecture/saving-states https://developer.android.com/topic/libraries/architecture/coroutines https://developer.android.com/topic/libraries/architecture/paging/v3-overview https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data https://developer.android.com/topic/libraries/architecture/paging/v3-network-db https://developer.android.com/topic/libraries/architecture/paging/v3-transform https://developer.android.com/topic/libraries/architecture/paging/load-state https://developer.android.com/topic/libraries/architecture/paging/test https://developer.android.com/topic/libraries/architecture/paging/v3-migration https://developer.android.com/topic/architecture/domain-layer https://developer.android.com/topic/architecture/data-layer https://developer.android.com/topic/architecture/data-layer/offline-first https://developer.android.com/topic/libraries/architecture/datastore https://developer.android.com/topic/libraries/app-startup https://developer.android.com/topic/modularization https://developer.android.com/topic/modularization/patterns \ No newline at end of file diff --git a/docs/plans/2026-03-12-conversation-architecture-modernization.md b/docs/plans/2026-03-12-conversation-architecture-modernization.md new file mode 100644 index 0000000..eed6777 --- /dev/null +++ b/docs/plans/2026-03-12-conversation-architecture-modernization.md @@ -0,0 +1,665 @@ +# Conversation Feature Architecture Modernization Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Refactor the conversation/create-memory flow to a clearer Android architecture built around `Compose + UDF + ViewModel + Repository`, with domain orchestration extracted out of the current giant ViewModel. + +**Architecture:** Keep the app in a single Android module for now, but reorganize the conversation feature internally as `presentation/`, `domain/`, and `data/`. Replace the current `CreateMemoryViewModel` orchestration with a feature-scoped repository, a `ConversationSessionCoordinator` for realtime/session logic, and a `VoiceMessageOrchestrator` for recording/upload logic. Keep existing network ports/adapters as the transport boundary; do not add Hilt or new Gradle modules in this plan. + +**Tech Stack:** Kotlin, Jetpack Compose, Android ViewModel, StateFlow, Coroutines, Room DAOs, existing `ConversationApiPort` / `ConversationRealtimePort`, JUnit4, `kotlinx-coroutines-test`, `mockito-inline`. + +**Implementation Rules:** +- Use @test-driven-development for every task. +- Use @verification-before-completion before every commit. +- Do not introduce Hilt or multi-module Gradle changes in this plan. +- Treat this as the pilot feature; memoir/payment/profile can adopt the same pattern after this is stable. + +--- + +### Task 1: Introduce Conversation Presentation Contracts + +**Files:** +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/ConversationMessageUi.kt` +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryUiState.kt` +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryAction.kt` +- Test: `app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryUiStateTest.kt` + +**Step 1: Write the failing test** + +```kotlin +@Test +fun default_state_represents_a_new_idle_conversation() { + val state = CreateMemoryUiState() + + assertNull(state.conversationId) + assertEquals(CreateMemoryUiState.Connection.Disconnected, state.connection) + assertTrue(state.messages.isEmpty()) + assertFalse(state.isSending) + assertFalse(state.isRecording) + assertNull(state.errorMessage) +} +``` + +**Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.presentation.CreateMemoryUiStateTest` + +Expected: FAIL with unresolved references for `CreateMemoryUiState`. + +**Step 3: Write the minimal implementation** + +```kotlin +data class ConversationMessageUi( + val id: String, + val text: String, + val sender: Sender, + val timestamp: Long, + val isStreaming: Boolean = false, +) { + enum class Sender { USER, ASSISTANT, SYSTEM } +} + +data class CreateMemoryUiState( + val conversationId: String? = null, + val messages: List = emptyList(), + val draft: String = "", + val transcript: String = "", + val connection: Connection = Connection.Disconnected, + val isSending: Boolean = false, + val isRecording: Boolean = false, + val recordingDurationSeconds: Int = 0, + val isProcessing: Boolean = false, + val processingMessage: String? = null, + val errorMessage: String? = null, +) { + enum class Connection { Disconnected, Preparing, Connecting, Connected } +} + +sealed interface CreateMemoryAction { + data class DraftChanged(val value: String) : CreateMemoryAction + data object SendText : CreateMemoryAction + data object StartRecording : CreateMemoryAction + data object StopRecording : CreateMemoryAction + data object CancelGeneration : CreateMemoryAction + data class Initialize(val conversationId: String?) : CreateMemoryAction +} +``` + +**Step 4: Run the test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.presentation.CreateMemoryUiStateTest` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/ConversationMessageUi.kt \ + app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryUiState.kt \ + app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryAction.kt \ + app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/presentation/CreateMemoryUiStateTest.kt +git commit -m "refactor: add conversation presentation contracts" +``` + +### Task 2: Create a Feature-Scoped Conversation Repository + +**Files:** +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/domain/ConversationRepository.kt` +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationRemoteDataSource.kt` +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationLocalDataSource.kt` +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationRepositoryImpl.kt` +- Test: `app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/data/ConversationRepositoryImplTest.kt` + +**Step 1: Write the failing test** + +```kotlin +@Test +fun sync_messages_fetches_remote_and_persists_locally() = runTest { + val remote = FakeConversationRemoteDataSource( + messages = listOf(messageDto(id = "m1", conversationId = "c1")) + ) + val local = FakeConversationLocalDataSource() + val repository = ConversationRepositoryImpl(remote, local) + + repository.syncMessages("c1") + + assertEquals(listOf("c1"), remote.requestedMessageConversationIds) + assertEquals(listOf("m1"), local.insertedMessages.flatten().map { it.id }) +} +``` + +**Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.data.ConversationRepositoryImplTest` + +Expected: FAIL because the repository and data source classes do not exist. + +**Step 3: Write the minimal implementation** + +```kotlin +interface ConversationRepository { + fun observeConversations(): Flow> + fun observeMessages(conversationId: String): Flow> + suspend fun syncConversations() + suspend fun syncMessages(conversationId: String) + suspend fun createConversation(): Result + suspend fun deleteConversation(conversationId: String): Result +} + +class ConversationRemoteDataSource( + private val conversationApi: ConversationApiPort, +) { + suspend fun createConversation() = conversationApi.createConversation() + suspend fun getConversationList() = conversationApi.getConversationList() + suspend fun getMessages(conversationId: String) = conversationApi.getMessages(conversationId) + suspend fun deleteConversation(conversationId: String) = conversationApi.deleteConversation(conversationId) +} + +class ConversationLocalDataSource( + private val conversationDao: ConversationDao, + private val messageDao: MessageDao, + private val segmentDao: ConversationSegmentDao, +) { + fun observeConversations() = conversationDao.getAllConversations() + fun observeMessages(conversationId: String) = messageDao.getMessagesByConversationId(conversationId) + suspend fun insertConversations(items: List) = items.forEach(conversationDao::insertConversation) + suspend fun insertMessages(items: List) = messageDao.insertMessages(items) + suspend fun getConversation(id: String) = conversationDao.getConversationById(id) + suspend fun deleteConversation(entity: Conversation) = conversationDao.deleteConversation(entity) + suspend fun latestEmptyConversation() = conversationDao.getLatestEmptyConversation() + suspend fun deleteOtherEmptyConversations(keepId: String) = conversationDao.deleteOtherEmptyConversations(keepId) +} + +class ConversationRepositoryImpl( + private val remote: ConversationRemoteDataSource, + private val local: ConversationLocalDataSource, +) : ConversationRepository { + // Port existing create/sync/delete logic out of the old repositories here. +} +``` + +Map `MessageDto -> Message` and `ConversationListItemDto/CreateConversationResponse -> Conversation` inside `ConversationRepositoryImpl`. + +**Step 4: Run the test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.data.ConversationRepositoryImplTest` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/domain/ConversationRepository.kt \ + app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationRemoteDataSource.kt \ + app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationLocalDataSource.kt \ + app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/data/ConversationRepositoryImpl.kt \ + app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/data/ConversationRepositoryImplTest.kt +git commit -m "refactor: add conversation feature repository" +``` + +### Task 3: Extract Conversation Session Orchestration + +**Files:** +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/domain/ConversationSessionCoordinator.kt` +- Test: `app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/domain/ConversationSessionCoordinatorTest.kt` + +**Step 1: Write the failing test** + +```kotlin +@Test +fun initialize_existing_conversation_prepares_loads_history_and_connects() = runTest { + val repository = FakeConversationRepository(messages = listOf(message(id = "m1"))) + val realtime = FakeConversationRealtimePort() + val coordinator = ConversationSessionCoordinator( + repository = repository, + realtime = realtime, + accessTokenProvider = { "token-1" }, + ) + + coordinator.initializeExistingConversation("conversation-1") + advanceUntilIdle() + + assertEquals(listOf("prepare", "connect:conversation-1:token-1"), realtime.calls) + assertEquals("conversation-1", coordinator.state.value.conversationId) + assertEquals(1, coordinator.state.value.messages.size) +} +``` + +**Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.domain.ConversationSessionCoordinatorTest` + +Expected: FAIL because `ConversationSessionCoordinator` does not exist. + +**Step 3: Write the minimal implementation** + +```kotlin +data class ConversationSessionState( + val conversationId: String? = null, + val messages: List = emptyList(), + val connection: CreateMemoryUiState.Connection = CreateMemoryUiState.Connection.Disconnected, +) + +class ConversationSessionCoordinator( + private val repository: ConversationRepository, + private val realtime: ConversationRealtimePort, + private val accessTokenProvider: suspend () -> String?, +) { + private val _state = MutableStateFlow(ConversationSessionState()) + val state: StateFlow = _state.asStateFlow() + + suspend fun initializeExistingConversation(conversationId: String) { + _state.value = _state.value.copy( + conversationId = conversationId, + connection = CreateMemoryUiState.Connection.Preparing, + ) + realtime.prepare() + repository.syncMessages(conversationId) + val token = accessTokenProvider() + _state.value = _state.value.copy( + connection = CreateMemoryUiState.Connection.Connecting, + messages = repository.observeMessages(conversationId).first(), + ) + realtime.connect(conversationId, token, onMessage = ::handleIncomingMessage) + } + + suspend fun startNewConversation() { /* create via repository + connect */ } + suspend fun sendText(text: String) { /* delegate to realtime */ } + suspend fun endConversation() { /* delegate to realtime */ } + private fun handleIncomingMessage(message: WebSocketMessage) { /* minimal state updates */ } +} +``` + +Keep the first implementation intentionally narrow: existing conversation init, new conversation start, send text, end conversation, and basic incoming message handling for transcript/agent text/connect/error. + +**Step 4: Run the test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.conversation.domain.ConversationSessionCoordinatorTest` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/domain/ConversationSessionCoordinator.kt \ + app-android/app/src/test/java/com/huaga/life_echo/feature/conversation/domain/ConversationSessionCoordinatorTest.kt +git commit -m "refactor: extract conversation session coordinator" +``` + +### Task 4: Extract Voice Recording and Upload Orchestration + +**Files:** +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceMessageOrchestrator.kt` +- Test: `app-android/app/src/test/java/com/huaga/life_echo/feature/voice/VoiceMessageOrchestratorTest.kt` + +**Step 1: Write the failing test** + +```kotlin +@Test +fun stop_and_send_recording_dispatches_segments_in_order() = runTest { + val recordingCoordinator = FakeRecordingCoordinator( + finishedResult = finishedRecording( + voiceSessionId = "voice-1", + files = listOf(segment(index = 0), segment(index = 1)), + ) + ) + val sessionCoordinator = FakeConversationSessionCoordinator(conversationId = "conversation-1") + val orchestrator = VoiceMessageOrchestrator( + recordingCoordinator = recordingCoordinator, + sessionCoordinator = sessionCoordinator, + ) + + orchestrator.stopAndSendRecording() + + assertEquals(listOf(0, 1), sessionCoordinator.sentSegmentIndexes) +} +``` + +**Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.voice.VoiceMessageOrchestratorTest` + +Expected: FAIL because `VoiceMessageOrchestrator` does not exist. + +**Step 3: Write the minimal implementation** + +```kotlin +class VoiceMessageOrchestrator( + private val recordingCoordinator: RecordingCoordinator, + private val sessionCoordinator: ConversationSessionCoordinator, +) { + suspend fun startRecording(): RecordingStartResult = + recordingCoordinator.startRecording() + + suspend fun stopAndSendRecording(recordingId: String? = null): RecordingFinishResult { + val finishResult = recordingCoordinator.finishRecording(recordingId) + if (finishResult is RecordingFinishResult.Success) { + finishResult.segmentFiles.forEach { file -> + sessionCoordinator.sendAudioSegment( + voiceSessionId = finishResult.voiceSessionId, + segment = file, + ) + } + } + return finishResult + } + + suspend fun cancelRecording(): RecordingCancelResult = + recordingCoordinator.cancelRecording() +} +``` + +Move only recording/segment dispatch and retry scheduling into this class. Leave audio playback UI state in the ViewModel for now. + +**Step 4: Run the test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.feature.voice.VoiceMessageOrchestratorTest` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add app-android/app/src/main/java/com/huaga/life_echo/feature/voice/VoiceMessageOrchestrator.kt \ + app-android/app/src/test/java/com/huaga/life_echo/feature/voice/VoiceMessageOrchestratorTest.kt +git commit -m "refactor: extract voice message orchestrator" +``` + +### Task 5: Rebuild CreateMemoryViewModel Around UiState and Actions + +**Files:** +- Modify: `app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt` +- Modify: `app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt` +- Test: `app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelTest.kt` +- Modify: `app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt` +- Modify: `app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt` + +**Step 1: Write the failing test** + +```kotlin +@Test +fun send_text_action_updates_ui_state_and_delegates_to_session() = runTest { + val sessionCoordinator = FakeConversationSessionCoordinator(conversationId = "conversation-1") + val viewModel = CreateMemoryViewModel( + sessionCoordinator = sessionCoordinator, + voiceOrchestrator = FakeVoiceMessageOrchestrator(), + ) + + viewModel.onAction(CreateMemoryAction.DraftChanged("hello")) + viewModel.onAction(CreateMemoryAction.SendText) + advanceUntilIdle() + + assertEquals("", viewModel.uiState.value.draft) + assertEquals(listOf("hello"), sessionCoordinator.sentTexts) +} +``` + +**Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelWarmupTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelRecordingCoordinatorTest` + +Expected: FAIL because the ViewModel still exposes dozens of mutable flows and does not support `uiState`/`onAction`. + +**Step 3: Write the minimal implementation** + +```kotlin +class CreateMemoryViewModel( + private val sessionCoordinator: ConversationSessionCoordinator, + private val voiceOrchestrator: VoiceMessageOrchestrator, +) : ViewModel() { + private val _uiState = MutableStateFlow(CreateMemoryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onAction(action: CreateMemoryAction) { + when (action) { + is CreateMemoryAction.DraftChanged -> { + _uiState.update { it.copy(draft = action.value) } + } + CreateMemoryAction.SendText -> viewModelScope.launch { + val text = _uiState.value.draft.trim() + if (text.isBlank()) return@launch + sessionCoordinator.sendText(text) + _uiState.update { it.copy(draft = "", isSending = false) } + } + is CreateMemoryAction.Initialize -> viewModelScope.launch { + action.conversationId?.let(sessionCoordinator::initializeExistingConversation) + ?: sessionCoordinator.startNewConversation() + } + CreateMemoryAction.StartRecording -> viewModelScope.launch { voiceOrchestrator.startRecording() } + CreateMemoryAction.StopRecording -> viewModelScope.launch { voiceOrchestrator.stopAndSendRecording() } + CreateMemoryAction.CancelGeneration -> viewModelScope.launch { sessionCoordinator.cancelGeneration() } + } + } +} +``` + +In `CreateMemoryScreen`, replace the many `collectAsState()` calls with one `val uiState by viewModel.uiState.collectAsState()`. Read `uiState.messages`, `uiState.connection`, `uiState.draft`, `uiState.isRecording`, etc., and dispatch UI events via `viewModel.onAction(...)`. + +**Step 4: Run the tests to verify they pass** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelWarmupTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelRecordingCoordinatorTest` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModel.kt \ + app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt \ + app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelTest.kt \ + app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelWarmupTest.kt \ + app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelRecordingCoordinatorTest.kt +git commit -m "refactor: migrate create memory flow to ui state" +``` + +### Task 6: Move Conversation List to the Same Feature Repository Pattern + +**Files:** +- Create: `app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/ConversationListUiState.kt` +- Modify: `app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ConversationListViewModel.kt` +- Modify: `app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationListScreen.kt` +- Test: `app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/ConversationListViewModelTest.kt` + +**Step 1: Write the failing test** + +```kotlin +@Test +fun refresh_conversations_updates_ui_state_from_repository() = runTest { + val repository = FakeConversationRepository( + conversations = flowOf(listOf(conversation(id = "c1", title = "One"))) + ) + val viewModel = ConversationListViewModel(repository) + + viewModel.refreshConversations() + advanceUntilIdle() + + assertEquals(listOf("c1"), viewModel.uiState.value.conversations.map { it.id }) + assertTrue(viewModel.uiState.value.hasLoadedInitialConversations) +} +``` + +**Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.ConversationListViewModelTest` + +Expected: FAIL because the ViewModel does not expose `uiState`. + +**Step 3: Write the minimal implementation** + +```kotlin +data class ConversationListUiState( + val conversations: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val hasLoadedInitialConversations: Boolean = false, +) + +class ConversationListViewModel( + private val repository: ConversationRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ConversationListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + repository.observeConversations().collect { items -> + _uiState.update { it.copy(conversations = items) } + } + } + refreshConversations() + } + + fun refreshConversations() { /* set loading, call repository.syncConversations(), update state */ } +} +``` + +Update `ConversationListScreen` to consume `uiState` instead of separate `conversations`, `isLoading`, `error`, and `hasLoadedInitialConversations` flows. + +**Step 4: Run the test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.ConversationListViewModelTest` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add app-android/app/src/main/java/com/huaga/life_echo/feature/conversation/presentation/ConversationListUiState.kt \ + app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ConversationListViewModel.kt \ + app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ConversationListScreen.kt \ + app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/ConversationListViewModelTest.kt +git commit -m "refactor: migrate conversation list to ui state" +``` + +### Task 7: Wire the New Feature Components and Remove Legacy Repositories + +**Files:** +- Modify: `app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt` +- Modify: `app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt` +- Delete: `app-android/app/src/main/java/com/huaga/life_echo/data/repository/ConversationRepository.kt` +- Delete: `app-android/app/src/main/java/com/huaga/life_echo/data/repository/MessageRepository.kt` +- Modify: `docs/plans/2026-03-12-conversation-architecture-modernization.md` + +**Step 1: Write the failing verification test** + +Add one smoke-style test that proves `CreateMemoryViewModel` no longer requires the deleted repositories: + +```kotlin +@Test +fun create_memory_view_model_accepts_feature_components_only() { + val constructor = CreateMemoryViewModel::class.primaryConstructor!! + val parameterTypes = constructor.parameters.mapNotNull { it.type.classifier as? KClass<*> } + + assertFalse(parameterTypes.contains(com.huaga.life_echo.data.repository.ConversationRepository::class)) + assertFalse(parameterTypes.contains(com.huaga.life_echo.data.repository.MessageRepository::class)) +} +``` + +Test file: `app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelConstructorTest.kt` + +**Step 2: Run the verification test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelConstructorTest` + +Expected: FAIL because the old repositories are still present in the constructor and wiring. + +**Step 3: Write the minimal implementation** + +In `AppContainer`, construct and expose: + +```kotlin +val conversationRemoteDataSource by lazy { ConversationRemoteDataSource(conversationApi) } +val conversationLocalDataSource by lazy { + ConversationLocalDataSource( + conversationDao = database.conversationDao(), + messageDao = database.messageDao(), + segmentDao = database.conversationSegmentDao(), + ) +} +val conversationRepository: com.huaga.life_echo.feature.conversation.domain.ConversationRepository by lazy { + ConversationRepositoryImpl(conversationRemoteDataSource, conversationLocalDataSource) +} +val conversationSessionCoordinator by lazy { + ConversationSessionCoordinator( + repository = conversationRepository, + realtime = ConversationRealtimeAdapter(webSocketClient), + accessTokenProvider = TokenManager::getAccessToken, + ) +} +``` + +Then update `ViewModelFactory` to inject `conversationRepository`, `conversationSessionCoordinator`, and `VoiceMessageOrchestrator`. Delete the legacy `data/repository/ConversationRepository.kt` and `MessageRepository.kt` once there are no remaining imports. + +**Step 4: Run focused and full verification** + +Run: + +```bash +./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelConstructorTest +./gradlew :app:testDebugUnitTest --tests com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModelTest --tests com.huaga.life_echo.ui.viewmodel.ConversationListViewModelTest +./gradlew :app:testDebugUnitTest +git diff --check +rg -n "data\\.repository\\.(ConversationRepository|MessageRepository)" app-android/app/src/main/java +``` + +Expected: +- all tests PASS +- `git diff --check` prints nothing +- `rg` finds no remaining imports of the deleted repositories in production code + +**Step 5: Commit** + +```bash +git add app-android/app/src/main/java/com/huaga/life_echo/app/AppContainer.kt \ + app-android/app/src/main/java/com/huaga/life_echo/ui/viewmodel/ViewModelFactory.kt \ + app-android/app/src/test/java/com/huaga/life_echo/ui/viewmodel/CreateMemoryViewModelConstructorTest.kt \ + docs/plans/2026-03-12-conversation-architecture-modernization.md +git add -u app-android/app/src/main/java/com/huaga/life_echo/data/repository +git commit -m "refactor: finish conversation architecture modernization" +``` + +### Task 8: Write the Follow-Up Architecture Note + +**Files:** +- Create: `docs/plans/2026-03-12-conversation-architecture-followups.md` + +**Step 1: Write the failing checklist** + +Write a markdown checklist covering the next phases that are explicitly out of scope for this plan: + +```markdown +- [ ] Move auth/token state behind an injected session abstraction +- [ ] Split `ApiService` into feature remote data sources for memoir/payment/profile +- [ ] Introduce Hilt once feature boundaries stop moving +- [ ] Evaluate feature-first Gradle modules after the conversation pilot stabilizes +``` + +**Step 2: Save the checklist** + +Create `docs/plans/2026-03-12-conversation-architecture-followups.md` + +Expected: file exists with the checklist. + +**Step 3: Keep it intentionally short** + +Do not add implementation detail. The point is to freeze the next-phase scope, not start a second refactor. + +**Step 4: Verify the file exists and is tracked** + +Run: + +```bash +test -f docs/plans/2026-03-12-conversation-architecture-followups.md +git status --short docs/plans/2026-03-12-conversation-architecture-followups.md +``` + +Expected: +- `test` exits `0` +- `git status` shows the file as added or staged + +**Step 5: Commit** + +```bash +git add docs/plans/2026-03-12-conversation-architecture-followups.md +git commit -m "docs: capture conversation architecture follow-ups" +```