diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 9e32f6738..5c5284ea1 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -82,6 +82,9 @@ limitations under the License. android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/OpenTracksTheme"> + diff --git a/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java b/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java index 1f58a7b78..fc1f41b8e 100644 --- a/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java +++ b/src/main/java/de/dennisguse/opentracks/data/ContentProviderUtils.java @@ -32,13 +32,16 @@ import java.io.File; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.UUID; - +import java.util.Date; +import java.text.SimpleDateFormat; import de.dennisguse.opentracks.BuildConfig; import de.dennisguse.opentracks.data.models.ActivityType; import de.dennisguse.opentracks.data.models.Altitude; @@ -267,6 +270,21 @@ public Track getTrack(@NonNull Track.Id trackId) { } return null; } + public Track getTrack(@NonNull Date date) { + long millisecondsSinceEpoch = date.getTime(); + LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + LocalDate modifiedLocalDate = localDate.plusDays(1); + Date modifiedDate = Date.from(modifiedLocalDate.atStartOfDay(ZoneId.systemDefault()).toInstant()); + long millisNextDaySinceEpoch = modifiedDate.getTime(); + + String query = TracksColumns.STARTTIME + ">? AND " + TracksColumns.STARTTIME + "<=?"; + try (Cursor cursor = getTrackCursor(query, new String[]{Long.toString(millisecondsSinceEpoch), Long.toString(millisNextDaySinceEpoch)}, TracksColumns._ID)) { + if (cursor != null && cursor.moveToNext()) { + return createTrack(cursor); + } + } + return null; + } public Track getTrack(@NonNull UUID trackUUID) { String trackUUIDsearch = UUIDUtils.toHex(trackUUID); diff --git a/src/main/java/de/dennisguse/opentracks/data/models/TrackSegment.java b/src/main/java/de/dennisguse/opentracks/data/models/TrackSegment.java new file mode 100644 index 000000000..f0800ee34 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/data/models/TrackSegment.java @@ -0,0 +1,86 @@ +package de.dennisguse.opentracks.data.models; + +import androidx.annotation.NonNull; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.ArrayList; +import java.util.Optional; + +public class TrackSegment { + + private static final String TAG = TrackSegment.class.getSimpleName(); + private TrackPoint.Id id; + @NonNull + private final Instant time; + + private List trackPoints; + + public TrackSegment(@NonNull Instant time) { + this.time = time; + trackPoints = new ArrayList<>(); + } + + public void addTrackPoint(TrackPoint point) { + this.trackPoints.add(point); + } + + public int getTrackPointCount() { + return trackPoints.size(); + } + + public Boolean hasTrackPoints() { + return !trackPoints.isEmpty(); + } + + public double getInitialElevation() { + Optional firstPoint = trackPoints.stream().findFirst(); + if (firstPoint.isPresent()) { + TrackPoint point = firstPoint.get(); + return point.getAltitude().toM(); + } + return 0; + } + + public long getDisplacement() { + long displacement = 0; + for (TrackPoint point: trackPoints) { + if (point.hasAltitudeGain()) { + displacement += point.getAltitudeGain(); + } + + if (point.hasAltitudeLoss()) { + displacement += point.getAltitudeLoss(); + } + } + + return displacement; + } + public Distance getDistance() { + if (trackPoints == null) { + return null; + } + TrackPoint first = trackPoints.get(0); + TrackPoint last = trackPoints.get(trackPoints.size() - 1); + return last.distanceToPrevious(first); + } + + public Duration getTotalTime(){ + + if(trackPoints == null){ + return null; + } + TrackPoint startTime = trackPoints.get(0); + TrackPoint endTime = trackPoints.get(trackPoints.size() - 1); + + return Duration.between(startTime.getTime(), endTime.getTime()); + } + + public double getSpeed(){ + // in m/s + double totalDistance = getDistance().toM(); + long totalTime = getTotalTime().toSeconds(); + return totalDistance/totalTime; + } +} \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsAdapter.java b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsAdapter.java index 949466bdc..9691930bf 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsAdapter.java +++ b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsAdapter.java @@ -22,6 +22,7 @@ import de.dennisguse.opentracks.settings.PreferencesUtils; import de.dennisguse.opentracks.settings.UnitSystem; import de.dennisguse.opentracks.ui.aggregatedStatistics.SeasonStats.SeasonStatActivity; +import de.dennisguse.opentracks.ui.aggregatedStatistics.daySpecificStats.DaySpecificActivity; import de.dennisguse.opentracks.util.StringUtils; public class AggregatedStatisticsAdapter extends RecyclerView.Adapter { @@ -93,22 +94,32 @@ public void onClick(View v) { } }); - viewBinding.runsAndLiftsButton.setOnClickListener(new View.OnClickListener(){ + viewBinding.runsAndLiftsButton.setOnClickListener(new View.OnClickListener() { @Override - public void onClick(View v){ - Intent intent=new Intent(v.getContext(), CalendarActivity.class); + public void onClick(View v) { + Intent intent = new Intent(v.getContext(), CalendarActivity.class); intent.putExtra("Display Fields", "Runs and Lifts"); v.getContext().startActivity(intent); } }); - viewBinding.elevationAndSpeedButton.setOnClickListener(new View.OnClickListener(){ + viewBinding.elevationAndSpeedButton.setOnClickListener(new View.OnClickListener() { @Override - public void onClick(View v){ - Intent intent=new Intent(v.getContext(), CalendarActivity.class); + public void onClick(View v) { + Intent intent = new Intent(v.getContext(), CalendarActivity.class); intent.putExtra("Display Fields", "Elevation and Speed"); v.getContext().startActivity(intent); } }); + + viewBinding.calendarButton.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + Context context = viewBinding.getRoot().getContext(); + Intent intent = new Intent(context, DaySpecificActivity.class); + context.startActivity(intent); + } + }); } public void setSpeed(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { diff --git a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/daySpecificStats/DaySpecificActivity.java b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/daySpecificStats/DaySpecificActivity.java new file mode 100644 index 000000000..6007514ae --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/daySpecificStats/DaySpecificActivity.java @@ -0,0 +1,136 @@ +package de.dennisguse.opentracks.ui.aggregatedStatistics.daySpecificStats; + +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import de.dennisguse.opentracks.AbstractActivity; +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.TrackListActivity; +import de.dennisguse.opentracks.data.ContentProviderUtils; +import de.dennisguse.opentracks.data.TrackPointIterator; +import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.data.models.TrackPoint; +import de.dennisguse.opentracks.data.models.TrackSegment; +import de.dennisguse.opentracks.databinding.DaySpecificActivityBinding; + +import java.time.format.DateTimeFormatter; +import java.time.LocalDate; +import java.util.Date; +import java.time.ZoneId; +import java.util.List; +import java.util.ArrayList; + +public class DaySpecificActivity extends AbstractActivity { + + private DaySpecificActivityBinding viewBinding; + private static final String TAG = DaySpecificActivity.class.getSimpleName(); + public static final String EXTRA_TRACK_DATE = "track_date"; + private Date activityDate; + private ContentProviderUtils contentProviderUtils; + private Track.Id trackId; + private List trackSegments; + private DaySpecificAdapter dataAdapter; + + private String fallbackDate = "2024-03-09"; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + trackSegments = new ArrayList<>(); + contentProviderUtils = new ContentProviderUtils(this); + handleIntent(getIntent()); + updateTrackSegments(); + setSupportActionBar(viewBinding.bottomAppBarLayout.bottomAppBar); + + dataAdapter = new DaySpecificAdapter(this, viewBinding.segmentList); + dataAdapter.swapData(trackSegments); + viewBinding.segmentList.setAdapter(dataAdapter); + viewBinding.segmentListToolbar.setTitle(fallbackDate); + } + + @Override + protected void onStart() { + super.onStart(); + } + + @Override + protected void onResume() { + super.onResume(); + updateTrackSegments(); + dataAdapter.swapData(trackSegments); + } + + public void updateTrackSegments() { + if (trackId == null) { + return; + } + try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, null)) { + TrackSegment currentSegment = null; + while (trackPointIterator.hasNext()) { + TrackPoint nextPoint = trackPointIterator.next(); + + switch (nextPoint.getType()) { + case SEGMENT_START_AUTOMATIC: + case SEGMENT_START_MANUAL: + if (currentSegment != null) { + trackSegments.add(currentSegment); + } + currentSegment = new TrackSegment(nextPoint.getTime()); + break; + + case SEGMENT_END_MANUAL: + trackSegments.add(currentSegment); + currentSegment = null; + + case TRACKPOINT: + if (currentSegment != null) { + currentSegment.addTrackPoint(nextPoint); + } + break; + + default: + Log.d(TAG, "No Action for TrackPoint IDLE/SENSORPOINT while recording segments"); + } + } + } + } + + private void showNoTracksFoundToast() { + finish(); + Toast.makeText(DaySpecificActivity.this, "No Tracks found for date: " + fallbackDate + "\n Please import GPX file from Moodle", Toast.LENGTH_LONG).show(); + } + private Date getFallbackDate() { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + LocalDate localDate = LocalDate.parse(fallbackDate, formatter); + return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant()); + } + + private void handleIntent(Intent intent) { + Date dateFromCalendar = intent.getParcelableExtra(EXTRA_TRACK_DATE); + if (dateFromCalendar == null) { + Log.e(TAG, DaySpecificActivity.class.getSimpleName() + " needs EXTRA_TRACK_ID."); + + // None provided, we will assume a specific date on our own + activityDate = getFallbackDate(); + } else { + activityDate = dateFromCalendar; + } + + Track track = contentProviderUtils.getTrack(activityDate); + if (track == null) { + showNoTracksFoundToast(); + } else { + trackId = track.getId(); + } + } + + @Override + protected View getRootView() { + viewBinding = DaySpecificActivityBinding.inflate(getLayoutInflater()); + return viewBinding.getRoot(); + } +} \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/daySpecificStats/DaySpecificAdapter.java b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/daySpecificStats/DaySpecificAdapter.java new file mode 100644 index 000000000..00e883a18 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/daySpecificStats/DaySpecificAdapter.java @@ -0,0 +1,139 @@ +package de.dennisguse.opentracks.ui.aggregatedStatistics.daySpecificStats; + +import android.database.Cursor; +import android.util.SparseBooleanArray; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.data.models.Distance; +import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.data.models.TrackSegment; +import de.dennisguse.opentracks.databinding.DaySpecificActivityItemBinding; +import de.dennisguse.opentracks.ui.util.ActivityUtils; + +public class DaySpecificAdapter extends RecyclerView.Adapter implements ActionMode.Callback { + + private static final String TAG = DaySpecificAdapter.class.getSimpleName(); + DaySpecificActivityItemBinding viewBinding; + private final AppCompatActivity context; + private final RecyclerView recyclerView; + + private final SparseBooleanArray selection = new SparseBooleanArray(); + + private Cursor cursor; + + private ActivityUtils.ContextualActionModeCallback actionModeCallback; + private List trackSegments; + + public DaySpecificAdapter(AppCompatActivity context, RecyclerView recyclerView) { + this.context = context; + this.recyclerView = recyclerView; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.day_specific_activity_item, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + DaySpecificAdapter.ViewHolder viewHolder = (DaySpecificAdapter.ViewHolder) holder; + TrackSegment segment = trackSegments.get(position); + viewHolder.bind(segment); + } + + public void swapData(List segments) { + this.trackSegments = segments; + this.notifyDataSetChanged(); + } + @Override + public int getItemCount() { + return trackSegments.size(); + } + + public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { + + private final DaySpecificActivityItemBinding viewBinding; + private final View view; + + private Track.Id trackId; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + + viewBinding = DaySpecificActivityItemBinding.bind(itemView); + view = itemView; + + view.setOnClickListener(this); + view.setOnLongClickListener(this); + } + + public void bind(TrackSegment segment) { + double speed = segment.getSpeed(); + String formattedSpeed = String.format("%.2f", speed); + + double distance = segment.getDistance().toM(); + String formattedDistance = String.format("%.2f", distance); + + double elevation = segment.getInitialElevation(); + String formattedElevation = String.format("%.1f", elevation); + + viewBinding.daySpecificActivityDisplacement.setText(formattedElevation + " m"); + viewBinding.daySpecificActivityDistance.setText(formattedDistance + " mts"); + viewBinding.daySpecificActivitySpeed.setText(formattedSpeed + " m/s"); + viewBinding.daySpecificActivityTime.setText(segment.getTotalTime().toMinutes() + " minutes"); + } + + public void setSelected(boolean isSelected) { + selection.put((int) getId(), isSelected); + view.setActivated(isSelected); + } + + public long getId() { + return trackId.id(); + } + + @Override + public void onClick(View view) { + + } + + @Override + public boolean onLongClick(View view) { + return false; + } + } +} diff --git a/src/main/res/layout/aggregated_stats_list_item.xml b/src/main/res/layout/aggregated_stats_list_item.xml index 9d5eeae43..5b5b50fe4 100644 --- a/src/main/res/layout/aggregated_stats_list_item.xml +++ b/src/main/res/layout/aggregated_stats_list_item.xml @@ -244,4 +244,32 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/aggregated_stats_horizontal_line" /> + + + +