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/main/assets/chart.html b/app/src/main/assets/chart.html index 24379136..c2574ca0 100644 --- a/app/src/main/assets/chart.html +++ b/app/src/main/assets/chart.html @@ -77,6 +77,8 @@ {% set class = summaryValue | format_values(row.item.cssClass) %} {% set style = summaryValue | format_values(row.item.cssStyle) %}
{{order.medication}}
-
{{order.dosage}} 
+
{{order.dosage}} {{order.frequency != null ? order.frequency + 'x daily' : ''}}
{% set previousActive = false %} {% set future = false %} 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/DiagnosticsModule.java b/app/src/main/java/org/projectbuendia/client/diagnostics/DiagnosticsModule.java index 1b1d2a07..4e8e0600 100644 --- a/app/src/main/java/org/projectbuendia/client/diagnostics/DiagnosticsModule.java +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/DiagnosticsModule.java @@ -43,7 +43,8 @@ public class DiagnosticsModule { return ImmutableSet.of( new WifiHealthCheck(application, settings), new BuendiaApiHealthCheck(application, connectionDetails), - new PackageServerHealthCheck(application, settings)); + new PackageServerHealthCheck(application, settings), + new UnsentFormHealthCheck(application)); } @Provides diff --git a/app/src/main/java/org/projectbuendia/client/diagnostics/HealthIssue.java b/app/src/main/java/org/projectbuendia/client/diagnostics/HealthIssue.java index 7798ea56..f3082cf3 100644 --- a/app/src/main/java/org/projectbuendia/client/diagnostics/HealthIssue.java +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/HealthIssue.java @@ -27,7 +27,8 @@ public enum HealthIssue { SERVER_INTERNAL_ISSUE, SERVER_NOT_RESPONDING, PACKAGE_SERVER_HOST_UNREACHABLE, - PACKAGE_SERVER_INDEX_NOT_FOUND; + PACKAGE_SERVER_INDEX_NOT_FOUND, + PENDING_FORM_SUBMISSION; /** The event to be posted when a health issue is discovered. */ public final DiscoveredEvent discovered = new DiscoveredEvent(); diff --git a/app/src/main/java/org/projectbuendia/client/diagnostics/Troubleshooter.java b/app/src/main/java/org/projectbuendia/client/diagnostics/Troubleshooter.java index fac03723..1649cfc5 100644 --- a/app/src/main/java/org/projectbuendia/client/diagnostics/Troubleshooter.java +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/Troubleshooter.java @@ -87,6 +87,16 @@ private Set getNetworkConnectivityTroubleshootingActions( return actions; } + private Set getSynchronizationTroubleshootingActions() { + Set actions = new HashSet<>(); + + if (mActiveIssues.contains(HealthIssue.PENDING_FORM_SUBMISSION)) { + actions.add(TroubleshootingAction.RESUBMIT_PENDING_FORM); + } + + return actions; + } + private Set getConfigurationTroubleshootingActions() { Set actions = new HashSet<>(); @@ -118,7 +128,7 @@ private void postTroubleshootingEvents(HealthIssue solvedIssue) { actionsBuilder.addAll(getNetworkConnectivityTroubleshootingActions()); actionsBuilder.addAll(getConfigurationTroubleshootingActions()); actionsBuilder.addAll(getPackageServerTroubleshootingActions()); - + actionsBuilder.addAll(getSynchronizationTroubleshootingActions()); ImmutableSet actions = actionsBuilder.build(); if (mLastTroubleshootingActionsChangedEvent != null) { diff --git a/app/src/main/java/org/projectbuendia/client/diagnostics/TroubleshootingAction.java b/app/src/main/java/org/projectbuendia/client/diagnostics/TroubleshootingAction.java index 926e97a6..eadfcf57 100644 --- a/app/src/main/java/org/projectbuendia/client/diagnostics/TroubleshootingAction.java +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/TroubleshootingAction.java @@ -34,4 +34,6 @@ public enum TroubleshootingAction { CHECK_PACKAGE_SERVER_REACHABILITY, CHECK_PACKAGE_SERVER_CONFIGURATION, + + RESUBMIT_PENDING_FORM } diff --git a/app/src/main/java/org/projectbuendia/client/diagnostics/UnsentFormHealthCheck.java b/app/src/main/java/org/projectbuendia/client/diagnostics/UnsentFormHealthCheck.java new file mode 100644 index 00000000..11bd1f60 --- /dev/null +++ b/app/src/main/java/org/projectbuendia/client/diagnostics/UnsentFormHealthCheck.java @@ -0,0 +1,99 @@ +// Copyright 2015 The Project Buendia Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy +// of the License at: http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distrib- +// uted under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// specific language governing permissions and limitations under the License. + +package org.projectbuendia.client.diagnostics; + +import android.app.Application; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.conn.HttpHostConnectException; +import org.apache.http.impl.client.DefaultHttpClient; +import org.projectbuendia.client.App; +import org.projectbuendia.client.AppSettings; +import org.projectbuendia.client.models.UnsentForm; +import org.projectbuendia.client.ui.OdkActivityLauncher; +import org.projectbuendia.client.utils.Logger; + +import java.io.IOException; +import java.net.UnknownHostException; +import java.util.List; + +/** A {@link HealthCheck} that checks whether there are saved forms which are not submitted and need + * to be resubmitted to the server. */ +public class UnsentFormHealthCheck extends HealthCheck { + + private static final Logger LOG = Logger.create(); + + /** Check for issues with this frequency. */ + private static final int CHECK_PERIOD_MS = 10000; + + private final Object mLock = new Object(); + + private HandlerThread mHandlerThread; + private Handler mHandler; + private final Runnable mHealthCheckRunnable = new Runnable() { + + @Override public void run() { + performCheck(); + + synchronized (mLock) { + if (mHandler != null) { + mHandler.postDelayed(this, CHECK_PERIOD_MS); + } + } + } + + private void performCheck() { + final List forms = OdkActivityLauncher.getUnsetForms(App.getInstance() + .getContentResolver()); + + if(!forms.isEmpty()) { + LOG.w("There are %d unsent forms saved locally and need to be resent to the server", + forms.size()); + reportIssue(HealthIssue.PENDING_FORM_SUBMISSION); + return; + } else { + resolveIssue(HealthIssue.PENDING_FORM_SUBMISSION); + } + } + }; + + UnsentFormHealthCheck(Application application) { + super(application); + } + + @Override protected void startImpl() { + synchronized (mLock) { + if (mHandlerThread == null) { + mHandlerThread = new HandlerThread("Buendia Unsent Form Health Check"); + mHandlerThread.start(); + mHandler = new Handler(mHandlerThread.getLooper()); + mHandler.post(mHealthCheckRunnable); + } + } + } + + @Override protected void stopImpl() { + synchronized (mLock) { + if (mHandlerThread != null) { + mHandlerThread.quit(); + mHandlerThread = null; + mHandler = null; + } + } + } +} diff --git a/app/src/main/java/org/projectbuendia/client/events/SubmitXformFailedEvent.java b/app/src/main/java/org/projectbuendia/client/events/SubmitXformFailedEvent.java index f5508b82..495f39ad 100644 --- a/app/src/main/java/org/projectbuendia/client/events/SubmitXformFailedEvent.java +++ b/app/src/main/java/org/projectbuendia/client/events/SubmitXformFailedEvent.java @@ -25,7 +25,8 @@ public enum Reason { SERVER_BAD_ENDPOINT, SERVER_TIMEOUT, SERVER_ERROR, - CLIENT_ERROR + CLIENT_ERROR, + PENDING_FORM_SUBMISSION } public SubmitXformFailedEvent(Reason reason, @Nullable Exception exception) { 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..c607f275 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; /** @@ -194,10 +196,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)); } /** 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..5d8da8ec 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,13 +60,15 @@ 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; } /** @@ -79,18 +76,17 @@ public Encounter( * {@link JsonEncounter} object and corresponding patient UUID. */ 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 +97,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 +109,7 @@ public JSONObject toJson() throws JSONException { } json.put(Server.ENCOUNTER_ORDER_UUIDS, orderUuidsJson); } + json.put(Server.ENCOUNTER_USER_UUID, userUuid); return json; } @@ -153,31 +146,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 +193,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/UnsentForm.java b/app/src/main/java/org/projectbuendia/client/models/UnsentForm.java new file mode 100644 index 00000000..9073c111 --- /dev/null +++ b/app/src/main/java/org/projectbuendia/client/models/UnsentForm.java @@ -0,0 +1,52 @@ +// Copyright 2015 The Project Buendia Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy +// of the License at: http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distrib- +// uted under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES +// OR CONDITIONS OF ANY KIND, either express or implied. See the License for +// specific language governing permissions and limitations under the License. + +package org.projectbuendia.client.models; + +import android.content.ContentValues; +import android.database.Cursor; + +import org.projectbuendia.client.providers.Contracts; +import org.projectbuendia.client.utils.Utils; + +import javax.annotation.concurrent.Immutable; + +@Immutable +public final class UnsentForm { + public final String uuid; + public final String patientUuid; + public final String formContents; + + public UnsentForm(String uuid, String patientUuid, String formContents ) { + this.uuid = uuid; + this.patientUuid = patientUuid; + this.formContents = formContents; + } + + /** Puts this object's fields in a {@link ContentValues} object for insertion into a database. */ + public ContentValues toContentValues() { + ContentValues cv = new ContentValues(); + cv.put(Contracts.UnsentForms.UUID, uuid); + cv.put(Contracts.UnsentForms.PATIENT_UUID, patientUuid); + cv.put(Contracts.UnsentForms.FORM_CONTENTS, formContents); + return cv; + } + + /** An {@link CursorLoader} that loads {@link UnsentForm}s. */ + @Immutable + public static class Loader implements CursorLoader { + @Override public UnsentForm fromCursor(Cursor cursor) { + return new UnsentForm(Utils.getString(cursor, Contracts.UnsentForms.UUID), + Utils.getString(cursor, Contracts.UnsentForms.PATIENT_UUID), + Utils.getString(cursor, Contracts.UnsentForms.FORM_CONTENTS)); + } + } +} 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/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..bc70d9ed 100644 --- a/app/src/main/java/org/projectbuendia/client/net/OpenMrsServer.java +++ b/app/src/main/java/org/projectbuendia/client/net/OpenMrsServer.java @@ -152,7 +152,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)); } }; diff --git a/app/src/main/java/org/projectbuendia/client/net/OpenMrsXformsConnection.java b/app/src/main/java/org/projectbuendia/client/net/OpenMrsXformsConnection.java index 2eb5ced1..14f2acf6 100644 --- a/app/src/main/java/org/projectbuendia/client/net/OpenMrsXformsConnection.java +++ b/app/src/main/java/org/projectbuendia/client/net/OpenMrsXformsConnection.java @@ -129,15 +129,15 @@ public void listXforms(final Response.Listener> lis * Send a single Xform to the OpenMRS server. * @param patientUuid null if this is to add a new patient, non-null for observation on existing * patient - * @param resultListener the listener to be informed of the form asynchronously + * @param successListener the listener to be informed of the form asynchronously * @param errorListener a listener to be informed of any errors */ public void postXformInstance( - @Nullable String patientUuid, - String entererUuid, - String xform, - final Response.Listener resultListener, - Response.ErrorListener errorListener) { + final @Nullable String patientUuid, + final String entererUuid, + final String xform, + final Response.Listener successListener, + final Response.ErrorListener errorListener) { // The JsonObject members in the API as written at the moment. // int "patient_id" @@ -163,11 +163,8 @@ public void postXformInstance( OpenMrsJsonRequest request = new OpenMrsJsonRequest( mConnectionDetails, "/xforminstances", postBody, // non-null implies POST - new Response.Listener() { - @Override public void onResponse(JSONObject response) { - resultListener.onResponse(response); - } - }, errorListener + successListener, + errorListener ); // Set a permissive timeout. request.setRetryPolicy(new DefaultRetryPolicy(Common.REQUEST_TIMEOUT_MS_MEDIUM, 1, 1f)); 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..c83e4bb0 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 diff --git a/app/src/main/java/org/projectbuendia/client/providers/BuendiaProvider.java b/app/src/main/java/org/projectbuendia/client/providers/BuendiaProvider.java index afd5817d..e80beab5 100644 --- a/app/src/main/java/org/projectbuendia/client/providers/BuendiaProvider.java +++ b/app/src/main/java/org/projectbuendia/client/providers/BuendiaProvider.java @@ -162,6 +162,18 @@ public SQLiteDatabaseTransactionHelper getDbTransactionHelper() { Table.SYNC_TOKENS, Contracts.SyncTokens.TABLE_NAME)); + registry.registerDelegate( + Contracts.UnsentForms.CONTENT_URI.getPath(), + new GroupProviderDelegate( + Contracts.UnsentForms.ITEM_CONTENT_TYPE, + Table.UNSENT_FORMS)); + registry.registerDelegate( + Contracts.UnsentForms.CONTENT_URI.getPath() + "/*", + new ItemProviderDelegate( + Contracts.UnsentForms.GROUP_CONTENT_TYPE, + Table.UNSENT_FORMS, + Contracts.UnsentForms.UUID)); + return registry; } } diff --git a/app/src/main/java/org/projectbuendia/client/providers/Contracts.java b/app/src/main/java/org/projectbuendia/client/providers/Contracts.java index 6a2ff072..f47f0a19 100644 --- a/app/src/main/java/org/projectbuendia/client/providers/Contracts.java +++ b/app/src/main/java/org/projectbuendia/client/providers/Contracts.java @@ -36,7 +36,8 @@ public enum Table { ORDERS("orders"), PATIENTS("patients"), USERS("users"), - SYNC_TOKENS("sync_tokens"); + SYNC_TOKENS("sync_tokens"), + UNSENT_FORMS("unset_forms"); public String name; @@ -165,9 +166,13 @@ public interface Observations { /** * UUID is populated if the record was retrieved from the server. If this observation was - * written locally as a cached value from a submitted XForm, UUID is null. As part of every - * successful sync, all observations with null UUIDs are deleted, on the basis that an - * authoritative version for each has been obtained from the server. + * written locally as a cached value from a submitted XForm, UUID is null. For further + * details please check + * {@link org.projectbuendia.client.ui.OdkActivityLauncher#updateObservationCache}. + * As part of every successful sync, all observations with null UUIDs are deleted, + * on the basis that an authoritative version for each has been obtained from the server. + * For further details, please check + * {@link org.projectbuendia.client.sync.controllers.ObservationsSyncPhaseRunnable} */ String UUID = "uuid"; String PATIENT_UUID = "patient_uuid"; @@ -239,6 +244,16 @@ public interface PatientCounts { String PATIENT_COUNT = "patient_count"; } + public interface UnsentForms { + Uri CONTENT_URI = buildContentUri("unsent-forms"); + String GROUP_CONTENT_TYPE = buildGroupType("unsent-form"); + String ITEM_CONTENT_TYPE = buildItemType("unsent-form"); + + String UUID = "uuid"; + String PATIENT_UUID = "patient_uuid"; + String FORM_CONTENTS = "form_contents"; + } + public static Uri buildContentUri(String path) { return BASE_CONTENT_URI.buildUpon().appendPath(path).build(); } diff --git a/app/src/main/java/org/projectbuendia/client/sync/Database.java b/app/src/main/java/org/projectbuendia/client/sync/Database.java index 9fbfcdc4..be0fc783 100644 --- a/app/src/main/java/org/projectbuendia/client/sync/Database.java +++ b/app/src/main/java/org/projectbuendia/client/sync/Database.java @@ -165,6 +165,12 @@ public class Database extends SQLiteOpenHelper { SCHEMAS.put(Table.SYNC_TOKENS, "" + "table_name TEXT PRIMARY KEY NOT NULL," + "sync_token TEXT NOT NULL"); + + SCHEMAS.put(Table.UNSENT_FORMS, "" + + "uuid TEXT PRIMARY KEY NOT NULL," + + "patient_uuid TEXT NOT NULL," + + "form_contents TEXT NOT NULL"); + } public Database(Context context) { diff --git a/app/src/main/java/org/projectbuendia/client/sync/controllers/IncrementalSyncPhaseRunnable.java b/app/src/main/java/org/projectbuendia/client/sync/controllers/IncrementalSyncPhaseRunnable.java index 5faf9ff5..35b9d81d 100644 --- a/app/src/main/java/org/projectbuendia/client/sync/controllers/IncrementalSyncPhaseRunnable.java +++ b/app/src/main/java/org/projectbuendia/client/sync/controllers/IncrementalSyncPhaseRunnable.java @@ -85,7 +85,12 @@ protected IncrementalSyncPhaseRunnable( public final void sync(ContentResolver contentResolver, SyncResult syncResult, ContentProviderClient providerClient) throws Throwable { - beforeSyncStarted(contentResolver, syncResult, providerClient); + boolean okToProceedSync = beforeSyncStarted(contentResolver, syncResult, providerClient); + + if(!okToProceedSync) { + LOG.w("Skipping synchronization for %s", this.getClass().getSimpleName()); + return; + } String syncToken = SyncAdapter.getLastSyncToken(providerClient, dbTable); LOG.i("Using sync token `%s`", syncToken); @@ -121,11 +126,14 @@ protected abstract ArrayList getUpdateOps( // Optional callbacks - /** Called before any records have been synced from the server. */ - protected void beforeSyncStarted( + /** + * Called before any records have been synced from the server. Returns {@code true} + * if the sync phase is good to proceed. Otherwise, returns {@code false} to skip the sync + * phase */ + protected boolean beforeSyncStarted( ContentResolver contentResolver, SyncResult syncResult, - ContentProviderClient providerClient) throws Throwable {} + ContentProviderClient providerClient) throws Throwable {return true;} /** * Called after all records have been synced from the server, even if the number of synced 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..92f627d3 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 @@ -24,6 +24,7 @@ import org.projectbuendia.client.json.JsonObservation; import org.projectbuendia.client.providers.Contracts; import org.projectbuendia.client.providers.Contracts.Observations; +import org.projectbuendia.client.ui.OdkActivityLauncher; import org.projectbuendia.client.utils.Logger; import java.util.ArrayList; @@ -37,14 +38,14 @@ public class ObservationsSyncPhaseRunnable extends IncrementalSyncPhaseRunnable< public ObservationsSyncPhaseRunnable() { super( - "observations", - Contracts.Table.OBSERVATIONS, - JsonObservation.class); + "observations", + Contracts.Table.OBSERVATIONS, + JsonObservation.class); } @Override - protected ArrayList getUpdateOps( - JsonObservation[] list, SyncResult syncResult) { + protected ArrayList getUpdateOps(JsonObservation[] list, + SyncResult syncResult) { int deletes = 0; int inserts = 0; ArrayList ops = new ArrayList<>(); @@ -55,7 +56,7 @@ protected ArrayList getUpdateOps( deletes++; } else { ops.add(ContentProviderOperation.newInsert(Observations.CONTENT_URI) - .withValues(getObsValuesToInsert(observation)).build()); + .withValues(getObsValuesToInsert(observation)).build()); inserts++; } } @@ -66,8 +67,7 @@ protected ArrayList getUpdateOps( } /** Converts an encounter data response into appropriate inserts in the encounters table. */ - public static ContentValues getObsValuesToInsert( - JsonObservation observation) { + public static ContentValues getObsValuesToInsert(JsonObservation observation) { ContentValues cvs = new ContentValues(); cvs.put(Observations.UUID, observation.uuid); cvs.put(Observations.PATIENT_UUID, observation.patient_uuid); @@ -80,14 +80,20 @@ public static ContentValues getObsValuesToInsert( return cvs; } + @Override + protected boolean beforeSyncStarted(ContentResolver contentResolver, SyncResult syncResult, + ContentProviderClient providerClient) throws Throwable { + return OdkActivityLauncher.submitUnsetFormsToServer(contentResolver); + } + @Override protected void afterSyncFinished( - ContentResolver contentResolver, - SyncResult syncResult, - ContentProviderClient providerClient) throws RemoteException { + 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]); + Observations.UUID + " IS NULL", + new String[0]); } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/projectbuendia/client/ui/BaseActivity.java b/app/src/main/java/org/projectbuendia/client/ui/BaseActivity.java index ce337de4..ba956382 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/BaseActivity.java +++ b/app/src/main/java/org/projectbuendia/client/ui/BaseActivity.java @@ -12,11 +12,13 @@ package org.projectbuendia.client.ui; import android.app.AlertDialog; +import android.content.ContentResolver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.res.Configuration; import android.net.wifi.WifiManager; +import android.os.AsyncTask; import android.os.Bundle; import android.provider.Settings; import android.support.annotation.StringRes; @@ -31,7 +33,9 @@ import org.projectbuendia.client.R; import org.projectbuendia.client.diagnostics.HealthIssue; import org.projectbuendia.client.diagnostics.TroubleshootingAction; +import org.projectbuendia.client.events.SubmitXformFailedEvent; import org.projectbuendia.client.events.diagnostics.TroubleshootingActionsChangedEvent; +import org.projectbuendia.client.ui.chart.PatientChartActivity; import org.projectbuendia.client.updater.AvailableUpdateInfo; import org.projectbuendia.client.updater.DownloadedUpdateInfo; import org.projectbuendia.client.utils.Logger; @@ -42,6 +46,9 @@ import de.greenrobot.event.EventBus; +import static org.projectbuendia.client.events.SubmitXformFailedEvent.Reason + .PENDING_FORM_SUBMISSION; + /** * An abstract {@link FragmentActivity} that is the base for all activities, providing a "content * view" that can be populated by implementing classes and a "status view" that can be used for @@ -327,6 +334,15 @@ public void onEventMainThread(TroubleshootingActionsChangedEvent event) { } }, 999, false); break; + case RESUBMIT_PENDING_FORM: + snackBar(R.string.troubleshoot_pending_form_submission, + R.string.troubleshoot_pending_form_submission_action_resubmit, + new View.OnClickListener() { + @Override public void onClick(View view) { + new submitUnsentFormsAsyncTask().execute(App.getInstance().getContentResolver()); + } + }, 994, false); + break; default: LOG.w("Troubleshooting action '%1$s' is unknown.", troubleshootingAction); return; @@ -334,6 +350,18 @@ public void onEventMainThread(TroubleshootingActionsChangedEvent event) { } } + private class submitUnsentFormsAsyncTask extends AsyncTask { + @Override protected Boolean doInBackground(ContentResolver... params) { + return OdkActivityLauncher.submitUnsetFormsToServer((ContentResolver)params[0]); + } + + protected void onPostExecute(Boolean result) { + if(!result) { + BigToast.show(BaseActivity.this, R.string.submit_xform_failed_unknown_reason); + } + } + } + private void displayProblemSolvedMessage(HealthIssue solvedIssue) { // The troubleShootingMessages Map have the issue as the key and the TroubleshootingMessage // object as it's value. @@ -393,6 +421,12 @@ private void displayProblemSolvedMessage(HealthIssue solvedIssue) { R.string.troubleshoot_package_server_misconfigured_solved, 10 )); + troubleshootingMessages.put(HealthIssue.PENDING_FORM_SUBMISSION, + new TroubleshootingMessage( + R.string.troubleshoot_pending_form_submission, + R.string.troubleshoot_pending_form_submission_solved, + 10 + )); TroubleshootingMessage messages = troubleshootingMessages.get(solvedIssue); 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..cf4db36b 100644 --- a/app/src/main/java/org/projectbuendia/client/ui/OdkActivityLauncher.java +++ b/app/src/main/java/org/projectbuendia/client/ui/OdkActivityLauncher.java @@ -19,6 +19,8 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; +import android.os.AsyncTask; +import android.os.Looper; import com.android.volley.Response; import com.android.volley.TimeoutError; @@ -45,6 +47,7 @@ import org.projectbuendia.client.events.SubmitXformSucceededEvent; import org.projectbuendia.client.exception.ValidationException; import org.projectbuendia.client.json.JsonUser; +import org.projectbuendia.client.models.UnsentForm; import org.projectbuendia.client.net.OdkDatabase; import org.projectbuendia.client.net.OdkXformSyncTask; import org.projectbuendia.client.net.OpenMrsXformIndexEntry; @@ -66,6 +69,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CountDownLatch; import javax.annotation.Nullable; @@ -76,6 +80,10 @@ import static org.odk.collect.android.provider.InstanceProviderAPI.InstanceColumns.CONTENT_ITEM_TYPE; import static org.odk.collect.android.provider.InstanceProviderAPI.InstanceColumns.INSTANCE_FILE_PATH; + +import static org.projectbuendia.client.events.SubmitXformFailedEvent.Reason.PENDING_FORM_SUBMISSION; +import static org.projectbuendia.client.providers.Contracts.UnsentForms; + /** Convenience class for launching ODK to display an Xform. */ public class OdkActivityLauncher { @@ -277,22 +285,24 @@ private static OpenMrsXformIndexEntry findUuid( } /** - * Convenient shared code for handling an ODK activity result. + * Convenient shared code for handling an ODK activity result. This method submits the ODK form + * to the server and saves it locally, whether or not the form was successfully submitted. + * If an error occurs over the submission, the form is kept to be resubmitted later. + * (See {@link #updateObservationCache(String, TreeElement, ContentResolver)}) + * This method returns {@code true} if it tries to send a request to the server, successfully + * or not. If any error occurs before submission, it returns {@code false}. + * * @param context the application context * @param settings the application settings * @param patientUuid the patient to add an observation to, or null to create a new patient - * @param resultCode the result code sent from Android activity transition * @param data the incoming intent */ - public static void sendOdkResultToServer( - final Context context, + public static boolean sendOdkResultToServer( + final BaseActivity context, final AppSettings settings, @Nullable final String patientUuid, - int resultCode, Intent data) { - if(isActivityCanceled(resultCode, data)) return; - try { final Uri uri = data.getData(); if(!validateContentUriType(context, uri, CONTENT_ITEM_TYPE)) { @@ -310,7 +320,6 @@ 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 @@ -321,29 +330,133 @@ public static void sendOdkResultToServer( throw new ValidationException("Xml form is not valid for uri: " + uri); } - sendFormToServer(patientUuid, xml, + // Always cache new observations, whether or not it is successfully sent to the server. + if (patientUuid != null) { + updateObservationCache(patientUuid, savedRoot, + context.getContentResolver()); + } + + /* We should prevent application to submit new forms if there are still unsent forms. + * In a scenario where an user tries to submit an form 'A' unsuccessfully, this form is + * saved to be sent later. Then, if the user tries to submit another form 'B', + * the latter can only be submitted if the former was submitted first. + * In the case of the former still can't be resent, the latter form will be saved to be + * sent all together in a future moment. + * + * Note that OdkActivityLauncher#submitUnsetFormsToServer is a blocking method and + * it must not be called by main thread. + */ + final boolean result[] = new boolean[1]; + new AsyncTask() { + @Override + protected Boolean doInBackground(Void... params) { + return submitUnsetFormsToServer(App.getInstance().getContentResolver()); + } + + @Override + protected void onPostExecute(Boolean proceed) { + if (proceed) { + submitFormToServer(patientUuid, xml, + new Response.Listener() { + @Override public void onResponse(JSONObject response) { + LOG.i("Created new encounter successfully on server" + response.toString()); + if (!settings.getKeepFormInstancesLocally()) { + deleteLocalFormInstances(formIdToDelete); + } + EventBus.getDefault().post(new SubmitXformSucceededEvent()); + } + }, new Response.ErrorListener() { + @Override public void onErrorResponse(VolleyError error) { + LOG.e(error, "Error submitting form to server"); + saveUnsentForm(patientUuid, xml, context.getContentResolver()); + handleSubmitError(error); + } + }); + result[0] = true; + } else { + saveUnsentForm(patientUuid, xml, context.getContentResolver()); + EventBus.getDefault().post(new SubmitXformFailedEvent(PENDING_FORM_SUBMISSION, null)); + result[0] = false; + } + } + }.execute(); + + return result[0]; + } catch(ValidationException ve) { + LOG.e(ve.getMessage()); + EventBus.getDefault().post( + new SubmitXformFailedEvent(SubmitXformFailedEvent.Reason.CLIENT_ERROR)); + return false; + } + } + + /** Tries to submit all unsent forms to the server . This method has a synchronization barrier + * which blocks the current thread waiting until all requests return prior to proceed its own flow. + * As Volley's {@link com.android.volley.RequestQueue} response event calls back the Main thread, + * the main thread must not be the method caller, otherwise it will be in deadlock state. + * Returns {@code true} if there are no more unsent forms. Otherwise returns {@code false}. + */ + public static final boolean submitUnsetFormsToServer(final ContentResolver contentResolver) { + if (Looper.getMainLooper() == Looper.myLooper()) { + // We're on the main thread + throw new RuntimeException("This call is blocking, you should not call it from the main thread"); + } + + final boolean hasUnsubmittedForms[] = new boolean[]{false}; + final List forms = getUnsetForms(contentResolver); + + //Creating a sync barrier to wait for all returning async submissions + final CountDownLatch countDownLatch = new CountDownLatch(forms.size()); + for(final UnsentForm unsentForm : forms) { + submitFormToServer(unsentForm.patientUuid, unsentForm.formContents, 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 (!settings.getKeepFormInstancesLocally()) { - deleteLocalFormInstances(formIdToDelete); - } - EventBus.getDefault().post(new SubmitXformSucceededEvent()); + LOG.i("Created new encounter successfully on server. " + response + .toString()); + deleteUnsentForm(unsentForm.uuid, contentResolver); + countDownLatch.countDown(); + } }, new Response.ErrorListener() { @Override public void onErrorResponse(VolleyError error) { - LOG.e(error, "Error submitting form to server"); - handleSubmitError(error); + //Just log it and flag returning value as pendent. It is not necessary to + // keep its content, since this form is already persisted. + LOG.e(error, format("Error resubmitting %s form to server ", unsentForm + .uuid)); + hasUnsubmittedForms[0] = true; + countDownLatch.countDown(); } }); - } catch(ValidationException ve) { - LOG.e(ve.getMessage()); - EventBus.getDefault().post( - new SubmitXformFailedEvent(SubmitXformFailedEvent.Reason.CLIENT_ERROR)); + } + try { + //Waiting until all form submissions return from server + countDownLatch.await(); + } catch (InterruptedException e) { + LOG.e("Interrupted whilst waiting for unsubmitted forms to be uploaded", e); + return false; + } + + return !hasUnsubmittedForms[0]; + } + + public static void deleteUnsentForm(final String uuid, final ContentResolver contentResolver) { + LOG.i("Removing the unsent form from the db"); + contentResolver.delete(UnsentForms.CONTENT_URI, format("%s='%s'", UnsentForms.UUID, uuid), + null); + } + + /** Returns all local forms which were NOT submitted to the server yet*/ + public static List getUnsetForms(final ContentResolver contentResolver) { + try (Cursor cursor = contentResolver.query(UnsentForms.CONTENT_URI, + new String[]{UnsentForms.UUID, UnsentForms.PATIENT_UUID, UnsentForms.FORM_CONTENTS}, + null, null, null)) { + List unsentForms = new ArrayList<>(); + while (cursor.moveToNext()) { + unsentForms.add(new UnsentForm(Utils.getString(cursor, UnsentForms.UUID, null), + Utils.getString(cursor, UnsentForms.PATIENT_UUID, ""), + Utils.getString(cursor, UnsentForms.FORM_CONTENTS, ""))); + } + return unsentForms; } } @@ -386,10 +499,8 @@ private static boolean validateXml(String xml) { private static void deleteLocalFormInstances(Long formIdToDelete) { //Code largely copied from InstanceUploaderTask to delete on upload - DeleteInstancesTask dit = new DeleteInstancesTask(); - dit.setContentResolver( - Collect.getInstance().getApplication() - .getContentResolver()); + DeleteInstancesTask dit = new DeleteInstancesTask(Collect.getInstance().getApplication() + .getContentResolver()); dit.execute(formIdToDelete); } @@ -428,7 +539,7 @@ private static Long getIdToDeleteAfterUpload(final Context context, final Uri ur int columnIndex = instanceCursor.getColumnIndex(_ID); if (columnIndex == -1) return null; - return instanceCursor.getLong(columnIndex); + return instanceCursor.getLong(columnIndex); } finally { if (instanceCursor != null) { instanceCursor.close(); @@ -454,32 +565,21 @@ private static Cursor getCursorAtRightPosition(final Context context, final Uri return instanceCursor; } - - /** - * Returns true if the activity was canceled - * @param resultCode the result code sent from Android activity transition - * @param data the incoming intent - */ - private static boolean isActivityCanceled(int resultCode, Intent data) { - if (resultCode == Activity.RESULT_CANCELED) return true; - if (data == null || data.getData() == null) { - LOG.i("No data for form result, probably cancelled."); - return true; - } - return false; - } - - private static void sendFormToServer(String patientUuid, String xml, - Response.Listener successListener, - Response.ErrorListener errorListener) { + + private static void submitFormToServer(String patientUuid, String xml, + Response.Listener successListener, + Response.ErrorListener errorListener) { OpenMrsXformsConnection connection = new OpenMrsXformsConnection(App.getConnectionDetails()); JsonUser activeUser = App.getUserManager().getActiveUser(); + //TODO: Define what to do if there is no logged user and app tries do resubmit the form + // (i.e. using SnackBar prior to login) + String entererId = (activeUser != null)? activeUser.id : null; connection.postXformInstance( - patientUuid, activeUser.id, xml, successListener, errorListener); + patientUuid, entererId, xml, successListener, errorListener); } - private static void handleSubmitError(VolleyError error) { + private static void handleSubmitError(final VolleyError error) { SubmitXformFailedEvent.Reason reason = SubmitXformFailedEvent.Reason.UNKNOWN; if (error instanceof TimeoutError) { @@ -533,7 +633,7 @@ private static void handleFetchError(VolleyError error) { /** * Returns the xml form as a String from the path. If for any reason, the file couldn't be read, - * it returns null + * it returns {@code null} * @param path the path to be read */ private static String readFromPath(String path) { @@ -552,13 +652,34 @@ private static String readFromPath(String path) { } /** - * Caches the observation changes locally for a given patient. + * Saves the forms which couldn't be submitted to server into the local db. So that, when the + * application connects to server again, it can try to resend the form again. Note that this + * method just save the data as is required to be resend to the server. Please, check + * {@link #updateObservationCache} to see how the observation itself is saved into db. + * The {@link org.projectbuendia.client.sync.controllers.ObservationsSyncPhaseRunnable} + * will check if there are unsent observations, and if is the case, it will try to resend it + * prior to pull new ones (See {@link #submitUnsetFormsToServer}). + */ + private static void saveUnsentForm(final String patientUuid, final String xml, + final ContentResolver contentResolver) { + contentResolver.insert(UnsentForms.CONTENT_URI, new UnsentForm( + UUID.randomUUID().toString(), patientUuid, xml).toContentValues()); + } + + /** + * Caches the observation changes locally for a given patient. Saving the observations locally + * allows them to be used by users even it the application is connected to the server at that + * time. In this case, when the app becomes online and synchronizes with the server, this + * temporary observations are deleted. Please, see {@link #saveUnsentForm} and + * {@link org.projectbuendia.client.sync.controllers.ObservationsSyncPhaseRunnable} for more + * details. */ private static void updateObservationCache(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. + // It's critical that UUID is {@code null} and SUBMITTED is {@code false} for temporary + // observations, so we make it explicit here. See {@link Contracts.Observations.UUID} + // and {@link Contracts.Observations.SUBMITTED} for details. common.put(Contracts.Observations.UUID, (String) null); common.put(Contracts.Observations.PATIENT_UUID, patientUuid); @@ -608,7 +729,7 @@ private static Map mapFormConceptIdToUuid(Set xformConc } /** - * Returns a {@link ContentValues} list containing the id concept and the answer valeu from + * Returns a {@link ContentValues} list containing the id concept and the answer value from * all answered observations. Returns a empty {@link List} if no observation was answered. * * @param common the current content values. @@ -616,8 +737,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); @@ -677,7 +798,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; 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..47427dd4 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; @@ -36,6 +33,7 @@ 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 +49,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 +82,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 +104,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; 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..4b941a81 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 @@ -236,8 +236,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..a990f89d 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 @@ -13,23 +13,33 @@ import android.app.ActionBar; import android.app.ProgressDialog; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.graphics.Point; +import android.os.AsyncTask; 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 android.widget.Toast; 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,11 +101,13 @@ 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 PatientChartController mController; private boolean mIsFetchingXform = false; private ProgressDialog mFormLoadingDialog; private ProgressDialog mFormSubmissionDialog; + private Ui mUi; private ChartRenderer mChartRenderer; @Inject AppModel mAppModel; @@ -104,13 +116,19 @@ 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_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; private static final String EN_DASH = "\u2013"; @@ -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); @@ -232,11 +260,12 @@ public void onPageFinished(WebView view, String url) { }); mChartRenderer = new ChartRenderer(mGridWebView, getResources()); + mUi = new Ui(); + final OdkResultSender odkResultSender = new OdkResultSender() { - @Override public void sendOdkResultToServer(String patientUuid, int resultCode, Intent data) { - OdkActivityLauncher.sendOdkResultToServer( - PatientChartActivity.this, mSettings, - patientUuid, resultCode, data); + @Override public boolean sendOdkResultToServer(String patientUuid, Intent data) { + return OdkActivityLauncher.sendOdkResultToServer(PatientChartActivity.this, + mSettings, patientUuid, data); } }; final MinimalHandler minimalHandler = new MinimalHandler() { @@ -250,7 +279,7 @@ public void onPageFinished(WebView view, String url) { mAppModel, new EventBusWrapper(mEventBus), mCrudEventBusProvider.get(), - new Ui(), + mUi, getIntent().getStringExtra("uuid"), odkResultSender, mChartDataHelper, @@ -268,12 +297,145 @@ 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 + + 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"), + ConceptUuids.NOTES_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() { @@ -517,6 +679,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..fb4cab6e 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,6 +38,7 @@ 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.PatientUpdateFailedEvent; @@ -74,7 +75,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 +99,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 +128,8 @@ final class PatientChartController implements ChartRenderer.GridJsInterface { // Store chart's last scroll position private Point mLastScrollPosition; + private Encounter mPendingNotesEncounter; + public Point getLastScrollPosition() { return mLastScrollPosition; } @@ -185,13 +186,14 @@ void showOrderExecutionDialog(Order order, Interval interval, List executionTimes); void showEditPatientDialog(Patient patient); void showObservationsDialog(ArrayList obs); + void indicateNoteSubmitted(); + void indicateNoteSubmissionFailed(); } /** Sends ODK form data. */ public interface OdkResultSender { - void sendOdkResultToServer( + boolean sendOdkResultToServer( @Nullable String patientUuid, - int resultCode, Intent data); } @@ -286,21 +288,21 @@ public void suspend() { } } - public void onXFormResult(int requestCode, int resultCode, Intent data) { - FormRequest request = popFormRequest(requestCode); - if (request == null) { + public void onXFormResult(final int requestCode, final int resultCode, final Intent data) { + final FormRequest request = popFormRequest(requestCode); + if (request == null) { LOG.e("Unknown form request code: " + requestCode); return; } - boolean shouldShowSubmissionDialog = (resultCode != Activity.RESULT_CANCELED); - String action = (resultCode == Activity.RESULT_CANCELED) - ? "form_discard_pressed" : "form_save_pressed"; - Utils.logUserAction(action, - "form", request.formUuid, - "patient_uuid", request.patientUuid); - mOdkResultSender.sendOdkResultToServer(request.patientUuid, resultCode, data); - mUi.showFormSubmissionDialog(shouldShowSubmissionDialog); + final boolean isSubmissionCanceled = (resultCode == Activity.RESULT_CANCELED); + Utils.logUserAction(isSubmissionCanceled ? "form_discard_pressed" : "form_save_pressed", + "form", request.formUuid, "patient_uuid", request.patientUuid); + if(isSubmissionCanceled) return; + + final boolean isSubmittingForm = mOdkResultSender.sendOdkResultToServer(request.patientUuid, + data); + mUi.showFormSubmissionDialog(isSubmittingForm); } FormRequest popFormRequest(int requestIndex) { @@ -361,6 +363,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()); @@ -482,6 +503,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 +512,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); } @@ -602,6 +624,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 +667,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) { @@ -716,6 +765,9 @@ public void onEventMainThread(SubmitXformFailedEvent event) { case SERVER_TIMEOUT: errorMessageResource = R.string.submit_xform_failed_server_timeout; break; + case PENDING_FORM_SUBMISSION: + errorMessageResource = R.string.submit_xform_failed_pending_form_submission; + break; default: errorMessageResource = R.string.submit_xform_failed_unknown_reason; } @@ -787,7 +839,9 @@ public void onEventMainThread(VoidObservationsRequestEvent event) { 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/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/GoToPatientDialogFragment.java b/app/src/main/java/org/projectbuendia/client/ui/dialogs/GoToPatientDialogFragment.java index bec7ec42..984784c4 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,8 +137,8 @@ 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, ""); 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/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/res/layout/fragment_patient_chart.xml b/app/src/main/res/layout/fragment_patient_chart.xml index ef05ba39..a29a525d 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +