From b4ce99440a364485c5f2e08e238efefe7c9c3e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A1n=20Gin=C3=A9s?= Date: Wed, 15 Dec 2021 23:22:13 +0100 Subject: [PATCH] AggregatedStatisticsActivity: added filter option (by category, start and/or end date). Fixes #1039. --- .idea/misc.xml | 9 +- drawable-svg/ic_clear_filter.svg | 62 ++++++ .../EspressoAggregatedFilterTest.java | 81 +++++++ .../content/data/TrackSelectionTest.java | 205 ++++++++++++++++++ .../AggregatedStatisticsActivity.java | 83 ++++++- .../adapters/AggregatedStatisticsAdapter.java | 19 +- .../content/data/TrackSelection.java | 77 +++++++ .../provider/ContentProviderUtils.java | 15 +- .../content/provider/SelectionData.java | 24 ++ .../fragments/FilterDialogFragment.java | 188 ++++++++++++++++ .../opentracks/util/StringUtils.java | 8 + .../viewmodels/AggregatedStatisticsModel.java | 17 +- .../drawable/ic_baseline_date_range_24.xml | 10 + .../drawable/ic_baseline_filter_alt_24.xml | 10 + .../res/drawable/ic_clear_filter_24dp.xml | 14 ++ src/main/res/layout/aggregated_stats.xml | 1 + .../res/layout/fragment_filter_dialog.xml | 106 +++++++++ .../layout/fragment_filter_dialog_item.xml | 9 + src/main/res/menu/aggregated_statistics.xml | 15 ++ src/main/res/values/strings.xml | 5 + 20 files changed, 940 insertions(+), 18 deletions(-) create mode 100644 drawable-svg/ic_clear_filter.svg create mode 100644 src/androidTest/java/de/dennisguse/opentracks/EspressoAggregatedFilterTest.java create mode 100644 src/androidTest/java/de/dennisguse/opentracks/content/data/TrackSelectionTest.java create mode 100644 src/main/java/de/dennisguse/opentracks/content/data/TrackSelection.java create mode 100644 src/main/java/de/dennisguse/opentracks/content/provider/SelectionData.java create mode 100644 src/main/java/de/dennisguse/opentracks/fragments/FilterDialogFragment.java create mode 100644 src/main/res/drawable/ic_baseline_date_range_24.xml create mode 100644 src/main/res/drawable/ic_baseline_filter_alt_24.xml create mode 100644 src/main/res/drawable/ic_clear_filter_24dp.xml create mode 100644 src/main/res/layout/fragment_filter_dialog.xml create mode 100644 src/main/res/layout/fragment_filter_dialog_item.xml create mode 100644 src/main/res/menu/aggregated_statistics.xml diff --git a/.idea/misc.xml b/.idea/misc.xml index 505618cb6..a66e7459d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,9 +1,16 @@ + + + - + \ No newline at end of file diff --git a/drawable-svg/ic_clear_filter.svg b/drawable-svg/ic_clear_filter.svg new file mode 100644 index 000000000..194489baa --- /dev/null +++ b/drawable-svg/ic_clear_filter.svg @@ -0,0 +1,62 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoAggregatedFilterTest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoAggregatedFilterTest.java new file mode 100644 index 000000000..f2cda7425 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoAggregatedFilterTest.java @@ -0,0 +1,81 @@ +package de.dennisguse.opentracks; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withParent; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; + +import android.util.Pair; +import android.view.View; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.espresso.ViewInteraction; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.filters.LargeTest; +import androidx.test.rule.GrantPermissionRule; +import androidx.test.runner.AndroidJUnit4; + +import org.hamcrest.core.IsInstanceOf; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +import de.dennisguse.opentracks.content.data.TestDataUtil; +import de.dennisguse.opentracks.content.data.Track; +import de.dennisguse.opentracks.content.data.TrackPoint; +import de.dennisguse.opentracks.content.provider.ContentProviderUtils; + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class EspressoAggregatedFilterTest { + + private final String CATEGORY = "category"; + + @Rule + public ActivityScenarioRule mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class); + + @Rule + public GrantPermissionRule mGrantPermissionRule = GrantPermissionRule.grant(android.Manifest.permission.ACCESS_FINE_LOCATION); + + @Before + public void setUp() { + Pair> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 20); + pair.first.setCategory(CATEGORY); + TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), pair.first, pair.second); + } + + @Test + public void espressoAggregatedFilterTest() { + // open AggregatedStatisticsActivity through toolbar's menu item + onView(withId(R.id.toolbar)).check(matches(isDisplayed())); + onView(withId(R.id.toolbar)).check(matches(hasDescendant(withId(R.id.track_list_aggregated_stats)))); + onView(withId(R.id.track_list_aggregated_stats)).perform(click()); + + // open FilterDialogFragment through toolbar's menu item + onView(withId(R.id.toolbar)).check(matches(isDisplayed())); + onView(withId(R.id.toolbar)).check(matches(hasDescendant(withId(R.id.aggregated_statistics_filter)))); + onView(withId(R.id.aggregated_statistics_filter)).perform(click()); + + // check there's a checkbox with CATEGORY text + ViewInteraction checkBox = onView( + allOf(withId(R.id.filter_dialog_check_button), withText(CATEGORY), + withParent(allOf(withId(R.id.filter_items), + withParent(IsInstanceOf.instanceOf(android.view.ViewGroup.class)))), + isDisplayed())); + checkBox.check(matches(isDisplayed())); + + // check there's an edit text for "from date" + onView(withId(R.id.filter_date_edit_text_from)).check(matches(isDisplayed())); + + // check there's an edit text for "to date" + onView(withId(R.id.filter_date_edit_text_to)).check(matches(isDisplayed())); + } +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/content/data/TrackSelectionTest.java b/src/androidTest/java/de/dennisguse/opentracks/content/data/TrackSelectionTest.java new file mode 100644 index 000000000..6b7f2ccde --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/content/data/TrackSelectionTest.java @@ -0,0 +1,205 @@ +package de.dennisguse.opentracks.content.data; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import junit.framework.TestCase; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.time.Instant; + +import de.dennisguse.opentracks.content.provider.SelectionData; + +@RunWith(AndroidJUnit4.class) +public class TrackSelectionTest extends TestCase { + @Test + public void testFilterBuildSelection_empty() { + // given + TrackSelection filter = new TrackSelection(); + + // when + SelectionData selection = filter.buildSelection(); + + // Then + assertNull(selection.getSelection()); + assertNull(selection.getSelectionArgs()); + } + + @Test + public void testFilterBuildSelection_onlyOneTrackId() { + // given + Track.Id trackId = new Track.Id(1); + TrackSelection filter = new TrackSelection().addTrackId(trackId); + + // when + SelectionData selection = filter.buildSelection(); + + // Then + assertEquals(selection.getSelection(), "_id IN (?)"); + assertEquals(selection.getSelectionArgs().length, 1); + assertEquals(selection.getSelectionArgs()[0], "1"); + } + + @Test + public void testFilterBuildSelection_severalTracksId() { + // given + Track.Id trackId1 = new Track.Id(1); + Track.Id trackId2 = new Track.Id(2); + Track.Id trackId3 = new Track.Id(3); + TrackSelection filter = new TrackSelection() + .addTrackId(trackId1) + .addTrackId(trackId2) + .addTrackId(trackId3); + + // when + SelectionData selection = filter.buildSelection(); + + // Then + assertEquals(selection.getSelection(), "_id IN (?,?,?)"); + assertEquals(selection.getSelectionArgs().length, 3); + assertEquals(selection.getSelectionArgs()[0], "1"); + assertEquals(selection.getSelectionArgs()[1], "2"); + assertEquals(selection.getSelectionArgs()[2], "3"); + } + + @Test + public void testFilterBuildSelection_onlyOneCategory() { + // given + TrackSelection filter = new TrackSelection().addCategory("running"); + + // when + SelectionData selection = filter.buildSelection(); + + // Then + assertEquals(selection.getSelection(), "category IN (?)"); + assertEquals(selection.getSelectionArgs().length, 1); + assertEquals(selection.getSelectionArgs()[0], "running"); + } + + @Test + public void testFilterBuildSelection_severalCategories() { + // given + TrackSelection filter = new TrackSelection() + .addCategory("running") + .addCategory("road biking") + .addCategory("mountain biking") + .addCategory("trail walking"); + + // when + SelectionData selection = filter.buildSelection(); + + // Then + assertEquals(selection.getSelection(), "category IN (?,?,?,?)"); + assertEquals(selection.getSelectionArgs().length, 4); + assertEquals(selection.getSelectionArgs()[0], "running"); + assertEquals(selection.getSelectionArgs()[1], "road biking"); + assertEquals(selection.getSelectionArgs()[2], "mountain biking"); + assertEquals(selection.getSelectionArgs()[3], "trail walking"); + } + + @Test + public void testFilterBuildSelection_onlyDateRange() { + // given + Instant instant = Instant.now(); + long oneDay = 24 * 60 * 60 * 1000; + TrackSelection filterWrong1 = new TrackSelection().addDateRange(instant, null); + TrackSelection filterWrong2 = new TrackSelection().addDateRange(null, instant); + TrackSelection filterOk = new TrackSelection().addDateRange(instant, instant.plusMillis(oneDay)); + + // when + SelectionData selectionWrong1 = filterWrong1.buildSelection(); + SelectionData selectionWrong2 = filterWrong2.buildSelection(); + SelectionData selectionOk = filterOk.buildSelection(); + + // Then + assertNull(selectionWrong1.getSelection()); + assertNull(selectionWrong1.getSelectionArgs()); + + assertNull(selectionWrong2.getSelection()); + assertNull(selectionWrong2.getSelectionArgs()); + + assertEquals(selectionOk.getSelection(), "starttime BETWEEN ? AND ?"); + assertEquals(selectionOk.getSelectionArgs().length, 2); + assertEquals(selectionOk.getSelectionArgs()[0], Long.toString(instant.toEpochMilli())); + assertEquals(selectionOk.getSelectionArgs()[1], Long.toString(instant.toEpochMilli() + oneDay)); + } + + @Test + public void testFilterBuildSelection_tracksId_and_categories() { + // given + Track.Id trackId1 = new Track.Id(1); + Track.Id trackId2 = new Track.Id(2); + Track.Id trackId3 = new Track.Id(3); + TrackSelection filter = new TrackSelection() + .addTrackId(trackId1) + .addTrackId(trackId2) + .addTrackId(trackId3) + .addCategory("running") + .addCategory("road biking"); + + // when + SelectionData selection = filter.buildSelection(); + + // Then + assertEquals(selection.getSelection(), "_id IN (?,?,?) AND category IN (?,?)"); + assertEquals(selection.getSelectionArgs().length, 5); + assertEquals(selection.getSelectionArgs()[0], "1"); + assertEquals(selection.getSelectionArgs()[1], "2"); + assertEquals(selection.getSelectionArgs()[2], "3"); + assertEquals(selection.getSelectionArgs()[3], "running"); + assertEquals(selection.getSelectionArgs()[4], "road biking"); + } + + @Test + public void testFilterBuildSelection_tracksId_and_dateRange() { + // given + Instant instant = Instant.now(); + long oneDay = 24 * 60 * 60 * 1000; + + Track.Id trackId1 = new Track.Id(1); + Track.Id trackId2 = new Track.Id(2); + Track.Id trackId3 = new Track.Id(3); + + TrackSelection filter = new TrackSelection() + .addTrackId(trackId1) + .addTrackId(trackId2) + .addTrackId(trackId3) + .addDateRange(instant, instant.plusMillis(oneDay)); + + // when + SelectionData selection = filter.buildSelection(); + + // Then + assertEquals(selection.getSelection(), "_id IN (?,?,?) AND starttime BETWEEN ? AND ?"); + assertEquals(selection.getSelectionArgs().length, 5); + assertEquals(selection.getSelectionArgs()[0], "1"); + assertEquals(selection.getSelectionArgs()[1], "2"); + assertEquals(selection.getSelectionArgs()[2], "3"); + assertEquals(selection.getSelectionArgs()[3], Long.toString(instant.toEpochMilli())); + assertEquals(selection.getSelectionArgs()[4], Long.toString(instant.toEpochMilli() + oneDay)); + } + + @Test + public void testFilterBuildSelection_categories_and_dateRange() { + // given + Instant instant = Instant.now(); + long oneDay = 24 * 60 * 60 * 1000; + + TrackSelection filter = new TrackSelection() + .addCategory("running") + .addCategory("road biking") + .addDateRange(instant, instant.plusMillis(oneDay)); + + // when + SelectionData selection = filter.buildSelection(); + + // Then + assertEquals(selection.getSelection(), "category IN (?,?) AND starttime BETWEEN ? AND ?"); + assertEquals(selection.getSelectionArgs().length, 4); + assertEquals(selection.getSelectionArgs()[0], "running"); + assertEquals(selection.getSelectionArgs()[1], "road biking"); + assertEquals(selection.getSelectionArgs()[2], Long.toString(instant.toEpochMilli())); + assertEquals(selection.getSelectionArgs()[3], Long.toString(instant.toEpochMilli() + oneDay)); + } +} diff --git a/src/main/java/de/dennisguse/opentracks/AggregatedStatisticsActivity.java b/src/main/java/de/dennisguse/opentracks/AggregatedStatisticsActivity.java index da44589ca..83806ba5a 100644 --- a/src/main/java/de/dennisguse/opentracks/AggregatedStatisticsActivity.java +++ b/src/main/java/de/dennisguse/opentracks/AggregatedStatisticsActivity.java @@ -1,35 +1,61 @@ package de.dennisguse.opentracks; import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; import android.view.View; +import androidx.annotation.NonNull; import androidx.lifecycle.ViewModelProvider; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.List; import de.dennisguse.opentracks.adapters.AggregatedStatisticsAdapter; import de.dennisguse.opentracks.content.data.Track; +import de.dennisguse.opentracks.content.data.TrackSelection; import de.dennisguse.opentracks.databinding.AggregatedStatsBinding; +import de.dennisguse.opentracks.fragments.FilterDialogFragment; import de.dennisguse.opentracks.viewmodels.AggregatedStatisticsModel; -public class AggregatedStatisticsActivity extends AbstractActivity { +public class AggregatedStatisticsActivity extends AbstractActivity implements FilterDialogFragment.FilterDialogListener { public static final String EXTRA_TRACK_IDS = "track_ids"; + static final String STATE_ARE_FILTERS_APPLIED = "areFiltersApplied"; + private AggregatedStatsBinding viewBinding; private AggregatedStatisticsAdapter adapter; + private AggregatedStatisticsModel viewModel; + private final TrackSelection selection = new TrackSelection(); + + private boolean areFiltersApplied; + private MenuItem filterItem; + private MenuItem clearFilterItem; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); viewBinding.aggregatedStatsList.setEmptyView(viewBinding.aggregatedStatsEmptyView); - List trackIds = getIntent().getParcelableArrayListExtra(EXTRA_TRACK_IDS); + areFiltersApplied = savedInstanceState != null && savedInstanceState.getBoolean(STATE_ARE_FILTERS_APPLIED); - final AggregatedStatisticsModel viewModel = new ViewModelProvider(this).get(AggregatedStatisticsModel.class); - viewModel.getAggregatedStats(trackIds).observe(this, aggregatedStatistics -> { + List trackIds = getIntent().getParcelableArrayListExtra(EXTRA_TRACK_IDS); + if (trackIds != null && !trackIds.isEmpty()) { + trackIds.stream().forEach(selection::addTrackId); + } + + viewModel = new ViewModelProvider(this).get(AggregatedStatisticsModel.class); + viewModel.getAggregatedStats(selection).observe(this, aggregatedStatistics -> { + if ((aggregatedStatistics == null || aggregatedStatistics.getCount() == 0) && !selection.isEmpty()) { + viewBinding.aggregatedStatsEmptyView.setText(getString(R.string.aggregated_stats_filter_no_results)); + } if (aggregatedStatistics != null) { adapter = new AggregatedStatisticsAdapter(this, aggregatedStatistics); viewBinding.aggregatedStatsList.setAdapter(adapter); @@ -38,9 +64,58 @@ public class AggregatedStatisticsActivity extends AbstractActivity { }); } + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(STATE_ARE_FILTERS_APPLIED, areFiltersApplied); + } + @Override protected View getRootView() { viewBinding = AggregatedStatsBinding.inflate(getLayoutInflater()); return viewBinding.getRoot(); } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.aggregated_statistics, menu); + clearFilterItem = menu.findItem(R.id.aggregated_statistics_clear_filter); + filterItem = menu.findItem(R.id.aggregated_statistics_filter); + setMenuVisibility(areFiltersApplied); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == R.id.aggregated_statistics_filter) { + ArrayList filterItems = new ArrayList<>(); + adapter.getCategories().stream().forEach(category -> filterItems.add(new FilterDialogFragment.FilterItem(category, category, true))); + FilterDialogFragment.showDialog(getSupportFragmentManager(), filterItems); + return true; + } + + if (item.getItemId() == R.id.aggregated_statistics_clear_filter) { + setMenuVisibility(false); + viewModel.clearSelection(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void setMenuVisibility(boolean areFiltersApplied) { + this.areFiltersApplied = areFiltersApplied; + if (clearFilterItem != null && filterItem != null) { + clearFilterItem.setVisible(this.areFiltersApplied); + filterItem.setVisible(!this.areFiltersApplied); + } + } + + @Override + public void onFilterDone(ArrayList filterItems, LocalDateTime from, LocalDateTime to) { + setMenuVisibility(true); + selection.addDateRange(from.atZone(ZoneId.systemDefault()).toInstant(), to.atZone(ZoneId.systemDefault()).toInstant()); + filterItems.stream().filter(fi -> fi.isChecked).forEach(fi -> selection.addCategory(fi.value)); + viewModel.updateSelection(selection); + } } diff --git a/src/main/java/de/dennisguse/opentracks/adapters/AggregatedStatisticsAdapter.java b/src/main/java/de/dennisguse/opentracks/adapters/AggregatedStatisticsAdapter.java index dad8f6f1a..e7c5211db 100644 --- a/src/main/java/de/dennisguse/opentracks/adapters/AggregatedStatisticsAdapter.java +++ b/src/main/java/de/dennisguse/opentracks/adapters/AggregatedStatisticsAdapter.java @@ -9,6 +9,9 @@ import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; +import java.util.ArrayList; +import java.util.List; + import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.settings.PreferencesUtils; import de.dennisguse.opentracks.util.StringUtils; @@ -17,7 +20,7 @@ import de.dennisguse.opentracks.viewmodels.AggregatedStatistics; public class AggregatedStatisticsAdapter extends BaseAdapter { - private final AggregatedStatistics aggregatedStatistics; + private AggregatedStatistics aggregatedStatistics; private final Context context; public AggregatedStatisticsAdapter(Context context, AggregatedStatistics aggregatedStatistics) { @@ -66,6 +69,20 @@ public class AggregatedStatisticsAdapter extends BaseAdapter { return convertView; } + public AggregatedStatistics swapData(AggregatedStatistics aggregatedStatistics) { + this.aggregatedStatistics = aggregatedStatistics; + this.notifyDataSetChanged(); + return aggregatedStatistics; + } + + public List getCategories() { + List categories = new ArrayList<>(); + for (int i = 0; i < aggregatedStatistics.getCount(); i++) { + categories.add(aggregatedStatistics.getItem(i).getCategory()); + } + return categories; + } + private class ViewHolder { private final ImageView sportIcon; private final TextView typeLabel; diff --git a/src/main/java/de/dennisguse/opentracks/content/data/TrackSelection.java b/src/main/java/de/dennisguse/opentracks/content/data/TrackSelection.java new file mode 100644 index 000000000..381eae13c --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/content/data/TrackSelection.java @@ -0,0 +1,77 @@ +package de.dennisguse.opentracks.content.data; + +import android.text.TextUtils; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import de.dennisguse.opentracks.content.provider.ContentProviderUtils; +import de.dennisguse.opentracks.content.provider.SelectionData; + +public class TrackSelection implements ContentProviderUtils.ContentProviderSelectionInterface { + private final List trackIds = new ArrayList<>(); + private final List categories = new ArrayList<>(); + private Instant from; + private Instant to; + + public TrackSelection addDateRange(Instant from, Instant to) { + this.from = from; + this.to = to; + return this; + } + + public TrackSelection addTrackId(Track.Id trackId) { + if (!this.trackIds.contains(trackId)) { + this.trackIds.add(trackId); + } + return this; + } + + public TrackSelection addCategory(String category) { + if (!this.categories.contains(category)) { + this.categories.add(category); + } + return this; + } + + public boolean isEmpty() { + return trackIds.isEmpty() && categories.isEmpty() && from == null && to == null; + } + + @Override + public SelectionData buildSelection() { + String selection = ""; + String[] selectionArgs; + ArrayList fromToArgs = new ArrayList<>(); + + // Builds selection. + if (!trackIds.isEmpty()) { + selection = String.format(TracksColumns._ID + " IN (%s)", TextUtils.join(",", Collections.nCopies(trackIds.size(), "?"))); + } + if (!categories.isEmpty()) { + selection += selection.isEmpty() ? "" : " AND "; + selection += String.format(TracksColumns.CATEGORY + " IN (%s)", TextUtils.join(",", Collections.nCopies(categories.size(), "?"))); + } + if (from != null && to != null) { + selection += selection.isEmpty() ? "" : " AND "; + selection += TracksColumns.STARTTIME + " BETWEEN ? AND ?"; + fromToArgs.add(Long.toString(from.toEpochMilli())); + fromToArgs.add(Long.toString(to.toEpochMilli())); + } + + if (selection.isEmpty()) { + return new SelectionData(); + } + + // Builds selection arguments. + ArrayList args = trackIds.stream().map(id -> Long.toString(id.getId())).collect(Collectors.toCollection(ArrayList::new)); + args.addAll(categories); + args.addAll(fromToArgs); + selectionArgs = args.stream().toArray(String[]::new); + + return new SelectionData(selection, selectionArgs); + } +} diff --git a/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java b/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java index adba1c767..ad090fff8 100644 --- a/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java +++ b/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java @@ -73,6 +73,10 @@ public class ContentProviderUtils { private final ContentResolver contentResolver; + public interface ContentProviderSelectionInterface { + SelectionData buildSelection(); + } + public ContentProviderUtils(Context context) { contentResolver = context.getContentResolver(); } @@ -205,15 +209,10 @@ public class ContentProviderUtils { return tracks; } - public List getTracks(List trackIds) { - if (trackIds == null || trackIds.isEmpty()) { - return getTracks(); - } - - String selection = String.format(TracksColumns._ID + " IN (%s)", TextUtils.join(",", Collections.nCopies(trackIds.size(), "?"))); - String[] selectionArgs = trackIds.stream().map(id -> Long.toString(id.getId())).toArray(String[]::new); + public List getTracks(ContentProviderSelectionInterface selection) { + SelectionData selectionData = selection.buildSelection(); ArrayList tracks = new ArrayList<>(); - try (Cursor cursor = getTrackCursor(selection, selectionArgs, TracksColumns._ID)) { + try (Cursor cursor = getTrackCursor(selectionData.getSelection(), selectionData.getSelectionArgs(), TracksColumns._ID)) { if (cursor != null && cursor.moveToFirst()) { tracks.ensureCapacity(cursor.getCount()); do { diff --git a/src/main/java/de/dennisguse/opentracks/content/provider/SelectionData.java b/src/main/java/de/dennisguse/opentracks/content/provider/SelectionData.java new file mode 100644 index 000000000..c185b1a99 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/content/provider/SelectionData.java @@ -0,0 +1,24 @@ +package de.dennisguse.opentracks.content.provider; + +public class SelectionData { + private final String selection; + private final String[] selectionArgs; + + public SelectionData() { + selection = null; + selectionArgs = null; + } + + public SelectionData(String selection, String[] selectionArgs) { + this.selection = selection; + this.selectionArgs = selectionArgs; + } + + public String getSelection() { + return selection; + } + + public String[] getSelectionArgs() { + return selectionArgs; + } +} diff --git a/src/main/java/de/dennisguse/opentracks/fragments/FilterDialogFragment.java b/src/main/java/de/dennisguse/opentracks/fragments/FilterDialogFragment.java new file mode 100644 index 000000000..1fc7708e0 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/fragments/FilterDialogFragment.java @@ -0,0 +1,188 @@ +package de.dennisguse.opentracks.fragments; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.CheckBox; +import android.widget.DatePicker; +import android.widget.GridLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.textfield.TextInputEditText; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.WeekFields; +import java.util.ArrayList; +import java.util.Locale; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.util.StringUtils; + +public class FilterDialogFragment extends DialogFragment { + + private static final String TAG = FilterDialogFragment.class.getSimpleName(); + public static final String KEY_FILTER_ITEMS = "filterItems"; + + private FilterDialogListener filterDialogListener; + private ArrayList filterItems = new ArrayList<>(); + + public static void showDialog(FragmentManager fragmentManager) { + FilterDialogFragment filterDialogFragment = new FilterDialogFragment(); + filterDialogFragment.show(fragmentManager, TAG); + } + + public static void showDialog(FragmentManager fragmentManager, ArrayList items) { + Bundle bundle = new Bundle(); + bundle.putParcelableArrayList(KEY_FILTER_ITEMS, items); + + FilterDialogFragment filterDialogFragment = new FilterDialogFragment(); + filterDialogFragment.setArguments(bundle); + filterDialogFragment.show(fragmentManager, TAG); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + filterItems = getArguments().getParcelableArrayList(KEY_FILTER_ITEMS); + + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(getString(R.string.generic_filter)); + + LayoutInflater inflater = LayoutInflater.from(getActivity()); + View layout = inflater.inflate(R.layout.fragment_filter_dialog, null, false); + GridLayout itemsLayout = layout.findViewById(R.id.filter_items); + builder.setView(layout); + + for (FilterItem item : filterItems) { + View view = inflater.inflate(R.layout.fragment_filter_dialog_item, null); + + CheckBox checkBox = view.findViewById(R.id.filter_dialog_check_button); + checkBox.setText(item.value); + checkBox.setChecked(item.isChecked); + checkBox.setTag(item.id); + checkBox.setOnClickListener(v -> item.isChecked = !item.isChecked); + + itemsLayout.addView(view); + } + + DatePicker datePickerFrom = layout.findViewById(R.id.filter_date_picker_from); + DatePicker datePickerTo = layout.findViewById(R.id.filter_date_picker_to); + TextInputEditText dateFrom = layout.findViewById(R.id.filter_date_edit_text_from); + TextInputEditText dateTo = layout.findViewById(R.id.filter_date_edit_text_to); + + LocalDateTime firstDayThisWeek = LocalDate.now().with(WeekFields.of(Locale.getDefault()).getFirstDayOfWeek()).atStartOfDay(); + dateFrom.setText(StringUtils.formatDate(getActivity(), firstDayThisWeek)); + datePickerFrom.init(firstDayThisWeek.getYear(), firstDayThisWeek.getMonthValue() - 1, firstDayThisWeek.getDayOfMonth(), (view, year, monthOfYear, dayOfMonth) -> { + LocalDateTime localDateTime = LocalDateTime.of(year, monthOfYear + 1, dayOfMonth, 0, 0, 0); + dateFrom.setText(StringUtils.formatDate(getActivity(), localDateTime)); + datePickerFrom.setVisibility(View.GONE); + datePickerTo.setMinDate(localDateTime.toInstant(ZoneOffset.ofTotalSeconds(0)).toEpochMilli()); + if (localDateTime.isAfter(LocalDateTime.of(datePickerTo.getYear(), datePickerTo.getMonth() + 1, datePickerTo.getDayOfMonth(), 23, 59, 59))) { + datePickerTo.updateDate(year, monthOfYear, dayOfMonth); + } + }); + + LocalDateTime lastDayThisWeek = firstDayThisWeek.plusDays(6).withHour(23).withMinute(59).withSecond(59); + dateTo.setText(StringUtils.formatDate(getActivity(), lastDayThisWeek)); + datePickerTo.init(lastDayThisWeek.getYear(), lastDayThisWeek.getMonthValue() - 1, lastDayThisWeek.getDayOfMonth(), (view, year, monthOfYear, dayOfMonth) -> { + LocalDateTime localDateTime = LocalDateTime.of(year, monthOfYear + 1, dayOfMonth, 23, 59, 59); + dateTo.setText(StringUtils.formatDate(getActivity(), localDateTime)); + datePickerTo.setVisibility(View.GONE); + }); + + dateFrom.setOnClickListener(v -> { + datePickerFrom.setVisibility(View.VISIBLE); + datePickerTo.setVisibility(View.GONE); + }); + + dateTo.setOnClickListener(v -> { + datePickerFrom.setVisibility(View.GONE); + datePickerTo.setVisibility(View.VISIBLE); + }); + + builder.setPositiveButton(android.R.string.ok, (dialog, which) -> { + filterDialogListener.onFilterDone( + filterItems, + LocalDateTime.of(datePickerFrom.getYear(), datePickerFrom.getMonth() + 1, datePickerFrom.getDayOfMonth(), 0, 0, 0), + LocalDateTime.of(datePickerTo.getYear(), datePickerTo.getMonth() + 1, datePickerTo.getDayOfMonth(), 23, 59, 59) + ); + }); + + builder.setNegativeButton(android.R.string.cancel, null); + + return builder.create(); + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + try { + filterDialogListener = (FilterDialogListener) context; + } catch (ClassCastException e) { + throw new ClassCastException(context + " must implement " + FilterDialogListener.class.getSimpleName()); + } + } + + public interface FilterDialogListener { + void onFilterDone(ArrayList filters, LocalDateTime from, LocalDateTime to); + } + + public static class FilterItem implements Parcelable { + public final String id; + public final String value; + public boolean isChecked; + + public FilterItem(String id, String value) { + this.id = id; + this.value = value; + this.isChecked = true; + } + + public FilterItem(String id, String value, boolean isChecked) { + this.id = id; + this.value = value; + this.isChecked = isChecked; + } + + protected FilterItem(Parcel in) { + id = in.readString(); + value = in.readString(); + isChecked = in.readByte() != 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public FilterItem createFromParcel(Parcel in) { + return new FilterItem(in); + } + + @Override + public FilterItem[] newArray(int size) { + return new FilterItem[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(value); + dest.writeByte((byte) (isChecked ? 1 : 0)); + } + } +} diff --git a/src/main/java/de/dennisguse/opentracks/util/StringUtils.java b/src/main/java/de/dennisguse/opentracks/util/StringUtils.java index fe5b5ec54..7990a40a1 100644 --- a/src/main/java/de/dennisguse/opentracks/util/StringUtils.java +++ b/src/main/java/de/dennisguse/opentracks/util/StringUtils.java @@ -29,6 +29,7 @@ import java.text.DecimalFormat; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -61,6 +62,13 @@ public class StringUtils { + " " + DateUtils.formatDateTime(context, time.toEpochMilli(), DateUtils.FORMAT_SHOW_TIME); } + /** + * Formats the date and time based on user's phone date/time preferences. + */ + public static String formatDate(Context context, LocalDateTime localDateTime) { + return DateUtils.formatDateTime(context, localDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(), DateUtils.FORMAT_SHOW_DATE); + } + /** * Formats the time using the ISO 8601 date time format with fractional seconds in UTC time zone. */ diff --git a/src/main/java/de/dennisguse/opentracks/viewmodels/AggregatedStatisticsModel.java b/src/main/java/de/dennisguse/opentracks/viewmodels/AggregatedStatisticsModel.java index 2eb29470c..f931ff4b5 100644 --- a/src/main/java/de/dennisguse/opentracks/viewmodels/AggregatedStatisticsModel.java +++ b/src/main/java/de/dennisguse/opentracks/viewmodels/AggregatedStatisticsModel.java @@ -11,6 +11,7 @@ import androidx.lifecycle.MutableLiveData; import java.util.List; import de.dennisguse.opentracks.content.data.Track; +import de.dennisguse.opentracks.content.data.TrackSelection; import de.dennisguse.opentracks.content.provider.ContentProviderUtils; public class AggregatedStatisticsModel extends AndroidViewModel { @@ -21,18 +22,26 @@ public class AggregatedStatisticsModel extends AndroidViewModel { super(application); } - public LiveData getAggregatedStats(@Nullable List trackIds) { + public LiveData getAggregatedStats(@Nullable TrackSelection selection) { if (aggregatedStats == null) { aggregatedStats = new MutableLiveData<>(); - loadAggregatedStats(trackIds); + loadAggregatedStats(selection); } return aggregatedStats; } - private void loadAggregatedStats(@Nullable List trackIds) { + public void updateSelection(TrackSelection selection) { + loadAggregatedStats(selection); + } + + public void clearSelection() { + loadAggregatedStats(new TrackSelection()); + } + + private void loadAggregatedStats(TrackSelection selection) { new Thread(() -> { ContentProviderUtils contentProviderUtils = new ContentProviderUtils(getApplication().getApplicationContext()); - List tracks = contentProviderUtils.getTracks(trackIds); + List tracks = selection != null ? contentProviderUtils.getTracks(selection) : contentProviderUtils.getTracks(); AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks); diff --git a/src/main/res/drawable/ic_baseline_date_range_24.xml b/src/main/res/drawable/ic_baseline_date_range_24.xml new file mode 100644 index 000000000..439470312 --- /dev/null +++ b/src/main/res/drawable/ic_baseline_date_range_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_baseline_filter_alt_24.xml b/src/main/res/drawable/ic_baseline_filter_alt_24.xml new file mode 100644 index 000000000..ccb8fd223 --- /dev/null +++ b/src/main/res/drawable/ic_baseline_filter_alt_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_clear_filter_24dp.xml b/src/main/res/drawable/ic_clear_filter_24dp.xml new file mode 100644 index 000000000..489a5eaec --- /dev/null +++ b/src/main/res/drawable/ic_clear_filter_24dp.xml @@ -0,0 +1,14 @@ + + + diff --git a/src/main/res/layout/aggregated_stats.xml b/src/main/res/layout/aggregated_stats.xml index 4a304af2f..b61477f00 100644 --- a/src/main/res/layout/aggregated_stats.xml +++ b/src/main/res/layout/aggregated_stats.xml @@ -16,6 +16,7 @@ style="@style/TextLarge" android:layout_width="match_parent" android:layout_height="match_parent" + android:padding="16dp" android:gravity="center_horizontal|center_vertical" android:text="@string/aggregated_stats_empty_message" /> diff --git a/src/main/res/layout/fragment_filter_dialog.xml b/src/main/res/layout/fragment_filter_dialog.xml new file mode 100644 index 000000000..fb18ee10d --- /dev/null +++ b/src/main/res/layout/fragment_filter_dialog.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/layout/fragment_filter_dialog_item.xml b/src/main/res/layout/fragment_filter_dialog_item.xml new file mode 100644 index 000000000..d1ac69690 --- /dev/null +++ b/src/main/res/layout/fragment_filter_dialog_item.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/main/res/menu/aggregated_statistics.xml b/src/main/res/menu/aggregated_statistics.xml new file mode 100644 index 000000000..4767247bc --- /dev/null +++ b/src/main/res/menu/aggregated_statistics.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index b8e0c6584..12ef6bfc4 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -239,6 +239,9 @@ limitations under the License. Click back again to cancel Choose an option Undo + Filter + From + To Starting GPS Waiting for a better GPS signal @@ -306,6 +309,7 @@ limitations under the License. Voice speed Resume track New Layout + Filter Recording service Deletion service @@ -491,6 +495,7 @@ limitations under the License. Start recording your next adventure here Record your first track to see aggregated statistics + There are not activities for the specified filter There are not any interval yet