The core module is the foundation of MVVMate. It provides the base classes for state management, action handling, and side effects using the MVI (Model-View-Intent) pattern.
| Class | Purpose |
|---|---|
BaseViewModel<S, A> |
Manages state and handles actions |
BaseViewModelWithEffect<S, A, E> |
Adds one-time side effect support (guaranteed delivery) |
AppError |
Typed error hierarchy for structured error handling |
MvvMateLogger |
Pluggable logging interface |
MvvMate |
Global configuration (logger, debug mode) |
UiState |
Marker interface for state classes |
UiAction |
Marker interface for user actions |
UiEffect |
Marker interface for side effects |
Every screen in your app needs three things: State, Action, and optionally Effect.
import com.helloanwar.mvvmate.core.UiAction
import com.helloanwar.mvvmate.core.UiEffect
import com.helloanwar.mvvmate.core.UiState
// 1. Define the state — everything the UI needs to render
data class ProfileState(
val isLoading: Boolean = false,
val username: String = "",
val email: String = "",
val avatarUrl: String? = null,
val error: String? = null
) : UiState
// 2. Define actions — everything the user can do
sealed interface ProfileAction : UiAction {
data object LoadProfile : ProfileAction
data class UpdateUsername(val name: String) : ProfileAction
data class UpdateEmail(val email: String) : ProfileAction
data object SaveProfile : ProfileAction
data object Logout : ProfileAction
}
// 3. Define effects — one-time events (optional)
sealed interface ProfileEffect : UiEffect {
data class ShowToast(val message: String) : ProfileEffect
data object NavigateToLogin : ProfileEffect
data class ShowError(val error: String) : ProfileEffect
}Tip: Use
sealed interfaceinstead ofsealed classfor actions and effects — it's more concise and allows multiple inheritance.
import com.helloanwar.mvvmate.core.BaseViewModel
class ProfileViewModel : BaseViewModel<ProfileState, ProfileAction>(
initialState = ProfileState()
) {
override suspend fun onAction(action: ProfileAction) {
when (action) {
ProfileAction.LoadProfile -> {
updateState { copy(isLoading = true) }
// Load data from repository...
updateState {
copy(
isLoading = false,
username = "John Doe",
email = "john@example.com"
)
}
}
is ProfileAction.UpdateUsername -> {
updateState { copy(username = action.name) }
}
is ProfileAction.UpdateEmail -> {
updateState { copy(email = action.email) }
}
ProfileAction.SaveProfile -> {
updateState { copy(isLoading = true) }
// Save to API...
updateState { copy(isLoading = false) }
}
ProfileAction.Logout -> {
// Handle logout
}
}
}
}| Method | Description |
|---|---|
updateState { ... } |
Thread-safe state update via reducer lambda |
onAction(action) |
Override to handle each action type |
onError(action, error) |
Override to handle exceptions thrown in onAction |
state: StateFlow<S> |
Observable state for the UI |
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
if (state.isLoading) {
CircularProgressIndicator()
}
TextField(
value = state.username,
onValueChange = { viewModel.handleAction(ProfileAction.UpdateUsername(it)) }
)
Button(onClick = { viewModel.handleAction(ProfileAction.SaveProfile) }) {
Text("Save")
}
}Use this when you need one-time events that shouldn't be part of the permanent state — navigation, toasts, dialogs, etc.
Guaranteed delivery: Effects are backed by a buffered
Channel, so they are never lost even if no collector is active when the effect is emitted. They queue up and are delivered when a collector subscribes.
import com.helloanwar.mvvmate.core.BaseViewModelWithEffect
class ProfileViewModel : BaseViewModelWithEffect<ProfileState, ProfileAction, ProfileEffect>(
initialState = ProfileState()
) {
override suspend fun onAction(action: ProfileAction) {
when (action) {
ProfileAction.SaveProfile -> {
updateState { copy(isLoading = true) }
try {
// Save to API...
updateState { copy(isLoading = false) }
emitSideEffect(ProfileEffect.ShowToast("Profile saved!"))
} catch (e: Exception) {
updateState { copy(isLoading = false) }
emitSideEffect(ProfileEffect.ShowError(e.message ?: "Save failed"))
}
}
ProfileAction.Logout -> {
// Clear session...
emitSideEffect(ProfileEffect.NavigateToLogin)
}
// ... other actions
}
}
}@Composable
fun ProfileScreen(
viewModel: ProfileViewModel = viewModel(),
onNavigateToLogin: () -> Unit
) {
val state by viewModel.state.collectAsState()
// Collect one-time effects
LaunchedEffect(Unit) {
viewModel.sideEffects.collect { effect ->
when (effect) {
is ProfileEffect.ShowToast -> {
// Show snackbar or toast
}
ProfileEffect.NavigateToLogin -> {
onNavigateToLogin()
}
is ProfileEffect.ShowError -> {
// Show error dialog
}
}
}
}
// ... render UI from state
}BaseViewModel includes a built-in error hook. If onAction throws an exception, onError is called automatically:
class SafeViewModel : BaseViewModel<MyState, MyAction>(MyState()) {
override suspend fun onAction(action: MyAction) {
when (action) {
MyAction.FetchData -> {
val data = riskyApiCall() // May throw!
updateState { copy(data = data) }
}
}
}
override fun onError(action: MyAction, error: Exception) {
// This is called automatically if onAction throws
updateState { copy(error = error.message) }
}
}Note:
CancellationExceptionis never caught byonError— it propagates normally to preserve structured concurrency.
- Always use
updateState— never mutate_statedirectly - Reducers are atomic — each
updateStatecall is thread-safe - Use
copy()— state classes should bedata classfor immutability - Keep state flat — avoid deeply nested objects for easier updates
// ✅ Good: flat state
data class MyState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val selectedId: String? = null
) : UiState
// ❌ Bad: deeply nested state
data class MyState(
val ui: UiProperties = UiProperties(),
val data: DataContainer = DataContainer()
) : UiStateInstead of passing raw strings for errors, MVVMate provides a structured AppError sealed class:
import com.helloanwar.mvvmate.core.AppError
// Available error types:
AppError.Network(message, httpCode?, isRetryable) // HTTP/connectivity errors
AppError.Timeout(message, durationMs) // Timeout errors
AppError.Validation(message, field?) // Input validation errors
AppError.Unknown(message, cause?) // Catch-all
// All types have a .message property for display:
updateState { copy(error = appError.message) }
// Pattern matching for specific handling:
when (error) {
is AppError.Network -> if (error.isRetryable) retryLastAction()
is AppError.Timeout -> showRetryDialog()
is AppError.Validation -> highlightField(error.field)
is AppError.Unknown -> logAndShowGeneric(error)
}
// Auto-classify exceptions:
val error = AppError.from(exception) // TimeoutException → Timeout, others → UnknownMVVMate automatically logs actions, state changes, effects, errors, and network lifecycle events through a pluggable logger:
import com.helloanwar.mvvmate.core.MvvMate
import com.helloanwar.mvvmate.core.PrintLogger
// Enable during development:
MvvMate.logger = PrintLogger
MvvMate.isDebug = true // Enables state change loggingWhat gets logged automatically:
| Event | When | Debug-only? |
|---|---|---|
| Action dispatch | Every handleAction() call |
No |
| State change | Every updateState() with changed state |
Yes |
| Effect emission | Every emitSideEffect() |
No |
| Errors | Every exception in onAction |
No |
| Network lifecycle | Start, retry, success, failure, cancel, timeout | No |
object TimberLogger : MvvMateLogger {
override fun logAction(viewModelName: String, action: UiAction) {
Timber.d("$viewModelName :: ${action::class.simpleName}")
}
override fun logStateChange(viewModelName: String, oldState: UiState, newState: UiState) {
Timber.v("$viewModelName :: $newState")
}
override fun logEffect(viewModelName: String, effect: Any) {
Timber.d("$viewModelName :: $effect")
}
override fun logError(viewModelName: String, error: Throwable, context: String) {
Timber.e(error, "$viewModelName [$context]")
}
override fun logNetwork(tag: String, phase: MvvMateLogger.NetworkPhase, details: String) {
Timber.d("Network [$tag] ${phase.name}: $details")
}
}
// Set in Application.onCreate():
MvvMate.logger = TimberLoggerIf you want an advanced logging mechanism that tracks the last N events in memory for crash reporting, use the MvvMateAiLogger. It includes an automatic PrivacyRedactor to strip sensitive Personal Identifiable Information (PII) before saving the crash dump.
val aiLogger = MvvMateAiLogger(
delegate = TimberLogger, // Wrap your existing logger
maxHistorySize = 50, // Keep last 50 events in memory
redactor = RegexPrivacyRedactor() // Strip emails, passwords, and CCs automatically
)
MvvMate.logger = aiLogger
// Setup a global UncaughtExceptionHandler (e.g., in Android Application class)
Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
// Grab the grammatical timeline of exactly what happened before the crash
val crashTimeline = aiLogger.takeRedactedSnapshotString()
// Send this rich context to Crashlytics, Sentry, or your LLM backend
Crashlytics.log(crashTimeline)
}StateDiffUtil computes human-readable diffs between two state snapshots. It works with any data class by parsing the toString() representation:
import com.helloanwar.mvvmate.core.StateDiffUtil
val oldState = UsersState(isLoading = false, users = emptyList(), error = null)
val newState = UsersState(isLoading = true, users = emptyList(), error = null)
// Get list of changed fields
val changes = StateDiffUtil.diff(oldState, newState)
// [FieldChange(field=isLoading, oldValue=false, newValue=true)]
// Get a compact summary string
val summary = StateDiffUtil.diffSummary(oldState, newState)
// "isLoading: false → true"Note:
StateDiffUtilis used internally byPrintLoggerandTimelineLoggerto provide concise state change logs instead of dumping the full state.
TimelineLogger records timestamped events into a chronological timeline, useful for debugging complex action sequences and post-mortem analysis:
import com.helloanwar.mvvmate.core.TimelineLogger
// Create and set as active logger
val timeline = TimelineLogger(
delegate = PrintLogger, // Also forward to PrintLogger (optional)
maxEntries = 500 // Ring buffer size (default: 500)
)
MvvMate.logger = timeline// Print full history
timeline.dump()
// Print last 10 entries
timeline.dumpLast(10)
// Get entries as a string (for crash reports)
val report = timeline.formatAsString()
// Filter by ViewModel
val cartEvents = timeline.getEntriesFor("CartViewModel")
// Reset
timeline.clear()[MVVMate Timeline] 5 entries:
────────────────────────────────────────────────────────────────
[+0ms] ▶ CartViewModel :: AddItem
[+1ms] 🔄 CartViewModel :: items: [] → [Item(id=1)]
[+2ms] ⚡ CartViewModel :: ShowToast: ShowToast(message=Item added!)
[+150ms] ▶ CartViewModel :: Checkout
[+152ms] 🌐 Network[checkout] START
────────────────────────────────────────────────────────────────
MVVMate makes it trivial to turn any screen into an "Agentic UI", meaning you can allow an LLM or remote AI Agent to "drive" the application by dispatching standard UiAction events instead of relying on real human taps.
To do this safely without allowing an LLM to accidentally drop databases or perform unauthorized payments, you use the AiActionBridge and an AiActionPolicy.
The Policy enforces what actions the LLM is allowed to execute based on the current state.
import com.helloanwar.mvvmate.core.ai.*
val aiPolicy = object : AiActionPolicy<CartState, CartAction> {
override fun isActionAllowed(action: CartAction, currentState: CartState): Boolean {
// AI is allowed to Add/Remove items
if (action is CartAction.AddItem || action is CartAction.RemoveItem) return true
// AI is strictly FORBIDDEN from clearing the cart or checking out
if (action is CartAction.ClearCart || action is CartAction.Checkout) return false
return false // Default deny
}
}Bind the LLM bridge to your ViewModel. You must provide a parser that converts the raw text the LLM outputs (like JSON) back into your sealed action class.
val bridge = AiActionBridge(
viewModel = cartViewModel,
policy = aiPolicy,
parser = MyJsonActionParser(), // Your custom deserializer
redactor = RegexPrivacyRedactor() // Strip sensitive data before giving state to AI
)Read the safe, redacted state to feed to your LLM prompt, and pass the resulting string backward.
// Safely feed standard JSON state to your LLM without leaking passwords
val statePromptContext = bridge.getCurrentState()
val llmResponseString = callLlm(statePromptContext) // e.g. "{ "type": "AddItem" }"
// Dispatch safely
val result = bridge.dispatch(llmResponseString)
if (result.isFailure) {
// LLM tried to hallucinate a bad command or bypass security policy
}