Each screen follows this pattern:
{Screen}Screen.kt
├── @Composable fun {Screen}Screen(viewModel, navCallbacks)
│ └── Collects state: val state by viewModel.state.collectAsState()
│ └── Renders based on sealed class state
│
└── @HiltViewModel
class {Screen}ViewModel @Inject constructor(
private val repository: EmailRepository,
private val settingsDataStore: SettingsDataStore,
savedStateHandle: SavedStateHandle
) : ViewModel() ViewModels expose a single StateFlow of a sealed class:
sealed class InboxState {
data object Loading : InboxState()
data class Success(
val threads: List<EmailThread>,
val currentTab: InboxTab,
val isRefreshing: Boolean,
val isLoadingMore: Boolean,
val nextPageToken: String?
) : InboxState()
data class Error(val message: String) : InboxState()
} // Combine multiple reactive sources
val state: StateFlow<InboxState> = combine(
currentTab,
activeAccountId,
unifiedInboxEnabled,
organizeByThread
) { tab, accountId, unified, threaded ->
// Derive query parameters, return InboxState
}.flatMapLatest { params ->
// Switch between different Room queries based on params
when (params.tab) {
InboxTab.INBOX -> repository.getInboxThreadsFlow(params.accountId)
// ...
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), InboxState.Loading) private val _events = MutableSharedFlow<InboxEvent>()
val events: SharedFlow<InboxEvent> = _events.asSharedFlow() animateContentSize with spring physics on cards and sections.graphicsLayer(scaleX/Y) for press-scale animations.updateTransition for dock tab animated state changes.Monochrome palette:
Light: Background #FFFFFF, Surface #FAFAFA, SurfaceContainer #F3F3F3
Dark: Background #000000, Surface #0D0D0D, SurfaceContainer #141414
Primary: Black (light) / White (dark)
OnSurfaceMuted: #5C5C5C (light) / #A0A0A0 (dark)
Shapes:
extraSmall: 8dp, small: 12dp, medium: 16dp, large: 24dp, extraLarge: 32dp
Typography:
Google Sans Rounded (W500 display, W600 headlines/titles, W400 body, W500 labels)