diff --git a/.gitignore b/.gitignore index 80165643..a4e3f5d7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ local.properties .classpath .settings .project + +# OpenMRS password file. +.openmrs_password diff --git a/app/build.gradle b/app/build.gradle index 86797d56..5e7d10b1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -32,6 +32,11 @@ android { preDexLibraries = false javaMaxHeapSize = '4g' } + + // Enable multidex support. + defaultConfig { + multiDexEnabled true + } } dependencies { // Build plugins @@ -44,10 +49,9 @@ dependencies { compile project(':third_party:odkcollect') // External dependencies - compile 'com.android.support:appcompat-v7:22.2.0' - compile 'com.android.support:support-annotations:22.2.0' + compile 'com.android.support:appcompat-v7:23.1.1' + compile 'com.android.support:support-annotations:23.1.1' compile 'com.google.code.gson:gson:2.3' // JSON parser - compile 'com.google.guava:guava:18.0' // Google common libraries compile 'com.jakewharton:butterknife:5.1.2' // View injection compile 'com.mcxiaoke.volley:library:1.0.6' // HTTP framework compile 'com.joanzapata.android:android-iconify:1.0.8' // Font-based icons @@ -59,9 +63,14 @@ dependencies { compile 'com.mitchellbosecke:pebble:1.5.1' // HTML templating compile 'org.slf4j:slf4j-simple:1.7.12' // HTML templating dependency compile 'org.apache.commons:commons-lang3:3.4' + // Magic sliding panel that we use for the notes view. + compile 'com.sothree.slidinguppanel:library:3.2.1' // Testing androidTestCompile 'com.android.support.test:runner:0.3' + // Explicitly add this dep at 23.1.1, because the above entry depends on 22.2.0, and the + // discrepancy can introduce differences in behaviour between prod and test. + androidTestCompile 'com.android.support:support-annotations:23.1.1' // Espresso androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2' androidTestCompile 'com.android.support.test.espresso:espresso-web:2.2' @@ -71,6 +80,11 @@ dependencies { androidTestCompile 'com.google.dexmaker:dexmaker-mockito:1.0' androidTestCompile 'com.google.dexmaker:dexmaker:1.0' androidTestCompile 'org.mockito:mockito-core:1.9.5' + + // Multidex. + // NOTE: This is temporary only! See https://slack-files.com/T02T5LNM4-F0JQ1UDRV-716ebe431f + // for more information. + compile 'com.android.support:multidex:1.0.1' } apply plugin: 'spoon' @@ -170,8 +184,11 @@ logger.info("Default package server root URL: ${packageServerRootUrl}") logger.info("Database encryption password: ${encryptionPassword}") android { - compileSdkVersion 21 - buildToolsVersion '19.1.0' + compileSdkVersion 23 + buildToolsVersion '23.0.2' + // TODO: Port the various health checks to use HttpURLConnection instead and remove this + // dependency. + useLibrary 'org.apache.http.legacy' sourceSets.main { jniLibs.srcDir 'libs' @@ -231,6 +248,8 @@ android { exclude 'META-INF/NOTICE.txt' exclude 'META-INF/LICENSE' exclude 'META-INF/LICENSE.txt' + exclude 'META-INF/maven/com.google.guava/guava/pom.properties' + exclude 'META-INF/maven/com.google.guava/guava/pom.xml' } lintOptions { diff --git a/app/src/androidTest/java/org/projectbuendia/client/ui/chart/PatientChartControllerTest.java b/app/src/androidTest/java/org/projectbuendia/client/ui/chart/PatientChartControllerTest.java index 24cfa4bc..effbf113 100644 --- a/app/src/androidTest/java/org/projectbuendia/client/ui/chart/PatientChartControllerTest.java +++ b/app/src/androidTest/java/org/projectbuendia/client/ui/chart/PatientChartControllerTest.java @@ -107,6 +107,7 @@ public void testPatientDetailsLoaded_SetsObservationsOnUi() { verify(mMockUi).updateTilesAndGrid( null, recentObservations, allObservations, ImmutableList. of(), null, null); verify(mMockUi).updateAdmissionDateAndFirstSymptomsDateUi(null, null); + verify(mMockUi).updateWeightUi(recentObservations); verify(mMockUi).updateEbolaPcrTestResultUi(recentObservations); verify(mMockUi).updatePregnancyAndIvStatusUi(recentObservations); } diff --git a/app/src/androidTest/java/org/projectbuendia/client/ui/dialogs/EditPatientDialogFragmentTest.java b/app/src/androidTest/java/org/projectbuendia/client/ui/dialogs/EditPatientDialogFragmentTest.java index 11d63162..b73b69f9 100644 --- a/app/src/androidTest/java/org/projectbuendia/client/ui/dialogs/EditPatientDialogFragmentTest.java +++ b/app/src/androidTest/java/org/projectbuendia/client/ui/dialogs/EditPatientDialogFragmentTest.java @@ -59,7 +59,7 @@ public void testNewPatient() { // The symptom onset date should not be assigned a default value. expectVisible(viewThat( - hasAncestorThat(withId(R.id.attribute_symptoms_onset_days)), + hasAncestorThat(withId(R.id.attribute_weight)), hasText("–"))); // The admission date should be visible right after adding a patient. diff --git a/app/src/main/assets/chart.css b/app/src/main/assets/chart.css index 687ec599..79a63ed2 100644 --- a/app/src/main/assets/chart.css +++ b/app/src/main/assets/chart.css @@ -70,6 +70,15 @@ th, td { font-size: 1.9rem; padding-bottom: 3.8rem; font-weight: bold; + overflow: visible; +} +/* +HACK: freezepanes.js puts a div inside our th, which (because of the rest of our CSS) triggers +overflow: hidden. We don't want this for command buttons, because they're usually rows, so we +explicitly turn it on for the div inside "command" cells. +*/ +#grid th[scope="row"].command > div { + overflow: visible; } /* Tiles */ diff --git a/app/src/main/assets/chart.html b/app/src/main/assets/chart.html index 24379136..c041e102 100644 --- a/app/src/main/assets/chart.html +++ b/app/src/main/assets/chart.html @@ -18,7 +18,14 @@ {% set values = tile.points | values %} {% set class = values | format_values(tile.item.cssClass) %} {% set style = values | format_values(tile.item.cssStyle) %} - +
{{tile.item.label}}
{{values | format_values(tile.item.format) | line_break_html | raw}}
{{values | format_values(tile.item.captionFormat) | line_break_html | raw}}
@@ -49,7 +56,7 @@ - Observations + {{ string_resource("observations") }} {% for column in columns %} {% if prevColumn is not null and column.start != prevColumn.stop %} @@ -77,6 +84,8 @@ {% set class = summaryValue | format_values(row.item.cssClass) %} {% set style = summaryValue | format_values(row.item.cssStyle) %} - Treatment Plan + {{ string_resource("treatment_plan_title") }} {% for column in columns %} {% if prevColumn is not null and column.start != prevColumn.stop %} @@ -118,7 +135,8 @@
{{order.medication}}
-
{{order.dosage}} 
+ +
{{order.dosage}} {{order.frequency != null ? order.frequency + string_resource("times_daily") : ''}}
{% set previousActive = false %} {% set future = false %} @@ -148,7 +166,7 @@ - Add a New Treatment + {{ string_resource("treatment_add_new") }} diff --git a/app/src/main/assets/chart.js b/app/src/main/assets/chart.js index 216c5d4a..711c2088 100644 --- a/app/src/main/assets/chart.js +++ b/app/src/main/assets/chart.js @@ -1,3 +1,13 @@ +function isRowEmpty(parentNode) { + var rowEmpty = true; + $(parentNode).find('td').each(function(index, element) { + var innerHtml = element.innerHTML.trim(); + if ( innerHtml != "" ) { + rowEmpty = false; + } + }); + return rowEmpty; +} function popup(name, pairs) { // var dialog = document.getElementById('obs-dialog'); diff --git a/app/src/main/java/org/projectbuendia/client/App.java b/app/src/main/java/org/projectbuendia/client/App.java index d4f1f61d..b84f7d96 100644 --- a/app/src/main/java/org/projectbuendia/client/App.java +++ b/app/src/main/java/org/projectbuendia/client/App.java @@ -12,7 +12,9 @@ package org.projectbuendia.client; import android.app.Application; +import android.content.Context; import android.preference.PreferenceManager; +import android.support.multidex.MultiDex; import com.facebook.stetho.Stetho; @@ -85,6 +87,13 @@ public static synchronized OpenMrsConnectionDetails getConnectionDetails() { mHealthMonitor.start(); } + @Override + public void attachBaseContext(Context base) { + // Set up Multidex. + super.attachBaseContext(base); + MultiDex.install(this); + } + public void inject(Object obj) { mObjectGraph.inject(obj); } diff --git a/app/src/main/java/org/projectbuendia/client/diagnostics/BuendiaApiHealthCheck.java b/app/src/main/java/org/projectbuendia/client/diagnostics/BuendiaApiHealthCheck.java index fe7e6043..8639833e 100644 --- a/app/src/main/java/org/projectbuendia/client/diagnostics/BuendiaApiHealthCheck.java +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/BuendiaApiHealthCheck.java @@ -71,6 +71,8 @@ public class BuendiaApiHealthCheck extends HealthCheck { private Handler mHandler; private BuendiaModuleHealthCheckRunnable mRunnable; + private boolean httpUnauthorized; + BuendiaApiHealthCheck( Application application, OpenMrsConnectionDetails connectionDetails) { @@ -118,6 +120,20 @@ protected int getCheckPeriodMillis() { ? CHECK_PERIOD_MS : FAST_CHECK_PERIOD_MS; } + @Override + public boolean isApiUnavailable() { + // Prevents continued http request attempts after a 401 http error. + return httpUnauthorized; + } + + /** Clears all the issues for this health check. */ + @Override + public void clear() { + // Back to original state. + httpUnauthorized = false; + } + + private class BuendiaModuleHealthCheckRunnable implements Runnable { public final AtomicBoolean isRunning; @@ -142,32 +158,38 @@ public BuendiaModuleHealthCheckRunnable(Handler handler) { } try { - httpGet.addHeader(BasicScheme.authenticate( - new UsernamePasswordCredentials( - mConnectionDetails.getUser(), - mConnectionDetails.getPassword()), - "UTF-8", false)); - HttpResponse httpResponse = httpClient.execute(httpGet); - if (httpResponse.getStatusLine().getStatusCode() != HttpURLConnection.HTTP_OK) { - LOG.w("The OpenMRS URL '%1$s' returned unexpected error code: %2$s", - uri, httpResponse.getStatusLine().getStatusCode()); - switch (httpResponse.getStatusLine().getStatusCode()) { - case HttpURLConnection.HTTP_INTERNAL_ERROR: - reportIssue(HealthIssue.SERVER_INTERNAL_ISSUE); - break; - case HttpURLConnection.HTTP_FORBIDDEN: - case HttpURLConnection.HTTP_UNAUTHORIZED: - reportIssue(HealthIssue.SERVER_AUTHENTICATION_ISSUE); - break; - case HttpURLConnection.HTTP_NOT_FOUND: - default: - reportIssue(HealthIssue.SERVER_NOT_RESPONDING); - break; - } - if (hasIssue(HealthIssue.SERVER_HOST_UNREACHABLE)){ - resolveIssue(HealthIssue.SERVER_HOST_UNREACHABLE); - } + if (httpUnauthorized) { + reportIssue(HealthIssue.SERVER_AUTHENTICATION_ISSUE); return; + } else { + httpGet.addHeader(BasicScheme.authenticate( + new UsernamePasswordCredentials( + mConnectionDetails.getUser(), + mConnectionDetails.getPassword()), + "UTF-8", false)); + HttpResponse httpResponse = httpClient.execute(httpGet); + if (httpResponse.getStatusLine().getStatusCode() != HttpURLConnection.HTTP_OK) { + LOG.w("The OpenMRS URL '%1$s' returned unexpected error code: %2$s", + uri, httpResponse.getStatusLine().getStatusCode()); + switch (httpResponse.getStatusLine().getStatusCode()) { + case HttpURLConnection.HTTP_INTERNAL_ERROR: + reportIssue(HealthIssue.SERVER_INTERNAL_ISSUE); + break; + case HttpURLConnection.HTTP_FORBIDDEN: + case HttpURLConnection.HTTP_UNAUTHORIZED: + httpUnauthorized = true; + reportIssue(HealthIssue.SERVER_AUTHENTICATION_ISSUE); + break; + case HttpURLConnection.HTTP_NOT_FOUND: + default: + reportIssue(HealthIssue.SERVER_NOT_RESPONDING); + break; + } + if (hasIssue(HealthIssue.SERVER_HOST_UNREACHABLE)){ + resolveIssue(HealthIssue.SERVER_HOST_UNREACHABLE); + } + return; + } } } catch (UnknownHostException | IllegalArgumentException e) { LOG.w("OpenMRS server unreachable: %s", uri); diff --git a/app/src/main/java/org/projectbuendia/client/diagnostics/DiagnosticsModule.java b/app/src/main/java/org/projectbuendia/client/diagnostics/DiagnosticsModule.java index 1b1d2a07..eade3f41 100644 --- a/app/src/main/java/org/projectbuendia/client/diagnostics/DiagnosticsModule.java +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/DiagnosticsModule.java @@ -40,10 +40,10 @@ public class DiagnosticsModule { Application application, OpenMrsConnectionDetails connectionDetails, AppSettings settings) { + // TODO: restore PackageServerHealthCheck, we'll probably want that again in the future. return ImmutableSet.of( new WifiHealthCheck(application, settings), - new BuendiaApiHealthCheck(application, connectionDetails), - new PackageServerHealthCheck(application, settings)); + new BuendiaApiHealthCheck(application, connectionDetails)); } @Provides diff --git a/app/src/main/java/org/projectbuendia/client/diagnostics/HealthCheck.java b/app/src/main/java/org/projectbuendia/client/diagnostics/HealthCheck.java index 7c7e3871..ea64a12c 100644 --- a/app/src/main/java/org/projectbuendia/client/diagnostics/HealthCheck.java +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/HealthCheck.java @@ -75,7 +75,7 @@ public final void stop() { protected abstract void stopImpl(); /** Clears all the issues for this health check. */ - public final void clear() { + public void clear() { mActiveIssues.clear(); } diff --git a/app/src/main/java/org/projectbuendia/client/diagnostics/PackageServerHealthCheck.java b/app/src/main/java/org/projectbuendia/client/diagnostics/PackageServerHealthCheck.java index b36e31b1..cb8c8bf9 100644 --- a/app/src/main/java/org/projectbuendia/client/diagnostics/PackageServerHealthCheck.java +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/PackageServerHealthCheck.java @@ -29,6 +29,9 @@ import java.net.UnknownHostException; /** A {@link HealthCheck} that checks whether the package server is up and running. */ +// TODO: restore PackageServerHealthCheck, we'll probably want it again once the packages are +// working on the server. +@SuppressWarnings("unused") public class PackageServerHealthCheck extends HealthCheck { private static final Logger LOG = Logger.create(); diff --git a/app/src/main/java/org/projectbuendia/client/events/data/EncounterAddFailedEvent.java b/app/src/main/java/org/projectbuendia/client/events/data/EncounterAddFailedEvent.java index 546d4583..6da7bcd7 100644 --- a/app/src/main/java/org/projectbuendia/client/events/data/EncounterAddFailedEvent.java +++ b/app/src/main/java/org/projectbuendia/client/events/data/EncounterAddFailedEvent.java @@ -12,6 +12,7 @@ package org.projectbuendia.client.events.data; import org.projectbuendia.client.events.DefaultCrudEventBus; +import org.projectbuendia.client.models.Encounter; /** * An event bus event indicating that adding an encounter failed. @@ -21,6 +22,7 @@ public class EncounterAddFailedEvent { public final Reason reason; public final Exception exception; + public final Encounter encounter; public enum Reason { UNKNOWN, @@ -33,7 +35,8 @@ public enum Reason { FAILED_TO_FETCH_SAVED_OBSERVATION } - public EncounterAddFailedEvent(Reason reason, Exception exception) { + public EncounterAddFailedEvent(Encounter encounter, Reason reason, Exception exception) { + this.encounter = encounter; this.reason = reason; this.exception = exception; } diff --git a/app/src/main/java/org/projectbuendia/client/json/JsonEncounter.java b/app/src/main/java/org/projectbuendia/client/json/JsonEncounter.java index 8e75de1a..4e043b4e 100644 --- a/app/src/main/java/org/projectbuendia/client/json/JsonEncounter.java +++ b/app/src/main/java/org/projectbuendia/client/json/JsonEncounter.java @@ -13,15 +13,13 @@ import org.joda.time.DateTime; -import java.util.Map; +import java.util.List; /** JSON representation of an OpenMRS Encounter; call Serializers.registerTo before use. */ public class JsonEncounter { public String patient_uuid; public String uuid; public DateTime timestamp; - public String enterer_id; - /** A {conceptUuid: value} map, where value can be a number, string, or answer UUID. */ - public Map observations; + public List observations; public String[] order_uuids; // orders executed during this encounter } diff --git a/app/src/main/java/org/projectbuendia/client/json/JsonPatient.java b/app/src/main/java/org/projectbuendia/client/json/JsonPatient.java index feb54edf..a36c932b 100644 --- a/app/src/main/java/org/projectbuendia/client/json/JsonPatient.java +++ b/app/src/main/java/org/projectbuendia/client/json/JsonPatient.java @@ -11,8 +11,6 @@ package org.projectbuendia.client.json; -import com.google.common.base.MoreObjects; - import org.joda.time.LocalDate; import java.io.Serializable; @@ -34,17 +32,4 @@ public class JsonPatient implements Serializable { public JsonPatient() { } - - @Override public String toString() { - return MoreObjects.toStringHelper(this) - .add("uuid", uuid) - .add("voided", voided) - .add("id", id) - .add("given_name", given_name) - .add("family_name", family_name) - .add("sex", sex) - .add("birthdate", birthdate.toString()) - .add("assigned_location", assigned_location) - .toString(); - } } diff --git a/app/src/main/java/org/projectbuendia/client/models/AppModel.java b/app/src/main/java/org/projectbuendia/client/models/AppModel.java index 5eb7a738..331913f1 100644 --- a/app/src/main/java/org/projectbuendia/client/models/AppModel.java +++ b/app/src/main/java/org/projectbuendia/client/models/AppModel.java @@ -36,6 +36,8 @@ import org.projectbuendia.client.utils.Logger; import org.projectbuendia.client.utils.Utils; +import javax.annotation.Nullable; + import de.greenrobot.event.NoSubscriberEvent; /** @@ -89,10 +91,6 @@ public DateTime getLastFullSyncTime() { } public void VoidObservation(CrudEventBus bus, VoidObs voidObs) { - String conditions = Contracts.Observations.UUID + " = ?"; - ContentValues values = new ContentValues(); - values.put(Contracts.Observations.VOIDED,1); - mContentResolver.update(Contracts.Observations.CONTENT_URI, values, conditions, new String[]{voidObs.Uuid}); mTaskFactory.voidObsTask(bus, voidObs).execute(); } @@ -194,10 +192,10 @@ public void deleteOrder(CrudEventBus bus, String orderUuid) { * Asynchronously adds an encounter that records an order as executed, posting a * {@link ItemCreatedEvent} when complete. */ - public void addOrderExecutedEncounter(CrudEventBus bus, Patient patient, String orderUuid) { + public void addOrderExecutedEncounter( + CrudEventBus bus, Patient patient, String orderUuid, @Nullable String userUuid) { addEncounter(bus, patient, new Encounter( - patient.uuid, null, DateTime.now(), null, new String[]{orderUuid} - )); + patient.uuid, null, DateTime.now(), null, new String[]{orderUuid}, userUuid)); } /** @@ -216,10 +214,6 @@ public void addEncounter(CrudEventBus bus, Patient patient, Encounter encounter) mTaskFactory = taskFactory; } - public void voidObservation(CrudEventBus bus, VoidObs obs) { - mTaskFactory.newVoidObsAsyncTask(obs, bus).execute(); - } - /** A subscriber that handles error events posted to {@link CrudEventBus}es. */ private static class CrudEventBusCleanupSubscriber implements CleanupSubscriber { diff --git a/app/src/main/java/org/projectbuendia/client/models/ChartItem.java b/app/src/main/java/org/projectbuendia/client/models/ChartItem.java index 8a09bb9c..0d9afc6f 100644 --- a/app/src/main/java/org/projectbuendia/client/models/ChartItem.java +++ b/app/src/main/java/org/projectbuendia/client/models/ChartItem.java @@ -16,6 +16,7 @@ import java.text.Format; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import javax.annotation.Nonnull; @@ -26,7 +27,7 @@ public class ChartItem { public final @Nonnull String label; public final @Nonnull String type; public final boolean required; - public final @Nonnull String[] conceptUuids; + public final @Nonnull List conceptUuids; public final @Nonnull List conceptIds; public final @Nullable Format format; public final @Nullable Format captionFormat; @@ -35,7 +36,7 @@ public class ChartItem { public final @Nonnull String script; public ChartItem(@Nullable String label, @Nullable String type, boolean required, - @Nullable String[] conceptUuids, @Nullable String format, + @Nullable List conceptUuids, @Nullable String format, @Nullable String captionFormat, @Nullable String cssClass, @Nullable String cssStyle, @Nullable String script) { this(label, type, required, conceptUuids, @@ -44,13 +45,15 @@ public ChartItem(@Nullable String label, @Nullable String type, boolean required } public ChartItem(@Nullable String label, @Nullable String type, boolean required, - @Nullable String[] conceptUuids, @Nullable Format format, + @Nullable List conceptUuids, @Nullable Format format, @Nullable Format captionFormat, @Nullable Format cssClass, @Nullable Format cssStyle, @Nullable String script) { this.label = label == null ? "" : label; this.type = type == null ? "" : type; this.required = required; - this.conceptUuids = conceptUuids == null ? new String[0] : conceptUuids; + this.conceptUuids = conceptUuids == null + ? Collections.emptyList() + : conceptUuids; this.format = format; this.captionFormat = captionFormat; this.cssClass = cssClass; diff --git a/app/src/main/java/org/projectbuendia/client/models/ConceptUuids.java b/app/src/main/java/org/projectbuendia/client/models/ConceptUuids.java index f05950bb..3cac6d21 100644 --- a/app/src/main/java/org/projectbuendia/client/models/ConceptUuids.java +++ b/app/src/main/java/org/projectbuendia/client/models/ConceptUuids.java @@ -11,7 +11,11 @@ package org.projectbuendia.client.models; +import android.content.ContentResolver; + import org.projectbuendia.client.resolvables.ResStatus; +import java.util.HashMap; +import java.util.HashSet; /** * Defines hardcoded concept ids expected to exist on the OpenMRS server. Over time, values in this @@ -19,18 +23,146 @@ * client. */ public class ConceptUuids { - public static final String CONSCIOUS_STATE_UUID = "162643AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - public static final String FLUIDS_UUID = "e96f504e-229a-4933-84d1-358abbd687e3"; public static final String GENERAL_CONDITION_UUID = "a3657203-cfed-44b8-8e3f-960f8d4cf3b3"; - public static final String HYDRATION_UUID = "162653AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; public static final String IV_UUID = "f50c9c63-3ff9-4c26-9d18-12bfc58a3d07"; public static final String PREGNANCY_UUID = "5272AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + // Persistent fields + public static final HashSet PERSISTENT_FIELDS = new HashSet<>(); + + static { + // TODO: Determine from the profile which fields should be persistent (on a per form basis), instead of here + + // These are case insensitive + + // Most of the time if you have a multi_select field used in a tile + // you will want to list it as a persistent field; otherwise when you submit any + // form containing that field and leave it blank, it will set it back to null. + + // Diagnoses + PERSISTENT_FIELDS.add("5272AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("f50c9c63-3ff9-4c26-9d18-12bfc58a3d07"); + PERSISTENT_FIELDS.add("1777000619AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000620AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777138868AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777000124AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777139457AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777145443AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777122604AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777148353AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777119356AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777000199AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777113224AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777149609AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000622AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777114431AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777149579AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777149716AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000700AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000701AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777114100AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777113809AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777121392AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777121009AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777114190AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("121375AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777159950AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777160155AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000645AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000646AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000647AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777005334AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777139438AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777120755AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777139407AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777139437AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000626AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777115247AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777143597AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777147267AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000627AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777136292AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777119905AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777112287AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000702AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777137693AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777150555AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777000134AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777124654AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777124650AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777000140AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777118731AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777148705AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777126707AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000628AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000629AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777146166AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000630AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000631AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000632AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777152209AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777115886AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777111633AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777127990AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777000892AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("124957AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777127394AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000633AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777114675AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777114702AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777123084AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777117152AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000635AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777115753AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777156204AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777111873AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777161355AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777111904AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000636AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777111946AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777124033AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777111967AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777117825AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777124028AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777138571AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + // Complications + PERSISTENT_FIELDS.add("777138061AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777117326AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("113054AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777118876AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("122983AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777147241AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777138099AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("150915AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000637AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777130473AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000638AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000639AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000640AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("777135595AAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000641AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000642AAAAAAAAAAAAAAAAAAAAAAAAAA"); + // Admission tiles + PERSISTENT_FIELDS.add("1777000589AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000590AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000591AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000592AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000593AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000594AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000595AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1777000601AAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1659AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1396AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + PERSISTENT_FIELDS.add("1401AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + + } + public static final String PULSE_UUID = "5087AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - public static final String RESPIRATION_UUID = "5242AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; public static final String PCR_NP_UUID = "162826AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; public static final String PCR_L_UUID = "162827AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; public static final String FIRST_SYMPTOM_DATE_UUID = "1730AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; public static final String ADMISSION_DATE_UUID = "162622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + public static final String WEIGHT_UUID = "5089AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; public static final String GENERAL_CONDITION_WELL_UUID = "1855AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; public static final String GENERAL_CONDITION_UNWELL_UUID = @@ -65,37 +197,6 @@ public class ConceptUuids { /** UUID for the (question) concept for the temperature. */ public static final String TEMPERATURE_UUID = "5088AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - /** UUID for the (question) concept for the weight. */ - public static final String WEIGHT_UUID = "5089AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - - /** UUID for the (question) concept for Diarrhea. */ - public static final String DIARRHEA_UUID = "1aa247f3-2d83-4efc-94bc-123b1a71b19f"; - - /** UUID for the (question) concept for (any) bleeding. */ - public static final String BLEEDING_UUID = "147241AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - - // TODO: This may break localization. - /** Group name for Bleeding Sites section. */ - public static final String BLEEDING_SITES_NAME = "Bleeding site"; - - /** UUID for the (question) concept for Vomiting. */ - public static final String VOMITING_UUID = "405ad95d-f6e1-4023-a459-28cffdb055c5"; - - /** UUID for the (question) concept for pain. */ - public static final String PAIN_UUID = "f75da5de-404c-42d0-b484-b69a4896e093"; - - /** UUID for the (question) concept for best conscious state (AVPU). */ - public static final String RESPONSIVENESS_UUID = "162643AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - - /** UUID for the (question) concept for (severe) weakness. */ - public static final String WEAKNESS_UUID = "5226AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - - /** UUID for the (question) concept for appetite. */ - public static final String APPETITE_UUID = "777000003AAAAAAAAAAAAAAAAAAAAAAAAAAA"; - - /** UUID for the (question) concept for oedema. */ - public static final String OEDEMA_UUID = "460AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - /** UUID for the (question) concept for the notes field. */ public static final String NOTES_UUID = "162169AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; @@ -111,9 +212,6 @@ public class ConceptUuids { /** UUID for the (answer) concept Severe. */ public static final String SEVERE_UUID = "1500AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - /** UUID for the (question) concept for Mobility. */ - public static final String MOBILITY_UUID = "30143d74-f654-4427-bb92-685f68f92c15"; - /** UUID for the (answer) concept of Yes. */ public static final String YES_UUID = "1065AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; diff --git a/app/src/main/java/org/projectbuendia/client/models/Encounter.java b/app/src/main/java/org/projectbuendia/client/models/Encounter.java index 2a05ab19..9c108d56 100644 --- a/app/src/main/java/org/projectbuendia/client/models/Encounter.java +++ b/app/src/main/java/org/projectbuendia/client/models/Encounter.java @@ -18,14 +18,13 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import org.projectbuendia.client.net.Server; import org.projectbuendia.client.json.JsonEncounter; +import org.projectbuendia.client.json.JsonObservation; +import org.projectbuendia.client.net.Server; import org.projectbuendia.client.providers.Contracts.Observations; -import org.projectbuendia.client.utils.Logger; import java.util.ArrayList; import java.util.List; -import java.util.Map; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; @@ -37,10 +36,6 @@ * * https://wiki.openmrs.org/display/docs/Encounters+and+observations" * - *

- *

NOTE: Because of lack of typing info from the server, {@link Encounter} attempts to - * determine the most appropriate type, but this typing is not guaranteed to succeed; also, - * currently only DATE and UUID (coded) types are supported. */ @Immutable public class Encounter extends Base { @@ -50,7 +45,7 @@ public class Encounter extends Base { public final DateTime timestamp; public final Observation[] observations; public final String[] orderUuids; - private static final Logger LOG = Logger.create(); + public final @Nullable String userUuid; /** * Creates a new Encounter for the given patient. @@ -65,32 +60,34 @@ public Encounter( @Nullable String encounterUuid, DateTime timestamp, Observation[] observations, - String[] orderUuids) { + String[] orderUuids, + @Nullable String userUuid) { id = encounterUuid; this.patientUuid = patientUuid; this.encounterUuid = id; this.timestamp = timestamp; this.observations = observations == null ? new Observation[] {} : observations; this.orderUuids = orderUuids == null ? new String[] {} : orderUuids; + this.userUuid = userUuid; } /** * Creates an instance of {@link Encounter} from a network * {@link JsonEncounter} object and corresponding patient UUID. */ + // TODO: JsonEncounter includes a patient_uuid field, use that instead of passing it separately. public static Encounter fromJson(String patientUuid, JsonEncounter encounter) { - List observations = new ArrayList(); + List observations = new ArrayList<>(); if (encounter.observations != null) { - for (Map.Entry observation : encounter.observations.entrySet()) { + for (JsonObservation observation : encounter.observations) { observations.add(new Observation( - (String) observation.getKey(), - (String) observation.getValue(), - Observation.estimatedTypeFor((String) observation.getValue()) + observation.concept_uuid, + observation.value )); } } return new Encounter(patientUuid, encounter.uuid, encounter.timestamp, - observations.toArray(new Observation[observations.size()]), encounter.order_uuids); + observations.toArray(new Observation[observations.size()]), encounter.order_uuids, null); } /** Serializes this into a {@link JSONObject}. */ @@ -101,12 +98,8 @@ public JSONObject toJson() throws JSONException { if (observations.length > 0) { JSONArray observationsJson = new JSONArray(); for (Observation obs : observations) { - JSONObject observationJson = new JSONObject(); - observationJson.put(Server.OBSERVATION_QUESTION_UUID, obs.conceptUuid); - String valueKey = obs.type == Observation.Type.DATE ? - Server.OBSERVATION_ANSWER_DATE : Server.OBSERVATION_ANSWER_UUID; - observationJson.put(valueKey, obs.value); - observationsJson.put(observationJson); + + observationsJson.put(obs.toJson()); } json.put(Server.ENCOUNTER_OBSERVATIONS_KEY, observationsJson); } @@ -117,6 +110,7 @@ public JSONObject toJson() throws JSONException { } json.put(Server.ENCOUNTER_ORDER_UUIDS, orderUuidsJson); } + json.put(Server.ENCOUNTER_USER_UUID, userUuid); return json; } @@ -153,31 +147,23 @@ public ContentValues[] toContentValuesArray() { public static final class Observation { public final String conceptUuid; public final String value; - public final Type type; - - /** Data type of the observation. */ - public enum Type { - DATE, - NON_DATE - } - public Observation(String conceptUuid, String value, Type type) { + public Observation(String conceptUuid, String value) { this.conceptUuid = conceptUuid; this.value = value; - this.type = type; } - /** - * Produces a best guess for the type of a given value, since the server doesn't give us - * typing information. - */ - public static Type estimatedTypeFor(String value) { + public JSONObject toJson() { + JSONObject observationJson = new JSONObject(); try { - new DateTime(Long.parseLong(value)); - return Type.DATE; - } catch (Exception e) { - return Type.NON_DATE; + observationJson.put(Server.OBSERVATION_QUESTION_UUID, conceptUuid); + observationJson.put(Server.OBSERVATION_ANSWER, value); + } catch (JSONException jsonException) { + // Should never occur, JSONException is only thrown for a null key or an invalid + // numeric value, neither of which will occur in this API. + throw new RuntimeException(jsonException); } + return observationJson; } } @@ -208,11 +194,11 @@ public Loader(String patientUuid) { String value = cursor.getString(cursor.getColumnIndex(Observations.VALUE)); observations.add(new Observation( cursor.getString(cursor.getColumnIndex(Observations.CONCEPT_UUID)), - value, Observation.estimatedTypeFor(value) + value )); } return new Encounter(mPatientUuid, encounterUuid, new DateTime(millis), - observations.toArray(new Observation[observations.size()]), null); + observations.toArray(new Observation[observations.size()]), null, null); } } } diff --git a/app/src/main/java/org/projectbuendia/client/models/ObsRow.java b/app/src/main/java/org/projectbuendia/client/models/ObsRow.java index 062f231f..776645c5 100644 --- a/app/src/main/java/org/projectbuendia/client/models/ObsRow.java +++ b/app/src/main/java/org/projectbuendia/client/models/ObsRow.java @@ -28,7 +28,7 @@ public ObsRow(String Uuid, DateTimeFormatter fmtDay = DateTimeFormat.forPattern("dd MMM yyyy"); day = fmtDay.print(new DateTime(Millis)); - DateTimeFormatter fmtTime = DateTimeFormat.forPattern("HH:MM"); + DateTimeFormatter fmtTime = DateTimeFormat.forPattern("HH:mm"); time = fmtTime.print(new DateTime(Millis)); uuid = Uuid; diff --git a/app/src/main/java/org/projectbuendia/client/models/Order.java b/app/src/main/java/org/projectbuendia/client/models/Order.java index 99ac3e13..e9063541 100644 --- a/app/src/main/java/org/projectbuendia/client/models/Order.java +++ b/app/src/main/java/org/projectbuendia/client/models/Order.java @@ -23,6 +23,7 @@ import org.projectbuendia.client.providers.Contracts; import org.projectbuendia.client.utils.Utils; +import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -135,6 +136,19 @@ public Interval getInterval() { return result; } + @Override + public boolean equals(Object obj) { + if (!(obj instanceof Order)) { + return false; + } + Order o = (Order) obj; + return Objects.equals(uuid, o.uuid) && + Objects.equals(patientUuid, o.patientUuid) && + Objects.equals(instructions, o.instructions) && + Objects.equals(start, o.start) && + Objects.equals(stop, o.stop); + } + public JSONObject toJson() throws JSONException { JSONObject json = new JSONObject(); json.put("patient_uuid", patientUuid); diff --git a/app/src/main/java/org/projectbuendia/client/models/PatientDelta.java b/app/src/main/java/org/projectbuendia/client/models/PatientDelta.java index 0cf8b7a7..50b839c9 100644 --- a/app/src/main/java/org/projectbuendia/client/models/PatientDelta.java +++ b/app/src/main/java/org/projectbuendia/client/models/PatientDelta.java @@ -15,13 +15,12 @@ import com.google.common.base.Optional; -import org.joda.time.DateTime; import org.joda.time.LocalDate; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import org.projectbuendia.client.net.Server; import org.projectbuendia.client.json.JsonPatient; +import org.projectbuendia.client.net.Server; import org.projectbuendia.client.providers.Contracts; import org.projectbuendia.client.utils.Logger; import org.projectbuendia.client.utils.Utils; @@ -103,24 +102,24 @@ public boolean toJson(JSONObject json) { JSONArray observations = new JSONArray(); if (admissionDate.isPresent()) { - JSONObject observation = new JSONObject(); - observation.put(Server.OBSERVATION_QUESTION_UUID, ConceptUuids.ADMISSION_DATE_UUID); - observation.put( - Server.OBSERVATION_ANSWER_DATE, - Utils.toString(admissionDate.get())); - observations.put(observation); + JSONObject jsonObs = + new Encounter.Observation( + ConceptUuids.ADMISSION_DATE_UUID, + Utils.toString(admissionDate.get())) + .toJson(); + + observations.put(jsonObs); } if (firstSymptomDate.isPresent()) { - JSONObject observation = new JSONObject(); - observation.put(Server.OBSERVATION_QUESTION_UUID, ConceptUuids.FIRST_SYMPTOM_DATE_UUID); - observation.put( - Server.OBSERVATION_ANSWER_DATE, - Utils.toString(firstSymptomDate.get())); - observations.put(observation); - } - if (observations != null) { - json.put(Server.ENCOUNTER_OBSERVATIONS_KEY, observations); + JSONObject jsonObs = + new Encounter.Observation( + ConceptUuids.FIRST_SYMPTOM_DATE_UUID, + Utils.toString(firstSymptomDate.get())) + .toJson(); + + observations.put(jsonObs); } + json.put(Server.ENCOUNTER_OBSERVATIONS_KEY, observations); if (assignedLocationUuid.isPresent()) { json.put( @@ -141,8 +140,4 @@ private static JSONObject getLocationObject(String assignedLocationUuid) throws location.put("uuid", assignedLocationUuid); return location; } - - private static long getTimestamp(DateTime dateTime) { - return dateTime.toInstant().getMillis()/1000; - } } diff --git a/app/src/main/java/org/projectbuendia/client/models/tasks/AddEncounterTask.java b/app/src/main/java/org/projectbuendia/client/models/tasks/AddEncounterTask.java index 66a48658..0503d076 100644 --- a/app/src/main/java/org/projectbuendia/client/models/tasks/AddEncounterTask.java +++ b/app/src/main/java/org/projectbuendia/client/models/tasks/AddEncounterTask.java @@ -90,7 +90,8 @@ public AddEncounterTask( try { jsonEncounter = future.get(); } catch (InterruptedException e) { - return new EncounterAddFailedEvent(EncounterAddFailedEvent.Reason.INTERRUPTED, e); + return new EncounterAddFailedEvent( + mEncounter, EncounterAddFailedEvent.Reason.INTERRUPTED, e); } catch (ExecutionException e) { LOG.e(e, "Server error while adding encounter"); @@ -106,7 +107,8 @@ public AddEncounterTask( } LOG.e("Error response: %s", ((VolleyError) e.getCause()).networkResponse); - return new EncounterAddFailedEvent(reason, (VolleyError) e.getCause()); + return new EncounterAddFailedEvent( + mEncounter, reason, (VolleyError) e.getCause()); } if (jsonEncounter.uuid == null) { @@ -114,10 +116,16 @@ public AddEncounterTask( "Although the server reported an encounter successfully added, it did not " + "return a UUID for that encounter. This indicates a server error."); - return new EncounterAddFailedEvent( - EncounterAddFailedEvent.Reason.FAILED_TO_SAVE_ON_SERVER, null /*exception*/); + return new EncounterAddFailedEvent(mEncounter, + EncounterAddFailedEvent.Reason.FAILED_TO_SAVE_ON_SERVER, null /*exception*/); } + // TODO: the encounter database saving code here doesn't correctly attribute observations to + // the user that created them, despite the fact that this data is sent from the server. + // This will be remedied on the next sync. + // Instead of adding a workaround here, we should unify the code that deals with + // observations as part of encounters and the code that deals with observations as entities + // that get synced. Encounter encounter = Encounter.fromJson(mPatient.uuid, jsonEncounter); ContentValues[] values = encounter.toContentValuesArray(); if (values.length > 0) { @@ -126,9 +134,9 @@ public AddEncounterTask( if (inserted != values.length) { LOG.w("Inserted %d observations for encounter. Expected: %d", inserted, encounter.observations.length); - return new EncounterAddFailedEvent( - EncounterAddFailedEvent.Reason.INVALID_NUMBER_OF_OBSERVATIONS_SAVED, - null /*exception*/); + return new EncounterAddFailedEvent(mEncounter, + EncounterAddFailedEvent.Reason.INVALID_NUMBER_OF_OBSERVATIONS_SAVED, + null /*exception*/); } } else { LOG.w("Encounter was sent to the server but contained no observations."); @@ -151,8 +159,8 @@ public AddEncounterTask( "Although an encounter add ostensibly succeeded, no UUID was set for the newly-" + "added encounter. This indicates a programming error."); - mBus.post(new EncounterAddFailedEvent( - EncounterAddFailedEvent.Reason.UNKNOWN, null /*exception*/)); + mBus.post(new EncounterAddFailedEvent(mEncounter, + EncounterAddFailedEvent.Reason.UNKNOWN, null /*exception*/)); return; } @@ -179,9 +187,9 @@ public void onEventMainThread(ItemFetchedEvent event) { } public void onEventMainThread(ItemFetchFailedEvent event) { - mBus.post(new EncounterAddFailedEvent( - EncounterAddFailedEvent.Reason.FAILED_TO_FETCH_SAVED_OBSERVATION, - new Exception(event.error))); + mBus.post(new EncounterAddFailedEvent(mEncounter, + EncounterAddFailedEvent.Reason.FAILED_TO_FETCH_SAVED_OBSERVATION, + new Exception(event.error))); mBus.unregister(this); } } diff --git a/app/src/main/java/org/projectbuendia/client/models/tasks/TaskFactory.java b/app/src/main/java/org/projectbuendia/client/models/tasks/TaskFactory.java index 257eaf4b..ade88cad 100644 --- a/app/src/main/java/org/projectbuendia/client/models/tasks/TaskFactory.java +++ b/app/src/main/java/org/projectbuendia/client/models/tasks/TaskFactory.java @@ -58,7 +58,7 @@ public DownloadSinglePatientTask newDownloadSinglePatientTask( public VoidObsTask voidObsTask(CrudEventBus bus, VoidObs voidObs) { return new VoidObsTask( - this, mLoaderSet, mServer, mContentResolver, voidObs, bus); + mServer, mContentResolver, voidObs, bus); } /** Creates a new {@link UpdatePatientTask}. */ @@ -84,7 +84,7 @@ public SaveOrderTask newSaveOrderTask(Order order, CrudEventBus bus) { public VoidObsTask newVoidObsAsyncTask( VoidObs obs, CrudEventBus bus) { return new VoidObsTask( - this, mLoaderSet, mServer, mContentResolver, obs, bus); + mServer, mContentResolver, obs, bus); } /** Creates a new {@link DeleteOrderTask}. */ diff --git a/app/src/main/java/org/projectbuendia/client/models/tasks/VoidObsTask.java b/app/src/main/java/org/projectbuendia/client/models/tasks/VoidObsTask.java index b1dd8c66..a77defc5 100644 --- a/app/src/main/java/org/projectbuendia/client/models/tasks/VoidObsTask.java +++ b/app/src/main/java/org/projectbuendia/client/models/tasks/VoidObsTask.java @@ -2,42 +2,30 @@ import android.content.ContentResolver; import android.os.AsyncTask; + +import com.android.volley.toolbox.RequestFuture; + import org.projectbuendia.client.events.CrudEventBus; -import org.projectbuendia.client.json.JsonVoidObs; +import org.projectbuendia.client.events.data.ItemDeletedEvent; +import org.projectbuendia.client.events.data.VoidObsFailedEvent; import org.projectbuendia.client.models.VoidObs; -import org.projectbuendia.client.models.LoaderSet; import org.projectbuendia.client.net.Server; import org.projectbuendia.client.providers.Contracts; -import org.projectbuendia.client.utils.Logger; - -import android.net.Uri; -import org.projectbuendia.client.events.data.VoidObsFailedEvent; -import com.android.volley.toolbox.RequestFuture; import java.util.concurrent.ExecutionException; -import javax.xml.transform.ErrorListener; - public class VoidObsTask extends AsyncTask { - private static final Logger LOG = Logger.create(); - - private final TaskFactory mTaskFactory; - private final LoaderSet mLoaderSet; private final Server mServer; private final ContentResolver mContentResolver; private final VoidObs mVoidObs; private final CrudEventBus mBus; public VoidObsTask( - TaskFactory taskFactory, - LoaderSet loaderSet, Server server, ContentResolver contentResolver, VoidObs voidObs, CrudEventBus bus) { - mTaskFactory = taskFactory; - mLoaderSet = loaderSet; mServer = server; mContentResolver = contentResolver; mVoidObs = voidObs; @@ -46,10 +34,35 @@ public VoidObsTask( @Override protected VoidObsFailedEvent doInBackground(Void... params) { - RequestFuture future = RequestFuture.newFuture(); - mServer.deleteObservation(mVoidObs.Uuid, future); + RequestFuture future = RequestFuture.newFuture(); + mServer.deleteObservation(mVoidObs.Uuid, future, future); + + try { + future.get(); + } catch (InterruptedException e) { + return new VoidObsFailedEvent(VoidObsFailedEvent.Reason.INTERRUPTED, e); + } catch (ExecutionException e) { + return new VoidObsFailedEvent(VoidObsFailedEvent.Reason.UNKNOWN_SERVER_ERROR, e); + } + + mContentResolver.delete( + Contracts.Observations.CONTENT_URI, + "uuid = ?", + new String[]{mVoidObs.Uuid} + ); return null; + } + + @Override + protected void onPostExecute(VoidObsFailedEvent event) { + // If an error occurred, post the error event. + if (event != null) { + mBus.post(event); + return; + } + // Otherwise, broadcast that the deletion succeeded. + mBus.post(new ItemDeletedEvent(mVoidObs.Uuid)); } } diff --git a/app/src/main/java/org/projectbuendia/client/net/OpenMrsErrorListener.java b/app/src/main/java/org/projectbuendia/client/net/OpenMrsErrorListener.java index 8c821395..b83c4151 100644 --- a/app/src/main/java/org/projectbuendia/client/net/OpenMrsErrorListener.java +++ b/app/src/main/java/org/projectbuendia/client/net/OpenMrsErrorListener.java @@ -46,7 +46,8 @@ public class OpenMrsErrorListener implements ErrorListener { */ @Override public void onErrorResponse(VolleyError error) { - displayErrorMessage(parseResponse(error)); + // TODO: re-enable this for dev, and for all users once its polished up a bit. + //displayErrorMessage(parseResponse(error)); } diff --git a/app/src/main/java/org/projectbuendia/client/net/OpenMrsServer.java b/app/src/main/java/org/projectbuendia/client/net/OpenMrsServer.java index 93dbcabe..dfaa33f9 100644 --- a/app/src/main/java/org/projectbuendia/client/net/OpenMrsServer.java +++ b/app/src/main/java/org/projectbuendia/client/net/OpenMrsServer.java @@ -12,11 +12,13 @@ package org.projectbuendia.client.net; import android.support.annotation.Nullable; +import android.text.TextUtils; import com.android.volley.DefaultRetryPolicy; import com.android.volley.Request; import com.android.volley.Response; import com.android.volley.VolleyError; +import com.android.volley.toolbox.StringRequest; import com.google.common.base.Joiner; import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; @@ -152,7 +154,8 @@ public static Response.ErrorListener wrapErrorListener( return new OpenMrsErrorListener() { @Override public void onErrorResponse(VolleyError error) { String message = parseResponse(error); - displayErrorMessage(message); + // TODO: re-enable this for dev, and for all users once its polished up a bit. + //displayErrorMessage(message); errorListener.onErrorResponse(new VolleyError(message, error)); } }; @@ -262,19 +265,26 @@ private JsonUser userFromJson(JSONObject object) throws JSONException { mConnectionDetails.getVolley().addToRequestQueue(request); } - @Override public void deleteObservation(String Uuid, - final Response.ErrorListener errorListener) { - OpenMrsJsonRequest request = mRequestFactory.newOpenMrsJsonRequest( - mConnectionDetails, - Request.Method.DELETE, - mConnectionDetails.getRestApiUrl() + "/obs/" + Uuid, - null, - new Response.Listener() { - @Override public void onResponse(JSONObject response) { - LOG.i("Voided observation"); - } - }, - wrapErrorListener(errorListener)); + @Override + public void deleteObservation( + String Uuid, + final Response.Listener successListener, + final Response.ErrorListener errorListener) { + StringRequest request = new OpenMrsStringRequest( + Request.Method.DELETE, + "/obs/" + Uuid, + new Response.Listener() { + @Override + public void onResponse(String response) { + if (!TextUtils.isEmpty(response)) { + // TODO: Didn't expect this, pass it through to the client. + LOG.w("Delete observation response returned a non-blank response."); + } else { + successListener.onResponse(null); + } + } + }, + wrapErrorListener(errorListener)); request.setRetryPolicy(new DefaultRetryPolicy(Common.REQUEST_TIMEOUT_MS_SHORT, 1, 1f)); mConnectionDetails.getVolley().addToRequestQueue(request); } diff --git a/app/src/main/java/org/projectbuendia/client/net/OpenMrsStringRequest.java b/app/src/main/java/org/projectbuendia/client/net/OpenMrsStringRequest.java new file mode 100644 index 00000000..ab4539c0 --- /dev/null +++ b/app/src/main/java/org/projectbuendia/client/net/OpenMrsStringRequest.java @@ -0,0 +1,42 @@ +package org.projectbuendia.client.net; + +import com.android.volley.AuthFailureError; +import com.android.volley.Response; +import com.android.volley.toolbox.StringRequest; + +import org.projectbuendia.client.App; + +import java.util.HashMap; +import java.util.Map; + +/** + * A request that targets the OpenMRS REST API (as opposed to the Buendia API) and returns a string. + */ +public class OpenMrsStringRequest extends StringRequest { + + public OpenMrsStringRequest( + int method, String urlSuffix, Response.Listener listener, + Response.ErrorListener errorListener) { + super( + method, + App.getConnectionDetails().getRestApiUrl() + urlSuffix, + listener, + errorListener); + } + + public OpenMrsStringRequest( + String urlSuffix, Response.Listener listener, + Response.ErrorListener errorListener) { + super(App.getConnectionDetails().getRestApiUrl() + urlSuffix, listener, errorListener); + } + + @Override + public Map getHeaders() throws AuthFailureError { + HashMap params = new HashMap<>(); + OpenMrsConnectionDetails.addAuthHeader( + App.getConnectionDetails().getUser(), + App.getConnectionDetails().getPassword(), + params); + return params; + } +} diff --git a/app/src/main/java/org/projectbuendia/client/net/Server.java b/app/src/main/java/org/projectbuendia/client/net/Server.java index 4c1e6353..2b6da8d4 100644 --- a/app/src/main/java/org/projectbuendia/client/net/Server.java +++ b/app/src/main/java/org/projectbuendia/client/net/Server.java @@ -42,9 +42,9 @@ public interface Server { public static final String ENCOUNTER_OBSERVATIONS_KEY = "observations"; public static final String ENCOUNTER_TIMESTAMP = "timestamp"; public static final String ENCOUNTER_ORDER_UUIDS = "order_uuids"; + public static final String ENCOUNTER_USER_UUID = "enterer_uuid"; public static final String OBSERVATION_QUESTION_UUID = "question_uuid"; - public static final String OBSERVATION_ANSWER_DATE = "answer_date"; - public static final String OBSERVATION_ANSWER_UUID = "answer_uuid"; + public static final String OBSERVATION_ANSWER = "answer_value"; /** * Logs an event by sending a dummy request to the server. (The server logs @@ -93,6 +93,7 @@ void addEncounter( */ void deleteObservation( String Uuid, + Response.Listener successListener, Response.ErrorListener errorListener); /** diff --git a/app/src/main/java/org/projectbuendia/client/sync/ChartDataHelper.java b/app/src/main/java/org/projectbuendia/client/sync/ChartDataHelper.java index 3743b092..9512e0c2 100644 --- a/app/src/main/java/org/projectbuendia/client/sync/ChartDataHelper.java +++ b/app/src/main/java/org/projectbuendia/client/sync/ChartDataHelper.java @@ -35,6 +35,7 @@ import org.projectbuendia.client.utils.Utils; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -65,7 +66,7 @@ public class ChartDataHelper { private static final Logger LOG = Logger.create(); /** When non-null, sConceptNames and sConceptTypes contain valid data for this locale. */ - private static Object sLoadingLock = new Object(); + private static final Object sLoadingLock = new Object(); private static String sLoadedLocale; private static Map sConceptNames; @@ -80,6 +81,13 @@ public static void invalidateLoadedConceptData() { sLoadedLocale = null; } + /** Returns the loaded concept name for the specified concept UUID */ + public @Nullable String getConceptNameByUuid(String uuid) { + if(sConceptNames == null) return ""; + + return sConceptNames.get(uuid); + } + /** Loads concept names and types from the app db into HashMaps in memory. */ public void loadConceptData(String locale) { synchronized (sLoadingLock) { @@ -333,7 +341,8 @@ public List getCharts(String uuid) { ChartItem item = new ChartItem(label, Utils.getString(c, ChartItems.TYPE), Utils.getLong(c, ChartItems.REQUIRED, 0L) > 0L, - Utils.getString(c, ChartItems.CONCEPT_UUIDS, "").split(","), + Arrays.asList + (Utils.getString(c, ChartItems.CONCEPT_UUIDS, "").split(",")), Utils.getString(c, ChartItems.FORMAT), Utils.getString(c, ChartItems.CAPTION_FORMAT), Utils.getString(c, ChartItems.CSS_CLASS), diff --git a/app/src/main/java/org/projectbuendia/client/sync/controllers/ObservationsSyncPhaseRunnable.java b/app/src/main/java/org/projectbuendia/client/sync/controllers/ObservationsSyncPhaseRunnable.java index 6ce15511..2615feb0 100644 --- a/app/src/main/java/org/projectbuendia/client/sync/controllers/ObservationsSyncPhaseRunnable.java +++ b/app/src/main/java/org/projectbuendia/client/sync/controllers/ObservationsSyncPhaseRunnable.java @@ -13,13 +13,10 @@ package org.projectbuendia.client.sync.controllers; -import android.content.ContentProviderClient; import android.content.ContentProviderOperation; -import android.content.ContentResolver; import android.content.ContentValues; import android.content.SyncResult; import android.net.Uri; -import android.os.RemoteException; import org.projectbuendia.client.json.JsonObservation; import org.projectbuendia.client.providers.Contracts; @@ -56,7 +53,12 @@ protected ArrayList getUpdateOps( } else { ops.add(ContentProviderOperation.newInsert(Observations.CONTENT_URI) .withValues(getObsValuesToInsert(observation)).build()); + // HACK: Delete any temporary observation with a matching Patient UUID and Concept + // UUID and null UUID. The proper way to do this is by supplying a JSON encounter + // on the server when an Xform is populated. + ops.add(createDeleteTemporaryOp(observation)); inserts++; + } } LOG.d("Observations processed! Inserts: %d, Deletes: %d", inserts, deletes); @@ -65,6 +67,17 @@ protected ArrayList getUpdateOps( return ops; } + private static ContentProviderOperation createDeleteTemporaryOp(JsonObservation observation) { + return ContentProviderOperation + .newDelete(Observations.CONTENT_URI) + .withSelection( + Observations.PATIENT_UUID + " =? AND " + + Observations.CONCEPT_UUID + " =? AND " + + Observations.UUID + " IS NULL", + new String[] { observation.patient_uuid, observation.concept_uuid }) + .build(); + } + /** Converts an encounter data response into appropriate inserts in the encounters table. */ public static ContentValues getObsValuesToInsert( JsonObservation observation) { @@ -79,15 +92,4 @@ public static ContentValues getObsValuesToInsert( return cvs; } - - @Override - protected void afterSyncFinished( - ContentResolver contentResolver, - SyncResult syncResult, - ContentProviderClient providerClient) throws RemoteException { - // Remove all temporary observations now we have the real ones - providerClient.delete(Observations.CONTENT_URI, - Observations.UUID + " IS NULL", - new String[0]); - } } diff --git a/app/src/main/java/org/projectbuendia/client/ui/BigToast.java b/app/src/main/java/org/projectbuendia/client/ui/BigToast.java index c93017f6..8d2d1dd2 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/BigToast.java +++ b/app/src/main/java/org/projectbuendia/client/ui/BigToast.java @@ -12,6 +12,7 @@ package org.projectbuendia.client.ui; import android.content.Context; +import android.support.annotation.StringRes; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; @@ -25,7 +26,7 @@ public final class BigToast { * @param context the Application or Activity context to use * @param messageResource the message to display */ - public static void show(Context context, int messageResource) { + public static void show(Context context, @StringRes int messageResource) { show(context, context.getResources().getString(messageResource)); } @@ -38,6 +39,7 @@ public static void show(Context context, String message) { Toast toast = Toast.makeText(context, message, Toast.LENGTH_LONG); LinearLayout layout = (LinearLayout) toast.getView(); TextView view = (TextView) layout.getChildAt(0); + //noinspection deprecation: The new method was only introduced in API 23. view.setTextAppearance(context, R.style.text_large_white); toast.show(); } diff --git a/app/src/main/java/org/projectbuendia/client/ui/OdkActivityLauncher.java b/app/src/main/java/org/projectbuendia/client/ui/OdkActivityLauncher.java index 1d241c35..f310635a 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/OdkActivityLauncher.java +++ b/app/src/main/java/org/projectbuendia/client/ui/OdkActivityLauncher.java @@ -25,6 +25,8 @@ import com.android.volley.VolleyError; import com.google.common.base.Charsets; import com.google.common.base.Joiner; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.instance.TreeElement; @@ -44,7 +46,10 @@ import org.projectbuendia.client.events.SubmitXformFailedEvent; import org.projectbuendia.client.events.SubmitXformSucceededEvent; import org.projectbuendia.client.exception.ValidationException; +import org.projectbuendia.client.json.JsonEncounter; import org.projectbuendia.client.json.JsonUser; +import org.projectbuendia.client.json.Serializers; +import org.projectbuendia.client.models.Encounter; import org.projectbuendia.client.net.OdkDatabase; import org.projectbuendia.client.net.OdkXformSyncTask; import org.projectbuendia.client.net.OpenMrsXformIndexEntry; @@ -87,17 +92,19 @@ public class OdkActivityLauncher { */ public static void fetchAndCacheAllXforms() { new OpenMrsXformsConnection(App.getConnectionDetails()).listXforms( - new Response.Listener>() { - @Override public void onResponse(final List response) { - for (OpenMrsXformIndexEntry formEntry : response) { - fetchAndCacheXForm(formEntry); + new Response.Listener>() { + @Override + public void onResponse(final List response) { + for (OpenMrsXformIndexEntry formEntry : response) { + fetchAndCacheXForm(formEntry); + } } - } - }, new Response.ErrorListener() { - @Override public void onErrorResponse(VolleyError error) { - handleFetchError(error); - } - }); + }, new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + handleFetchError(error); + } + }); } /** @@ -107,7 +114,7 @@ public static void fetchAndCacheAllXforms() { */ public static void fetchAndCacheXForm(OpenMrsXformIndexEntry formEntry) { new OdkXformSyncTask(null).fetchAndAddXFormToDb(formEntry.uuid, - formEntry.makeFileForForm()); + formEntry.makeFileForForm()); } /** @@ -135,23 +142,25 @@ public static void fetchAndShowXform( } new OpenMrsXformsConnection(App.getConnectionDetails()).listXforms( - new Response.Listener>() { - @Override public void onResponse(final List response) { - if (response.isEmpty()) { - LOG.i("No forms found"); - EventBus.getDefault().post(new FetchXformFailedEvent( - FetchXformFailedEvent.Reason.NO_FORMS_FOUND)); - return; + new Response.Listener>() { + @Override + public void onResponse(final List response) { + if (response.isEmpty()) { + LOG.i("No forms found"); + EventBus.getDefault().post(new FetchXformFailedEvent( + FetchXformFailedEvent.Reason.NO_FORMS_FOUND)); + return; + } + showForm(callingActivity, requestCode, patient, fields, findUuid(response, + uuidToShow)); } - showForm(callingActivity, requestCode, patient, fields, findUuid(response, - uuidToShow)); - } - }, new Response.ErrorListener() { - @Override public void onErrorResponse(VolleyError error) { - LOG.e(error, "Fetching xform list from server failed. "); - handleFetchError(error); - } - }); + }, new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + LOG.e(error, "Fetching xform list from server failed. "); + handleFetchError(error); + } + }); } /** @@ -310,24 +319,34 @@ public static void sendOdkResultToServer( throw new ValidationException("No id to delete for after upload: " + uri); } - // Temporary code for messing about with xform instance, reading values. - byte[] fileBytes = FileUtils.getFileAsBytes(new File(filePath)); - - // get the root of the saved and template instances - final TreeElement savedRoot = XFormParser.restoreDataModel(fileBytes, null).getRoot(); - final String xml = readFromPath(filePath); if(!validateXml(xml)) { throw new ValidationException("Xml form is not valid for uri: " + uri); } + byte[] fileBytes = FileUtils.getFileAsBytes(new File(filePath)); + // get the root of the saved and template instances + final TreeElement savedRoot = XFormParser.restoreDataModel(fileBytes, null).getRoot(); + sendFormToServer(patientUuid, xml, new Response.Listener() { @Override public void onResponse(JSONObject response) { LOG.i("Created new encounter successfully on server" + response.toString()); // Only locally cache new observations, not new patients. if (patientUuid != null) { - updateObservationCache(patientUuid, savedRoot, context.getContentResolver()); + if (!updateObservationCache( + response, + context.getContentResolver())) { + LOG.w("Couldn't update observations from Xforms response, " + + "updating from local form instance instead."); + updateObservationCacheFromXformData( + patientUuid, + savedRoot, + context.getContentResolver()); + } + } else { + LOG.i("Didn't update observations cache, encounter didn't have a " + + "patient UUID."); } if (!settings.getKeepFormInstancesLocally()) { deleteLocalFormInstances(formIdToDelete); @@ -447,7 +466,7 @@ private static Cursor getCursorAtRightPosition(final Context context, final Uri if (instanceCursor.getCount() != 1) { LOG.e("The form that we tried to load did not exist: " + uri); EventBus.getDefault().post( - new SubmitXformFailedEvent(SubmitXformFailedEvent.Reason.CLIENT_ERROR)); + new SubmitXformFailedEvent(SubmitXformFailedEvent.Reason.CLIENT_ERROR)); return null; } instanceCursor.moveToFirst(); @@ -546,16 +565,36 @@ private static String readFromPath(String path) { } return sb.toString(); } catch (IOException e) { - LOG.e(e, format("Failed to read xml form into a String. FilePath= ", path)); + LOG.e(e, format("Failed to read xml form into a String. FilePath= %s", path)); return null; } } /** - * Caches the observation changes locally for a given patient. + * Updates observations locally from a JSON response. Returns {@code true} if any observations + * were added. */ - private static void updateObservationCache(String patientUuid, TreeElement savedRoot, - ContentResolver resolver) { + private static boolean updateObservationCache(JSONObject response, ContentResolver resolver) { + // TODO: don't parse this here or do a roundtrip text --> JSON --> text --> GSON conversion + GsonBuilder gsonBuilder = new GsonBuilder(); + Serializers.registerTo(gsonBuilder); + Gson gson = gsonBuilder.create(); + JsonEncounter jsonEncounter = gson.fromJson(response.toString(), JsonEncounter.class); + Encounter encounter = Encounter.fromJson(jsonEncounter.patient_uuid, jsonEncounter); + ContentValues[] values = encounter.toContentValuesArray(); + if (values.length > 0) { + resolver.bulkInsert(Contracts.Observations.CONTENT_URI, values); + } + return values.length > 0; + } + + /** + * Updates observations locally from an Xforms XML document. Use this when observations need to + * be updated locally, and haven't been sent to a server yet. + * TODO: remove this once the server reliably sends Encounter data back with Xform submission. + */ + private static void updateObservationCacheFromXformData( + String patientUuid, TreeElement savedRoot, ContentResolver resolver) { ContentValues common = new ContentValues(); // It's critical that UUID is {@code null} for temporary observations, so we make it // explicit here. See {@link Contracts.Observations.UUID} for details. @@ -581,7 +620,7 @@ private static void updateObservationCache(String patientUuid, TreeElement saved } resolver.bulkInsert(Contracts.Observations.CONTENT_URI, - toInsert.toArray(new ContentValues[toInsert.size()])); + toInsert.toArray(new ContentValues[toInsert.size()])); } /** Get a map from XForm ids to UUIDs from our local concept database. */ @@ -591,14 +630,14 @@ private static Map mapFormConceptIdToUuid(Set xformConc HashMap xformIdToUuid = new HashMap<>(); Cursor cursor = resolver.query(Contracts.Concepts.CONTENT_URI, - new String[] {Contracts.Concepts.UUID, Contracts.Concepts.XFORM_ID}, - Contracts.Concepts.XFORM_ID + " IN (" + inClause + ")", - null, null); + new String[] {Contracts.Concepts.UUID, Contracts.Concepts.XFORM_ID}, + Contracts.Concepts.XFORM_ID + " IN (" + inClause + ")", + null, null); try { while (cursor.moveToNext()) { xformIdToUuid.put(Utils.getString(cursor, Contracts.Concepts.XFORM_ID), - Utils.getString(cursor, Contracts.Concepts.UUID)); + Utils.getString(cursor, Contracts.Concepts.UUID)); } } finally { cursor.close(); @@ -616,8 +655,8 @@ private static Map mapFormConceptIdToUuid(Set xformConc * @param xformConceptIdsAccumulator the set to store the form concept ids found */ private static List getAnsweredObservations(ContentValues common, - TreeElement savedRoot, - Set xformConceptIdsAccumulator) { + TreeElement savedRoot, + Set xformConceptIdsAccumulator) { List answeredObservations = new ArrayList<>(); for (int i = 0; i < savedRoot.getNumChildren(); i++) { TreeElement group = savedRoot.getChildAt(i); @@ -669,7 +708,7 @@ private static DateTime getEncounterAnswerDateTime(TreeElement root) { } TreeElement encounterDatetime = - encounter.getChild("encounter.encounter_datetime", 0); + encounter.getChild("encounter.encounter_datetime", 0); if (encounterDatetime == null) { LOG.e("No encounter date time found in instance"); return null; @@ -677,7 +716,7 @@ private static DateTime getEncounterAnswerDateTime(TreeElement root) { IAnswerData dateTimeValue = encounterDatetime.getValue(); try { - return ISODateTimeFormat.dateTime().parseDateTime((String) dateTimeValue.getValue()); + return ISODateTimeFormat.dateTime().parseDateTime((String) dateTimeValue.getValue()); } catch (IllegalArgumentException e) { LOG.e("Could not parse datetime" + dateTimeValue.getValue()); return null; @@ -693,7 +732,7 @@ private static Integer getConceptId(Set accumulator, String encodedConc } private static boolean mapIdToUuid( - Map idToUuid, ContentValues values, String key) { + Map idToUuid, ContentValues values, String key) { String id = (String) values.get(key); String uuid = idToUuid.get(id); if (uuid == null) { @@ -716,4 +755,6 @@ private static Integer getConceptId(String encodedConcept) { return null; } } -} + + + } diff --git a/app/src/main/java/org/projectbuendia/client/ui/chart/ChartRenderer.java b/app/src/main/java/org/projectbuendia/client/ui/chart/ChartRenderer.java index 03c6efc0..e4535fb5 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/chart/ChartRenderer.java +++ b/app/src/main/java/org/projectbuendia/client/ui/chart/ChartRenderer.java @@ -8,12 +8,9 @@ import com.google.common.collect.Lists; import com.mitchellbosecke.pebble.PebbleEngine; -import org.joda.time.Chronology; import org.joda.time.DateTime; -import org.joda.time.DateTimeZone; import org.joda.time.LocalDate; import org.joda.time.ReadableInstant; -import org.joda.time.chrono.ISOChronology; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -31,11 +28,11 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.SortedMap; import java.util.SortedSet; @@ -51,7 +48,6 @@ public class ChartRenderer { Resources mResources; // resources used for localizing the rendering private List mLastRenderedObs; // last set of observations rendered private List mLastRenderedOrders; // last set of orders rendered - private Chronology chronology = ISOChronology.getInstance(DateTimeZone.getDefault()); private String lastChart = ""; public interface GridJsInterface { @@ -85,8 +81,10 @@ public void render(Chart chart, Map latestObservations, mView.loadUrl("file:///android_asset/no_chart.html"); return; } - if ((observations.equals(mLastRenderedObs) && orders.equals(mLastRenderedOrders)) - && (lastChart.equals(chart.name))){ + + if (observations.equals(mLastRenderedObs) + && orders.equals(mLastRenderedOrders) + && Objects.equals(lastChart, chart.name)) { return; // nothing has changed; no need to render again } lastChart = chart.name; @@ -105,15 +103,13 @@ public void render(Chart chart, Map latestObservations, admissionDate, firstSymptomsDate).getHtml(); mView.loadDataWithBaseURL("file:///android_asset/", html, "text/html; charset=utf-8", "utf-8", null); - mView.setWebContentsDebuggingEnabled(true); + WebView.setWebContentsDebuggingEnabled(true); mLastRenderedObs = observations; mLastRenderedOrders = orders; } class GridHtmlGenerator { - List mTileConceptUuids; - List mGridConceptUuids; List mOrders; DateTime mNow; Column mNowColumn; @@ -138,16 +134,16 @@ class GridHtmlGenerator { for (ChartSection tileGroup : chart.tileGroups) { List tileRow = new ArrayList<>(); for (ChartItem item : tileGroup.items) { - ObsPoint[] points = new ObsPoint[item.conceptUuids.length]; + ObsPoint[] points = new ObsPoint[item.conceptUuids.size()]; for (int i = 0; i < points.length; i++) { - Obs obs = latestObservations.get(item.conceptUuids[i]); + Obs obs = latestObservations.get(item.conceptUuids.get(i)); if (obs != null) { points[i] = obs.getObsPoint(); } } tileRow.add(new Tile(item, points)); if (!item.script.trim().isEmpty()) { - mConceptsToDump.addAll(Arrays.asList(item.conceptUuids)); + mConceptsToDump.addAll(item.conceptUuids); } } mTileRows.add(tileRow); @@ -156,9 +152,9 @@ class GridHtmlGenerator { for (ChartItem item : section.items) { Row row = new Row(item); mRows.add(row); - mRowsByUuid.put(item.conceptUuids[0], row); + mRowsByUuid.put(item.conceptUuids.get(0), row); if (!item.script.trim().isEmpty()) { - mConceptsToDump.addAll(Arrays.asList(item.conceptUuids)); + mConceptsToDump.addAll(item.conceptUuids); } } } diff --git a/app/src/main/java/org/projectbuendia/client/ui/chart/ObsFormat.java b/app/src/main/java/org/projectbuendia/client/ui/chart/ObsFormat.java index 756cb039..a1a953fc 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/chart/ObsFormat.java +++ b/app/src/main/java/org/projectbuendia/client/ui/chart/ObsFormat.java @@ -6,6 +6,9 @@ import org.apache.commons.lang3.text.FormatFactory; import org.joda.time.DateTime; import org.joda.time.LocalDate; +import org.projectbuendia.client.App; +import org.projectbuendia.client.R; +import org.projectbuendia.client.models.ConceptUuids; import org.projectbuendia.client.models.ObsPoint; import org.projectbuendia.client.models.ObsValue; import org.projectbuendia.client.utils.Utils; @@ -51,6 +54,7 @@ public class ObsFormat extends Format { private static final Map> FORMAT_CLASSES = new HashMap<>(); static { + FORMAT_CLASSES.put("yes_no_unknown", ObsYesNoUnknownFormat.class); FORMAT_CLASSES.put("yes_no", ObsYesNoFormat.class); FORMAT_CLASSES.put("abbr", ObsAbbrFormat.class); FORMAT_CLASSES.put("name", ObsNameFormat.class); @@ -86,6 +90,11 @@ public ObsFormat(String pattern, @Nullable ObsFormat rootObsFormat) { } mPattern = pattern; // Allow plain numeric formats like "#0.00" as a shorthand for "{1,number,#0.00}". + // TODO: This breaks format strings for CSS that contain colours specified in hex or dec + // e.g. color:#ccc or color:rgb(204,204,204) as it considers them to be numerical formats + // We need a good way to detect this without breaking other nested formats that + // do contain numerical formating and we want to support e.g. {1,select,<10:#.00 kg;#.0 kg} + // For now, workaround is just to use named colours rather than hex / rgb if (!pattern.contains("{") && (pattern.contains("#") || pattern.contains("0"))) { try { new DecimalFormat(pattern); // check if it's a valid numeric format @@ -222,6 +231,37 @@ public ObsYesNoFormat(String pattern) { } } + /** "yes_no" format for values of any type. Typical use: {1,yes_no,Present;Not present} */ + class ObsYesNoUnknownFormat extends ObsOutputFormat { + String mYesText; + String mNoText; + String mUnknownText; + String mNullText; + + public ObsYesNoUnknownFormat(String pattern) { + String[] parts = pattern.split(";"); + mYesText = parts.length >= 1 ? parts[0] : ""; + mNoText = parts.length >= 2 ? parts[1] : ""; + mUnknownText = parts.length >= 3 ? parts[2] : App.getInstance().getResources().getString(R.string.unknown); + mNullText = parts.length >= 4 ? parts[3] : EN_DASH; + } + + @Override public String formatObsValue(@Nullable ObsValue value) { + if (value == null || value.uuid == null) return mNullText; + switch (value.uuid) { + case ConceptUuids.YES_UUID: + return mYesText; + case ConceptUuids.NO_UUID: + return mNoText; + case ConceptUuids.UNKNOWN_UUID: + return mUnknownText; + default: + throw new IllegalArgumentException( + "Expected YES/NO/UNKNOWN, got concept " + value.uuid); + } + } + } + /** "abbr" format for coded values (UUIDs). Typical use: {1,abbr} */ class ObsAbbrFormat extends ObsOutputFormat { public static final int MAX_ABBR_CHARS = 3; @@ -236,8 +276,10 @@ public ObsAbbrFormat(String pattern) { } int abbrevLength = name.indexOf('.'); if (abbrevLength >= 1 && abbrevLength <= MAX_ABBR_CHARS) { return name.substring(0, abbrevLength); - } else { + } else if (name.length() > MAX_ABBR_CHARS) { return name.substring(0, MAX_ABBR_CHARS) + ELLIPSIS; + } else { + return name; } } } diff --git a/app/src/main/java/org/projectbuendia/client/ui/chart/PatientChartActivity.java b/app/src/main/java/org/projectbuendia/client/ui/chart/PatientChartActivity.java index 3888f8ab..e55f755e 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/chart/PatientChartActivity.java +++ b/app/src/main/java/org/projectbuendia/client/ui/chart/PatientChartActivity.java @@ -18,18 +18,25 @@ import android.graphics.Point; import android.os.Bundle; import android.os.Handler; +import android.text.Editable; +import android.text.TextWatcher; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.inputmethod.InputMethodManager; import android.webkit.WebView; import android.webkit.WebViewClient; +import android.widget.EditText; +import android.widget.ListView; import android.widget.TextView; import com.google.common.base.Joiner; import com.joanzapata.android.iconify.IconDrawable; import com.joanzapata.android.iconify.Iconify; +import com.sothree.slidinguppanel.SlidingUpPanelLayout; import org.joda.time.DateTime; import org.joda.time.Interval; @@ -91,6 +98,8 @@ public final class PatientChartActivity extends BaseLoggedInActivity { private static final String KEY_CONTROLLER_STATE = "controllerState"; private static final String SEPARATOR_DOT = "\u00a0\u00a0\u00b7\u00a0\u00a0"; + private static final float PANEL_HEIGHT_FRAC = 0.6f; + private static final String BOTTOM_SHEET_CONCEPT_UUID = ConceptUuids.NOTES_UUID; private PatientChartController mController; private boolean mIsFetchingXform = false; @@ -104,13 +113,20 @@ public final class PatientChartActivity extends BaseLoggedInActivity { @Inject SyncManager mSyncManager; @Inject ChartDataHelper mChartDataHelper; @Inject AppSettings mSettings; - @InjectView(R.id.patient_chart_root) ViewGroup mRootView; + @InjectView(R.id.patient_chart_root) SlidingUpPanelLayout 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_weight) PatientAttributeView mWeightView; @InjectView(R.id.attribute_pcr) PatientAttributeView mPcr; @InjectView(R.id.patient_chart_pregnant) TextView mPatientPregnantOrIvView; @InjectView(R.id.chart_webview) WebView mGridWebView; + @InjectView(R.id.slide_up_notes_panel) View mSlideUpNotesPanel; + @InjectView(R.id.notes_panel_list) ListView mNotesList; + @InjectView(R.id.notes_panel_text_entry) EditText mAddNoteEntryText; + @InjectView(R.id.notes_panel_btn_save) View mAddNoteButton; + @InjectView(R.id.notes_panel_submit_spinner) View mAddNoteWaitingSpinner; + @InjectView(R.id.patient_chart_container) View mPatientChartContainer; + @InjectView(R.id.notes_panel_title) TextView mNotesPanelTitle; private static final String EN_DASH = "\u2013"; @@ -135,6 +151,7 @@ public static void start(Context caller, String uuid) { } }); + /* menu.findItem(R.id.action_go_to).setOnMenuItemClickListener( new MenuItem.OnMenuItemClickListener() { @@ -145,6 +162,7 @@ public static void start(Context caller, String uuid) { return true; } }); + */ MenuItem updateChart = menu.findItem(R.id.action_update_chart); updateChart.setIcon( @@ -194,6 +212,16 @@ public static void start(Context caller, String uuid) { return super.onOptionsItemSelected(item); } + @Override + public void onBackPressed() { + // If the notes view is open, collapse it before navigating back up to the parent activity. + if (mRootView.getPanelState() == SlidingUpPanelLayout.PanelState.EXPANDED) { + mRootView.setPanelState(SlidingUpPanelLayout.PanelState.COLLAPSED); + } else { + super.onBackPressed(); + } + } + @Override protected void onCreateImpl(Bundle savedInstanceState) { super.onCreateImpl(savedInstanceState); setContentView(R.layout.fragment_patient_chart); @@ -268,12 +296,146 @@ public void onPageFinished(WebView view, String url) { } }); mPcr.setOnClickListener(new View.OnClickListener() { - @Override public void onClick(View view) { + @Override + public void onClick(View view) { mController.onAddTestResultsPressed(); } }); initChartMenu(); + + // Notes panel + mNotesPanelTitle.setText(mChartDataHelper.getConceptNameByUuid(BOTTOM_SHEET_CONCEPT_UUID)); + + setNotesPanelMaxHeightToFracOfParent(PANEL_HEIGHT_FRAC); + + mRootView.setPanelSlideListener(new SlidingUpPanelLayout.SimplePanelSlideListener() { + // true if the chart height has been shrunk so that the notes panel doesn't obscure it. + public boolean mChartHeightShrunk; + + @Override + public void onPanelSlide(View panel, float slideOffset) { + // If the panel has only just started to close, then set the chart height to fill + // the available space + if (slideOffset < 1 && mChartHeightShrunk) { + mChartHeightShrunk = false; + setChartHeight(ViewGroup.LayoutParams.MATCH_PARENT); + } + } + + @Override + public void onPanelExpanded(View panel) { + // Once the panel opens, shrink the chart height so that it's possible to scroll + // all the way to the bottom. + mChartHeightShrunk = true; + // The collapsed panel height is excluded from the measure, so we can exclude it + // from the padding when the notes panel is expanded. + int chartHeight = mRootView.getHeight() - mSlideUpNotesPanel.getHeight(); + setChartHeight(chartHeight); + } + + @Override + public void onPanelCollapsed(View panel) { + // Dismiss the IME once the panel is collapsed. + View view = getCurrentFocus(); + if (view != null) { + InputMethodManager imm = + (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + } + }); + // Set up an adapter for the notes list, and register callbacks with the LoaderManager + // so that the list updates automatically. + PatientObservationsListAdapter adapter = new PatientObservationsListAdapter(this); + mNotesList.setAdapter(adapter); + getLoaderManager().initLoader(0, null, + new PatientObservationsListAdapter.ObservationsListLoaderCallbacks( + this, + getIntent().getStringExtra("uuid"), + BOTTOM_SHEET_CONCEPT_UUID, + adapter)); + + mNotesList.setEmptyView(findViewById(R.id.notes_panel_list_empty)); + mAddNoteEntryText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + mAddNoteButton.setEnabled(s.length() > 0); + } + }); + // Trigger the text changed listener. + mAddNoteEntryText.setText(""); + setNoteSubmissionState(false); + + mAddNoteButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mController.addNote(mAddNoteEntryText.getText().toString()); + // Lock out the text box and the button. + setNoteSubmissionState(true); + } + }); + } + + /** + * Set the margin_top to a percentage of the height of the root view. Here's why: + * - If we set a layout_weight on the notes panel, we can ensure that the notes panel + * takes up that fraction of the height of its parent. + * - When the keyboard is displayed, however, the panel height adjusts to (weight * + * new parent height). + * - For small values of layout_weight (e.g. < 0.5) this makes the notes panel noticeably + * larger when the keyboard is expanded. + * - So we compute the height at a time when we know the keyboard isn't showing, and assign + * (weight * parent height) to the margin, so that much of the underlying view will always + * be visible. + * + * @param panelHeightFrac the height to which the notes panel should be set at. + */ + private void setNotesPanelMaxHeightToFracOfParent(final float panelHeightFrac) { + mRootView.getViewTreeObserver().addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + mRootView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + int panelHeight = (int) (mRootView.getHeight() * panelHeightFrac); + int marginHeight = mRootView.getHeight() - panelHeight; + ViewGroup.MarginLayoutParams lp = + (ViewGroup.MarginLayoutParams) mSlideUpNotesPanel.getLayoutParams(); + lp.setMargins(0, marginHeight, 0, 0); + mSlideUpNotesPanel.setLayoutParams(lp); + } + }); + } + + private void setChartHeight(int height) { + ViewGroup.LayoutParams lp = mPatientChartContainer.getLayoutParams(); + lp.height = height; + // Re-set the chart height to trigger a measure. + mPatientChartContainer.setLayoutParams(lp); + } + + private void setNoteSubmissionState(boolean isSubmitting) { + if (isSubmitting) { + // Replace the "Submit" button with a spinner + mAddNoteButton.setVisibility(View.INVISIBLE); + mAddNoteWaitingSpinner.setVisibility(View.VISIBLE); + // Disable text entry. + mAddNoteEntryText.setEnabled(false); + } else { + mAddNoteButton.setVisibility(View.VISIBLE); + mAddNoteWaitingSpinner.setVisibility(View.INVISIBLE); + // Enable text entry. + mAddNoteEntryText.setEnabled(true); + } } private void initChartMenu() { @@ -348,10 +510,23 @@ private final class Ui implements PatientChartController.Ui { // 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 >= 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: This is specific to Nutrition + // Having the top row be defined in the profile would be better + @Override public void updateWeightUi(Map observations) { + Obs weightObs = observations.get(ConceptUuids.WEIGHT_UUID); + if (weightObs == null || weightObs.valueName == null) { + mWeightView.setValue("–"); + } else { + mWeightView.setValue(weightObs.valueName); + } } // TODO/cleanup: We don't need this special logic for the Ebola PCR test results @@ -467,12 +642,13 @@ public void updatePatientLocationUi(LocationTree locationTree, Patient patient) List labels = new ArrayList<>(); if (patient.gender == Patient.GENDER_MALE) { - labels.add("M"); + labels.add("M"); // TODO: i18n } else if (patient.gender == Patient.GENDER_FEMALE) { - labels.add("F"); + labels.add("F"); // TODO: i18n } - labels.add(patient.birthdate == null ? "age unknown" - : Utils.birthdateToAge(patient.birthdate, getResources())); // TODO/i18n + labels.add(patient.birthdate == null + ? getResources().getString(R.string.age_unknown) + : Utils.birthdateToAge(patient.birthdate, getResources())); String sexAge = Joiner.on(", ").join(labels); PatientChartActivity.this.setTitle(id + ". " + fullName + SEPARATOR_DOT + sexAge); } @@ -517,6 +693,21 @@ public void updatePatientLocationUi(LocationTree locationTree, Patient patient) .show(getSupportFragmentManager(), null); } + @Override + public void indicateNoteSubmitted() { + setNoteSubmissionState(false); + mAddNoteEntryText.setText(""); + //TODO: scroll to bottom to show the newly added note. + } + + @Override + public void indicateNoteSubmissionFailed() { + setNoteSubmissionState(false); + BigToast.show( + PatientChartActivity.this, + R.string.error_failed_to_submit_note); + } + @Override public void showOrderExecutionDialog( Order order, Interval interval, List executionTimes) { OrderExecutionDialogFragment.newInstance(order, interval, executionTimes) diff --git a/app/src/main/java/org/projectbuendia/client/ui/chart/PatientChartController.java b/app/src/main/java/org/projectbuendia/client/ui/chart/PatientChartController.java index 3da03e12..200534b7 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/chart/PatientChartController.java +++ b/app/src/main/java/org/projectbuendia/client/ui/chart/PatientChartController.java @@ -38,9 +38,12 @@ import org.projectbuendia.client.events.actions.VoidObservationsRequestEvent; import org.projectbuendia.client.events.data.AppLocationTreeFetchedEvent; import org.projectbuendia.client.events.data.EncounterAddFailedEvent; +import org.projectbuendia.client.events.data.ItemCreatedEvent; import org.projectbuendia.client.events.data.ItemDeletedEvent; import org.projectbuendia.client.events.data.ItemFetchedEvent; +import org.projectbuendia.client.events.data.OrderDeleteFailedEvent; import org.projectbuendia.client.events.data.PatientUpdateFailedEvent; +import org.projectbuendia.client.events.data.VoidObsFailedEvent; import org.projectbuendia.client.events.sync.SyncSucceededEvent; import org.projectbuendia.client.json.JsonUser; import org.projectbuendia.client.models.AppModel; @@ -57,6 +60,7 @@ import org.projectbuendia.client.models.VoidObs; import org.projectbuendia.client.sync.ChartDataHelper; import org.projectbuendia.client.sync.SyncManager; +import org.projectbuendia.client.ui.BigToast; import org.projectbuendia.client.ui.dialogs.AssignLocationDialog; import org.projectbuendia.client.utils.EventBusRegistrationInterface; import org.projectbuendia.client.utils.LocaleSelector; @@ -74,7 +78,6 @@ final class PatientChartController implements ChartRenderer.GridJsInterface { private static final Logger LOG = Logger.create(); - private static final boolean DEBUG = true; private static final String KEY_PENDING_UUIDS = "pendingUuids"; // Form UUIDs specific to Ebola deployments. @@ -99,7 +102,6 @@ final class PatientChartController implements ChartRenderer.GridJsInterface { // the savedInstanceState. // TODO: Use a map for this instead of an array. private final String[] mPatientUuids; - private int mNextIndex = 0; private Patient mPatient = Patient.builder().build(); private LocationTree mLocationTree; @@ -129,6 +131,8 @@ final class PatientChartController implements ChartRenderer.GridJsInterface { // Store chart's last scroll position private Point mLastScrollPosition; + private Encounter mPendingNotesEncounter; + public Point getLastScrollPosition() { return mLastScrollPosition; } @@ -142,6 +146,9 @@ void updateAdmissionDateAndFirstSymptomsDateUi( LocalDate admissionDate, LocalDate firstSymptomsDate); + /** Updates the UI showing the weight for the patient in the top part */ + void updateWeightUi(Map observations); + /** Updates the UI showing Ebola PCR lab test results for this patient. */ void updateEbolaPcrTestResultUi(Map observations); @@ -185,6 +192,8 @@ void showOrderExecutionDialog(Order order, Interval interval, List executionTimes); void showEditPatientDialog(Patient patient); void showObservationsDialog(ArrayList obs); + void indicateNoteSubmitted(); + void indicateNoteSubmissionFailed(); } /** Sends ODK form data. */ @@ -334,18 +343,7 @@ public void onAddObservationPressed(String targetGroup) { preset.clinicianName = user.fullName; } - Map observations = - mChartHelper.getLatestObservations(mPatientUuid); - - if (observations.containsKey(ConceptUuids.PREGNANCY_UUID) - && ConceptUuids.YES_UUID.equals(observations.get(ConceptUuids.PREGNANCY_UUID).value)) { - preset.pregnant = Preset.YES; - } - - if (observations.containsKey(ConceptUuids.IV_UUID) - && ConceptUuids.YES_UUID.equals(observations.get(ConceptUuids.IV_UUID).value)) { - preset.ivFitted = Preset.YES; - } + // TODO: implement persistent fields here, if we keep this code. preset.targetGroup = targetGroup; @@ -361,6 +359,25 @@ public void onEditPatientPressed() { mUi.showEditPatientDialog(mPatient); } + public void addNote(String note) { + Observation observation = new Observation( + ConceptUuids.NOTES_UUID, + note); + JsonUser user = App.getUserManager().getActiveUser(); + String userId = user == null ? null : user.id; + mPendingNotesEncounter = new Encounter( + mPatientUuid, + null, // Encounter UUID + DateTime.now(), + new Observation[]{observation}, + null, // Order UUIDs + userId); + mAppModel.addEncounter( + mCrudEventBus, + mPatient, + mPendingNotesEncounter); + } + private boolean dialogShowing() { return (mAssignGeneralConditionDialog != null && mAssignGeneralConditionDialog.isShowing()) || (mAssignLocationDialog != null && mAssignLocationDialog.isShowing()); @@ -408,6 +425,25 @@ public void onOpenFormPressed(String formUuid) { Utils.logUserAction("form_opener_pressed", "form", formUuid); mUi.showFormLoadingDialog(true); + + Map observations = + mChartHelper.getLatestObservations(mPatientUuid); + + // TODO: Refactor this as it's repeated in two methods (this is the one we're using!) + for(String uuid : ConceptUuids.PERSISTENT_FIELDS) { + if (observations.containsKey(uuid) + && ConceptUuids.YES_UUID.equals(observations.get(uuid).value)) { + // TODO: Ideally, we'd know how to determine whether a field was in a form, which + // would make this more efficent. But we can't at the moment. + String conceptName = mChartHelper.getConceptNameByUuid(uuid); + // The concept name could be null if there's an observation whose concept isn't in + // use in the profile. + if (conceptName != null) { + preset.persistentFieldsSelected.add(conceptName.toLowerCase()); + } + } + } + FormRequest request = newFormRequest(formUuid, mPatientUuid); mUi.fetchAndShowXform( request.requestIndex, request.formUuid, @@ -482,6 +518,8 @@ public void showAssignGeneralConditionDialog( public void setCondition(String newConditionUuid) { LOG.v("Assigning general condition: %s", newConditionUuid); + JsonUser user = App.getUserManager().getActiveUser(); + String userId = user == null ? null : user.id; Encounter encounter = new Encounter( mPatientUuid, null, // encounter UUID, which the server will generate @@ -489,9 +527,8 @@ public void setCondition(String newConditionUuid) { new Observation[] { new Observation( ConceptUuids.GENERAL_CONDITION_UUID, - newConditionUuid, - Observation.Type.NON_DATE) - }, null); + newConditionUuid) + }, null, userId); mAppModel.addEncounter(mCrudEventBus, mPatient, encounter); } @@ -546,6 +583,7 @@ public synchronized void updatePatientObsUi(int chartNum) { LocalDate firstSymptomsDate = getObservedDate( latestObservations, ConceptUuids.FIRST_SYMPTOM_DATE_UUID); mUi.updateAdmissionDateAndFirstSymptomsDateUi(admissionDate, firstSymptomsDate); + mUi.updateWeightUi(latestObservations); mUi.updateEbolaPcrTestResultUi(latestObservations); mUi.updatePregnancyAndIvStatusUi(latestObservations); @@ -602,6 +640,12 @@ public void onEventMainThread(SyncSucceededEvent event) { } public void onEventMainThread(EncounterAddFailedEvent event) { + if (event.encounter == mPendingNotesEncounter) { + mUi.indicateNoteSubmissionFailed(); + mPendingNotesEncounter = null; + return; + } + if (mAssignGeneralConditionDialog != null) { mAssignGeneralConditionDialog.dismiss(); mAssignGeneralConditionDialog = null; @@ -639,6 +683,27 @@ public void onEventMainThread(EncounterAddFailedEvent event) { mUi.showError(messageResource, exceptionMessage); } + public void onEventMainThread(ItemCreatedEvent event) { + if (objectIsNoteCreationEncounter(event.item)) { + mUi.indicateNoteSubmitted(); + mPendingNotesEncounter = null; + } + } + + /** + * There's no reference equality after an item has been created, and our data model + * is a mess so we can't use .equals(), so we do a "close enough" comparison to work out + * if a note was submitted. + */ + private boolean objectIsNoteCreationEncounter(Object object) { + if (!(object instanceof Encounter)) { + return false; + } + Encounter encounter = (Encounter) object; + return encounter.observations.length != 0 + && ConceptUuids.NOTES_UUID.equals(encounter.observations[0].conceptUuid); + } + // We get a ItemFetchedEvent when the initial patient data is loaded // from SQLite or after an edit has been successfully posted to the server. public void onEventMainThread(ItemFetchedEvent event) { @@ -784,10 +849,22 @@ public void onEventMainThread(VoidObservationsRequestEvent event) { updatePatientObsUi(lastChartIndex); } + public void onEventMainThread(OrderDeleteFailedEvent event) { + // TODO: don't use App.getInstance for this. + BigToast.show(App.getInstance(), R.string.order_delete_failed); + } + + public void onEventMainThread(VoidObsFailedEvent event) { + // TODO: don't use App.getInstance for this. + BigToast.show(App.getInstance(), R.string.observation_delete_failed); + } + public void onEventMainThread(OrderExecutionSaveRequestedEvent event) { Order order = mOrdersByUuid.get(event.orderUuid); if (order != null) { - mAppModel.addOrderExecutedEncounter(mCrudEventBus, mPatient, order.uuid); + JsonUser user = App.getUserManager().getActiveUser(); + String userId = user == null ? null : user.id; + mAppModel.addOrderExecutedEncounter(mCrudEventBus, mPatient, order.uuid, userId); } } } diff --git a/app/src/main/java/org/projectbuendia/client/ui/chart/PatientObservationsListAdapter.java b/app/src/main/java/org/projectbuendia/client/ui/chart/PatientObservationsListAdapter.java new file mode 100644 index 00000000..e4eecf37 --- /dev/null +++ b/app/src/main/java/org/projectbuendia/client/ui/chart/PatientObservationsListAdapter.java @@ -0,0 +1,142 @@ +package org.projectbuendia.client.ui.chart; + +import android.app.LoaderManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; +import android.widget.TextView; + +import org.projectbuendia.client.R; +import org.projectbuendia.client.providers.Contracts; +import org.projectbuendia.client.providers.Contracts.Observations; + +import java.util.Date; + +/** + * A {@link android.widget.ListAdapter} that displays observations for a given patient, matching a + * given concept UUID. + *

+ * TODO: This adapter currently does some database queries on the main thread - we should + * offload these to a background thread for performance reasons. + */ +public class PatientObservationsListAdapter extends CursorAdapter { + + private static final String[] PROJECTION = new String[] { + "rowid AS _id", + Observations.ENTERER_UUID, + Observations.ENCOUNTER_MILLIS, + Observations.VALUE, + }; + + private final ContentResolver mContentResolver; + + public PatientObservationsListAdapter(Context context) { + super(context, null, 0); + mContentResolver = context.getContentResolver(); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + LayoutInflater inflater = LayoutInflater.from(context); + return inflater.inflate(R.layout.notes_list_adapter_note_template, parent, false); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + // Obtain data from cursor + Date encounterTimestamp = new Date(cursor.getLong( + cursor.getColumnIndexOrThrow(Observations.ENCOUNTER_MILLIS))); + String value = cursor.getString( + cursor.getColumnIndexOrThrow(Observations.VALUE)); + String entererUuid = cursor.getString( + cursor.getColumnIndexOrThrow(Observations.ENTERER_UUID)); + String enterer = getUsersNameFromUuid(entererUuid); + + // Obtain view references + ViewGroup viewGroup = (ViewGroup) view; + TextView metaLine = (TextView) viewGroup.findViewById(R.id.meta); + TextView content = (TextView) viewGroup.findViewById(R.id.observation_content); + + // Set content + metaLine.setText(context.getResources().getString( + enterer == null + ? R.string.notes_list_metadata_format_no_user_info + : R.string.notes_list_metadata_format, + encounterTimestamp, enterer)); + content.setText(value); + } + + /** + * Returns the users' full name from a UUID. Note that this performs a database query, and so + * ideally calls should be kept off the main thread. It does not perform a network request to + * check for new users on the server. + * + * @param uuid The uuid of the user whose name to return. Note that if {@code null} is passed, + * {@code null} will be returned. + * @return the users' name, if a user was found matching this UUID. {@code null} otherwise. + */ + public @Nullable String getUsersNameFromUuid(@Nullable String uuid) { + if (uuid == null) { + return null; + } + try (Cursor cursor = mContentResolver.query( + Contracts.Users.CONTENT_URI.buildUpon().appendPath(uuid).build(), + new String[]{Contracts.Users.FULL_NAME}, + null, + null, + null)) { + if (cursor == null || !cursor.moveToFirst()) { + // Either there wasn't a cursor, or the result set was empty. + // This is a user we don't know about. + return null; + } + return cursor.getString(0); + } + } + + public static class ObservationsListLoaderCallbacks + implements LoaderManager.LoaderCallbacks { + + private final Context mContext; + private final String mPatientUuid; + private final String mConceptUuid; + private final CursorAdapter mAdapter; + + public ObservationsListLoaderCallbacks( + Context context, String patientUuid, String conceptUuid, CursorAdapter adapter) { + mContext = context; + mPatientUuid = patientUuid; + mConceptUuid = conceptUuid; + mAdapter = adapter; + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + return new CursorLoader(mContext, + Observations.CONTENT_URI, + PROJECTION, + Observations.PATIENT_UUID + " = ? AND " + + Observations.CONCEPT_UUID + " = ? ", + new String[]{mPatientUuid, mConceptUuid}, + Observations.ENCOUNTER_MILLIS); + } + + @Override + public void onLoadFinished(Loader loader, Cursor data) { + mAdapter.swapCursor(data); + } + + @Override + public void onLoaderReset(Loader loader) { + mAdapter.swapCursor(null); + } + } +} diff --git a/app/src/main/java/org/projectbuendia/client/ui/chart/PebbleExtension.java b/app/src/main/java/org/projectbuendia/client/ui/chart/PebbleExtension.java index 8ed9743c..d6750dfa 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/chart/PebbleExtension.java +++ b/app/src/main/java/org/projectbuendia/client/ui/chart/PebbleExtension.java @@ -1,5 +1,9 @@ package org.projectbuendia.client.ui.chart; +import android.app.Application; +import android.support.annotation.StringRes; +import android.support.annotation.VisibleForTesting; + import com.google.common.collect.ImmutableList; import com.mitchellbosecke.pebble.extension.AbstractExtension; import com.mitchellbosecke.pebble.extension.Filter; @@ -11,6 +15,7 @@ import org.joda.time.LocalDate; import org.joda.time.ReadableInstant; import org.joda.time.format.DateTimeFormat; +import org.projectbuendia.client.App; import org.projectbuendia.client.models.ObsPoint; import org.projectbuendia.client.models.ObsValue; import org.projectbuendia.client.utils.Logger; @@ -36,7 +41,7 @@ public class PebbleExtension extends AbstractExtension { private static final Logger LOG = Logger.create(); - static Map filters = new HashMap<>(); + private static final Map filters = new HashMap<>(); static { filters.put("min", new MinFilter()); @@ -52,13 +57,14 @@ public class PebbleExtension extends AbstractExtension { filters.put("tosafechars", new toSafeCharsFilter()); } - static Map functions = new HashMap<>(); + private static final Map functions = new HashMap<>(); static { functions.put("get_latest_point", new GetLatestPointFunction()); functions.put("get_all_points", new GetAllPointsFunction()); functions.put("get_order_execution_count", new GetOrderExecutionCountFunction()); functions.put("intervals_overlap", new IntervalsOverlapFunction()); + functions.put("string_resource", new StringResourceFunction()); } public static final String TYPE_ERROR = "?"; @@ -77,7 +83,7 @@ abstract static class ZeroArgFilter implements Filter { } } - static class MinFilter extends ZeroArgFilter { + private static class MinFilter extends ZeroArgFilter { @Override public @Nullable Object apply(Object input, Map args) { if (input instanceof Collection) { return ((Collection) input).isEmpty() ? null : Collections.min((Collection) input); @@ -85,7 +91,7 @@ static class MinFilter extends ZeroArgFilter { } } - static class MaxFilter extends ZeroArgFilter { + private static class MaxFilter extends ZeroArgFilter { @Override public @Nullable Object apply(Object input, Map args) { if (input instanceof Collection) { return ((Collection) input).isEmpty() ? null : Collections.max((Collection) input); @@ -94,7 +100,7 @@ static class MaxFilter extends ZeroArgFilter { } /** Computes the average of a set of numbers or numeric ObsValues. */ - static class AvgFilter extends ZeroArgFilter { + private static class AvgFilter extends ZeroArgFilter { @Override public @Nullable Object apply(Object input, Map args) { double sum = 0; int count = 0; @@ -117,7 +123,7 @@ static class AvgFilter extends ZeroArgFilter { } /** Converts a Java null, boolean, integer, double, string, or DateTime to a JS expression. */ - static class JsFilter extends ZeroArgFilter { + private static class JsFilter extends ZeroArgFilter { @Override public Object apply(Object input, Map args) { if (input == null) { return "null"; @@ -137,7 +143,7 @@ static class JsFilter extends ZeroArgFilter { } /** points | values -> a list of the ObsValues in the given list of ObsPoints */ - static class ValuesFilter extends ZeroArgFilter { + private static class ValuesFilter extends ZeroArgFilter { @Override public Object apply(Object input, Map args) { List values = new ArrayList<>(); // The input is a tuple, so we must ensure that values has the same number of elements. @@ -155,7 +161,7 @@ static class ValuesFilter extends ZeroArgFilter { } /** Formats a single value. */ - static class FormatValueFilter implements Filter { + private static class FormatValueFilter implements Filter { @Override public List getArgumentNames() { return ImmutableList.of("format"); @@ -175,7 +181,7 @@ public Object apply(Object input, Map args) { * in the profile. This is for formatting values of different concepts together in one * string (e.g. systolic / diastolic blood pressure), not a series of values over time. */ - static class FormatValuesFilter implements Filter { + private static class FormatValuesFilter implements Filter { @Override public List getArgumentNames() { return ImmutableList.of("format"); @@ -200,11 +206,11 @@ public Object apply(Object input, Map args) { } } - static Format asFormat(Object arg) { + private static Format asFormat(Object arg) { return arg instanceof Format ? (Format) arg : arg == null ? null : new ObsFormat("" + arg); } - static String formatValues(List values, Format format) { + private static String formatValues(List values, Format format) { if (format == null) return ""; // we use null to represent an empty format // ObsFormat expects an array of Obs instances with a 1-based index. @@ -230,7 +236,7 @@ static String formatValues(List values, Format format) { } /** Formats a LocalDate. (For times, use format_time, not format_date.) */ - static class FormatDateFilter implements Filter { + private static class FormatDateFilter implements Filter { @Override public List getArgumentNames() { return ImmutableList.of("pattern"); } @@ -244,7 +250,7 @@ static class FormatDateFilter implements Filter { } } - static class toSafeCharsFilter implements Filter { + private static class toSafeCharsFilter implements Filter { @Override public List getArgumentNames() { return ImmutableList.of("input"); } @@ -255,6 +261,7 @@ static class toSafeCharsFilter implements Filter { } /** Formats an Instant or DateTime. (For dates, use format_date, not format_time.) */ + @VisibleForTesting static class FormatTimeFilter implements Filter { @Override public List getArgumentNames() { return ImmutableList.of("pattern"); @@ -271,14 +278,14 @@ static class FormatTimeFilter implements Filter { } /** line_break_html(text) -> HTML for the given text with newlines replaced by
*/ - static class LineBreakHtmlFilter extends ZeroArgFilter { + private static class LineBreakHtmlFilter extends ZeroArgFilter { @Override public Object apply(Object input, Map args) { return ("" + input).replace("&", "&").replace("<", "<").replace("\n", "
"); } } /** get_all_points(row, column) -> all ObsPoints for concept 1 in a given cell, in time order */ - static class GetAllPointsFunction implements Function { + private static class GetAllPointsFunction implements Function { @Override public List getArgumentNames() { return ImmutableList.of("row", "column"); } @@ -287,12 +294,12 @@ static class GetAllPointsFunction implements Function { // TODO/robustness: Check types before casting. Row row = (Row) args.get("row"); Column column = (Column) args.get("column"); - return column.pointSetByConceptUuid.get(row.item.conceptUuids[0]); + return column.pointSetByConceptUuid.get(row.item.conceptUuids.get(0)); } } /** get_latest_point(row, column) -> the latest ObsPoint for concept 1 in a given cell, or null */ - static class GetLatestPointFunction implements Function { + private static class GetLatestPointFunction implements Function { @Override public List getArgumentNames() { return ImmutableList.of("row", "column"); } @@ -301,12 +308,13 @@ static class GetLatestPointFunction implements Function { // TODO/robustness: Check types before casting. Row row = (Row) args.get("row"); Column column = (Column) args.get("column"); - SortedSet obsSet = column.pointSetByConceptUuid.get(row.item.conceptUuids[0]); + SortedSet obsSet = + column.pointSetByConceptUuid.get(row.item.conceptUuids.get(0)); return obsSet.isEmpty() ? null : obsSet.last(); } } - static class GetOrderExecutionCountFunction implements Function { + private static class GetOrderExecutionCountFunction implements Function { @Override public List getArgumentNames() { return ImmutableList.of("order_uuid", "column"); } @@ -320,7 +328,7 @@ static class GetOrderExecutionCountFunction implements Function { } } - static class IntervalsOverlapFunction implements Function { + private static class IntervalsOverlapFunction implements Function { @Override public List getArgumentNames() { return ImmutableList.of("a", "b"); } @@ -332,4 +340,28 @@ static class IntervalsOverlapFunction implements Function { return a.overlaps(b); } } + + /** Retrieves a string resource by ID. */ + private static class StringResourceFunction implements Function { + + private static final String RESOURCE_ID = "resource_id"; + + @Override + public List getArgumentNames() { + return ImmutableList.of(RESOURCE_ID); + } + + @Override + public Object execute(Map args) { + Application app = App.getInstance(); + String resourceName = (String) args.get(RESOURCE_ID); + @StringRes int id = + app.getResources().getIdentifier(resourceName, "string", app.getPackageName()); + if (id == 0) { + throw new IllegalArgumentException( + "Couldn't find string with resource ID \"" + resourceName + "\""); + } + return app.getResources().getString(id); + } + } } diff --git a/app/src/main/java/org/projectbuendia/client/ui/chart/Row.java b/app/src/main/java/org/projectbuendia/client/ui/chart/Row.java index 50f4c9eb..2a279371 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/chart/Row.java +++ b/app/src/main/java/org/projectbuendia/client/ui/chart/Row.java @@ -14,12 +14,13 @@ public class Row { static Map DEFAULTS = new HashMap<>(); static { DEFAULTS.put("select_one", new ChartItem("", "", false, null, "{1,abbr}", "{1,name}", "", "", "")); - DEFAULTS.put("yes_no", new ChartItem("", "", false, null, "{1,yes_no,\u25cf;\u25cb}", "{1,yes_no,Yes;No}", "", "{1,yes_no,color:#000;color:#ccc}", "")); + DEFAULTS.put("yes_no", new ChartItem("", "", false, null, "{1,yes_no,\u25cf;\u25cb}", "{1,yes_no,Yes;No}", "", "{1,yes_no,color:black;color:DarkGray}", "")); + DEFAULTS.put("yes_no_unknown", new ChartItem("", "", false, null, "{1,yes_no_unknown,\u25cf;\u25cb;?}", "{1,yes_no,Yes;No;Unknown}", "", "{1,yes_no,color:black;color:DarkGray}", "")); DEFAULTS.put("number", new ChartItem("", "", false, null, "0", "0", "", "", "")); DEFAULTS.put("text", new ChartItem("", "", false, null, "{1,text,5}", "{1,text,40}", "", "", "")); DEFAULTS.put("date", new ChartItem("", "", false, null, "{1,date,MMM dd}", "{1,date,MMM dd}", "", "", "")); DEFAULTS.put("time", new ChartItem("", "", false, null, "{1,time,HH:mm}", "{1,time,HH:mm}", "", "", "")); - DEFAULTS.put("severity_bars", new ChartItem("", "", false, null, "{1,select,1107:\u25cb;1498:\u002d;1499:\u003d;1500:\u2261}", "{1,name}", "{1,select,1500:critical}", "{1,select,1107:color:#ccc}", "")); + DEFAULTS.put("severity_bars", new ChartItem("", "", false, null, "{1,select,1107:\u25cb;1498:\u002d;1499:\u003d;1500:\u2261}", "{1,name}", "{1,select,1500:critical}", "{1,select,1107:color:DarkGray}", "")); } public Row(@Nonnull ChartItem item) { diff --git a/app/src/main/java/org/projectbuendia/client/ui/chart/Tile.java b/app/src/main/java/org/projectbuendia/client/ui/chart/Tile.java index af6ec652..dda6a073 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/chart/Tile.java +++ b/app/src/main/java/org/projectbuendia/client/ui/chart/Tile.java @@ -17,6 +17,7 @@ public class Tile { static { DEFAULTS.put("select_one", new ChartItem("", "", false, null, "{1,abbr}", "{1,name}", "", "", "")); DEFAULTS.put("yes_no", new ChartItem("", "", false, null, "{1,yes_no,Yes;No}", "", "", "", "")); + DEFAULTS.put("yes_no_unknown", new ChartItem("", "", false, null, "{1,yes_no_unknown,Yes;No;Unknown}", "", "", "", "")); DEFAULTS.put("number", new ChartItem("", "", false, null, "0", "", "", "", "")); DEFAULTS.put("text", new ChartItem("", "", false, null, "{1,text,60}", "", "", "", "")); DEFAULTS.put("date", new ChartItem("", "", false, null, "{1,date,YYYY-MM-dd}", "", "", "", "")); diff --git a/app/src/main/java/org/projectbuendia/client/ui/dialogs/DatePickerDialogFragment.java b/app/src/main/java/org/projectbuendia/client/ui/dialogs/DatePickerDialogFragment.java new file mode 100644 index 00000000..1e311231 --- /dev/null +++ b/app/src/main/java/org/projectbuendia/client/ui/dialogs/DatePickerDialogFragment.java @@ -0,0 +1,71 @@ +package org.projectbuendia.client.ui.dialogs; + +import android.app.DatePickerDialog; +import android.app.Dialog; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.DialogFragment; +import android.widget.DatePicker; + +import java.util.Calendar; +import java.util.Date; + +/** + * A {@link DialogFragment} that displays a {@link DatePickerDialog}. Basic usage: + *

    + *
  • call {@link #create(Date)} with a starting date. + *
  • call {@link #setListener(DateChosenListener)} to set a listener that will be triggered + * when a value has been set. + *
+ */ +public class DatePickerDialogFragment extends DialogFragment { + public interface DateChosenListener { + void onDateChosen(Date date); + } + + private static final String YEAR_KEY = "year"; + private static final String MONTH_KEY = "month"; + private static final String DAY_KEY = "day"; + + @Nullable + private DateChosenListener mListener; + + public static DatePickerDialogFragment create(@Nullable Date startingDate) { + if (startingDate == null) { + startingDate = new Date(); + } + Bundle args = new Bundle(); + Calendar calendar = Calendar.getInstance(); + calendar.setTime(startingDate); + args.putInt(YEAR_KEY, calendar.get(Calendar.YEAR)); + args.putInt(MONTH_KEY, calendar.get(Calendar.MONTH)); + args.putInt(DAY_KEY, calendar.get(Calendar.DAY_OF_MONTH)); + DatePickerDialogFragment fragment = new DatePickerDialogFragment(); + fragment.setArguments(args); + return fragment; + } + + public void setListener(@Nullable DateChosenListener listener) { + mListener = listener; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + int year = getArguments().getInt(YEAR_KEY); + int month = getArguments().getInt(MONTH_KEY); + int day = getArguments().getInt(DAY_KEY); + return new DatePickerDialog(getContext(), new DatePickerDialog.OnDateSetListener() { + @Override + public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) { + Calendar calendar = Calendar.getInstance(); + calendar.set(year, monthOfYear, dayOfMonth); + Date date = calendar.getTime(); + if (mListener != null) { + mListener.onDateChosen(date); + } + } + }, year, month, day); + } +} diff --git a/app/src/main/java/org/projectbuendia/client/ui/dialogs/EditPatientDialogFragment.java b/app/src/main/java/org/projectbuendia/client/ui/dialogs/EditPatientDialogFragment.java index ce733440..3e452214 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/dialogs/EditPatientDialogFragment.java +++ b/app/src/main/java/org/projectbuendia/client/ui/dialogs/EditPatientDialogFragment.java @@ -192,7 +192,7 @@ public void focusFirstEmptyField(Dialog dialog) { for (EditText field : fields) { if (field.getText().toString().isEmpty()) { field.requestFocus(); - break; + return; } } diff --git a/app/src/main/java/org/projectbuendia/client/ui/dialogs/GoToPatientDialogFragment.java b/app/src/main/java/org/projectbuendia/client/ui/dialogs/GoToPatientDialogFragment.java index bec7ec42..d81020f9 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/dialogs/GoToPatientDialogFragment.java +++ b/app/src/main/java/org/projectbuendia/client/ui/dialogs/GoToPatientDialogFragment.java @@ -137,15 +137,16 @@ class IdWatcher implements TextWatcher { mPatientUuid = null; mPatientSearchResult.setText(""); } else { - try (Cursor cursor = getActivity().getContentResolver().query( - Patients.CONTENT_URI, null, Patients.ID + " = ?", new String[] {id}, null)) { + try (Cursor cursor = getActivity().getContentResolver().query(Patients.CONTENT_URI, + null, Patients.ID + " LIKE ?", new String[] {"%" + id + "%"}, null)) { if (cursor.moveToNext()) { String uuid = Utils.getString(cursor, Patients.UUID, null); String givenName = Utils.getString(cursor, Patients.GIVEN_NAME, ""); String familyName = Utils.getString(cursor, Patients.FAMILY_NAME, ""); LocalDate birthdate = Utils.getLocalDate(cursor, Patients.BIRTHDATE); - String age = birthdate == null ? "age unknown" - : Utils.birthdateToAge(birthdate, getResources()); + String age = birthdate == null + ? getResources().getString(R.string.age_unknown) + : Utils.birthdateToAge(birthdate, getResources()); String gender = Utils.getString(cursor, Patients.GENDER, ""); mPatientUuid = uuid; mPatientSearchResult.setText(givenName + " " + familyName + diff --git a/app/src/main/java/org/projectbuendia/client/ui/dialogs/OrderDialogFragment.java b/app/src/main/java/org/projectbuendia/client/ui/dialogs/OrderDialogFragment.java index 12640ac5..14fc553b 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/dialogs/OrderDialogFragment.java +++ b/app/src/main/java/org/projectbuendia/client/ui/dialogs/OrderDialogFragment.java @@ -16,6 +16,7 @@ import android.content.DialogInterface; import android.os.Bundle; import android.support.annotation.NonNull; +import android.support.annotation.StringRes; import android.support.v4.app.DialogFragment; import android.text.Editable; import android.text.TextWatcher; @@ -35,20 +36,33 @@ import org.projectbuendia.client.models.Order; import org.projectbuendia.client.utils.Utils; +import java.util.Date; + import butterknife.ButterKnife; import butterknife.InjectView; import de.greenrobot.event.EventBus; /** A {@link DialogFragment} for adding a new user. */ public class OrderDialogFragment extends DialogFragment { + + /** For a duration < 3 days, provides strings expressing the duration in a friendly way. */ + private static final @StringRes int[] GIVE_FOR_DAYS_STATIC_STRINGS = new int[] { + R.string.order_duration_unspecified, + R.string.order_duration_stop_after_today, + R.string.order_duration_stop_after_tomorrow + }; + @InjectView(R.id.order_medication) EditText mMedication; @InjectView(R.id.order_dosage) EditText mDosage; @InjectView(R.id.order_frequency) EditText mFrequency; @InjectView(R.id.order_give_for_days) EditText mGiveForDays; + @InjectView(R.id.order_start_date) TextView mStartDateView; + @InjectView(R.id.order_start_date_change_button) View mStartDateChangeButton; @InjectView(R.id.order_give_for_days_label) TextView mGiveForDaysLabel; @InjectView(R.id.order_duration_label) TextView mDurationLabel; @InjectView(R.id.order_delete) Button mDelete; private LayoutInflater mInflater; + private DateTime mStartDate; /** Creates a new instance and registers the given UI, if specified. */ public static OrderDialogFragment newInstance(String patientUuid, Order order) { @@ -99,17 +113,28 @@ private void populateFields(Bundle args) { mDosage.setText(Order.getDosage(instructions)); mFrequency.setText(Order.getFrequency(instructions)); DateTime now = Utils.getDateTime(args, "now_millis"); + Long startMillis = Utils.getLong(args, "start_millis"); + mStartDate = (startMillis == null ? now : new DateTime(startMillis)); Long stopMillis = Utils.getLong(args, "stop_millis"); if (stopMillis != null) { LocalDate lastDay = new DateTime(stopMillis).toLocalDate(); - int days = Days.daysBetween(now.toLocalDate(), lastDay).getDays(); - if (days >= 0) { - mGiveForDays.setText("" + (days + 1)); // 1 day means stop after today - } + int days = Days.daysBetween(mStartDate.toLocalDate(), lastDay).getDays(); + // 1 day means stop after today, so we have to increment by 1. + mGiveForDays.setText(String.format("%d", days + 1)); } updateLabels(); } + private String formatDate(DateTime startDate) { + // If the start date is the current date, return "Today" + DateTime now = Utils.getDateTime(getArguments(), "now_millis"); + if (startDate.withTimeAtStartOfDay().equals(now.withTimeAtStartOfDay())) { + return getResources().getString(R.string.today); + } + + return getResources().getString(R.string.day_of_week_and_medium_date, startDate.toDate()); + } + public void onSubmit(Dialog dialog) { String uuid = getArguments().getString("uuid"); String patientUuid = getArguments().getString("patientUuid"); @@ -144,21 +169,9 @@ public void onSubmit(Dialog dialog) { dialog.dismiss(); - DateTime now = Utils.getDateTime(getArguments(), "now_millis"); - DateTime start = Utils.getDateTime(getArguments(), "start_millis"); - start = Utils.valueOrDefault(start, now); - - if (durationDays != null) { - // Adjust durationDays to account for a start date in the past. Entering "2" - // always means two more days, stopping after tomorrow, regardless of start date. - LocalDate firstDay = start.toLocalDate(); - LocalDate lastDay = now.toLocalDate().plusDays(durationDays - 1); - durationDays = Days.daysBetween(firstDay, lastDay).getDays() + 1; - } - // Post an event that triggers the PatientChartController to save the order. EventBus.getDefault().post(new OrderSaveRequestedEvent( - uuid, patientUuid, instructions, start, durationDays)); + uuid, patientUuid, instructions, mStartDate, durationDays)); } public void onDelete(Dialog dialog, final String orderUuid) { @@ -214,10 +227,19 @@ private void setError(EditText field, int resourceId) { } }); - // Hide or show the "Stop" and "Delete" buttons appropriately. - Long stopMillis = Utils.getLong(args, "stop_millis"); - Long nowMillis = Utils.getLong(args, "now_millis"); + mStartDateChangeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + DatePickerDialogFragment dlg = DatePickerDialogFragment.create(mStartDate.toDate()); + dlg.setListener(mDateChosenListener); + dlg.show(getFragmentManager(), "DatePicker"); + + } + }); + + // Hide or show the "Delete" and "Change start date" buttons appropriately. Utils.showIf(mDelete, !newOrder); + Utils.showIf(mStartDateChangeButton, newOrder); // Open the keyboard, ready to type into the medication field. dialog.getWindow().setSoftInputMode(LayoutParams.SOFT_INPUT_STATE_VISIBLE); @@ -227,20 +249,25 @@ private void setError(EditText field, int resourceId) { /** Updates the various labels in the form that react to changes in input fields. */ void updateLabels() { - DateTime now = Utils.getDateTime(getArguments(), "now_millis"); + // Start Date + mStartDateView.setText(formatDate(mStartDate)); + + // Duration String text = mGiveForDays.getText().toString().trim(); int days = text.isEmpty() ? 0 : Integer.parseInt(text); - LocalDate lastDay = now.toLocalDate().plusDays(days - 1); + LocalDate lastDay = mStartDate.toLocalDate().plusDays(days - 1); + // TODO: use R.plurals instead. mGiveForDaysLabel.setText( days == 0 ? R.string.order_give_for_days : days == 1 ? R.string.order_give_for_day : R.string.order_give_for_days); - mDurationLabel.setText(getResources().getString( - days == 0 ? R.string.order_duration_unspecified : - days == 1 ? R.string.order_duration_stop_after_today : - days == 2 ? R.string.order_duration_stop_after_tomorrow : - R.string.order_duration_stop_after_date - ).replace("%s", Utils.toShortString(lastDay))); + if (days < GIVE_FOR_DAYS_STATIC_STRINGS.length) { + mDurationLabel.setText(GIVE_FOR_DAYS_STATIC_STRINGS[days]); + } else { + mDurationLabel.setText(getResources().getString( + R.string.order_duration_stop_after_date, + Utils.toShortString(lastDay))); + } } class DurationDaysWatcher implements TextWatcher { @@ -254,4 +281,14 @@ class DurationDaysWatcher implements TextWatcher { updateLabels(); } } + + private final DatePickerDialogFragment.DateChosenListener mDateChosenListener = + new DatePickerDialogFragment.DateChosenListener() { + @Override + public void onDateChosen(Date date) { + mStartDate = new DateTime(date); + updateLabels(); + } + }; + } diff --git a/app/src/main/java/org/projectbuendia/client/ui/dialogs/ViewObservationsDialogFragment.java b/app/src/main/java/org/projectbuendia/client/ui/dialogs/ViewObservationsDialogFragment.java index 5dcf42d3..cd7487fc 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/dialogs/ViewObservationsDialogFragment.java +++ b/app/src/main/java/org/projectbuendia/client/ui/dialogs/ViewObservationsDialogFragment.java @@ -65,7 +65,7 @@ private void prepareData(ArrayList rows){ for (ObsRow row: rows) { - Title = row.conceptName + " " + row.day; + Title = row.conceptName + " " + getResources().getString(R.string.observation_on_day) + " " + row.day; if(!isExistingHeader(Title)){ listDataHeader.add(Title); @@ -79,10 +79,10 @@ private void prepareData(ArrayList rows){ for (ObsRow row: rows){ - verifyTitle = row.conceptName + " " + row.day; + verifyTitle = row.conceptName + " " + getResources().getString(R.string.observation_on_day) + " " + row.day; if (verifyTitle.equals(header)){ - child.add(row.time + " " + row.valueName); + child.add(row.time + " " + row.valueName); } } diff --git a/app/src/main/java/org/projectbuendia/client/ui/dialogs/VoidObservationsDialogFragment.java b/app/src/main/java/org/projectbuendia/client/ui/dialogs/VoidObservationsDialogFragment.java index ec29bae2..e3c34452 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/dialogs/VoidObservationsDialogFragment.java +++ b/app/src/main/java/org/projectbuendia/client/ui/dialogs/VoidObservationsDialogFragment.java @@ -108,7 +108,7 @@ public void onClick(DialogInterface dialogInterface, int i) { dialogInterface.dismiss(); } }) - .setPositiveButton(R.string.voiding, new DialogInterface.OnClickListener() { + .setPositiveButton(R.string.delete, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { @@ -118,7 +118,7 @@ public void onClick(DialogInterface dialogInterface, int i) { dialogInterface.dismiss(); } - }).setTitle(getResources().getString(R.string.void_observations)) + }).setTitle(getResources().getString(R.string.delete_observations)) .setView(fragment); return builder.create(); } diff --git a/app/src/main/java/org/projectbuendia/client/ui/lists/BaseSearchablePatientListActivity.java b/app/src/main/java/org/projectbuendia/client/ui/lists/BaseSearchablePatientListActivity.java index e727b372..f2328cb2 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/lists/BaseSearchablePatientListActivity.java +++ b/app/src/main/java/org/projectbuendia/client/ui/lists/BaseSearchablePatientListActivity.java @@ -84,6 +84,7 @@ public PatientSearchController getSearchController() { } }); + /* menu.findItem(R.id.action_go_to).setOnMenuItemClickListener( new MenuItem.OnMenuItemClickListener() { @@ -94,6 +95,7 @@ public PatientSearchController getSearchController() { return true; } }); + */ MenuItem search = menu.findItem(R.id.action_search); search.setIcon( diff --git a/app/src/main/java/org/projectbuendia/client/ui/lists/LocationListFragment.java b/app/src/main/java/org/projectbuendia/client/ui/lists/LocationListFragment.java index b150ef9e..ccf6c5dd 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/lists/LocationListFragment.java +++ b/app/src/main/java/org/projectbuendia/client/ui/lists/LocationListFragment.java @@ -12,6 +12,7 @@ package org.projectbuendia.client.ui.lists; import android.os.Bundle; +import android.os.Debug; import android.support.annotation.Nullable; import android.view.LayoutInflater; import android.view.View; diff --git a/app/src/main/java/org/projectbuendia/client/widgets/EditAndClearDataPreference.java b/app/src/main/java/org/projectbuendia/client/widgets/EditAndClearDataPreference.java index eea476a4..5e83ef93 100644 --- a/app/src/main/java/org/projectbuendia/client/widgets/EditAndClearDataPreference.java +++ b/app/src/main/java/org/projectbuendia/client/widgets/EditAndClearDataPreference.java @@ -17,6 +17,8 @@ import org.projectbuendia.client.App; import org.projectbuendia.client.R; +import org.projectbuendia.client.diagnostics.HealthCheck; +import org.projectbuendia.client.diagnostics.HealthMonitor; import org.projectbuendia.client.sync.Database; /** Custom Android preference widget that clears the database if new text is entered. */ @@ -30,6 +32,7 @@ public void onDialogClosed(boolean positive) { super.onDialogClosed(positive); if (positive) { new Database(App.getInstance().getApplicationContext()).clear(); + App.getInstance().getHealthMonitor().clear(); App.getUserManager().reset(); } } diff --git a/app/src/main/res/layout/fragment_patient_chart.xml b/app/src/main/res/layout/fragment_patient_chart.xml index ef05ba39..43cade6a 100644 --- a/app/src/main/res/layout/fragment_patient_chart.xml +++ b/app/src/main/res/layout/fragment_patient_chart.xml @@ -9,69 +9,174 @@ OR CONDITIONS OF ANY KIND, either express or implied. See the License for specific language governing permissions and limitations under the License. --> - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +