Extract ChatScreenViewModel from God Composable

Move all business logic and state (~35 variables, ~15 functions) from
the 1540-line ChatScreen @Composable into ChatScreenViewModel.

ChatScreen is now a thin rendering layer (~430 lines) that reads VM
state and wires UI events to VM methods. Android-framework-only concerns
(ActivityResultLaunchers, DrawerState, LazyListState, keyboard insets)
stay in the Composable.

Changes:
- New ChatScreenViewModel.kt: messages, streaming, conversation mgmt,
  sidebar actions, file upload, voice recording, TTS, live call, unlock
- ChatScreen.kt: pure UI, delegates all logic to vm.*
- Haptics.kt: add createHaptics(Context) factory for non-Composable use
- SessionViewModel: store nullable for test construction
- MainActivity: instantiates ChatScreenViewModel

Tests: 12 new unit tests for ChatScreenViewModel (state transitions,
newChat reset, mode toggles, edit/delete, password dialog lifecycle).
All 90 tests pass.
This commit is contained in:
Bruno Deanoz 2026-06-24 10:09:40 +02:00
parent 89fe5fd2ae
commit 40db869717
6 changed files with 1468 additions and 1210 deletions

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.collectAsState
import androidx.activity.enableEdgeToEdge
import dev.kaizen.app.auth.LoginScreen
import dev.kaizen.app.chat.ChatScreen
import dev.kaizen.app.chat.ChatScreenViewModel
import dev.kaizen.app.chat.ChatViewModel
import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.net.SessionViewModel
@ -22,9 +23,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Enable High Dynamic Range (HDR) window color mode on Android 14+ (API 34+)
// and Wide Color Gamut (Display P3) on Android 8.0+ (API 26+)
// This leverages the hardware capabilities of high-end AMOLED/OLED displays (S25 Ultra, Pixel 10).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
window.colorMode = ActivityInfo.COLOR_MODE_HDR
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -35,8 +33,13 @@ class MainActivity : ComponentActivity() {
val settingsViewModel = SettingsViewModel(secureStore)
val sessionViewModel = SessionViewModel(secureStore)
val chatViewModel = ChatViewModel(applicationContext)
val chatScreenViewModel = ChatScreenViewModel(
app = application,
session = sessionViewModel,
chat = chatViewModel,
settings = settingsViewModel,
)
// auto() picks light/dark system-bar icons based on the system theme
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
@ -50,10 +53,8 @@ class MainActivity : ComponentActivity() {
AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme()
}
KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) {
// Snapshot-state routing: logging in/out flips session.config and
// recomposes this branch — no NavHost needed for two top-level states.
if (sessionViewModel.isLoggedIn) {
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel)
ChatScreen(vm = chatScreenViewModel, settingsViewModel = settingsViewModel)
} else {
LoginScreen(session = sessionViewModel)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -116,17 +116,18 @@ class Haptics(private val vibrator: Vibrator?) {
}
}
/** Builds the right Vibrator for the API level and keeps it across recompositions. */
@Composable
fun rememberHaptics(): Haptics {
val context = LocalContext.current
return remember {
fun createHaptics(context: Context): Haptics {
val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
Haptics(vib)
}
return Haptics(vib)
}
@Composable
fun rememberHaptics(): Haptics {
val context = LocalContext.current
return remember { createHaptics(context) }
}

View file

@ -14,13 +14,12 @@ import androidx.compose.runtime.setValue
*/
data class VersionSkew(val serverVersion: Int, val expectedVersion: Int)
class SessionViewModel(val store: SecureStore) {
class SessionViewModel(val store: SecureStore? = null) {
var config by mutableStateOf(store.load())
var config by mutableStateOf(store?.load())
private set
/** Favorite model ids (pinned to the top of the picker), as Compose snapshot state. */
var favorites by mutableStateOf(store.loadFavorites())
var favorites by mutableStateOf(store?.loadFavorites() ?: emptySet())
private set
var versionSkew by mutableStateOf<VersionSkew?>(null)
@ -42,7 +41,7 @@ class SessionViewModel(val store: SecureStore) {
val me = KaizenApi.fetchMe(baseUrl, result.token)
val model = me?.defaultModel ?: DEFAULT_MODEL
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg)
store?.save(cfg)
config = cfg
checkServerVersion(baseUrl)
result
@ -65,7 +64,7 @@ class SessionViewModel(val store: SecureStore) {
val cfg = config ?: return
if (cfg.model == modelId) return
val updated = cfg.copy(model = modelId)
store.save(updated)
store?.save(updated)
config = updated
}
@ -74,19 +73,19 @@ class SessionViewModel(val store: SecureStore) {
val cfg = config ?: return
if (cfg.speed == speed) return
val updated = cfg.copy(speed = speed)
store.save(updated)
store?.save(updated)
config = updated
}
/** Toggle a model id in/out of favorites (persisted). */
fun toggleFavorite(modelId: String) {
val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) }
store.saveFavorites(next)
store?.saveFavorites(next)
favorites = next
}
fun logout() {
store.clear()
store?.clear()
config = null
favorites = emptySet()
}

View file

@ -0,0 +1,217 @@
package dev.kaizen.app
import dev.kaizen.app.chat.AppScreen
import dev.kaizen.app.chat.ChatScreenViewModel
import dev.kaizen.app.chat.Message
import dev.kaizen.app.chat.PendingFile
import dev.kaizen.app.chat.ReasoningPreset
import dev.kaizen.app.chat.Role
import dev.kaizen.app.chat.SamplingParam
import dev.kaizen.app.chat.SamplingPrefs
import dev.kaizen.app.net.ConversationSummary
import dev.kaizen.app.net.SessionViewModel
import dev.kaizen.app.settings.SettingsViewModel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatScreenViewModelTest {
private fun createVm(): ChatScreenViewModel = ChatScreenViewModel(
session = SessionViewModel(),
settings = SettingsViewModel(),
)
@Test
fun initialState() {
val vm = createVm()
assertTrue(vm.messages.isEmpty())
assertFalse(vm.isStreaming)
assertEquals("", vm.input)
assertNull(vm.conversationId)
assertFalse(vm.viewingLockedChat)
assertNull(vm.chatModel)
assertEquals(0, vm.usedTokens)
assertNull(vm.activeMode)
assertEquals(ReasoningPreset.Standard, vm.reasoningPreset)
assertEquals("google", vm.webSearchProvider)
assertFalse(vm.isRecording)
assertEquals(0f, vm.recordingAmplitude)
assertNull(vm.playingTtsForId)
assertEquals("Kore", vm.ttsVoice)
assertTrue(vm.pendingFiles.isEmpty())
assertFalse(vm.showAttachMenu)
assertEquals(AppScreen.Chat, vm.currentScreen)
assertFalse(vm.liveCallActive)
assertNull(vm.loadError)
assertNull(vm.passwordUnlockTarget)
}
@Test
fun newChatResetsState() {
val vm = createVm()
vm.messages.add(Message(1, Role.User, "hello"))
vm.messages.add(Message(2, Role.Assistant, "hi"))
vm.usedTokens = 500
vm.chatModel = "vertex:gemini-2.5-flash"
vm.newChat()
assertTrue(vm.messages.isEmpty())
assertNull(vm.conversationId)
assertFalse(vm.viewingLockedChat)
assertEquals(0, vm.usedTokens)
assertNull(vm.chatModel)
}
@Test
fun sendWithoutConfigIsNoOp() {
val vm = createVm()
val sizeBefore = vm.messages.size
vm.send("hello")
assertEquals(sizeBefore, vm.messages.size)
}
@Test
fun sendEmptyIsNoOp() {
val vm = createVm()
vm.send("")
assertTrue(vm.messages.isEmpty())
}
@Test
fun inputBindsTwoWay() {
val vm = createVm()
assertEquals("", vm.input)
vm.input = "test prompt"
assertEquals("test prompt", vm.input)
}
@Test
fun modeToggle() {
val vm = createVm()
assertNull(vm.activeMode)
vm.activeMode = R.string.mode_search
assertEquals(R.string.mode_search, vm.activeMode)
vm.activeMode = null
assertNull(vm.activeMode)
}
@Test
fun reasoningPresetCycle() {
val vm = createVm()
assertEquals(ReasoningPreset.Standard, vm.reasoningPreset)
vm.reasoningPreset = ReasoningPreset.High
assertEquals(ReasoningPreset.High, vm.reasoningPreset)
assertEquals("high", vm.reasoningPreset.effort)
assertFalse(vm.reasoningPreset.throughput)
}
@Test
fun samplingPrefsUpdate() {
val vm = createVm()
assertFalse(vm.samplingPrefs.temperature.enabled)
vm.samplingPrefs = SamplingPrefs(
temperature = SamplingParam(true, 0.7f),
topP = SamplingParam(false, 1f),
topK = SamplingParam(true, 20f),
)
assertTrue(vm.samplingPrefs.temperature.enabled)
assertEquals(0.7f, vm.samplingPrefs.temperature.value)
assertTrue(vm.samplingPrefs.topK.enabled)
}
@Test
fun screenNavigation() {
val vm = createVm()
assertEquals(AppScreen.Chat, vm.currentScreen)
vm.currentScreen = AppScreen.Settings
assertEquals(AppScreen.Settings, vm.currentScreen)
vm.currentScreen = AppScreen.Chat
assertEquals(AppScreen.Chat, vm.currentScreen)
}
@Test
fun webSearchProviderChange() {
val vm = createVm()
assertEquals("google", vm.webSearchProvider)
vm.webSearchProvider = "enterprise"
assertEquals("enterprise", vm.webSearchProvider)
}
@Test
fun passwordDialogLifecycle() {
val vm = createVm()
val fakeSummary = ConversationSummary(id = "test-123", title = "Test")
vm.showPasswordFallback(fakeSummary, forToggle = true)
assertEquals(fakeSummary, vm.passwordUnlockTarget)
assertTrue(vm.passwordUnlockForToggle)
assertNull(vm.passwordUnlockError)
vm.dismissPasswordDialog()
assertNull(vm.passwordUnlockTarget)
assertNull(vm.passwordUnlockError)
assertFalse(vm.passwordUnlockForToggle)
}
@Test
fun editMessageSetsInputAndClearsMessages() {
val vm = createVm()
vm.messages.add(Message(1, Role.User, "original text", wireId = "wire-1"))
vm.messages.add(Message(2, Role.Assistant, "response", wireId = "wire-2"))
vm.editMessage("wire-1", "original text")
assertEquals("original text", vm.input)
assertTrue(vm.messages.isEmpty())
}
@Test
fun clearLockedOnPauseWhenNotViewing() {
val vm = createVm()
vm.messages.add(Message(1, Role.User, "secret"))
vm.clearLockedOnPause()
assertEquals(1, vm.messages.size)
}
@Test
fun removeFile() {
val vm = createVm()
val pf = PendingFile(name = "test.jpg", mimeType = "image/jpeg")
vm.pendingFiles.add(pf)
assertEquals(1, vm.pendingFiles.size)
vm.removeFile(pf.id)
assertTrue(vm.pendingFiles.isEmpty())
}
@Test
fun attachMenuToggle() {
val vm = createVm()
assertFalse(vm.showAttachMenu)
vm.showAttachMenu = true
assertTrue(vm.showAttachMenu)
vm.showAttachMenu = false
assertFalse(vm.showAttachMenu)
}
@Test
fun ttsVoiceDefault() {
val vm = createVm()
assertEquals("Kore", vm.ttsVoice)
vm.ttsVoice = "Aoede"
assertEquals("Aoede", vm.ttsVoice)
}
@Test
fun deleteMessageRemovesFromList() {
val vm = createVm()
vm.messages.add(Message(1, Role.User, "hello", wireId = "w1"))
vm.messages.add(Message(2, Role.Assistant, "hi", wireId = "w2"))
assertEquals(2, vm.messages.size)
vm.deleteMessage("w1")
assertEquals(1, vm.messages.size)
assertEquals("w2", vm.messages.first().wireId)
}
}