// Copyright 2015 The Project Buendia Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy
// of the License at: http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software distrib-
// uted under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
// OR CONDITIONS OF ANY KIND, either express or implied. See the License for
// specific language governing permissions and limitations under the License.
package org.projectbuendia.client.ui.chart;
import android.app.ActionBar;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.graphics.Point;
import android.os.Bundle;
import android.os.Handler;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.TextView;
import com.google.common.base.Joiner;
import com.joanzapata.android.iconify.IconDrawable;
import com.joanzapata.android.iconify.Iconify;
import org.joda.time.DateTime;
import org.joda.time.Interval;
import org.joda.time.LocalDate;
import org.odk.collect.android.model.Preset;
import org.projectbuendia.client.App;
import org.projectbuendia.client.AppSettings;
import org.projectbuendia.client.R;
import org.projectbuendia.client.events.CrudEventBus;
import org.projectbuendia.client.models.AppModel;
import org.projectbuendia.client.models.Chart;
import org.projectbuendia.client.models.ConceptUuids;
import org.projectbuendia.client.models.Form;
import org.projectbuendia.client.models.Location;
import org.projectbuendia.client.models.LocationTree;
import org.projectbuendia.client.models.Obs;
import org.projectbuendia.client.models.ObsRow;
import org.projectbuendia.client.models.Order;
import org.projectbuendia.client.models.Patient;
import org.projectbuendia.client.sync.ChartDataHelper;
import org.projectbuendia.client.sync.SyncManager;
import org.projectbuendia.client.ui.BaseLoggedInActivity;
import org.projectbuendia.client.ui.BigToast;
import org.projectbuendia.client.ui.OdkActivityLauncher;
import org.projectbuendia.client.ui.chart.PatientChartController.MinimalHandler;
import org.projectbuendia.client.ui.chart.PatientChartController.OdkResultSender;
import org.projectbuendia.client.ui.dialogs.EditPatientDialogFragment;
import org.projectbuendia.client.ui.dialogs.GoToPatientDialogFragment;
import org.projectbuendia.client.ui.dialogs.OrderDialogFragment;
import org.projectbuendia.client.ui.dialogs.OrderExecutionDialogFragment;
import org.projectbuendia.client.ui.dialogs.ViewObservationsDialogFragment;
import org.projectbuendia.client.utils.EventBusWrapper;
import org.projectbuendia.client.utils.Logger;
import org.projectbuendia.client.utils.RelativeDateTimeFormatter;
import org.projectbuendia.client.utils.Utils;
import org.projectbuendia.client.widgets.PatientAttributeView;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import javax.inject.Inject;
import javax.inject.Provider;
import butterknife.ButterKnife;
import butterknife.InjectView;
import de.greenrobot.event.EventBus;
/** Activity displaying a patient's vitals and chart history. */
public final class PatientChartActivity extends BaseLoggedInActivity {
private static final Logger LOG = Logger.create();
// TODO/cleanup: We don't need this anymore. See updateEbolaPcrTestResultUi below.
// Minimum PCR Np or L value to be considered negative (displayed as "NEG").
// 39.95 is chosen as the threshold as it would be displayed as 40.0
// (and values slightly below 40.0 may be the result of rounding errors).
private static final double PCR_NEGATIVE_THRESHOLD = 39.95;
private static final String KEY_CONTROLLER_STATE = "controllerState";
private static final String SEPARATOR_DOT = "\u00a0\u00a0\u00b7\u00a0\u00a0";
private PatientChartController mController;
private boolean mIsFetchingXform = false;
private ProgressDialog mFormLoadingDialog;
private ProgressDialog mFormSubmissionDialog;
private ChartRenderer mChartRenderer;
@Inject AppModel mAppModel;
@Inject EventBus mEventBus;
@Inject Provider<CrudEventBus> mCrudEventBusProvider;
@Inject SyncManager mSyncManager;
@Inject ChartDataHelper mChartDataHelper;
@Inject AppSettings mSettings;
@InjectView(R.id.patient_chart_root) ViewGroup mRootView;
@InjectView(R.id.attribute_location) PatientAttributeView mPatientLocationView;
@InjectView(R.id.attribute_admission_days) PatientAttributeView mAdmissionDaysView;
@InjectView(R.id.attribute_symptoms_onset_days) PatientAttributeView mSymptomOnsetDaysView;
@InjectView(R.id.attribute_pcr) PatientAttributeView mPcr;
@InjectView(R.id.patient_chart_pregnant) TextView mPatientPregnantOrIvView;
@InjectView(R.id.chart_webview) WebView mGridWebView;
private static final String EN_DASH = "\u2013";
public static void start(Context caller, String uuid) {
Intent intent = new Intent(caller, PatientChartActivity.class);
intent.putExtra("uuid", uuid);
caller.startActivity(intent);
}
@Override public void onExtendOptionsMenu(Menu menu) {
// Inflate the menu items for use in the action bar
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.chart, menu);
menu.findItem(R.id.action_edit).setOnMenuItemClickListener(
new MenuItem.OnMenuItemClickListener() {
@Override public boolean onMenuItemClick(MenuItem menuItem) {
Utils.logUserAction("edit_patient_pressed");
mController.onEditPatientPressed();
return true;
}
});
menu.findItem(R.id.action_go_to).setOnMenuItemClickListener(
new MenuItem.OnMenuItemClickListener() {
@Override public boolean onMenuItemClick(MenuItem menuItem) {
Utils.logUserAction("go_to_patient_pressed");
GoToPatientDialogFragment.newInstance()
.show(getSupportFragmentManager(), null);
return true;
}
});
MenuItem updateChart = menu.findItem(R.id.action_update_chart);
updateChart.setIcon(
new IconDrawable(this, Iconify.IconValue.fa_pencil_square_o)
.color(0xCCFFFFFF)
.sizeDp(36));
updateChart.setOnMenuItemClickListener(
new MenuItem.OnMenuItemClickListener() {
@Override public boolean onMenuItemClick(MenuItem item) {
mController.onAddObservationPressed();
return true;
}
});
boolean clinicalObservationFormEnabled = false;
boolean ebolaLabTestFormEnabled = false;
for (final Form form : mChartDataHelper.getForms()) {
MenuItem item = menu.add(form.name);
item.setOnMenuItemClickListener(
new MenuItem.OnMenuItemClickListener() {
@Override public boolean onMenuItemClick(MenuItem menuItem) {
mController.onOpenFormPressed(form.uuid);
return true;
}
}
);
if (form.uuid.equals(PatientChartController.OBSERVATION_FORM_UUID)) {
clinicalObservationFormEnabled = true;
}
if (form.uuid.equals(PatientChartController.EBOLA_LAB_TEST_FORM_UUID)) {
ebolaLabTestFormEnabled = true;
}
}
updateChart.setVisible(clinicalObservationFormEnabled);
Utils.showIf(mPcr, ebolaLabTestFormEnabled);
}
@Override public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == android.R.id.home) {
// Go back rather than reloading the activity, so that the patient list retains its
// filter state.
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override protected void onCreateImpl(Bundle savedInstanceState) {
super.onCreateImpl(savedInstanceState);
setContentView(R.layout.fragment_patient_chart);
@Nullable Bundle controllerState = null;
if (savedInstanceState != null) {
controllerState = savedInstanceState.getBundle(KEY_CONTROLLER_STATE);
}
ButterKnife.inject(this);
App.getInstance().inject(this);
mFormLoadingDialog = new ProgressDialog(this);
mFormLoadingDialog.setIcon(android.R.drawable.ic_dialog_info);
mFormLoadingDialog.setTitle(getString(R.string.retrieving_encounter_form_title));
mFormLoadingDialog.setMessage(getString(R.string.retrieving_encounter_form_message));
mFormLoadingDialog.setIndeterminate(true);
mFormLoadingDialog.setCancelable(false);
mFormSubmissionDialog = new ProgressDialog(this);
mFormSubmissionDialog.setIcon(android.R.drawable.ic_dialog_info);
mFormSubmissionDialog.setTitle(getString(R.string.submitting_encounter_form_title));
mFormSubmissionDialog.setMessage(getString(R.string.submitting_encounter_form_message));
mFormSubmissionDialog.setIndeterminate(true);
mFormSubmissionDialog.setCancelable(false);
// Remembering scroll position and applying it after the chart finished loading.
mGridWebView.setWebViewClient(new WebViewClient() {
public void onPageFinished(WebView view, String url) {
Point scrollPosition = mController.getLastScrollPosition();
if (scrollPosition != null) {
view.loadUrl("javascript:$('#grid-scroller').scrollLeft(" + scrollPosition.x + ");");
view.loadUrl("javascript:$(window).scrollTop(" + scrollPosition.y + ");");
}
}
});
mChartRenderer = new ChartRenderer(mGridWebView, getResources());
final OdkResultSender odkResultSender = new OdkResultSender() {
@Override public void sendOdkResultToServer(String patientUuid, int resultCode, Intent data) {
OdkActivityLauncher.sendOdkResultToServer(
PatientChartActivity.this, mSettings,
patientUuid, resultCode, data);
}
};
final MinimalHandler minimalHandler = new MinimalHandler() {
private final Handler mHandler = new Handler();
@Override public void post(Runnable runnable) {
mHandler.post(runnable);
}
};
mController = new PatientChartController(
mAppModel,
new EventBusWrapper(mEventBus),
mCrudEventBusProvider.get(),
new Ui(),
getIntent().getStringExtra("uuid"),
odkResultSender,
mChartDataHelper,
controllerState,
mSyncManager,
minimalHandler);
// Show the Up button in the action bar.
getActionBar().setDisplayHomeAsUpEnabled(true);
mPatientLocationView.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) {
Utils.logUserAction("location_view_pressed");
mController.showAssignLocationDialog(PatientChartActivity.this);
}
});
mPcr.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) {
mController.onAddTestResultsPressed();
}
});
initChartMenu();
}
private void initChartMenu() {
List<Chart> charts = mController.getCharts();
if (charts.size() > 1) {
final ActionBar actionBar = getActionBar();
actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
ActionBar.TabListener tabListener = new ActionBar.TabListener() {
@Override
public void onTabSelected(ActionBar.Tab tab, android.app.FragmentTransaction ft) {
mController.updatePatientObsUi(tab.getPosition());
}
@Override
public void onTabUnselected(ActionBar.Tab tab, android.app.FragmentTransaction ft) {
}
@Override
public void onTabReselected(ActionBar.Tab tab, android.app.FragmentTransaction ft) {
}
};
String[] menuArray = new String[charts.size()];
for (int i = 0; i < charts.size(); i++) {
menuArray[i] = charts.get(i).name;
actionBar.addTab(
actionBar.newTab()
.setText(charts.get(i).name)
.setTabListener(tabListener));
}
}
}
@Override protected void onStartImpl() {
super.onStartImpl();
mController.init();
}
@Override protected void onStopImpl() {
mController.suspend();
super.onStopImpl();
}
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) {
mIsFetchingXform = false;
mController.onXFormResult(requestCode, resultCode, data);
}
@Override protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBundle(KEY_CONTROLLER_STATE, mController.getState());
}
private String getFormattedPcrString(double pcrValue) {
return pcrValue >= PCR_NEGATIVE_THRESHOLD ?
getResources().getString(R.string.pcr_negative) :
String.format("%1$.1f", pcrValue);
}
private final class Ui implements PatientChartController.Ui {
@Override public void setTitle(String title) {
PatientChartActivity.this.setTitle(title);
}
// TODO/cleanup: As soon as we implement an ObsFormat formatter that displays
// a date as a count of days (Utils.dayNumberSince), we can replace this logic
// with a format defined in the profile, decide how to arrange the tiles for
// admission date, first symptoms date, pregnancy status, IV status, and Ebola
// PCR test results, and delete this method.
@Override public void updateAdmissionDateAndFirstSymptomsDateUi(
LocalDate admissionDate, LocalDate firstSymptomsDate) {
// TODO: Localize strings in this function.
int day = Utils.dayNumberSince(admissionDate, LocalDate.now());
mAdmissionDaysView.setValue(
day >= 1 ? getResources().getString(R.string.day_n, day) : "–");
day = Utils.dayNumberSince(firstSymptomsDate, LocalDate.now());
mSymptomOnsetDaysView.setValue(
day >= 1 ? getResources().getString(R.string.day_n, day) : "–");
}
// TODO/cleanup: We don't need this special logic for the Ebola PCR test results
// any more, because the two-number format with a "NEG" displayed for numbers
// greater than 39.95 can be implemented using a format configured in the profile
// (e.g. the format "{1,select,>39.95:NEG;#} / {2,select,>39.95:NEG;#}" with the
// concepts "162826,162827"). The only reason we haven't deleted this code is
// that we need to do the other tiles like Admission Date to complete the layout.
@Override public void updateEbolaPcrTestResultUi(Map<String, Obs> observations) {
// PCR
Obs pcrLObservation = observations.get(ConceptUuids.PCR_L_UUID);
Obs pcrNpObservation = observations.get(ConceptUuids.PCR_NP_UUID);
mPcr.setIconDrawable(
new IconDrawable(PatientChartActivity.this, Iconify.IconValue.fa_flask)
.color(0x00000000)
.sizeDp(36));
if ((pcrLObservation == null || pcrLObservation.valueName == null)
&& (pcrNpObservation == null || pcrNpObservation.valueName == null)) {
mPcr.setValue("–");
} else {
String pcrLString = "–";
DateTime pcrObsTime = null;
if (pcrLObservation != null && pcrLObservation.valueName != null) {
pcrObsTime = pcrLObservation.time;
try {
double pcrL = Double.parseDouble(pcrLObservation.valueName);
pcrLString = getFormattedPcrString(pcrL);
} catch (NumberFormatException e) {
LOG.w(
"Retrieved a malformed L-gene PCR value: '%1$s'.",
pcrLObservation.valueName);
pcrLString = pcrLObservation.valueName;
}
}
String pcrNpString = "–";
if (pcrNpObservation != null && pcrNpObservation.valueName != null) {
pcrObsTime = pcrNpObservation.time;
try {
double pcrNp = Double.parseDouble(pcrNpObservation.valueName);
pcrNpString = getFormattedPcrString(pcrNp);
} catch (NumberFormatException e) {
LOG.w(
"Retrieved a malformed Np-gene PCR value: '%1$s'.",
pcrNpObservation.valueName);
pcrNpString = pcrNpObservation.valueName;
}
}
mPcr.setValue(String.format("%1$s / %2$s", pcrLString, pcrNpString));
if (pcrObsTime != null) {
LocalDate today = LocalDate.now();
LocalDate obsDay = pcrObsTime.toLocalDate();
String dateText = new RelativeDateTimeFormatter().format(today, obsDay);
mPcr.setName(getResources().getString(
R.string.latest_pcr_label_with_date, dateText));
}
}
}
// TODO/cleanup: We don't need this special logic for the pregnancy and IV fields
// anymore, because it can be implemented using a format configured in the profile
// (e.g. the format "{1,yes_no,Pregnant} / {2,yes_no,IV fitted}" with the concepts
// concepts "5272,f50c9c63-3ff9-4c26-9d18-12bfc58a3d07"). The only reason we haven't
// deleted this code is that we need to do the other tiles like Admission Date to
// complete the layout.
@Override public void updatePregnancyAndIvStatusUi(Map<String, Obs> observations) {
// Pregnancy & IV status
List<String> specialLabels = new ArrayList<>();
Obs obs;
obs = observations.get(ConceptUuids.PREGNANCY_UUID);
if (obs != null && ConceptUuids.YES_UUID.equals(obs.value)) {
specialLabels.add(getString(R.string.pregnant));
}
obs = observations.get(ConceptUuids.IV_UUID);
if (obs != null && ConceptUuids.YES_UUID.equals(obs.value)) {
specialLabels.add(getString(R.string.iv_fitted));
}
mPatientPregnantOrIvView.setText(Joiner.on("\n").join(specialLabels));
}
@Override public void updatePatientConditionUi(String generalConditionUuid) {
}
@Override public void updateTilesAndGrid(
Chart chart,
Map<String, Obs> latestObservations,
List<Obs> observations,
List<Order> orders,
LocalDate admissionDate,
LocalDate firstSymptomsDate) {
mChartRenderer.render(chart, latestObservations, observations, orders,
admissionDate, firstSymptomsDate, mController);
mRootView.invalidate();
}
public void updatePatientLocationUi(LocationTree locationTree, Patient patient) {
Location location = locationTree.findByUuid(patient.locationUuid);
String locationText = location == null ? "Unknown" : location.toString(); // TODO/i18n
mPatientLocationView.setValue(locationText);
mPatientLocationView.setIconDrawable(
new IconDrawable(PatientChartActivity.this, Iconify.IconValue.fa_map_marker)
.color(0x00000000)
.sizeDp(36));
}
@Override public void updatePatientDetailsUi(Patient patient) {
// TODO: Localize everything below.
String id = Utils.valueOrDefault(patient.id, EN_DASH);
String fullName = Utils.valueOrDefault(patient.givenName, EN_DASH) + " " +
Utils.valueOrDefault(patient.familyName, EN_DASH);
List<String> labels = new ArrayList<>();
if (patient.gender == Patient.GENDER_MALE) {
labels.add("M");
} else if (patient.gender == Patient.GENDER_FEMALE) {
labels.add("F");
}
labels.add(patient.birthdate == null ? "age unknown"
: Utils.birthdateToAge(patient.birthdate, getResources())); // TODO/i18n
String sexAge = Joiner.on(", ").join(labels);
PatientChartActivity.this.setTitle(id + ". " + fullName + SEPARATOR_DOT + sexAge);
}
@Override public void showError(int errorMessageResource, Object... args) {
BigToast.show(PatientChartActivity.this, getString(errorMessageResource, args));
}
@Override public void showError(int errorMessageResource) {
BigToast.show(PatientChartActivity.this, errorMessageResource);
}
@Override public synchronized void fetchAndShowXform(
int requestCode, String formUuid, org.odk.collect.android.model.Patient patient,
Preset preset) {
if (mIsFetchingXform) return;
mIsFetchingXform = true;
OdkActivityLauncher.fetchAndShowXform(
PatientChartActivity.this, formUuid, requestCode, patient, preset);
}
@Override public void reEnableFetch() {
mIsFetchingXform = false;
}
@Override public void showFormLoadingDialog(boolean show) {
Utils.showDialogIf(mFormLoadingDialog, show);
}
@Override public void showFormSubmissionDialog(boolean show) {
Utils.showDialogIf(mFormSubmissionDialog, show);
}
@Override public void showOrderDialog(String patientUuid, Order order) {
OrderDialogFragment.newInstance(patientUuid, order)
.show(getSupportFragmentManager(), null);
}
@Override public void showObservationsDialog(ArrayList<ObsRow> observations) {
ViewObservationsDialogFragment.newInstance(observations)
.show(getSupportFragmentManager(), null);
}
@Override public void showOrderExecutionDialog(
Order order, Interval interval, List<DateTime> executionTimes) {
OrderExecutionDialogFragment.newInstance(order, interval, executionTimes)
.show(getSupportFragmentManager(), null);
}
@Override public void showEditPatientDialog(Patient patient) {
EditPatientDialogFragment.newInstance(patient)
.show(getSupportFragmentManager(), null);
}
}
}