维护: 添加重构计划和参考文档
This commit is contained in:
@@ -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
|
||||||
665
docs/plans/2026-03-12-conversation-architecture-modernization.md
Normal file
665
docs/plans/2026-03-12-conversation-architecture-modernization.md
Normal file
@@ -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<ConversationMessageUi> = 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<List<Conversation>>
|
||||||
|
fun observeMessages(conversationId: String): Flow<List<Message>>
|
||||||
|
suspend fun syncConversations()
|
||||||
|
suspend fun syncMessages(conversationId: String)
|
||||||
|
suspend fun createConversation(): Result<String>
|
||||||
|
suspend fun deleteConversation(conversationId: String): Result<Unit>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Conversation>) = items.forEach(conversationDao::insertConversation)
|
||||||
|
suspend fun insertMessages(items: List<Message>) = 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<Message> = 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<ConversationSessionState> = _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<CreateMemoryUiState> = _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<Conversation> = 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<ConversationListUiState> = _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"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user