Files
android/.claude/skills/implementing-android-code/templates.md
2026-03-09 16:41:54 +00:00

637 lines
17 KiB
Markdown

# Code Templates - Bitwarden Android
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
---
## ViewModel Template (State-Action-Event Pattern)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
### State Class
```kotlin
@Parcelize
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
val dialogState: DialogState? = null,
) : Parcelable {
/**
* Dialog states for the Example screen.
*/
sealed class DialogState : Parcelable {
@Parcelize
data class Error(
val title: Text? = null,
val message: Text,
val error: Throwable? = null,
) : DialogState()
@Parcelize
data class Loading(val message: Text) : DialogState()
}
}
```
### Event Sealed Class
```kotlin
/**
* One-shot UI events for the Example screen.
*/
sealed class ExampleEvent {
data object NavigateBack : ExampleEvent()
data class ShowToast(val message: Text) : ExampleEvent()
}
```
### Action Sealed Class (with Internal)
```kotlin
/**
* User and system actions for the Example screen.
*/
sealed class ExampleAction {
data object BackClick : ExampleAction()
data object SubmitClick : ExampleAction()
data class InputChanged(val input: String) : ExampleAction()
data object ErrorDialogDismiss : ExampleAction()
/**
* Internal actions dispatched by the ViewModel from coroutines.
*/
sealed class Internal : ExampleAction() {
data class ReceiveDataState(
val dataState: DataState<ExampleData>,
) : Internal()
data class ReceiveDataResult(
val result: ExampleResult,
) : Internal()
}
}
```
### ViewModel
```kotlin
private const val KEY_STATE = "state"
/**
* ViewModel for the Example screen.
*/
@HiltViewModel
class ExampleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val exampleRepository: ExampleRepository,
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = savedStateHandle.toExampleArgs()
ExampleState(
data = args.itemId,
)
},
) {
init {
// Persist state for process death recovery
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
// Collect repository flows as internal actions
exampleRepository.dataFlow
.map { ExampleAction.Internal.ReceiveDataState(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: ExampleAction) {
when (action) {
ExampleAction.BackClick -> handleBackClick()
ExampleAction.SubmitClick -> handleSubmitClick()
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
is ExampleAction.InputChanged -> handleInputChanged(action)
is ExampleAction.Internal.ReceiveDataState -> {
handleReceiveDataState(action)
}
is ExampleAction.Internal.ReceiveDataResult -> {
handleReceiveDataResult(action)
}
}
}
private fun handleBackClick() {
sendEvent(ExampleEvent.NavigateBack)
}
private fun handleErrorDialogDismiss() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleSubmitClick() {
viewModelScope.launch {
val result = exampleRepository.submitData(state.data.orEmpty())
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
}
}
private fun handleInputChanged(action: ExampleAction.InputChanged) {
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
}
private fun handleReceiveDataState(
action: ExampleAction.Internal.ReceiveDataState,
) {
when (action.dataState) {
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
data = action.dataState.data.toString(),
)
}
}
is DataState.Loading -> {
mutableStateFlow.update { it.copy(isLoading = true) }
}
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
dialogState = ExampleState.DialogState.Error(
message = BitwardenString.generic_error_message.asText(),
error = action.dataState.error,
),
)
}
}
else -> Unit
}
}
private fun handleReceiveDataResult(
action: ExampleAction.Internal.ReceiveDataResult,
) {
when (val result = action.result) {
is ExampleResult.Success -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
data = result.data,
)
}
}
is ExampleResult.Error -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
dialogState = ExampleState.DialogState.Error(
message = result.message?.asText()
?: BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
}
```
---
## Navigation Template (Type-Safe Routes)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
```kotlin
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.feature.example
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* Route for the Example screen.
*/
@Serializable
@OmitFromCoverage
data class ExampleRoute(
val itemId: String,
val isEditMode: Boolean = false,
)
/**
* Args extracted from [SavedStateHandle] for the Example screen.
*/
@OmitFromCoverage
data class ExampleArgs(
val itemId: String,
val isEditMode: Boolean,
)
/**
* Extracts [ExampleArgs] from the [SavedStateHandle].
*/
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
val route = this.toRoute<ExampleRoute>()
return ExampleArgs(
itemId = route.itemId,
isEditMode = route.isEditMode,
)
}
/**
* Navigate to the Example screen.
*/
fun NavController.navigateToExample(
itemId: String,
isEditMode: Boolean = false,
navOptions: NavOptions? = null,
) {
this.navigate(
route = ExampleRoute(
itemId = itemId,
isEditMode = isEditMode,
),
navOptions = navOptions,
)
}
/**
* Add the Example screen destination to the navigation graph.
*/
fun NavGraphBuilder.exampleDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions<ExampleRoute> {
ExampleScreen(
onNavigateBack = onNavigateBack,
)
}
}
```
---
## Screen/Compose Template
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
```kotlin
package com.x8bit.bitwarden.ui.feature.example
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* The Example screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
is ExampleEvent.ShowToast -> {
// Handle toast
}
}
}
// Dialogs
ExampleDialogs(
dialogState = state.dialogState,
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.example),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
ExampleScreenContent(
state = state,
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
modifier = Modifier
.fillMaxSize(),
)
}
}
```
---
## Data Layer Template (Repository + Hilt Module)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
### Interface
```kotlin
/**
* Provides data operations for the Example feature.
*/
interface ExampleRepository {
/**
* Submits data and returns a typed result.
*/
suspend fun submitData(input: String): ExampleResult
/**
* Continuously observed data stream.
*/
val dataFlow: StateFlow<DataState<ExampleData>>
}
```
### Sealed Result Class
```kotlin
/**
* Domain-specific result for Example operations.
*/
sealed class ExampleResult {
data class Success(val data: String) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}
```
### Implementation
```kotlin
/**
* Default implementation of [ExampleRepository].
*/
class ExampleRepositoryImpl(
private val exampleDiskSource: ExampleDiskSource,
private val exampleService: ExampleService,
private val dispatcherManager: DispatcherManager,
) : ExampleRepository {
override val dataFlow: StateFlow<DataState<ExampleData>>
get() = // ...
override suspend fun submitData(input: String): ExampleResult {
return exampleService
.postData(input)
.fold(
onSuccess = { ExampleResult.Success(it.toModel()) },
onFailure = { ExampleResult.Error(it.message) },
)
}
}
```
### Hilt Module
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
@Provides
@Singleton
fun provideExampleRepository(
exampleDiskSource: ExampleDiskSource,
exampleService: ExampleService,
dispatcherManager: DispatcherManager,
): ExampleRepository = ExampleRepositoryImpl(
exampleDiskSource = exampleDiskSource,
exampleService = exampleService,
dispatcherManager = dispatcherManager,
)
}
```
---
## Security Templates
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
### Encrypted Disk Source (Module)
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object ExampleDiskModule {
@Provides
@Singleton
fun provideExampleDiskSource(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
json: Json,
): ExampleDiskSource = ExampleDiskSourceImpl(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
json = json,
)
}
```
### Encrypted Disk Source (Implementation)
```kotlin
/**
* Disk source for Example data using encrypted and unencrypted storage.
*/
class ExampleDiskSourceImpl(
encryptedSharedPreferences: SharedPreferences,
sharedPreferences: SharedPreferences,
private val json: Json,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
private companion object {
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
const val UNENCRYPTED_PREF_KEY = "examplePreference"
}
override var authToken: String?
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
override var uiPreference: Boolean
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
}
```
---
## Testing Templates
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
### ViewModel Test
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
// Mock dependencies
private val mockRepository = mockk<ExampleRepository>()
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
@BeforeEach
fun setup() {
every { mockRepository.dataFlow } returns mutableDataFlow
}
@Test
fun `initial state should be correct when there is no saved state`() {
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when there is a saved state`() {
val savedState = DEFAULT_STATE.copy(data = "saved")
val viewModel = createViewModel(state = savedState)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `SubmitClick should call repository and update state on success`() = runTest {
val expected = ExampleResult.Success(data = "result")
coEvery { mockRepository.submitData(any()) } returns expected
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Initial state
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.SubmitClick)
// Updated state after result
assertEquals(
DEFAULT_STATE.copy(data = "result", isLoading = false),
awaitItem(),
)
}
}
@Test
fun `SubmitClick should show error dialog on failure`() = runTest {
val expected = ExampleResult.Error(message = "Network error")
coEvery { mockRepository.submitData(any()) } returns expected
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.SubmitClick)
val errorState = awaitItem()
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
}
}
@Test
fun `BackClick should emit NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExampleAction.BackClick)
assertEquals(ExampleEvent.NavigateBack, awaitItem())
}
}
// Helper to create ViewModel with optional saved state
private fun createViewModel(
state: ExampleState? = DEFAULT_STATE,
): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle(
mapOf(KEY_STATE to state),
),
exampleRepository = mockRepository,
)
companion object {
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
)
}
}
```
### Flow Testing with stateEventFlow
```kotlin
@Test
fun `SubmitClick should update state and emit event`() = runTest {
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
viewModel.trySendAction(ExampleAction.SubmitClick)
// Assert state change
assertEquals(
DEFAULT_STATE.copy(data = "data"),
stateFlow.awaitItem(),
)
// Assert event emission
assertEquals(
ExampleEvent.ShowToast("Success".asText()),
eventFlow.awaitItem(),
)
}
}
```