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