Introduction
The first functionality we want to implement in the Ersa Android client is some basic dashboarding. We’ll display the latest temperature and humidity readings by origin (sensor node) as well as a calculated dew point value. A pager allows us to swipe between different origins. Data will be retrieved from our Ersa REST API and bound to a ViewModel that updates the fragments on display.
All code is available on GitHub: https://github.com/Pygmalion69/ErsaAndroid
Dependencies
We’ll use Retrofit to consume the API and a ViewModel from the Android Architecture Components to hold the data and update the UI. Values are displayed in Gauge views. The Ersa library is used for the needed calculations.
dependencies { // ... implementation 'com.squareup.retrofit2:retrofit:2.3.0' implementation 'com.squareup.retrofit2:converter-gson:2.1.0' implementation 'com.google.code.gson:gson:2.8.0' implementation 'android.arch.lifecycle:extensions:1.1.0' implementation 'com.github.Pygmalion69:Gauge:1.2.3' implementation 'com.github.Pygmalion69:Ersa:0.2' // ... }
Retrofit
Retrofit makes it extremely easy to consume REST. Let’s start with a POJO reflecting the entity from the API.
Reading.java
public class Reading { private Long id; private String origin; private Long timestamp; private Double temperature; private Double humidity; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getOrigin() { return origin; } public void setOrigin(String origin) { this.origin = origin; } public Long getTimestamp() { return timestamp; } public void setTimestamp(Long timestamp) { this.timestamp = timestamp; } public Double getTemperature() { return temperature; } public void setTemperature(Double temperature) { this.temperature = temperature; } public Double getHumidity() { return humidity; } public void setHumidity(Double humidity) { this.humidity = humidity; } } |
We need to define an interface for our endpoint:
MainActivity.java
public interface ApiEndpointInterface { @GET("latest") Call<List<Reading>> getLatestReadings(); } |
Now we can build a Retrofit instance and create an API Service:
MainActivity.java
String url = "http://services.yourdomain.tld:8080/ersa/"; Retrofit retrofit = new Retrofit.Builder() .baseUrl(url) .addConverterFactory(GsonConverterFactory.create()) .build(); final ApiEndpointInterface apiService = retrofit.create(ApiEndpointInterface.class); |
Next we can create a Call and get our results in a Callback. In this case we’ll update a ViewModel from the Callback.
MainActivity.java
Call<List<Reading>> call = apiService.getLatestReadings(); call.enqueue(new Callback<List<Reading>>() { @Override public void onResponse(@NonNull Call<List<Reading>> call, @NonNull Response<List<Reading>> response) { Log.d(TAG, response.toString()); if (response.body() != null && mViewModel != null) { mViewModel.getReadings().postValue(response.body()); mViewModel.getNumberOfOrigins().postValue(response.body().size()); } } @Override public void onFailure(@NonNull Call<List<Reading>> call, @NonNull Throwable t) { t.printStackTrace(); } }); |
ViewModel
We use a ViewModel to decouple our data from out business logic. Furthermore, the LiveData in our ViewModel is lifecycle-aware. It only updates Observers that are in the STARTED or the RESUMED state.
Let’s create two observable properties:
ViewModel.java
public class DashboardViewModel extends ViewModel { private MutableLiveData<List<Reading>> mReadings; private MutableLiveData<Integer> mNumberOfOrigins; public MutableLiveData<List<Reading>> getReadings() { if (mReadings == null) { mReadings = new MutableLiveData<>(); } return mReadings; } public MutableLiveData<Integer> getNumberOfOrigins() { if (mNumberOfOrigins == null) { mNumberOfOrigins = new MutableLiveData<>(); } return mNumberOfOrigins; } } |
– When there is a change in the readings data we want to update the UI.
– When there is a change in the number of origins we need to notify the pager adapter (as the number of dashboard fragments needs to change accordingly).
Here’s the Observer in our activity that notifies the adapter. Note that we set the activity (‘this’) as the lifecycle owner, so that the ViewModel is aware of its state. We should also do this in our fragments when we access the same ViewModel.
mAdapter = new DashboardAdapter(getSupportFragmentManager()); ViewPager pager = findViewById(R.id.pager); pager.setAdapter(mAdapter); mViewModel = ViewModelProviders.of(this).get(DashboardViewModel.class); final Observer<Integer> originObserver = numberOfOrigins -> { if (mViewModel.getReadings() != null && mViewModel.getReadings().getValue() != null) { origins.clear(); // List<String> for (Reading reading : mViewModel.getReadings().getValue()) { origins.add(reading.getOrigin()); } mAdapter.notifyDataSetChanged(); } }; mViewModel.getNumberOfOrigins().observe(this, originObserver); |
In the dashboard fragment, we create an Observer to update the UI.
DashboardFragment.java
mViewModel = ViewModelProviders.of(getActivity()).get(DashboardViewModel.class); final Observer<List<Reading>> readingsObserver = readings -> { if (readings != null) { for (Reading reading : readings) { if (mOrigin.equals(reading.getOrigin())) { mReading = reading; break; } } update(); } }; mViewModel.getReadings().observe(getActivity(), readingsObserver); |
Adapter
As we all know, Android can be a bitch. You might be familiar with the issue that UI fragments in a pager do not update when the dataset changes. So we create an adapter that assures UI updates in this case and an interface to be implemented by updatable fragments. See https://stackoverflow.com/a/32639894/959505.
Updatable.java
public interface Updatable { void update(); } |
UpdatableFragmentStatePagerAdapter.java
public abstract class UpdatableFragmentStatePagerAdapter extends FragmentStatePagerAdapter { private WeakHashMap<Integer, Fragment> mFragments; public UpdatableFragmentStatePagerAdapter(FragmentManager fm) { super(fm); mFragments = new WeakHashMap<>(); } @Override public Fragment getItem(int position) { Fragment item = getFragmentItem(position); mFragments.put(position, item); return item; } @Override public void destroyItem(ViewGroup container, int position, Object object) { super.destroyItem(container, position, object); Integer key = position; if (mFragments.containsKey(key)) { mFragments.remove(key); } } @Override public void notifyDataSetChanged() { super.notifyDataSetChanged(); for (Integer position : mFragments.keySet()) { //Make sure we only update fragments that should be seen if (position != null && mFragments.get(position) != null && position < getCount()) { updateFragmentItem(position, mFragments.get(position)); } } } @Override public int getItemPosition(Object object) { //If the object is a fragment, check to see if we have it in the hashmap if (object instanceof Fragment) { int position = findFragmentPositionHashMap((Fragment) object); //If fragment found in the hashmap check if it should be shown if (position >= 0) { //Return POSITION_NONE if it shouldn't be displayed return (position >= getCount() ? POSITION_NONE : position); } } return super.getItemPosition(object); } /** * Find the position of a fragment in the hashmap if it is being viewed * * @param object the Fragment we want to check for * @return the position if found else -1 */ private int findFragmentPositionHashMap(Fragment object) { for (Integer position : mFragments.keySet()) { if (position != null && mFragments.get(position) != null && mFragments.get(position) == object) { return position; } } return -1; } public abstract Fragment getFragmentItem(int position); public abstract void updateFragmentItem(int position, Fragment fragment); } |
In our DashboardAdapter we override getFragmentItem() instead of getItem():
MainActivity.java:
public static class DashboardAdapter extends UpdatableFragmentStatePagerAdapter { DashboardAdapter(FragmentManager fm) { super(fm); } @Override public int getCount() { return origins.size(); } @Override public Fragment getFragmentItem(int position) { return DashboardFragment.newInstance(origins.get(position)); } @Override public void updateFragmentItem(int position, Fragment fragment) { if (fragment instanceof Updatable) { ((Updatable) fragment).update(); } } } |
And finally we can update the fragments.
DashboardFragment.java
public class DashboardFragment extends Fragment implements Updatable { // ... @Override public void update() { if (mViewCreated) { mTemperature.setTemperature(mReading.getTemperature()); double relativeHumidity = mReading.getHumidity(); double kelvin = mTemperature.getKelvin(); try { mDewPoint.setKelvin(mDew.dewPoint(relativeHumidity, kelvin)); } catch (SolverException e) { e.printStackTrace(); } mTvOrigin.setText(mOrigin); mTvDateTime.setText((new Date(1000 * mReading.getTimestamp()).toString())); mGaugeTemperature.moveToValue(mReading.getTemperature().floatValue()); mGaugeHumidity.moveToValue(mReading.getHumidity().floatValue()); mGaugeDewPoint.moveToValue((float) mDewPoint.getTemperature()); } } } |