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