CPS251 Android Development by Scott Shaper

WeatherApp Screen

The MainActivity will call the WeatherAppScreen which will be our UI.

WeatherAppScreen.kt

@Composable
    fun WeatherAppScreen() {
        val weatherApiService = RetrofitClient.weatherApiService
        val weatherRepository = remember { WeatherRepository(weatherApiService) }
        val weatherViewModel: WeatherViewModel = viewModel(
            factory = WeatherViewModel.provideFactory(weatherRepository)
        )
    
        // Collect states from the ViewModel
        val zipcode by weatherViewModel.zipcode.collectAsState()
        val weatherResponse by weatherViewModel.weatherResponse.collectAsState()
        val errorMessage by weatherViewModel.errorMessage.collectAsState()
    
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            OutlinedTextField(
                value = zipcode,
                onValueChange = { weatherViewModel.onZipcodeChange(it) },
                label = { Text("Enter Zip Code") },
                singleLine = true,
                modifier = Modifier.padding(bottom = 8.dp)
            )
    
            Button(onClick = { weatherViewModel.fetchWeather() }) {
                Text("Get Weather")
            }
    
            when {
                weatherResponse != null -> {
                    Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalAlignment = Alignment.Start) {
                        Text("City: ${weatherResponse?.name}")
                        Text("Temperature: ${weatherResponse?.main?.temp}°C")
                        Text("Description: ${weatherResponse?.weather?.firstOrNull()?.description}")
                        Spacer(modifier = Modifier.height(16.dp))
                        Text("Other Info:")
                        Text("Temp Min: ${weatherResponse?.main?.temp_min}")
                        Text("Temp Max: ${weatherResponse?.main?.temp_max}")
                        Text("Pressure: ${weatherResponse?.main?.pressure}")
                        Text("Humidity: ${weatherResponse?.main?.humidity}")
                        Text("Wind Speed: ${weatherResponse?.wind?.speed}")
                        Text("Wind Direction: ${weatherResponse?.wind?.deg}")
                        Text("Clouds: ${weatherResponse?.clouds?.all}")
                        Text("Sunrise: ${weatherResponse?.sys?.sunrise}")
                        Text("Sunset: ${weatherResponse?.sys?.sunset}")
                    }
                }
                errorMessage != null -> {
                    Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalAlignment = Alignment.Start) {
                        Text("No weather found for that zip code")
                    }
                }
                else -> {
                    Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalAlignment = Alignment.Start) {
                        Text("Enter a zip code and press 'Get Weather'")
                    }
                }
            }
        }
    }

Code Explanation

The WeatherAppScreen composable function is the main UI component for our weather application. It observes UI state from a WeatherViewModel and handles user interactions, while all data fetching logic is delegated to the ViewModel and Repository layers.

State Management

The WeatherAppScreen composable function utilizes a WeatherViewModel to manage and expose all UI state, including the zipcode input, weather data, and error states. This promotes better separation of concerns and testability.


    val weatherApiService = RetrofitClient.weatherApiService
    val weatherRepository = remember { WeatherRepository(weatherApiService) }
    val weatherViewModel: WeatherViewModel = viewModel(
        factory = WeatherViewModel.provideFactory(weatherRepository)
    )
    
    // Collect states from the ViewModel
    val zipcode by weatherViewModel.zipcode.collectAsState()
    val weatherResponse by weatherViewModel.weatherResponse.collectAsState()
    val errorMessage by weatherViewModel.errorMessage.collectAsState()
    

Dependencies and Architecture

The application follows a clean architecture pattern with clear separation of concerns:

UI Layout and Components

The UI is structured using Jetpack Compose's Column composable.


    // ... existing code ...
        Column(
            modifier = Modifier.align(Alignment.Center),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // UI elements go here
        }
    }
    // ... existing code ...
    
Input Field

    OutlinedTextField(
        value = zipcode,
        onValueChange = { weatherViewModel.onZipcodeChange(it) },
        label = { Text("Enter Zip Code") },
        singleLine = true,
        modifier = Modifier.padding(bottom = 8.dp)
    )
    

An OutlinedTextField allows the user to input a zip code. The value is bound to the zipcode state collected from the ViewModel, and onValueChange calls the ViewModel's onZipcodeChange() method to update the state in the ViewModel. This ensures all state management is centralized in the ViewModel.

Get Weather Button

    Button(onClick = { weatherViewModel.fetchWeather() }) {
        Text("Get Weather")
    }
    

The "Get Weather" button triggers the data fetching process when clicked. The fetchWeather() method in the ViewModel doesn't require a parameter because it reads the current zipcode value from the ViewModel's internal state. This further demonstrates the separation of concerns—the UI simply triggers an action, and the ViewModel handles the rest.

Weather Data Fetching Logic

The core logic for fetching weather data follows this flow:

  1. The UI calls weatherViewModel.fetchWeather() when the button is clicked.
  2. The ViewModel's fetchWeather() method reads the current zipcode from its internal state and launches a coroutine using viewModelScope.
  3. The ViewModel calls repository.getCurrentWeather(zipcode), which handles the actual network request.
  4. The repository uses the WeatherApiService to make the API call and returns a Result<WeatherResponse>.
  5. The ViewModel updates its _weatherResponse or _errorMessage StateFlow based on the result.
  6. The UI automatically recomposes when the StateFlow values change, thanks to collectAsState().

This architecture ensures that the UI composable is not directly involved in data fetching, network operations, or error handling. Instead, it simply triggers the ViewModel to perform these operations and observes the results via reactive StateFlows.

Displaying Errors, Loading, and Weather

The UI conditionally displays information based on the state observed from the WeatherViewModel. A when statement is used to handle different scenarios:


    // ... existing code ...
            when {
                weatherResponse != null -> {
                    Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalAlignment = Alignment.Start) {
                        Text("City: ${weatherResponse?.name}")
                        Text("Temperature: ${weatherResponse?.main?.temp}°C")
                        Text("Description: ${weatherResponse?.weather?.firstOrNull()?.description}")
                        Spacer(modifier = Modifier.height(16.dp))
                        Text("Other Info:")
                        Text("Temp Min: ${weatherResponse?.main?.temp_min}")
                        Text("Temp Max: ${weatherResponse?.main?.temp_max}")
                        Text("Pressure: ${weatherResponse?.main?.pressure}")
                        Text("Humidity: ${weatherResponse?.main?.humidity}")
                        Text("Wind Speed: ${weatherResponse?.wind?.speed}")
                        Text("Wind Direction: ${weatherResponse?.wind?.deg}")
                        Text("Clouds: ${weatherResponse?.clouds?.all}")
                        Text("Sunrise: ${weatherResponse?.sys?.sunrise}")
                        Text("Sunset: ${weatherResponse?.sys?.sunset}")
                    }
                }
                errorMessage != null -> {
                    Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalAlignment = Alignment.Start) {
                        Text("No weather found for that zip code")
                    }
                }
                else -> {
                    Column(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalAlignment = Alignment.Start) {
                        Text("Enter a zip code and press 'Get Weather'")
                    }
                }
            }
    // ... existing code ...
    

Learning Aids

Tips for Success

  • Keep your composables focused on UI and observe state from the ViewModel using collectAsState().
  • Use StateFlow in your ViewModel for reactive state management. All UI-related state should be managed by the ViewModel, not stored locally in composables.
  • Use remember for dependencies like repositories that should persist across recompositions but don't need to be part of the ViewModel's state.
  • Structure your UI using appropriate Jetpack Compose layout composables like Column, Row, and Box for better organization and responsiveness.
  • Use a ViewModel factory pattern when your ViewModel requires dependencies, as shown in this example.

Common Mistakes to Avoid

  • Performing complex business logic or directly initiating network requests within composables; this should be handled by the ViewModel and Repository.
  • Storing UI state (like input field values) locally in composables using mutableStateOf instead of managing it in the ViewModel. The ViewModel should own all UI-related state.
  • Forgetting to use collectAsState() when observing StateFlow values from the ViewModel in composables.
  • Not using a ViewModel factory when the ViewModel requires dependencies, leading to difficulties in testing and dependency injection.
  • Creating deeply nested composable hierarchies, which can negatively impact readability, maintainability, and rendering performance.
  • Not using viewModelScope in the ViewModel for coroutines, which can lead to memory leaks if coroutines aren't properly cancelled.

Best Practices

  • Follow the Repository pattern to abstract data sources. The Repository should handle API calls, caching, and data transformation, while the ViewModel coordinates between the Repository and UI.
  • Use StateFlow in ViewModels for all UI-related state. This provides a reactive, lifecycle-aware way to manage state that integrates seamlessly with Jetpack Compose's collectAsState().
  • Delegate all data fetching, error handling, and business logic to the ViewModel and Repository, keeping your composables lean and focused solely on UI.
  • Use a ViewModel factory pattern when your ViewModel requires dependencies. This makes dependency injection clean and enables easier testing.
  • Always use viewModelScope for launching coroutines in ViewModels to ensure proper lifecycle management and automatic cancellation.
  • Keep UI state management centralized in the ViewModel rather than scattering it across composables. This makes state predictable and easier to test.
  • Break down large composables into smaller, focused, and reusable composables for better modularity and easier maintenance.