playbook/antigravity-awesome-skills/plugins/antigravity-awesome-skills-.../skills/android-dev/references/native-android.md

7.6 KiB

Native Android Reference (Kotlin + Jetpack Compose)

Project Structure

app/
├── src/
│   ├── main/
│   │   ├── AndroidManifest.xml
│   │   ├── java/com.example.app/
│   │   │   ├── MyApp.kt                     # Application class, Hilt entry point
│   │   │   ├── MainActivity.kt              # Single activity, NavHost host
│   │   │   ├── ui/
│   │   │   │   ├── theme/                   # MaterialTheme, Color, Type, Shape
│   │   │   │   ├── components/              # Shared design system composables
│   │   │   │   └── feature/
│   │   │   │       ├── home/
│   │   │   │       │   ├── HomeScreen.kt
│   │   │   │       │   ├── HomeViewModel.kt
│   │   │   │       │   └── HomeUiState.kt
│   │   │   ├── domain/
│   │   │   │   ├── model/                   # Domain models (pure Kotlin, no Android deps)
│   │   │   │   ├── repository/              # Interfaces only
│   │   │   │   └── usecase/                 # One class per use case
│   │   │   ├── data/
│   │   │   │   ├── remote/                  # Retrofit services, DTOs, mappers
│   │   │   │   ├── local/                   # Room DB, DAOs, entities
│   │   │   │   └── repository/              # Repository implementations
│   │   │   └── di/                          # Hilt modules
│   └── test/                                # Unit tests
│   └── androidTest/                         # Instrumented tests
├── build.gradle.kts
└── proguard-rules.pro

ViewModel Pattern

// UiState — sealed class for exhaustive when()
sealed class HomeUiState {
    object Loading : HomeUiState()
    data class Success(val items: List<Item>) : HomeUiState()
    data class Error(val message: String) : HomeUiState()
}

// UiEvent — one-shot events (navigation, snackbars)
sealed class HomeUiEvent {
    data class NavigateTo(val route: String) : HomeUiEvent()
    data class ShowSnackbar(val message: String) : HomeUiEvent()
}

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getItemsUseCase: GetItemsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    private val _uiEvent = Channel<HomeUiEvent>()
    val uiEvent = _uiEvent.receiveAsFlow()

    init { loadItems() }

    fun loadItems() {
        viewModelScope.launch {
            _uiState.value = HomeUiState.Loading
            getItemsUseCase()
                .onSuccess { _uiState.value = HomeUiState.Success(it) }
                .onFailure { _uiState.value = HomeUiState.Error(it.message ?: "Unknown error") }
        }
    }
}

Repository Pattern

// Interface in domain layer
interface ItemRepository {
    fun observeItems(): Flow<List<Item>>
    suspend fun refreshItems(): Result<Unit>
    suspend fun getItemById(id: String): Result<Item>
}

// Implementation in data layer
class ItemRepositoryImpl @Inject constructor(
    private val remoteSource: ItemRemoteDataSource,
    private val localSource: ItemLocalDataSource,
    private val mapper: ItemMapper
) : ItemRepository {

    override fun observeItems(): Flow<List<Item>> =
        localSource.observeAll().map { mapper.toDomain(it) }

    override suspend fun refreshItems(): Result<Unit> = runCatching {
        val dto = remoteSource.fetchItems()
        localSource.insertAll(mapper.toEntity(dto))
    }

    override suspend fun getItemById(id: String): Result<Item> = runCatching {
        // Example implementation fetching from local cache
        val entity = localSource.getById(id) ?: throw Exception("Item not found")
        mapper.toDomain(entity)
    }
}

Compose Screen

@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    onNavigate: (String) -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }

    // One-shot event handling
    LaunchedEffect(Unit) {
        viewModel.uiEvent.collect { event ->
            when (event) {
                is HomeUiEvent.NavigateTo -> onNavigate(event.route)
                is HomeUiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
            }
        }
    }

    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
        when (val state = uiState) {
            is HomeUiState.Loading -> LoadingContent()
            is HomeUiState.Success -> HomeContent(state.items, Modifier.padding(padding))
            is HomeUiState.Error -> ErrorContent(state.message, onRetry = viewModel::loadItems)
        }
    }
}

Room Database

@Entity(tableName = "items")
data class ItemEntity(
    @PrimaryKey val id: String,
    val title: String,
    val updatedAt: Long = System.currentTimeMillis()
)

@Dao
interface ItemDao {
    @Query("SELECT * FROM items ORDER BY updatedAt DESC")
    fun observeAll(): Flow<List<ItemEntity>>

    @Upsert
    suspend fun upsertAll(items: List<ItemEntity>)

    @Query("DELETE FROM items")
    suspend fun deleteAll()
}

@Database(entities = [ItemEntity::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
    abstract fun itemDao(): ItemDao
}

Hilt DI Setup

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder()
        .baseUrl(BuildConfig.API_BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(buildOkHttpClient())
        .build()
}

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds @Singleton
    abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}

Key Dependencies (libs.versions.toml)

[versions]
kotlin = "2.0.0"
compose-bom = "2024.06.00"
hilt = "2.51"
room = "2.6.1"
retrofit = "2.11.0"
coroutines = "1.8.1"
lifecycle = "2.8.2"

[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }

Testing Setup

// ViewModel unit test
@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModelTest {
    @get:Rule val mainDispatcherRule = MainDispatcherRule()

    private val getItemsUseCase = mockk<GetItemsUseCase>()
    private lateinit var viewModel: HomeViewModel

    @BeforeEach
    fun setup() { viewModel = HomeViewModel(getItemsUseCase) }

    @Test
    fun `loadItems emits Success when use case succeeds`() = runTest {
        val items = listOf(Item("1", "Test"))
        coEvery { getItemsUseCase() } returns Result.success(items)

        viewModel.uiState.test {
            skipItems(1) // Loading
            assertThat(awaitItem()).isEqualTo(HomeUiState.Success(items))
        }
    }
}