Bỏ qua

Mobile App

Kotlin
Jetpack Compose
MapLibre SDK
MVI Architecture

Kiến Trúc MVI

Ứng dụng sử dụng kiến trúc MVI (Model-View-Intent) kết hợp Clean Architecture:

graph LR
    UI[UI Layer<br/>Compose] -->|User Intent| VM[ViewModel<br/>MVI State]
    VM -->|UI State| UI
    VM -->|Call| UC[Use Cases]
    UC -->|Interface| Repo[Repository]
    Repo -->|Implement| Remote[Remote Data Source]
    Repo -->|Implement| Local[Local Data Source]

Cấu Trúc Thư Mục

app/src/main/java/com/houhackathon/greenmap_app/
├── core/                   # Core utilities
│   ├── network/            # Network configuration
│   ├── datastore/          # Local storage
│   └── mvi/                # Base MVI classes
├── data/                   # Data Layer
│   ├── remote/             # API services
│   ├── local/              # Room database
│   └── repository/         # Repository implementations
├── domain/                 # Domain Layer
│   ├── model/              # Domain models
│   ├── repository/         # Repository interfaces
│   └── usecase/            # Use cases
├── ui/                     # UI Layer
│   ├── theme/              # Material 3 theme
│   ├── components/         # Reusable composables
│   ├── home/               # Home screen
│   ├── map/                # Map screen
│   ├── report/             # Report screen
│   └── profile/            # Profile screen
├── navigation/             # Navigation setup
└── di/                     # Hilt modules

Base MVI ViewModel

abstract class BaseMviViewModel<State, Intent>(
    initialState: State
) : ViewModel() {

    private val _state = MutableStateFlow(initialState)
    val state: StateFlow<State> = _state.asStateFlow()

    abstract fun handleIntent(intent: Intent)

    protected fun updateState(reducer: State.() -> State) {
        _state.update { it.reducer() }
    }
}

Ví Dụ: Map Screen

State & Intent

data class MapState(
    val isLoading: Boolean = false,
    val locations: List<Location> = emptyList(),
    val selectedLayer: MapLayer = MapLayer.AQI,
    val error: String? = null
)

sealed class MapIntent {
    data class SelectLayer(val layer: MapLayer) : MapIntent()
    object RefreshData : MapIntent()
    data class SelectLocation(val id: String) : MapIntent()
}

ViewModel

@HiltViewModel
class MapViewModel @Inject constructor(
    private val getLocationsUseCase: GetLocationsUseCase
) : BaseMviViewModel<MapState, MapIntent>(MapState()) {

    override fun handleIntent(intent: MapIntent) {
        when (intent) {
            is MapIntent.SelectLayer -> selectLayer(intent.layer)
            is MapIntent.RefreshData -> loadData()
            is MapIntent.SelectLocation -> selectLocation(intent.id)
        }
    }

    private fun loadData() {
        viewModelScope.launch {
            updateState { copy(isLoading = true) }
            getLocationsUseCase()
                .onSuccess { locations ->
                    updateState { copy(isLoading = false, locations = locations) }
                }
                .onFailure { error ->
                    updateState { copy(isLoading = false, error = error.message) }
                }
        }
    }
}

Composable Screen

@Composable
fun MapScreen(viewModel: MapViewModel = hiltViewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    Column(modifier = Modifier.fillMaxSize()) {
        // Layer selector
        LayerSelector(
            selectedLayer = state.selectedLayer,
            onLayerSelected = { viewModel.handleIntent(MapIntent.SelectLayer(it)) }
        )

        // Map view
        MapLibreView(
            locations = state.locations,
            modifier = Modifier.weight(1f)
        )

        // Loading indicator
        if (state.isLoading) {
            LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
        }
    }
}

MapLibre Integration

@Composable
fun MapLibreView(
    locations: List<Location>,
    modifier: Modifier = Modifier
) {
    AndroidView(
        modifier = modifier,
        factory = { context ->
            MapView(context).apply {
                getMapAsync { map ->
                    map.setStyle(styleUrl) {
                        // Add markers
                        locations.forEach { location ->
                            map.addMarker(MarkerOptions()
                                .position(LatLng(location.lat, location.lng))
                                .title(location.name)
                            )
                        }
                    }
                }
            }
        }
    )
}

Dependency Injection (Hilt)

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(AuthInterceptor())
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BuildConfig.API_BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

Build Variants

// build.gradle.kts
android {
    buildTypes {
        debug {
            isMinifyEnabled = false
            buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:8000/\"")
        }
        release {
            isMinifyEnabled = true
            buildConfigField("String", "API_BASE_URL", "\"https://api.greenmap.hanoi/\"")
        }
    }
}