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

17 KiB

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

@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

/**
 * 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)

/**
 * 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

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

@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

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

/**
 * 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

/**
 * Domain-specific result for Example operations.
 */
sealed class ExampleResult {
    data class Success(val data: String) : ExampleResult()
    data class Error(val message: String?) : ExampleResult()
}

Implementation

/**
 * 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

@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)

@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)

/**
 * 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

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

@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(),
        )
    }
}