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 androidx.activity.enableEdgeToEdge
import dev.kaizen.app.auth.LoginScreen import dev.kaizen.app.auth.LoginScreen
import dev.kaizen.app.chat.ChatScreen import dev.kaizen.app.chat.ChatScreen
import dev.kaizen.app.chat.ChatScreenViewModel
import dev.kaizen.app.chat.ChatViewModel import dev.kaizen.app.chat.ChatViewModel
import dev.kaizen.app.net.SecureStore import dev.kaizen.app.net.SecureStore
import dev.kaizen.app.net.SessionViewModel import dev.kaizen.app.net.SessionViewModel
@ -22,9 +23,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
window.colorMode = ActivityInfo.COLOR_MODE_HDR window.colorMode = ActivityInfo.COLOR_MODE_HDR
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -35,8 +33,13 @@ class MainActivity : ComponentActivity() {
val settingsViewModel = SettingsViewModel(secureStore) val settingsViewModel = SettingsViewModel(secureStore)
val sessionViewModel = SessionViewModel(secureStore) val sessionViewModel = SessionViewModel(secureStore)
val chatViewModel = ChatViewModel(applicationContext) 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( enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle = 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() AppAppearance.System -> androidx.compose.foundation.isSystemInDarkTheme()
} }
KaizenTheme(themeId = themeId.value, darkTheme = darkOverride) { 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) { if (sessionViewModel.isLoggedIn) {
ChatScreen(settingsViewModel = settingsViewModel, session = sessionViewModel, chat = chatViewModel) ChatScreen(vm = chatScreenViewModel, settingsViewModel = settingsViewModel)
} else { } else {
LoginScreen(session = sessionViewModel) 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. */ fun createHaptics(context: Context): Haptics {
@Composable
fun rememberHaptics(): Haptics {
val context = LocalContext.current
return remember {
val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vib: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator (context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator 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) 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 private set
/** Favorite model ids (pinned to the top of the picker), as Compose snapshot state. */ var favorites by mutableStateOf(store?.loadFavorites() ?: emptySet())
var favorites by mutableStateOf(store.loadFavorites())
private set private set
var versionSkew by mutableStateOf<VersionSkew?>(null) var versionSkew by mutableStateOf<VersionSkew?>(null)
@ -42,7 +41,7 @@ class SessionViewModel(val store: SecureStore) {
val me = KaizenApi.fetchMe(baseUrl, result.token) val me = KaizenApi.fetchMe(baseUrl, result.token)
val model = me?.defaultModel ?: DEFAULT_MODEL val model = me?.defaultModel ?: DEFAULT_MODEL
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim()) val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg) store?.save(cfg)
config = cfg config = cfg
checkServerVersion(baseUrl) checkServerVersion(baseUrl)
result result
@ -65,7 +64,7 @@ class SessionViewModel(val store: SecureStore) {
val cfg = config ?: return val cfg = config ?: return
if (cfg.model == modelId) return if (cfg.model == modelId) return
val updated = cfg.copy(model = modelId) val updated = cfg.copy(model = modelId)
store.save(updated) store?.save(updated)
config = updated config = updated
} }
@ -74,19 +73,19 @@ class SessionViewModel(val store: SecureStore) {
val cfg = config ?: return val cfg = config ?: return
if (cfg.speed == speed) return if (cfg.speed == speed) return
val updated = cfg.copy(speed = speed) val updated = cfg.copy(speed = speed)
store.save(updated) store?.save(updated)
config = updated config = updated
} }
/** Toggle a model id in/out of favorites (persisted). */ /** Toggle a model id in/out of favorites (persisted). */
fun toggleFavorite(modelId: String) { fun toggleFavorite(modelId: String) {
val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) } val next = favorites.toMutableSet().apply { if (!add(modelId)) remove(modelId) }
store.saveFavorites(next) store?.saveFavorites(next)
favorites = next favorites = next
} }
fun logout() { fun logout() {
store.clear() store?.clear()
config = null config = null
favorites = emptySet() 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)
}
}