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:
parent
89fe5fd2ae
commit
40db869717
6 changed files with 1468 additions and 1210 deletions
|
|
@ -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
1046
app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt
Normal file
1046
app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
217
app/src/test/java/dev/kaizen/app/ChatScreenViewModelTest.kt
Normal file
217
app/src/test/java/dev/kaizen/app/ChatScreenViewModelTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue