diff --git a/app/build.gradle b/app/build.gradle
index cacbabd28..0365d0a49 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -221,6 +221,13 @@ dependencies {
implementation "androidx.camera:camera-view:$camerax_version"
implementation "androidx.camera:camera-core:$camerax_version"
+ // The view and compose calendar library
+ implementation 'com.kizitonwose.calendar:view:2.5.0'
+ implementation 'com.kizitonwose.calendar:compose:2.5.0'
+
+ // Calendar date range picker library
+ implementation 'io.github.architshah248.calendar:awesome-calendar:2.0.0'
+
//AppIntro
implementation 'com.github.AppIntro:AppIntro:6.3.1'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 862b2d46e..b6551f734 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,20 +7,20 @@
android:name=".application.FieldBook"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
- android:roundIcon="@mipmap/ic_launcher"
android:label="@string/field_book"
android:largeHeap="true"
android:requestLegacyExternalStorage="true"
+ android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/BaseAppTheme"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning"
tools:replace="android:label">
-
-
-
+
+
-
-
-
+
-
-
+ android:windowSoftInputMode="adjustPan|stateHidden" />
-
-
+ android:screenOrientation="portrait" />
-
-
+ tools:replace="android:screenOrientation" />
-
-
+ android:theme="@style/AppTheme" />
-
+
+
@@ -246,9 +238,7 @@
-
-
diff --git a/app/src/main/java/com/fieldbook/tracker/activities/ConfigActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/ConfigActivity.java
index 703a95596..7940cb394 100644
--- a/app/src/main/java/com/fieldbook/tracker/activities/ConfigActivity.java
+++ b/app/src/main/java/com/fieldbook/tracker/activities/ConfigActivity.java
@@ -30,6 +30,7 @@
import com.fieldbook.tracker.R;
import com.fieldbook.tracker.adapters.ImageListAdapter;
import com.fieldbook.tracker.database.DataHelper;
+import com.fieldbook.tracker.database.models.ObservationModel;
import com.fieldbook.tracker.database.models.ObservationUnitModel;
import com.fieldbook.tracker.fragments.ImportDBFragment;
import com.fieldbook.tracker.objects.FieldObject;
@@ -293,9 +294,9 @@ private void loadScreen() {
settingsList = findViewById(R.id.myList);
String[] configList = new String[]{getString(R.string.settings_fields),
- getString(R.string.settings_traits), getString(R.string.settings_collect), getString(R.string.settings_export), getString(R.string.settings_advanced), getString(R.string.about_title)};
+ getString(R.string.settings_traits), getString(R.string.settings_collect), getString(R.string.settings_export), getString(R.string.settings_advanced), getString(R.string.settings_statistics), getString(R.string.about_title)};
- Integer[] image_id = {R.drawable.ic_nav_drawer_fields, R.drawable.ic_nav_drawer_traits, R.drawable.ic_nav_drawer_collect_data, R.drawable.trait_date_save, R.drawable.ic_nav_drawer_settings, R.drawable.ic_tb_info};
+ Integer[] image_id = {R.drawable.ic_nav_drawer_fields, R.drawable.ic_nav_drawer_traits, R.drawable.ic_nav_drawer_collect_data, R.drawable.trait_date_save, R.drawable.ic_nav_drawer_settings, R.drawable.ic_nav_drawer_statistics, R.drawable.ic_tb_info};
settingsList.setOnItemClickListener((av, arg1, position, arg3) -> {
Intent intent = new Intent();
@@ -325,6 +326,13 @@ private void loadScreen() {
startActivity(intent);
break;
case 5:
+ if (checkObservationsExist() > 0) {
+ intent.setClassName(ConfigActivity.this,
+ StatisticsActivity.class.getName());
+ startActivity(intent);
+ }
+ break;
+ case 6:
intent.setClassName(ConfigActivity.this,
AboutActivity.class.getName());
startActivity(intent);
@@ -377,6 +385,19 @@ private int checkTraitsExist() {
return 1;
}
+ /**
+ * Checks if any observations are collected.
+ * @return -1 if there are no observations, else 1
+ */
+ private int checkObservationsExist() {
+ final ObservationModel[] observations = database.getAllObservations();
+ if (observations.length == 0) {
+ Utils.makeToast(getApplicationContext(), getString(R.string.warning_no_observations));
+ return -1;
+ }
+ return 1;
+ }
+
// Helper function to merge arrays
String[] concat(String[] a1, String[] a2) {
String[] n = new String[a1.length + a2.length];
diff --git a/app/src/main/java/com/fieldbook/tracker/activities/StatisticsActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/StatisticsActivity.java
new file mode 100644
index 000000000..80eda21c6
--- /dev/null
+++ b/app/src/main/java/com/fieldbook/tracker/activities/StatisticsActivity.java
@@ -0,0 +1,162 @@
+package com.fieldbook.tracker.activities;
+
+import android.app.AlertDialog;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Menu;
+import android.view.MenuItem;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.Toolbar;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fieldbook.tracker.R;
+import com.fieldbook.tracker.adapters.StatisticsAdapter;
+import com.fieldbook.tracker.database.DataHelper;
+import com.fieldbook.tracker.database.models.ObservationModel;
+import com.fieldbook.tracker.dialogs.StatisticsCalendarFragment;
+import com.google.android.material.tabs.TabLayout;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import javax.inject.Inject;
+
+import dagger.hilt.android.AndroidEntryPoint;
+
+@AndroidEntryPoint
+public class StatisticsActivity extends ThemedActivity {
+ public static String TAG = "Statistics Activity";
+ @Inject
+ DataHelper database;
+ List seasons = new ArrayList<>();
+ RecyclerView rvStatisticsCard;
+ private ToggleVariable toggleVariable = ToggleVariable.TOTAL;
+ AlertDialog loadingDialog;
+ public enum ToggleVariable {
+ TOTAL,
+ YEAR,
+ MONTH
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_statistics);
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setTitle(getString(R.string.settings_statistics));
+ getSupportActionBar().getThemedContext();
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ getSupportActionBar().setHomeButtonEnabled(true);
+ }
+
+ rvStatisticsCard = findViewById(R.id.statistics_card_rv);
+ rvStatisticsCard.setLayoutManager(new LinearLayoutManager(this));
+
+ TabLayout tabLayout = findViewById(R.id.tab_layout);
+
+ tabLayout.addTab(tabLayout.newTab().setText(getString(R.string.stats_tab_layout_total)));
+ tabLayout.addTab(tabLayout.newTab().setText(getString(R.string.stats_tab_layout_year)));
+ tabLayout.addTab(tabLayout.newTab().setText(getString(R.string.stats_tab_layout_month)));
+
+ tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
+ @Override
+ public void onTabSelected(TabLayout.Tab tab) {
+ switch (tab.getPosition()) {
+ case 0: toggleVariable = ToggleVariable.TOTAL; break;
+ case 1: toggleVariable = ToggleVariable.YEAR; break;
+ case 2: toggleVariable = ToggleVariable.MONTH; break;
+ }
+ loadData();
+ }
+
+ @Override
+ public void onTabUnselected(TabLayout.Tab tab) {
+
+ }
+
+ @Override
+ public void onTabReselected(TabLayout.Tab tab) {
+
+ }
+ });
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(this, R.style.AppAlertDialog);
+ builder.setView(getLayoutInflater().inflate(R.layout.dialog_loading, null));
+ loadingDialog = builder.create();
+
+ loadData();
+
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.menu_statistics, menu);
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+
+ final int heatmapId = R.id.stats_heatmap;
+
+ int itemId = item.getItemId();
+
+ if (itemId == heatmapId) {
+ StatisticsCalendarFragment calendarFragment = new StatisticsCalendarFragment(this);
+ getSupportFragmentManager().beginTransaction().replace(android.R.id.content, calendarFragment).addToBackStack(null).commit();
+ } else if (itemId == android.R.id.home) {
+ onBackPressed();
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @NonNull
+ public DataHelper getDatabase() {
+ return database;
+ }
+
+ /**
+ * Displays the loading screen and loads the statistics asynchronously
+ */
+ public void loadData() {
+ loadingDialog.show();
+
+ Handler mainHandler = new Handler(Looper.getMainLooper());
+ mainHandler.post(this::setSeasons);
+ }
+
+ public void setSeasons() {
+
+ if (toggleVariable == ToggleVariable.TOTAL) {
+ seasons.clear();
+ seasons.add("");
+ } else {
+ Set uniqueSeasons = new TreeSet<>(Comparator.reverseOrder());
+
+ ObservationModel[] observations = database.getAllObservations();
+ for (ObservationModel observation : observations) {
+ String timeStamp = observation.getObservation_time_stamp();
+ if (toggleVariable == ToggleVariable.YEAR)
+ uniqueSeasons.add(timeStamp.substring(0, 4));
+ else
+ uniqueSeasons.add(timeStamp.substring(0, 7));
+ }
+
+ seasons = new ArrayList<>(uniqueSeasons);
+ }
+ rvStatisticsCard.setAdapter(new StatisticsAdapter(this, seasons, toggleVariable));
+
+ // Dismiss the dialog after the recycler view loads all its children
+ rvStatisticsCard.post(() -> loadingDialog.dismiss());
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsAdapter.java b/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsAdapter.java
new file mode 100644
index 000000000..f1095e22e
--- /dev/null
+++ b/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsAdapter.java
@@ -0,0 +1,286 @@
+package com.fieldbook.tracker.adapters;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.core.content.ContextCompat;
+import androidx.documentfile.provider.DocumentFile;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fieldbook.tracker.R;
+import com.fieldbook.tracker.activities.StatisticsActivity;
+import com.fieldbook.tracker.database.DataHelper;
+import com.fieldbook.tracker.database.models.ObservationModel;
+import com.fieldbook.tracker.objects.StatisticObject;
+import com.fieldbook.tracker.utilities.CategoryJsonUtil;
+import com.fieldbook.tracker.utilities.FileUtil;
+
+import org.brapi.v2.model.pheno.BrAPIScaleValidValuesCategories;
+import org.phenoapps.utils.BaseDocumentTreeUtil;
+
+import java.io.OutputStream;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+public class StatisticsAdapter extends RecyclerView.Adapter {
+
+ StatisticsActivity originActivity;
+ DataHelper database;
+ List seasons;
+ private final SimpleDateFormat timeStampFormat;
+ private static final String TIME_STAMP_PATTERN = "yyyy-MM-dd HH:mm:ss.SSSZZZZZ";
+ private static final String DATE_FORMAT_PATTERN = "MM-dd-yy";
+ private static final String YEAR_MONTH_PATTERN = "yyyy-MM";
+ private static final String MONTH_VIEW_CARD_TITLE_PATTERN ="MMMM yyyy";
+ private final SimpleDateFormat yearMonthFormat;
+ private final SimpleDateFormat monthViewCardTitle;
+ private static final int intervalThreshold = 30;
+ private Toast toast;
+ StatisticsActivity.ToggleVariable cardType;
+
+ public StatisticsAdapter(StatisticsActivity context, List seasons, StatisticsActivity.ToggleVariable cardType) {
+ this.originActivity = context;
+ this.database = originActivity.getDatabase();
+ this.seasons = seasons;
+ this.timeStampFormat = new SimpleDateFormat(TIME_STAMP_PATTERN, Locale.getDefault());
+ this.yearMonthFormat = new SimpleDateFormat(YEAR_MONTH_PATTERN, Locale.getDefault());
+ this.monthViewCardTitle = new SimpleDateFormat(MONTH_VIEW_CARD_TITLE_PATTERN, Locale.getDefault());
+ this.cardType = cardType;
+ }
+
+ public class ViewHolder extends RecyclerView.ViewHolder {
+ TextView year_text_view;
+ ConstraintLayout statisticsCard;
+ ImageView exportCard;
+ RecyclerView rvStatsContainer;
+
+ public ViewHolder(@NonNull View itemView) {
+ super(itemView);
+ statisticsCard = itemView.findViewById(R.id.statistics_card);
+ rvStatsContainer = itemView.findViewById(R.id.rv_stats_container);
+ exportCard = itemView.findViewById(R.id.export_card);
+ year_text_view = itemView.findViewById(R.id.year_text_view);
+ }
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View statsCardView = LayoutInflater.from(parent.getContext()).inflate(R.layout.statistics_card, parent, false);
+ return new ViewHolder(statsCardView);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+
+ ObservationModel[] observations = database.getAllObservationsFromAYear(seasons.get(position));
+
+ Set fields = new HashSet<>();
+ Set observationUnits = new HashSet<>();
+ Set collectors = new HashSet<>();
+ ArrayList dateObjects = new ArrayList<>();
+ Map dateCount = new HashMap<>();
+ Map observationCount = new HashMap<>();
+ int imageCount = 0;
+
+ for (ObservationModel observation : observations) {
+
+ fields.add(observation.getStudy_id());
+
+ observationUnits.add(observation.getObservation_unit_id());
+
+ String collector = observation.getCollector();
+ if (collector != null && !collector.trim().isEmpty()) {
+ collectors.add(collector);
+ }
+
+ String time = observation.getObservation_time_stamp();
+ Date dateObject;
+ try {
+ dateObject = timeStampFormat.parse(time);
+ } catch (ParseException e) {
+ throw new RuntimeException(e);
+ }
+ dateObjects.add(dateObject);
+
+ if (observation.getObservation_variable_field_book_format().equals("photo")) {
+ imageCount++;
+ }
+
+ String date = new SimpleDateFormat(DATE_FORMAT_PATTERN, Locale.getDefault()).format(dateObject);
+ dateCount.put(date, dateCount.getOrDefault(date, 0) + 1);
+
+ String observationUnitId = observation.getObservation_unit_id();
+ observationCount.put(observationUnitId, observationCount.getOrDefault(observationUnitId, 0) + 1);
+
+ }
+
+ long totalInterval = 0;
+ for (int i = 1; i< dateObjects.size(); i++){
+ long diff = dateObjects.get(i).getTime() - dateObjects.get(i-1).getTime();
+ if (diff <= TimeUnit.MINUTES.toMillis(intervalThreshold)){
+ totalInterval += TimeUnit.MILLISECONDS.toSeconds(diff);
+ }
+ }
+ String timeString = String.format("%.2f", totalInterval / 3600.0);
+
+ int maxObservationsInADay = 0;
+ String dateWithMostObservations = null;
+ for (Map.Entry entry : dateCount.entrySet()) {
+ if (entry.getValue() > maxObservationsInADay) {
+ maxObservationsInADay = entry.getValue();
+ dateWithMostObservations = entry.getKey();
+ }
+ }
+
+ int maxObservationsOnSingleUnit = 0;
+ String unitWithMostObservations = null;
+ for (Map.Entry entry : observationCount.entrySet()) {
+ if (entry.getValue() > maxObservationsOnSingleUnit) {
+ maxObservationsOnSingleUnit = entry.getValue();
+ unitWithMostObservations = entry.getKey();
+ }
+ }
+
+ String cardTitle = "";
+ Date date;
+
+ switch (cardType) {
+ case TOTAL: cardTitle = originActivity.getString(R.string.stats_tab_layout_total); break;
+ case YEAR: cardTitle = seasons.get(position); break;
+ case MONTH:
+ try {
+ date = yearMonthFormat.parse(seasons.get(position));
+ cardTitle = monthViewCardTitle.format(date);
+ } catch (ParseException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ holder.year_text_view.setText(cardTitle);
+
+ List fieldNames = new ArrayList<>();
+ for (String field : fields) {
+ fieldNames.add(database.getFieldObject(Integer.valueOf(field)).getExp_name());
+ }
+
+ List unitWithMostObservationsList = new ArrayList<>();
+ for (ObservationModel observation : observations) {
+ if (observation.getObservation_unit_id().equals(unitWithMostObservations)) {
+ final String traitFormat = observation.getObservation_variable_field_book_format();
+ if (traitFormat.equals("categorical") || traitFormat.equals("multicat") || traitFormat.equals("qualitative"))
+ unitWithMostObservationsList.add(observation.getObservation_variable_name() + ": " + decodeCategorical(observation.getValue()));
+ else if (traitFormat.equals("photo"))
+ unitWithMostObservationsList.add(observation.getObservation_variable_name() + ": " + "");
+ else
+ unitWithMostObservationsList.add(observation.getObservation_variable_name() + ": " + observation.getValue());
+ }
+ }
+
+ holder.exportCard.setOnClickListener(view -> exportCard(holder));
+
+ List statisticObjectList = new ArrayList<>();
+
+ statisticObjectList.add(new StatisticObject(originActivity.getString(R.string.stat_title_fields), String.valueOf(fields.size()), R.drawable.ic_stats_field, 0, originActivity.getString(R.string.stat_fields_dialog_title) + " " + seasons.get(position), fieldNames, ""));
+ statisticObjectList.add(new StatisticObject(originActivity.getString(R.string.stat_title_entries), String.valueOf(observationUnits.size()), R.drawable.ic_stats_plot, 1, "", null, observationUnits.size() + " " + originActivity.getString(R.string.stat_entries_toast_message)));
+ statisticObjectList.add(new StatisticObject(originActivity.getString(R.string.stat_title_data), String.valueOf(observations.length), R.drawable.ic_stats_observation, 1, "", null, observations.length + " " + originActivity.getString(R.string.stat_data_toast_message)));
+ statisticObjectList.add(new StatisticObject(originActivity.getString(R.string.stat_title_hours), timeString, R.drawable.ic_stats_time, 1, "", null, timeString + " " + originActivity.getString(R.string.stat_hours_toast_message)));
+ statisticObjectList.add(new StatisticObject(originActivity.getString(R.string.stat_title_people), String.valueOf(collectors.size()), R.drawable.ic_stats_people, 0, originActivity.getString(R.string.stat_people_dialog_title), new ArrayList<>(collectors), ""));
+ statisticObjectList.add(new StatisticObject(originActivity.getString(R.string.stat_title_photos), String.valueOf(imageCount), R.drawable.ic_stats_photo, 1, "", null, imageCount + " " + originActivity.getString(R.string.stat_photos_toast_message)));
+ statisticObjectList.add(new StatisticObject(originActivity.getString(R.string.stat_title_busiest), dateWithMostObservations, R.drawable.ic_stats_busiest, 1, "", null, maxObservationsInADay + " " + originActivity.getString(R.string.stat_busiest_toast_message) + " " + dateWithMostObservations));
+ statisticObjectList.add(new StatisticObject(originActivity.getString(R.string.stat_title_most), String.valueOf(maxObservationsOnSingleUnit), R.drawable.ic_stats_most_obs, 0, unitWithMostObservations, unitWithMostObservationsList, ""));
+
+ GridLayoutManager layoutManager = new GridLayoutManager(originActivity, 4);
+
+ holder.rvStatsContainer.setLayoutManager(layoutManager);
+ holder.rvStatsContainer.setAdapter(new StatisticsCardAdapter(originActivity, this, statisticObjectList));
+
+ }
+
+ @Override
+ public int getItemCount() {
+ return seasons.size();
+ }
+
+ /**
+ * Exports a statistics card as an image
+ */
+ public void exportCard(ViewHolder holder) {
+
+ CharSequence originalText = holder.year_text_view.getText();
+ if (cardType == StatisticsActivity.ToggleVariable.TOTAL)
+ holder.year_text_view.setText(originActivity.getString(R.string.stat_card_export_title_total));
+ else
+ holder.year_text_view.setText(String.format("%s %s", originActivity.getString(R.string.stat_card_export_title), originalText));
+
+ Bitmap cardBitmap = Bitmap.createBitmap(holder.statisticsCard.getWidth(), holder.statisticsCard.getHeight(), Bitmap.Config.ARGB_8888);
+ Canvas cardCanvas = new Canvas(cardBitmap);
+ cardCanvas.drawColor(Color.WHITE);
+ holder.statisticsCard.draw(cardCanvas);
+
+ // Adding the field book logo
+ Drawable drawable = ContextCompat.getDrawable(originActivity, R.mipmap.ic_launcher);
+ Bitmap logoBitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth() / 2, drawable.getIntrinsicHeight() / 2, Bitmap.Config.ARGB_8888);
+ Canvas logoCanvas = new Canvas(logoBitmap);
+ drawable.setBounds(0, 0, logoCanvas.getWidth(), logoCanvas.getHeight());
+ drawable.draw(logoCanvas);
+ cardCanvas.drawBitmap(logoBitmap, cardBitmap.getWidth() - logoBitmap.getWidth() - 12, 12, null); // setting the co-ordinates for the logo with a padding of 10dp
+
+ try {
+ DocumentFile imagesDir = BaseDocumentTreeUtil.Companion.getDirectory(originActivity, R.string.dir_media_photos);
+ if (imagesDir != null && imagesDir.exists()) {
+ DocumentFile exportImage = imagesDir.createFile("image/jpg", holder.year_text_view.getText() + "_" + System.currentTimeMillis() + ".jpg");
+ if (exportImage != null && exportImage.exists()) {
+ OutputStream outputStream = originActivity.getContentResolver().openOutputStream(exportImage.getUri());
+ if (outputStream != null) {
+ cardBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
+ outputStream.close();
+ FileUtil.shareFile(originActivity, originActivity.getPrefs(), exportImage);
+ }
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ } finally {
+ holder.year_text_view.setText(originalText);
+ }
+ }
+
+ private String decodeCategorical(String value) {
+ ArrayList cats = CategoryJsonUtil.Companion.decode(value);
+ StringBuilder v = new StringBuilder(cats.get(0).getValue());
+ if (cats.size() > 1) {
+ for (int i = 1; i < cats.size(); i++)
+ v.append(", ").append(cats.get(i).getValue());
+ }
+ return v.toString();
+ }
+
+ public Toast getToast() {
+ return toast;
+ }
+
+ public void setToast(Toast toast) {
+ this.toast = toast;
+ }
+}
diff --git a/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsCardAdapter.java b/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsCardAdapter.java
new file mode 100644
index 000000000..203aa7bf0
--- /dev/null
+++ b/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsCardAdapter.java
@@ -0,0 +1,117 @@
+package com.fieldbook.tracker.adapters;
+
+import android.app.AlertDialog;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.fieldbook.tracker.R;
+import com.fieldbook.tracker.activities.StatisticsActivity;
+import com.fieldbook.tracker.objects.StatisticObject;
+
+import java.util.List;
+
+public class StatisticsCardAdapter extends RecyclerView.Adapter {
+ StatisticsActivity originActivity;
+ List statsList;
+ StatisticsAdapter adapter;
+
+ public StatisticsCardAdapter(StatisticsActivity context, StatisticsAdapter adapter, List statsList) {
+ this.originActivity = context;
+ this.statsList = statsList;
+ this.adapter = adapter;
+ }
+
+ public class ViewHolder extends RecyclerView.ViewHolder {
+ LinearLayout individualStatContainer;
+ TextView statTitle, statValue;
+ ImageView statIcon;
+
+ public ViewHolder(@NonNull View itemView) {
+ super(itemView);
+
+ individualStatContainer = itemView.findViewById(R.id.individual_stat_container);
+ statTitle = itemView.findViewById(R.id.stat_title);
+ statIcon = itemView.findViewById(R.id.stat_icon);
+ statValue = itemView.findViewById(R.id.stat_value);
+ }
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View individualStatsView = LayoutInflater.from(parent.getContext()).inflate(R.layout.statistics_card_individual_stats, parent, false);
+ return new StatisticsCardAdapter.ViewHolder(individualStatsView);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+
+ StatisticObject statObject = statsList.get(position);
+
+ holder.statTitle.setText(statObject.getStatTitle());
+ holder.statValue.setText(statObject.getStatValue());
+ holder.statIcon.setImageResource(statObject.getStatIconId());
+
+ if (statObject.getIsToast() == 1) {
+ holder.individualStatContainer.setOnClickListener(view -> displayToast(statObject.getToastMessage()));
+ } else {
+ holder.individualStatContainer.setOnClickListener(view -> displayDialog(statObject.getDialogTitle(), statObject.getDialogData()));
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return statsList.size();
+ }
+
+ /**
+ * Displays a dialog with the list of matching items of a statistic
+ * @param titleString title of the dialog
+ * @param data list of items to be displayed
+ */
+ public void displayDialog(String titleString, List data) {
+
+ if (adapter.getToast() != null) {
+ adapter.getToast().cancel();
+ }
+
+ if (data.size() == 0) {
+ displayToast(originActivity.getString(R.string.warning_no_data));
+ return;
+ }
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(originActivity, R.style.AppAlertDialog);
+
+ View layout = originActivity.getLayoutInflater().inflate(R.layout.dialog_individual_statistics, null);
+ builder.setTitle(titleString).setView(layout);
+ builder.setNegativeButton(R.string.dialog_close, (dialogInterface, id) -> dialogInterface.dismiss());
+
+ final AlertDialog dialog = builder.create();
+
+ ListView statsList = layout.findViewById(R.id.statsList);
+ statsList.setAdapter(new StatisticsListAdapter(originActivity, data));
+
+ dialog.show();
+ }
+
+ /**
+ * Displays a toast with the given message
+ * @param toastMessage message to be displayed
+ */
+ public void displayToast(String toastMessage) {
+ if (adapter.getToast() != null) {
+ adapter.getToast().cancel();
+ }
+ adapter.setToast(Toast.makeText(originActivity, toastMessage, Toast.LENGTH_LONG));
+ adapter.getToast().show();
+ }
+}
diff --git a/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsListAdapter.java b/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsListAdapter.java
new file mode 100644
index 000000000..3d0448d7d
--- /dev/null
+++ b/app/src/main/java/com/fieldbook/tracker/adapters/StatisticsListAdapter.java
@@ -0,0 +1,60 @@
+package com.fieldbook.tracker.adapters;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.TextView;
+
+import com.fieldbook.tracker.R;
+
+import java.util.List;
+
+public class StatisticsListAdapter extends BaseAdapter {
+
+ private List data;
+ private LayoutInflater inflater;
+
+ public StatisticsListAdapter(Context context, List data) {
+ this.data = data;
+ this.inflater = LayoutInflater.from(context);
+ }
+
+ @Override
+ public int getCount() {
+ return data.size();
+ }
+
+ @Override
+ public String getItem(int i) {
+ return data.get(i);
+ }
+
+ @Override
+ public long getItemId(int i) {
+ return i;
+ }
+
+ @Override
+ public View getView(int i, View view, ViewGroup viewGroup) {
+ ViewHolder holder;
+
+ if (view == null) {
+ holder = new ViewHolder();
+ view = inflater.inflate(R.layout.list_item_individual_statistics, null);
+ holder.listItem = view.findViewById(R.id.list_item_individual_stats);
+ view.setTag(holder);
+ } else {
+ holder = (ViewHolder) view.getTag();
+ }
+
+ holder.listItem.setText(getItem(i));
+
+ return view;
+ }
+
+ private class ViewHolder {
+ TextView listItem;
+ }
+}
diff --git a/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java b/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java
index 0cf79c561..89c60d748 100644
--- a/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java
+++ b/app/src/main/java/com/fieldbook/tracker/database/DataHelper.java
@@ -2765,6 +2765,20 @@ public ObservationModel[] getAllObservations(String studyId, String plotId, Stri
return ObservationDao.Companion.getAll(studyId, plotId, traitDbId);
}
+// public ObservationModel[] getAllObservationsFromAYear(String startDate, String endDate) {
+//
+// open();
+//
+// return ObservationDao.Companion.getAllFromAYear(startDate, endDate);
+// }
+
+ public ObservationModel[] getAllObservationsFromAYear(String year) {
+
+ open();
+
+ return ObservationDao.Companion.getAllFromAYear(year);
+ }
+
public ObservationModel[] getRepeatedValues(String studyId, String plotId, String traitDbId) {
open();
diff --git a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationDao.kt b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationDao.kt
index 344b314a0..ba2f08036 100644
--- a/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationDao.kt
+++ b/app/src/main/java/com/fieldbook/tracker/database/dao/ObservationDao.kt
@@ -86,6 +86,32 @@ class ObservationDao {
} ?: emptyArray()
+// fun getAllFromAYear(startDate: String, endDate: String): Array = withDatabase { db ->
+//
+// db.query(
+// Observation.tableName,
+// where = "SUBSTR(observation_time_stamp, 1, 10) BETWEEN ? AND ? AND study_id > 0",
+// whereArgs = arrayOf(startDate, endDate)
+// )
+// .toTable()
+// .map { ObservationModel(it) }
+// .toTypedArray()
+//
+// } ?: emptyArray()
+
+ fun getAllFromAYear(year: String): Array = withDatabase { db ->
+
+ db.query(
+ Observation.tableName,
+ where = "observation_time_stamp LIKE ? AND study_id > 0",
+ whereArgs = arrayOf("$year%")
+ )
+ .toTable()
+ .map { ObservationModel(it) }
+ .toTypedArray()
+
+ } ?: emptyArray()
+
fun getAllRepeatedValues(studyId: String, obsUnit: String, traitDbId: String) =
getAll(studyId, obsUnit, traitDbId)
diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/DateRangePickerDialog.java b/app/src/main/java/com/fieldbook/tracker/dialogs/DateRangePickerDialog.java
new file mode 100644
index 000000000..3f30dd950
--- /dev/null
+++ b/app/src/main/java/com/fieldbook/tracker/dialogs/DateRangePickerDialog.java
@@ -0,0 +1,83 @@
+package com.fieldbook.tracker.dialogs;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+
+import com.archit.calendardaterangepicker.customviews.CalendarListener;
+import com.archit.calendardaterangepicker.customviews.DateRangeCalendarView;
+import com.fieldbook.tracker.R;
+import com.fieldbook.tracker.activities.StatisticsActivity;
+
+import java.time.LocalDate;
+import java.util.Calendar;
+
+public class DateRangePickerDialog extends DialogFragment {
+ StatisticsActivity originActivity;
+ Calendar dateSelectorStartRange;
+ Calendar dateSelectorEndRange;
+ Calendar heatmapStartRange;
+ Calendar heatmapEndRange;
+ onDateRangeSelectedListener listener;
+
+ public DateRangePickerDialog(StatisticsActivity statisticsActivity, Calendar dateSelectorStartRange, Calendar dateSelectorEndRange, onDateRangeSelectedListener listener) {
+ this.originActivity = statisticsActivity;
+ this.dateSelectorStartRange = dateSelectorStartRange;
+ this.dateSelectorEndRange = dateSelectorEndRange;
+ this.listener = listener;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(originActivity, R.style.AppAlertDialog);
+
+ View customView = getLayoutInflater().inflate(R.layout.dialog_calendar_range_picker, null);
+ builder.setTitle(getString(R.string.stats_date_range_picker_title)).setCancelable(true).setView(customView);
+
+ DateRangeCalendarView calendar = customView.findViewById(R.id.calendarRange);
+
+ calendar.setCalendarListener(new CalendarListener() {
+ @Override
+ public void onFirstDateSelected(@NonNull Calendar start) {
+ heatmapStartRange = start;
+ heatmapEndRange = null;
+ }
+
+ @Override
+ public void onDateRangeSelected(@NonNull Calendar start, @NonNull Calendar end) {
+ heatmapStartRange = start;
+ heatmapEndRange = end;
+ }
+ });
+
+ calendar.setSelectableDateRange(dateSelectorStartRange, dateSelectorEndRange);
+
+ builder.setPositiveButton(R.string.dialog_ok, (dialogInterface1, id1) -> {
+ if (heatmapStartRange == null || heatmapEndRange == null) {
+ Toast.makeText(originActivity, R.string.warning_invalid_date_range, Toast.LENGTH_SHORT).show();
+ } else {
+ LocalDate start = LocalDate.of(heatmapStartRange.get(Calendar.YEAR), heatmapStartRange.get(Calendar.MONTH) + 1, heatmapStartRange.get(Calendar.DAY_OF_MONTH));
+ LocalDate end = LocalDate.of(heatmapEndRange.get(Calendar.YEAR), heatmapEndRange.get(Calendar.MONTH) + 1, heatmapEndRange.get(Calendar.DAY_OF_MONTH));
+ listener.onDateRangeSelected(start, end);
+ }
+ });
+
+ return builder.create();
+ }
+
+ public interface onDateRangeSelectedListener {
+ /**
+ * Sets the heatmap date range
+ */
+ void onDateRangeSelected(LocalDate start, LocalDate end);
+
+ }
+
+}
diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/StatisticsCalendarFragment.java b/app/src/main/java/com/fieldbook/tracker/dialogs/StatisticsCalendarFragment.java
new file mode 100644
index 000000000..ee95054df
--- /dev/null
+++ b/app/src/main/java/com/fieldbook/tracker/dialogs/StatisticsCalendarFragment.java
@@ -0,0 +1,293 @@
+package com.fieldbook.tracker.dialogs;
+
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+
+import com.fieldbook.tracker.R;
+import com.fieldbook.tracker.activities.StatisticsActivity;
+import com.fieldbook.tracker.database.DataHelper;
+import com.fieldbook.tracker.database.models.ObservationModel;
+import com.kizitonwose.calendar.core.CalendarDay;
+import com.kizitonwose.calendar.core.CalendarMonth;
+import com.kizitonwose.calendar.core.DayPosition;
+import com.kizitonwose.calendar.view.CalendarView;
+import com.kizitonwose.calendar.view.MonthDayBinder;
+import com.kizitonwose.calendar.view.MonthHeaderFooterBinder;
+import com.kizitonwose.calendar.view.ViewContainer;
+
+import java.text.SimpleDateFormat;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.time.format.TextStyle;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static com.kizitonwose.calendar.core.ExtensionsKt.daysOfWeek;
+
+public class StatisticsCalendarFragment extends Fragment implements DateRangePickerDialog.onDateRangeSelectedListener {
+ DataHelper database;
+ StatisticsActivity originActivity;
+ Toolbar toolbar;
+ CalendarView monthCalendarView;
+ int dateToggle;
+ YearMonth firstMonth, lastMonth;
+ LocalDate heatMapStartDate, heatMapEndDate;
+ Calendar dateSelectorStartRange, dateSelectorEndRange;
+ DateTimeFormatter timeStampFormat;
+ private static final String TIME_STAMP_PATTERN = "yyyy-MM-dd HH:mm:ss.SSSZZZZZ";
+ private static final String MONTH_HEADER_PATTERN = "MMMM yyyy";
+ private static final int THRESHOLD_LOW = 1;
+ private static final int THRESHOLD_MEDIUM = 5;
+ private static final int THRESHOLD_HIGH = 8;
+
+ public StatisticsCalendarFragment(StatisticsActivity statisticsActivity) {
+ this.originActivity = statisticsActivity;
+ this.dateToggle = 0;
+ this.dateSelectorStartRange = Calendar.getInstance();
+ this.dateSelectorEndRange = Calendar.getInstance();
+ this.timeStampFormat = DateTimeFormatter.ofPattern(TIME_STAMP_PATTERN);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+
+ database = originActivity.getDatabase();
+ View customView = inflater.inflate(R.layout.calendar_view, container, false);
+
+ toolbar = customView.findViewById(R.id.toolbar);
+ setupToolbar();
+
+ Map observationCount = getObservationCount();
+
+ monthCalendarView = customView.findViewById(R.id.calendarView);
+ monthCalendarView.setDayBinder(new MonthDayBinder() {
+ @NonNull
+ @Override
+ public DayViewContainer create(@NonNull View view) {
+ return new DayViewContainer(view);
+ }
+
+ @Override
+ public void bind(@NonNull DayViewContainer container, CalendarDay day) {
+ int count = observationCount.getOrDefault(day.getDate(), 0);
+
+ // Displays the date or the number of observations collected on the date based on the toggle
+ if (dateToggle == 0)
+ container.calendarDayText.setText(String.valueOf(day.getDate().getDayOfMonth()));
+ else container.calendarDayText.setText(String.valueOf(count));
+
+ // Reset the text and background color
+ container.calendarDayText.setTextColor(Color.TRANSPARENT);
+ container.circleBackground.setBackgroundTintList(ColorStateList.valueOf(Color.TRANSPARENT));
+
+ if (day.getPosition() == DayPosition.MonthDate) {
+ // Setting the color if the date is out of the selected range
+ if (day.getDate().isBefore(heatMapStartDate) || day.getDate().isAfter(heatMapEndDate)) {
+ container.calendarDayText.setTextColor(Color.GRAY);
+ } else {
+ // Setting the text and background color for the date
+ container.calendarDayText.setTextColor(Color.BLACK);
+ if (count > 0)
+ container.circleBackground.setBackgroundTintList(ColorStateList.valueOf(getColorForObservations(count)));
+ }
+ }
+ }
+ });
+
+ monthCalendarView.setMonthHeaderBinder(new MonthHeaderFooterBinder() {
+ @NonNull
+ @Override
+ public MonthViewContainer create(@NonNull View view) {
+ return new MonthViewContainer(view);
+ }
+
+ @Override
+ public void bind(@NonNull MonthViewContainer container, @NonNull CalendarMonth month) {
+ container.calendarMonthTitle.setText(month.getYearMonth().format(DateTimeFormatter.ofPattern(MONTH_HEADER_PATTERN)));
+ }
+ });
+
+ YearMonth currentMonth = YearMonth.now();
+ List daysOfWeek = daysOfWeek();
+
+ monthCalendarView.setup(firstMonth, currentMonth, daysOfWeek.get(0));
+ monthCalendarView.scrollToMonth(currentMonth);
+
+ // Displaying the days of the week titles
+ ViewGroup titlesContainer = customView.findViewById(R.id.calendarDayTitlesContainer);
+ for (int i = 0; i < titlesContainer.getChildCount(); i++) {
+ View childView = titlesContainer.getChildAt(i);
+ if (childView instanceof TextView) {
+ TextView textView = (TextView) childView;
+ String title = daysOfWeek.get(i).getDisplayName(TextStyle.SHORT, Locale.getDefault());
+ textView.setText(title);
+ }
+ }
+
+ return customView;
+ }
+
+ private void setupToolbar() {
+ originActivity.setSupportActionBar(toolbar);
+
+ if (originActivity.getSupportActionBar() != null) {
+ originActivity.getSupportActionBar().setTitle(getString(R.string.stats_heatmap_title));
+ originActivity.getSupportActionBar().getThemedContext();
+ originActivity.getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ originActivity.getSupportActionBar().setHomeButtonEnabled(true);
+ }
+ setHasOptionsMenu(true);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ menu.clear();
+ inflater.inflate(R.menu.menu_statistics_calendar_heatmap, menu);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+
+ final int firstDay = R.id.stats_first_day;
+ final int lastDay = R.id.stats_last_day;
+ final int calendarRange = R.id.stats_calendar_range;
+ final int counter = R.id.stats_counter;
+
+ int itemId = item.getItemId();
+
+ if (itemId == firstDay) {
+ monthCalendarView.scrollToMonth(this.firstMonth);
+ } else if (itemId == lastDay) {
+ monthCalendarView.scrollToMonth(this.lastMonth);
+ } else if (itemId == calendarRange) {
+ DateRangePickerDialog dialog = new DateRangePickerDialog(originActivity, dateSelectorStartRange, dateSelectorEndRange, this);
+ dialog.show(originActivity.getSupportFragmentManager(), "StatisticsActivity");
+ } else if (itemId == counter) {
+ dateToggle = 1 - dateToggle;
+ monthCalendarView.notifyCalendarChanged();
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ /**
+ * Calculates the number of observations collected on each day
+ */
+ private Map getObservationCount() {
+ ObservationModel[] observations = database.getAllObservations();
+
+ Map observationCount = new HashMap<>();
+
+ // Sets the heatmap date range when the page loads for the first time
+ heatMapStartDate = LocalDate.parse(observations[0].getObservation_time_stamp(), timeStampFormat);
+ heatMapEndDate = LocalDate.now();
+
+ setFirstAndLastDates();
+
+ SimpleDateFormat sdf = new SimpleDateFormat(TIME_STAMP_PATTERN, Locale.getDefault());
+
+ // Sets the limits for the date range selection calendar
+ try {
+ dateSelectorStartRange.setTime(sdf.parse(observations[0].getObservation_time_stamp()));
+ dateSelectorEndRange.setTime(sdf.parse(observations[observations.length - 1].getObservation_time_stamp()));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+
+ for (ObservationModel observation : observations) {
+ LocalDate date = LocalDate.parse(observation.getObservation_time_stamp(), timeStampFormat);
+ observationCount.put(date, observationCount.getOrDefault(date, 0) + 1);
+ }
+ return observationCount;
+ }
+
+ @Override
+ public void onDateRangeSelected(LocalDate start, LocalDate end) {
+
+ heatMapStartDate = start;
+ heatMapEndDate = end;
+ setFirstAndLastDates();
+ monthCalendarView.updateMonthData(YearMonth.from(heatMapStartDate), YearMonth.from(heatMapEndDate));
+ }
+
+ /**
+ * Sets the first and last dates with an observation within the heatmap date range.
+ */
+ public void setFirstAndLastDates() {
+
+ ObservationModel[] observations = database.getAllObservations();
+
+ for (ObservationModel observation : observations) {
+ LocalDate date = LocalDate.parse(observation.getObservation_time_stamp(), timeStampFormat);
+ if (date.isEqual(heatMapStartDate) || (date.isAfter(heatMapStartDate) && date.isBefore(heatMapEndDate))) {
+ firstMonth = YearMonth.from(date);
+ break;
+ }
+ }
+
+ for (int i = observations.length - 1; i >= 0; i--) {
+ ObservationModel observation = observations[i];
+ LocalDate date = LocalDate.parse(observation.getObservation_time_stamp(), timeStampFormat);
+ if (date.isEqual(heatMapEndDate) || (date.isAfter(heatMapStartDate) && date.isBefore(heatMapEndDate))) {
+ lastMonth = YearMonth.from(date);
+ break;
+ }
+ }
+ }
+
+ public class DayViewContainer extends ViewContainer {
+ public final TextView calendarDayText;
+ public final View circleBackground;
+
+ public DayViewContainer(View view) {
+ super(view);
+ calendarDayText = view.findViewById(R.id.calendarDayText);
+ circleBackground = view.findViewById(R.id.circleBackground);
+ }
+ }
+
+ public class MonthViewContainer extends ViewContainer {
+ TextView calendarMonthTitle;
+
+ MonthViewContainer(@NonNull View view) {
+ super(view);
+ calendarMonthTitle = view.findViewById(R.id.calendarMonthTitle);
+ }
+ }
+
+ /**
+ * Returns the color for the heatmap based on the number of observations
+ * TODO: Change the threshold values (and possibly colors)
+ * @return ID of the color
+ */
+ private int getColorForObservations(int observations) {
+ if (observations == THRESHOLD_LOW) {
+ return ContextCompat.getColor(requireContext(),R.color.heatmap_color_low);
+ } else if (observations < THRESHOLD_MEDIUM) {
+ return ContextCompat.getColor(requireContext(),R.color.heatmap_color_medium);
+ } else if (observations < THRESHOLD_HIGH) {
+ return ContextCompat.getColor(requireContext(),R.color.heatmap_color_high);
+ } else {
+ return ContextCompat.getColor(requireContext(),R.color.heatmap_color_max);
+ }
+ }
+}
diff --git a/app/src/main/java/com/fieldbook/tracker/objects/StatisticObject.java b/app/src/main/java/com/fieldbook/tracker/objects/StatisticObject.java
new file mode 100644
index 000000000..23f82a756
--- /dev/null
+++ b/app/src/main/java/com/fieldbook/tracker/objects/StatisticObject.java
@@ -0,0 +1,61 @@
+package com.fieldbook.tracker.objects;
+
+import java.util.List;
+
+public class StatisticObject {
+ private String statTitle;
+ private String statValue;
+ private int statIconId;
+ private int isToast;
+ private String dialogTitle;
+ private List dialogData;
+ private String toastMessage;
+
+ /**
+ * Container for individual stats within the statistics card
+ * @param statTitle Title of the statistic
+ * @param statValue Value of the statistic
+ * @param statIconId Resource ID for the icon of the statistic
+ * @param isToast 1: if a toast message is required when clicked, 0: if a dialog is required when clicked
+ * @param dialogTitle Title of the dialog; empty string if isToast = 1
+ * @param dialogData List to be displayed within the dialog; null string if isToast = 1
+ * @param toastMessage Message to be displayed in the toast; empty string if isToast = 0
+ */
+ public StatisticObject(String statTitle, String statValue, int statIconId, int isToast, String dialogTitle, List dialogData, String toastMessage) {
+ this.statTitle = statTitle;
+ this.statValue = statValue;
+ this.statIconId = statIconId;
+ this.isToast = isToast;
+ this.dialogTitle = dialogTitle;
+ this.dialogData = dialogData;
+ this.toastMessage = toastMessage;
+ }
+
+ public String getStatTitle() {
+ return statTitle;
+ }
+
+ public String getStatValue() {
+ return statValue;
+ }
+
+ public int getStatIconId() {
+ return statIconId;
+ }
+
+ public int getIsToast() {
+ return isToast;
+ }
+
+ public String getDialogTitle() {
+ return dialogTitle;
+ }
+
+ public List getDialogData() {
+ return dialogData;
+ }
+
+ public String getToastMessage() {
+ return toastMessage;
+ }
+}
diff --git a/app/src/main/res/drawable/ic_nav_drawer_statistics.xml b/app/src/main/res/drawable/ic_nav_drawer_statistics.xml
new file mode 100644
index 000000000..56770ba07
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_drawer_statistics.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_stats_busiest.xml b/app/src/main/res/drawable/ic_stats_busiest.xml
new file mode 100644
index 000000000..db23520b7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_busiest.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_calendar.xml b/app/src/main/res/drawable/ic_stats_calendar.xml
new file mode 100644
index 000000000..ba9f7dbb3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_calendar.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_calendar_range.xml b/app/src/main/res/drawable/ic_stats_calendar_range.xml
new file mode 100644
index 000000000..945e2e4d6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_calendar_range.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_counter.xml b/app/src/main/res/drawable/ic_stats_counter.xml
new file mode 100644
index 000000000..4c71aaabe
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_counter.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_export.xml b/app/src/main/res/drawable/ic_stats_export.xml
new file mode 100644
index 000000000..496da6f88
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_export.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_field.xml b/app/src/main/res/drawable/ic_stats_field.xml
new file mode 100644
index 000000000..a07661010
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_field.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_most_obs.xml b/app/src/main/res/drawable/ic_stats_most_obs.xml
new file mode 100644
index 000000000..5d9e8db05
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_most_obs.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_observation.xml b/app/src/main/res/drawable/ic_stats_observation.xml
new file mode 100644
index 000000000..bb2d5f319
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_observation.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_people.xml b/app/src/main/res/drawable/ic_stats_people.xml
new file mode 100644
index 000000000..63caa0626
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_people.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_photo.xml b/app/src/main/res/drawable/ic_stats_photo.xml
new file mode 100644
index 000000000..7585ec36e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_photo.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_plot.xml b/app/src/main/res/drawable/ic_stats_plot.xml
new file mode 100644
index 000000000..4042398f0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_plot.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_scroll_bottom.xml b/app/src/main/res/drawable/ic_stats_scroll_bottom.xml
new file mode 100644
index 000000000..d4bdced88
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_scroll_bottom.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_scroll_top.xml b/app/src/main/res/drawable/ic_stats_scroll_top.xml
new file mode 100644
index 000000000..b9823e7e9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_scroll_top.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_stats_time.xml b/app/src/main/res/drawable/ic_stats_time.xml
new file mode 100644
index 000000000..765804a7a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stats_time.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_statistics.xml b/app/src/main/res/layout/activity_statistics.xml
new file mode 100644
index 000000000..3bc1b1e5c
--- /dev/null
+++ b/app/src/main/res/layout/activity_statistics.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/calendar_day_layout.xml b/app/src/main/res/layout/calendar_day_layout.xml
new file mode 100644
index 000000000..4ddf1b562
--- /dev/null
+++ b/app/src/main/res/layout/calendar_day_layout.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/calendar_day_title_text.xml b/app/src/main/res/layout/calendar_day_title_text.xml
new file mode 100644
index 000000000..654baf7de
--- /dev/null
+++ b/app/src/main/res/layout/calendar_day_title_text.xml
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/calendar_month_title.xml b/app/src/main/res/layout/calendar_month_title.xml
new file mode 100644
index 000000000..dd2321387
--- /dev/null
+++ b/app/src/main/res/layout/calendar_month_title.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/calendar_view.xml b/app/src/main/res/layout/calendar_view.xml
new file mode 100644
index 000000000..3782e35d7
--- /dev/null
+++ b/app/src/main/res/layout/calendar_view.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_calendar_range_picker.xml b/app/src/main/res/layout/dialog_calendar_range_picker.xml
new file mode 100644
index 000000000..4411cf0de
--- /dev/null
+++ b/app/src/main/res/layout/dialog_calendar_range_picker.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_individual_statistics.xml b/app/src/main/res/layout/dialog_individual_statistics.xml
new file mode 100644
index 000000000..8c6523e4b
--- /dev/null
+++ b/app/src/main/res/layout/dialog_individual_statistics.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_loading.xml b/app/src/main/res/layout/dialog_loading.xml
new file mode 100644
index 000000000..6b9c552b3
--- /dev/null
+++ b/app/src/main/res/layout/dialog_loading.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_individual_statistics.xml b/app/src/main/res/layout/list_item_individual_statistics.xml
new file mode 100644
index 000000000..2a940168a
--- /dev/null
+++ b/app/src/main/res/layout/list_item_individual_statistics.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/statistics_card.xml b/app/src/main/res/layout/statistics_card.xml
new file mode 100644
index 000000000..c3ef7c404
--- /dev/null
+++ b/app/src/main/res/layout/statistics_card.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/statistics_card_individual_stats.xml b/app/src/main/res/layout/statistics_card_individual_stats.xml
new file mode 100644
index 000000000..1933de2e4
--- /dev/null
+++ b/app/src/main/res/layout/statistics_card_individual_stats.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/menu_statistics.xml b/app/src/main/res/menu/menu_statistics.xml
new file mode 100644
index 000000000..6c99a6b9d
--- /dev/null
+++ b/app/src/main/res/menu/menu_statistics.xml
@@ -0,0 +1,13 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_statistics_calendar_heatmap.xml b/app/src/main/res/menu/menu_statistics_calendar_heatmap.xml
new file mode 100644
index 000000000..5f224adf8
--- /dev/null
+++ b/app/src/main/res/menu/menu_statistics_calendar_heatmap.xml
@@ -0,0 +1,29 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 38704f65a..06ad521fa 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -99,4 +99,10 @@
#20FEFEFE
#FFFFFF
+
+ #b2f2bb
+ #69db7c
+ #40c057
+ #2f9e44
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f0e5f8b97..a8bd988df 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -66,6 +66,7 @@
Export
Settings
Profile
+ Statistics
Database could not retrieve a field or plot with code: %s
@@ -851,6 +852,48 @@
Barcode Scanning using MLKit enabled
Enabling this feature will switch the barcode scanning library from ZXing to MLKit.
+
+
+
+ No observations have been collected
+ No data to display
+ Select a valid date range
+ Heatmap
+ View a heatmap of the collected observations
+ Loading…
+ Scroll to the first day with a collected observation
+ Scroll to the last day with a collected observation
+ Select a date range to filter the displayed dates
+ Swap the date with the number of observations collected that day
+ Select a date range
+
+ Total
+ Year
+ Month
+
+ Total Field Book Stats
+ Field Book Stats for
+
+ Fields
+ Entries
+ Data
+ Hours
+ People
+ Photos
+ Busiest
+ Most
+
+ Fields imported in
+ List of People
+ List of Observations
+
+ entries have been phenotyped
+ observations have been collected
+ hours spent phenotyping
+ photos have been captured
+ observations were collected on
+
+
About
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 672f18f81..5ef910aa5 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Wed May 04 16:02:28 CDT 2022
+#Mon Mar 11 16:25:47 EDT 2024
distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
distributionPath=wrapper/dists
-zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists