AgentStack
SKILL verified MIT Self-run

Android Patterns

skill-marvinrichter-clarc-android-patterns · by marvinrichter

Android/Jetpack Compose patterns — state hoisting, UDF with ViewModel/UiState, side effects (LaunchedEffect/rememberUpdatedState), Material Design 3 components, type-safe Navigation, Hilt DI, Room database, and Compose performance (derivedStateOf, key).

No reviews yet
0 installs
14 views
0.0% view→install

Install

$ agentstack add skill-marvinrichter-clarc-android-patterns

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Android Patterns? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Android Patterns (Jetpack Compose)

When to Activate

  • Designing architecture for a new Android feature or app
  • Choosing between Compose patterns (state hoisting, side effects)
  • Setting up Hilt dependency injection and scoping
  • Designing Room database schema with migrations
  • Implementing ViewModel with StateFlow and UiState
  • Reviewing Android code for best practices and Google guidelines
  • Modularizing a growing Android codebase

Jetpack Compose Basics

Composables are pure functions — they receive data and emit UI. Never hold mutable state inside a Composable directly; hoist it up.

// Stateless Composable — accepts value + callback (testable, reusable)
@Composable
fun CounterButton(
    count: Int,
    onIncrement: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(onClick = onIncrement, modifier = modifier) {
        Text("Count: $count")
    }
}

// Stateful wrapper — owns state, passes it down
@Composable
fun CounterScreen() {
    var count by rememberSaveable { mutableIntStateOf(0) }
    CounterButton(count = count, onIncrement = { count++ })
}

State rules:

  • remember { } — survives recomposition, lost on configuration change
  • rememberSaveable { } — survives recomposition AND configuration changes (and process death for primitives)
  • rememberSaveable(saver = ...) — custom Saver for complex types

State Hoisting

State hoisting = lifting state up so a Composable becomes stateless. Classic pattern:

// WRONG: state trapped inside — untestable, not reusable
@Composable
fun SearchBox() {
    var query by remember { mutableStateOf("") }
    TextField(value = query, onValueChange = { query = it })
}

// CORRECT: hoisted — caller controls state
@Composable
fun SearchBox(
    query: String,
    onQueryChange: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    TextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = modifier,
        placeholder = { Text("Search…") },
    )
}

// Usage: state lives in ViewModel or parent Composable
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
    val query by viewModel.query.collectAsStateWithLifecycle()
    SearchBox(query = query, onQueryChange = viewModel::onQueryChange)
}

Side Effects

| Effect | When to Use | |--------|-------------| | LaunchedEffect(key) | Launch a coroutine tied to Composable lifecycle. Re-launches when key changes. | | DisposableEffect(key) | Register/unregister listeners (e.g. lifecycle observers). Cleanup via onDispose. | | SideEffect | Sync Compose state to non-Compose objects (e.g. analytics). Runs every recomposition. | | rememberCoroutineScope() | Get scope for event-driven coroutines (button clicks). | | produceState | Convert callback-based APIs to Compose State. |

// LaunchedEffect: load data on screen entry
@Composable
fun ProductDetailScreen(productId: String, viewModel: ProductViewModel = hiltViewModel()) {
    LaunchedEffect(productId) {
        viewModel.loadProduct(productId) // Reloads if productId changes
    }
    // ...
}

// DisposableEffect: register a lifecycle observer
@Composable
fun LifecycleLogger(tag: String) {
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            Log.d(tag, "Lifecycle: $event")
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
    }
}

Material Design 3

// Theme setup (generated via Material Theme Builder)
@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true, // Android 12+ wallpaper colors
    content: @Composable () -> Unit,
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}

// Common M3 components
@Composable
fun ProductScreen(product: Product, onBack: () -> Unit) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(product.name) },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
                    }
                },
            )
        },
        floatingActionButton = {
            ExtendedFloatingActionButton(
                onClick = { /* add to cart */ },
                icon = { Icon(Icons.Filled.ShoppingCart, "Add to cart") },
                text = { Text("Add to Cart") },
            )
        },
    ) { padding ->
        ProductDetail(product = product, modifier = Modifier.padding(padding))
    }
}

Navigation Component (Type-Safe)

// Define routes with Kotlin Serialization (Navigation 2.8+)
@Serializable object HomeRoute
@Serializable data class ProductDetailRoute(val productId: String)
@Serializable data class OrderConfirmationRoute(val orderId: String)

// NavHost
@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(navController = navController, startDestination = HomeRoute) {
        composable {
            HomeScreen(onProductClick = { id ->
                navController.navigate(ProductDetailRoute(id))
            })
        }
        composable { backStackEntry ->
            val route = backStackEntry.toRoute()
            ProductDetailScreen(
                productId = route.productId,
                onOrderPlaced = { orderId ->
                    navController.navigate(OrderConfirmationRoute(orderId)) {
                        popUpTo() // Clear detail from back stack
                    }
                },
            )
        }
        composable { backStackEntry ->
            val route = backStackEntry.toRoute()
            OrderConfirmationScreen(orderId = route.orderId)
        }
    }
}

Key navigation options:

navController.navigate(SomeRoute) {
    popUpTo { inclusive = false } // clear back stack up to Home
    launchSingleTop = true                   // avoid duplicate destinations
    restoreState = true                      // restore saved state on re-select
}

ViewModel + UiState (Unidirectional Data Flow)

// 1. Define sealed UiState
sealed interface ProductUiState {
    data object Loading : ProductUiState
    data class Success(val product: Product, val isInCart: Boolean) : ProductUiState
    data class Error(val message: String) : ProductUiState
}

// 2. UiEvent (one-time effects via SharedFlow)
sealed interface ProductUiEvent {
    data class NavigateToCart(val cartId: String) : ProductUiEvent
    data class ShowSnackbar(val message: String) : ProductUiEvent
}

// 3. ViewModel
@HiltViewModel
class ProductViewModel @Inject constructor(
    private val getProduct: GetProductUseCase,
    private val addToCart: AddToCartUseCase,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val productId: String = checkNotNull(savedStateHandle["productId"])

    private val _uiState = MutableStateFlow(ProductUiState.Loading)
    val uiState: StateFlow = _uiState.asStateFlow()

    private val _uiEvents = MutableSharedFlow()
    val uiEvents: SharedFlow = _uiEvents.asSharedFlow()

    init {
        loadProduct()
    }

    private fun loadProduct() {
        viewModelScope.launch {
            _uiState.value = ProductUiState.Loading
            getProduct(productId)
                .onSuccess { product ->
                    _uiState.value = ProductUiState.Success(product, isInCart = false)
                }
                .onFailure { error ->
                    _uiState.value = ProductUiState.Error(error.message ?: "Unknown error")
                }
        }
    }

    fun onAddToCart() {
        val currentState = _uiState.value as? ProductUiState.Success ?: return
        viewModelScope.launch {
            addToCart(currentState.product.id)
                .onSuccess { cartId ->
                    _uiEvents.emit(ProductUiEvent.NavigateToCart(cartId))
                }
                .onFailure {
                    _uiEvents.emit(ProductUiEvent.ShowSnackbar("Failed to add to cart"))
                }
        }
    }
}

// 4. Composable
@Composable
fun ProductDetailScreen(viewModel: ProductViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }

    // Collect one-time events
    LaunchedEffect(Unit) {
        viewModel.uiEvents.collect { event ->
            when (event) {
                is ProductUiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
                is ProductUiEvent.NavigateToCart -> { /* navigate */ }
            }
        }
    }

    when (val state = uiState) {
        is ProductUiState.Loading -> CircularProgressIndicator()
        is ProductUiState.Error -> ErrorScreen(message = state.message)
        is ProductUiState.Success -> ProductContent(
            product = state.product,
            onAddToCart = viewModel::onAddToCart,
        )
    }
}

Critical: Use collectAsStateWithLifecycle() (from lifecycle-runtime-compose), NOT collectAsState(). The lifecycle-aware version pauses collection when the app is in the background, saving resources.


Hilt Dependency Injection

// Application class
@HiltAndroidApp
class MyApp : Application()

// Activity (entry point)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { MyAppTheme { AppNavHost(rememberNavController()) } }
    }
}

// Module: provide network dependencies as Singletons
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(HttpLoggingInterceptor().apply { level = BODY })
        .build()

    @Provides
    @Singleton
    fun provideRetrofit(okHttp: OkHttpClient): Retrofit = Retrofit.Builder()
        .baseUrl(BuildConfig.API_BASE_URL)
        .client(okHttp)
        .addConverterFactory(MoshiConverterFactory.create())
        .build()

    @Provides
    @Singleton
    fun provideProductApi(retrofit: Retrofit): ProductApi =
        retrofit.create(ProductApi::class.java)
}

// Module: provide Repository (Singleton scope)
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    @Singleton
    abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
}

// Hilt scopes guide:
// @Singleton       — app lifetime (one instance across entire app)
// @ActivityRetained — survives configuration changes, per Activity
// @ViewModelScoped  — same as Activity/Fragment's ViewModel scope
// @ActivityScoped  — per Activity instance (destroyed on finish)
// @FragmentScoped  — per Fragment instance

Room Persistence

// Entity
@Entity(
    tableName = "products",
    indices = [Index(value = ["category_id"]), Index(value = ["sku"], unique = true)],
)
data class ProductEntity(
    @PrimaryKey val id: String,
    val name: String,
    val price: Double,
    @ColumnInfo(name = "category_id") val categoryId: String,
    val sku: String,
    @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)

// DAO
@Dao
interface ProductDao {
    @Query("SELECT * FROM products WHERE id = :id")
    suspend fun getById(id: String): ProductEntity?

    @Query("SELECT * FROM products WHERE category_id = :categoryId ORDER BY name ASC")
    fun getByCategory(categoryId: String): Flow> // Flow for reactive updates

    @Upsert
    suspend fun upsert(product: ProductEntity)

    @Delete
    suspend fun delete(product: ProductEntity)

    @Query("DELETE FROM products WHERE id = :id")
    suspend fun deleteById(id: String)
}

// Relation: one-to-many
data class CategoryWithProducts(
    @Embedded val category: CategoryEntity,
    @Relation(parentColumn = "id", entityColumn = "category_id")
    val products: List,
)

// Database
@Database(
    entities = [ProductEntity::class, CategoryEntity::class],
    version = 2,
    exportSchema = true, // ALWAYS true — export to schemas/ directory for migration testing
)
@TypeConverters(DateConverters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun productDao(): ProductDao
    abstract fun categoryDao(): CategoryDao
}

// Migration: NEVER increase version without migration
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE products ADD COLUMN sku TEXT NOT NULL DEFAULT ''")
        db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_products_sku ON products(sku)")
    }
}

// Provide database via Hilt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
            .addMigrations(MIGRATION_1_2)
            .build()

    @Provides
    fun provideProductDao(db: AppDatabase): ProductDao = db.productDao()
}

Kotlin Coroutines in Android

// Repository: bridge between data sources
class ProductRepositoryImpl @Inject constructor(
    private val api: ProductApi,
    private val dao: ProductDao,
    private val dispatchers: CoroutineDispatchers,
) : ProductRepository {

    override fun getProduct(id: String): Flow> = flow {
        // Emit cached data first (offline-first)
        dao.getById(id)?.let { emit(Result.success(it.toDomain())) }

        // Fetch fresh data
        val result = runCatching { api.getProduct(id) }
        result.onSuccess { dto ->
            dao.upsert(dto.toEntity())
            emit(Result.success(dto.toDomain()))
        }.onFailure { error ->
            emit(Result.failure(error))
        }
    }.flowOn(dispatchers.io)
}

// Dispatcher injection (testable)
interface CoroutineDispatchers {
    val main: CoroutineDispatcher
    val io: CoroutineDispatcher
    val default: CoroutineDispatcher
}

class DefaultDispatchers @Inject constructor() : CoroutineDispatchers {
    override val main = Dispatchers.Main
    override val io = Dispatchers.IO
    override val default = Dispatchers.Default
}

// In tests: inject TestDispatchers
class TestDispatchers(testDispatcher: TestCoroutineDispatcher) : CoroutineDispatchers {
    override val main = testDispatcher
    override val io = testDispatcher
    override val default = testDispatcher
}

App Modularization

Recommended module structure for apps with 3+ features:

:app                          — Application entry point, DI graph assembly
:feature:home                 — Home screen feature module
:feature:product-detail       — Product detail feature module
:feature:cart                 — Cart feature module
:core:network                 — Retrofit, OkHttp, API definitions
:core:database                — Room database, DAOs
:core:ui                      — Shared Composables, theme, design system
:core:domain                  — Domain models, use case interfaces
:core:data                    — Repository implementations (depends on :core:network, :core:database)
:core:testing                 — Shared test utilities, fakes, test rules
// build.gradle.kts — feature module
plugins {
    alias(libs.plugins.android.library)

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [marvinrichter](https://github.com/marvinrichter)
- **Source:** [marvinrichter/clarc](https://github.com/marvinrichter/clarc)
- **License:** MIT
- **Homepage:** https://marvinrichter.github.io/clarc

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.