Package structure: moved intervals.

Part of #963.
This commit is contained in:
Dennis Guse
2022-01-15 20:09:27 +01:00
parent 46cf7f9acf
commit 5053c41bec
10 changed files with 10 additions and 14 deletions
@@ -0,0 +1,184 @@
package de.dennisguse.opentracks.ui.intervals;
import androidx.annotation.Nullable;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import de.dennisguse.opentracks.content.data.Distance;
import de.dennisguse.opentracks.content.data.Speed;
import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.provider.TrackPointIterator;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.stats.TrackStatisticsUpdater;
public class IntervalStatistics {
private TrackStatisticsUpdater trackStatisticsUpdater = new TrackStatisticsUpdater();
private final List<Interval> intervalList;
private final Distance distanceInterval;
private final Distance minGPSDistance;
private Interval interval, lastInterval;
/**
* @param distanceInterval distance of every interval.
* @param minGPSDistance the setting value for GPS distance.
*/
public IntervalStatistics(Distance distanceInterval, Distance minGPSDistance) {
this.distanceInterval = distanceInterval;
this.minGPSDistance = minGPSDistance;
interval = new Interval();
lastInterval = new Interval();
intervalList = new ArrayList<>();
intervalList.add(lastInterval);
}
/**
* Complete intervals with the tracks points from the iterator.
*
* @return the last track point's id used to compute the intervals.
*/
public TrackPoint.Id addTrackPoints(TrackPointIterator trackPointIterator) {
boolean newIntervalAdded = false;
TrackPoint trackPoint = null;
while (trackPointIterator.hasNext()) {
trackPoint = trackPointIterator.next();
trackStatisticsUpdater.addTrackPoint(trackPoint, minGPSDistance);
if (trackStatisticsUpdater.getTrackStatistics().getTotalDistance().plus(interval.distance).greaterOrEqualThan(distanceInterval)) {
interval.add(trackStatisticsUpdater.getTrackStatistics(), trackPoint);
double adjustFactor = distanceInterval.dividedBy(interval.distance);
Interval adjustedInterval = new Interval(interval);
adjustedInterval.adjust(adjustFactor);
intervalList.set(intervalList.size() - 1, adjustedInterval);
interval = new Interval(interval.distance.minus(adjustedInterval.distance), interval.time.minus(adjustedInterval.time));
trackStatisticsUpdater = new TrackStatisticsUpdater();
trackStatisticsUpdater.addTrackPoint(trackPoint, minGPSDistance);
lastInterval = new Interval(interval);
intervalList.add(lastInterval);
newIntervalAdded = true;
}
}
if (newIntervalAdded) {
lastInterval.add(trackStatisticsUpdater.getTrackStatistics(), null);
} else {
lastInterval.set(trackStatisticsUpdater.getTrackStatistics());
}
return trackPoint != null ? trackPoint.getId() : null;
}
public List<Interval> getIntervalList() {
return intervalList;
}
/**
* Return the last completed interval.
* An interval is complete if its distance is equal to distanceInterval_m.
*
* @return the interval object or null if any interval is completed.
*/
public Interval getLastInterval() {
if (intervalList.size() == 1 && intervalList.get(0).getDistance().lessThan(distanceInterval)) {
return null;
}
for (int i = intervalList.size() - 1; i >= 0; i--) {
if (intervalList.get(i).getDistance().greaterOrEqualThan(distanceInterval)) {
return this.intervalList.get(i);
}
}
return null;
}
public static class Interval {
private Distance distance = Distance.of(0);
private Duration time = Duration.ofSeconds(0);
private Float gain_m;
private Float loss_m;
public Interval() {
}
public Interval(Distance distance, Duration time) {
this.distance = distance;
this.time = time;
}
public Interval(Interval i, double adjustFactor) {
distance = i.distance.multipliedBy(adjustFactor);
time = Duration.ofMillis((long) (i.time.toMillis() * adjustFactor));
time = i.time;
gain_m = i.gain_m;
loss_m = i.loss_m;
}
public Interval(Interval i) {
distance = i.distance;
time = i.time;
gain_m = i.gain_m;
loss_m = i.loss_m;
}
private void adjust(double adjustFactor) {
distance = distance.multipliedBy(adjustFactor);
time = Duration.ofMillis((long) (time.toMillis() * adjustFactor));
}
public Distance getDistance() {
return distance;
}
public Speed getSpeed() {
return Speed.of(distance, time);
}
public boolean hasGain() {
return gain_m != null;
}
public Float getGain_m() {
return gain_m;
}
public boolean hasLoss() {
return loss_m != null;
}
public Float getLoss_m() {
return loss_m;
}
private void add(TrackStatistics trackStatistics, @Nullable TrackPoint lastTrackPoint) {
distance = distance.plus(trackStatistics.getTotalDistance());
time = time.plus(trackStatistics.getTotalTime());
gain_m = trackStatistics.hasTotalAltitudeGain() ? trackStatistics.getTotalAltitudeGain() : gain_m;
loss_m = trackStatistics.hasTotalAltitudeLoss() ? trackStatistics.getTotalAltitudeLoss() : loss_m;
if (lastTrackPoint == null) {
return;
}
if (hasGain() && lastTrackPoint.hasAltitudeGain()) {
gain_m = gain_m - lastTrackPoint.getAltitudeGain();
}
if (hasLoss() && lastTrackPoint.hasAltitudeLoss()) {
loss_m = loss_m - lastTrackPoint.getAltitudeLoss();
}
}
private void set(TrackStatistics trackStatistics) {
distance = trackStatistics.getTotalDistance();
time = trackStatistics.getTotalTime();
gain_m = trackStatistics.hasTotalAltitudeGain() ? trackStatistics.getTotalAltitudeGain() : gain_m;
loss_m = trackStatistics.hasTotalAltitudeLoss() ? trackStatistics.getTotalAltitudeLoss() : loss_m;
}
}
}
@@ -0,0 +1,108 @@
package de.dennisguse.opentracks.ui.intervals;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.data.Distance;
import de.dennisguse.opentracks.util.StringUtils;
public class IntervalStatisticsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private List<IntervalStatistics.Interval> intervalList;
private final Context context;
private final StackMode stackMode;
private boolean metricUnits;
private boolean isReportSpeed;
public IntervalStatisticsAdapter(Context context, StackMode stackMode, boolean metricUnits, boolean isReportSpeed) {
this.metricUnits = metricUnits;
this.context = context;
this.stackMode = stackMode;
this.isReportSpeed = isReportSpeed;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.interval_stats_list_item, parent, false);
return new IntervalStatisticsAdapter.ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
int actualPosition = stackMode == StackMode.STACK_FROM_TOP ? position : getItemCount() - 1 - position;
int nextPosition = actualPosition + 1;
boolean isLast = actualPosition == getItemCount() - 1;
IntervalStatisticsAdapter.ViewHolder viewHolder = (IntervalStatisticsAdapter.ViewHolder) holder;
IntervalStatistics.Interval interval = intervalList.get(actualPosition);
viewHolder.itemView.setTag(actualPosition);
Distance sumDistance;
if (isLast && actualPosition > 0) {
sumDistance = intervalList.get(actualPosition - 1).getDistance()
.multipliedBy(actualPosition)
.plus(interval.getDistance());
} else {
sumDistance = interval.getDistance().multipliedBy(nextPosition);
}
viewHolder.distance.setText(StringUtils.formatDistance(context, sumDistance, metricUnits));
viewHolder.rate.setText(StringUtils.formatSpeed(context, interval.getSpeed(), metricUnits, isReportSpeed));
viewHolder.gain.setText(StringUtils.formatAltitudeChange(context, interval.getGain_m(), metricUnits));
viewHolder.loss.setText(StringUtils.formatAltitudeChange(context, interval.getLoss_m(), metricUnits));
}
@Override
public int getItemCount() {
if (intervalList == null) {
return 0;
}
return intervalList.size();
}
public List<IntervalStatistics.Interval> swapData(List<IntervalStatistics.Interval> data, boolean metricUnits, boolean isReportSpeed) {
this.metricUnits = metricUnits;
this.isReportSpeed = isReportSpeed;
intervalList = data;
if (data != null) {
this.notifyDataSetChanged();
}
return data;
}
/**
* Defines the two modes of list items stacking: from top or from bottom.
*/
public enum StackMode {
STACK_FROM_BOTTOM,
STACK_FROM_TOP
}
private static class ViewHolder extends RecyclerView.ViewHolder {
final TextView distance;
final TextView rate;
final TextView gain;
final TextView loss;
public ViewHolder(@NonNull View itemView) {
super(itemView);
distance = itemView.findViewById(R.id.interval_item_distance);
rate = itemView.findViewById(R.id.interval_item_rate);
gain = itemView.findViewById(R.id.interval_item_gain);
loss = itemView.findViewById(R.id.interval_item_loss);
}
}
}
@@ -0,0 +1,155 @@
package de.dennisguse.opentracks.ui.intervals;
import android.app.Application;
import android.content.ContentResolver;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.MutableLiveData;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import de.dennisguse.opentracks.content.data.Distance;
import de.dennisguse.opentracks.content.data.Track;
import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.data.TrackPointsColumns;
import de.dennisguse.opentracks.content.provider.ContentProviderUtils;
import de.dennisguse.opentracks.content.provider.TrackPointIterator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
/**
* This model is used to load intervals for a track.
* It uses a default interval but it can be set from outside to manage the interval length.
*/
public class IntervalStatisticsModel extends AndroidViewModel {
private static final String TAG = IntervalStatisticsModel.class.getSimpleName();
private MutableLiveData<List<IntervalStatistics.Interval>> intervalsLiveData;
private IntervalStatistics intervalStatistics;
private Distance distanceInterval;
private final Distance minGPSDistance;
private final ContentResolver contentResolver;
private ContentObserver trackPointsTableObserver;
private TrackPoint.Id lastTrackPointId;
private final Executor executor = Executors.newSingleThreadExecutor();
private HandlerThread handlerThread;
private Handler handler;
public IntervalStatisticsModel(@NonNull Application application) {
super(application);
minGPSDistance = PreferencesUtils.getRecordingDistanceInterval();
contentResolver = getApplication().getContentResolver();
handlerThread = new HandlerThread(TAG);
handlerThread.start();
handler = new Handler(handlerThread.getLooper());
}
@Override
protected void onCleared() {
super.onCleared();
if (trackPointsTableObserver != null) {
contentResolver.unregisterContentObserver(trackPointsTableObserver);
trackPointsTableObserver = null;
}
if (handlerThread != null) {
handlerThread.getLooper().quit();
handlerThread = null;
}
handler = null;
}
public MutableLiveData<List<IntervalStatistics.Interval>> getIntervalStats(Track.Id trackId, boolean metricUnits, @Nullable IntervalOption interval) {
if (intervalsLiveData == null) {
if (interval == null) {
interval = IntervalOption.OPTION_1;
}
intervalsLiveData = new MutableLiveData<>();
distanceInterval = interval.getDistance(metricUnits);
intervalStatistics = new IntervalStatistics(distanceInterval, minGPSDistance);
loadIntervalStatistics(trackId);
}
trackPointsTableObserver = new ContentObserver(handler) {
@Override
public void onChange(boolean selfChange, Uri uri) {
loadIntervalStatistics(trackId);
}
};
contentResolver.registerContentObserver(TrackPointsColumns.CONTENT_URI_BY_TRACKID, false, trackPointsTableObserver);
return intervalsLiveData;
}
private void loadIntervalStatistics(Track.Id trackId) {
executor.execute(() -> {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(getApplication());
try (TrackPointIterator trackPointIterator = contentProviderUtils.getTrackPointLocationIterator(trackId, lastTrackPointId)) {
lastTrackPointId = intervalStatistics.addTrackPoints(trackPointIterator);
intervalsLiveData.postValue(intervalStatistics.getIntervalList());
}
});
}
public void onPause() {
if (trackPointsTableObserver != null) {
contentResolver.unregisterContentObserver(trackPointsTableObserver);
}
}
public void update(Track.Id trackId, boolean metricUnits, @Nullable IntervalOption interval) {
if (interval == null) {
interval = IntervalOption.OPTION_1;
}
lastTrackPointId = null;
distanceInterval = interval.getDistance(metricUnits);
intervalStatistics = new IntervalStatistics(distanceInterval, minGPSDistance);
loadIntervalStatistics(trackId);
}
/**
* Intervals length this view model support.
*/
public enum IntervalOption {
OPTION_1(1),
OPTION_2(2),
OPTION_3(3),
OPTION_4(4),
OPTION_5(5),
OPTION_10(10),
OPTION_20(20),
OPTION_50(50);
private final int multiplier;
IntervalOption(int multiplier) {
this.multiplier = multiplier;
}
public Distance getDistance(boolean metricUnits) {
return Distance
.one(metricUnits)
.multipliedBy(multiplier);
}
public boolean sameMultiplier(IntervalOption intervalOption) {
return intervalOption != null && this.multiplier == intervalOption.multiplier;
}
@Override
public String toString() {
return "" + multiplier; //TODO Somehow IntervalsFragment relies on a parsable Integer.
}
}
}
@@ -0,0 +1,192 @@
package de.dennisguse.opentracks.ui.intervals;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.data.Track;
import de.dennisguse.opentracks.content.provider.ContentProviderUtils;
import de.dennisguse.opentracks.databinding.IntervalListViewBinding;
import de.dennisguse.opentracks.settings.PreferencesUtils;
/**
* A fragment to display the intervals from recorded track.
*/
public class IntervalsFragment extends Fragment {
private static final String TAG = IntervalsFragment.class.getSimpleName();
private static final String FROM_TOP_TO_BOTTOM_KEY = "fromTopToBottom";
private static final String TRACK_ID_KEY = "trackId";
private IntervalStatisticsModel viewModel;
protected IntervalStatisticsAdapter.StackMode stackModeListView;
private IntervalStatisticsModel.IntervalOption selectedInterval;
private Track.Id trackId;
private boolean metricUnits;
private IntervalStatisticsAdapter adapter;
private ArrayAdapter<IntervalStatisticsModel.IntervalOption> spinnerAdapter;
private boolean isReportSpeed;
private IntervalListViewBinding viewBinding;
protected final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = (sharedPreferences, key) -> {
if (PreferencesUtils.isKey(R.string.stats_units_key, key) || PreferencesUtils.isKey(R.string.stats_rate_key, key)) {
updateIntervals(PreferencesUtils.isMetricUnits(), selectedInterval);
if (spinnerAdapter != null) {
spinnerAdapter.notifyDataSetChanged();
}
}
};
/**
* Creates an instance of this class.
*
* @param fromTopToBottom If true then the intervals are shown from top to bottom (the first interval on top). Otherwise the intervals are shown from bottom to top.
* @return IntervalsFragment instance.
*/
public static Fragment newInstance(Track.Id trackId, boolean fromTopToBottom) {
Bundle bundle = new Bundle();
bundle.putBoolean(FROM_TOP_TO_BOTTOM_KEY, fromTopToBottom);
bundle.putParcelable(TRACK_ID_KEY, trackId);
IntervalsFragment intervalsFragment = new IntervalsFragment();
intervalsFragment.setArguments(bundle);
return intervalsFragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
stackModeListView = getArguments().getBoolean(FROM_TOP_TO_BOTTOM_KEY, true) ? IntervalStatisticsAdapter.StackMode.STACK_FROM_TOP : IntervalStatisticsAdapter.StackMode.STACK_FROM_BOTTOM;
trackId = getArguments().getParcelable(TRACK_ID_KEY);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
viewBinding = IntervalListViewBinding.inflate(inflater, container, false);
return viewBinding.getRoot();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
adapter = new IntervalStatisticsAdapter(getContext(), stackModeListView, metricUnits, isReportSpeed);
viewBinding.intervalList.setLayoutManager(new LinearLayoutManager(getContext()));
// TODO handle empty view: before we did viewBinding.intervalList.setEmptyView(viewBinding.intervalListEmptyView);
viewBinding.intervalList.setAdapter(adapter);
spinnerAdapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_dropdown_item, IntervalStatisticsModel.IntervalOption.values()) {
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
TextView v = (TextView) super.getView(position, convertView, parent);
if (metricUnits) {
v.setText(getContext().getString(R.string.value_integer_kilometer, Integer.parseInt(v.getText().toString())));
} else {
v.setText(getContext().getString(R.string.value_integer_mile, Integer.parseInt(v.getText().toString())));
}
return v;
}
@Override
public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
return getView(position, convertView, parent);
}
};
viewBinding.spinnerIntervals.setAdapter(spinnerAdapter);
viewBinding.spinnerIntervals.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
updateIntervals(metricUnits, IntervalStatisticsModel.IntervalOption.values()[i]);
}
@Override
public void onNothingSelected(AdapterView<?> adapterView) {
}
});
}
@Override
public void onResume() {
super.onResume();
PreferencesUtils.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(getContext());
Track track = contentProviderUtils.getTrack(trackId);
if (track != null) {
isReportSpeed = PreferencesUtils.isReportSpeed(track.getCategory());
}
viewModel = new ViewModelProvider(getActivity()).get(IntervalStatisticsModel.class);
loadIntervals();
}
@Override
public void onPause() {
super.onPause();
PreferencesUtils.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
if (viewModel != null) {
viewModel.onPause();
}
}
@Override
public void onDestroyView() {
super.onDestroyView();
viewBinding = null;
}
@Override
public void onDestroy() {
super.onDestroy();
adapter = null;
viewModel = null;
}
/**
* Update intervals through {@link IntervalStatisticsModel} view model.
*/
protected synchronized void loadIntervals() {
if (viewModel == null) {
return;
}
viewBinding.intervalRate.setText(isReportSpeed ? getString(R.string.stats_speed) : getString(R.string.stats_pace));
LiveData<List<IntervalStatistics.Interval>> liveData = viewModel.getIntervalStats(trackId, metricUnits, selectedInterval);
liveData.observe(getActivity(), intervalList -> adapter.swapData(intervalList, metricUnits, isReportSpeed));
}
private synchronized void updateIntervals(boolean metricUnits, IntervalStatisticsModel.IntervalOption selectedInterval) {
boolean update = metricUnits != this.metricUnits
|| selectedInterval == null
|| !selectedInterval.sameMultiplier(this.selectedInterval);
this.metricUnits = metricUnits;
this.selectedInterval = selectedInterval;
if (update && viewModel != null) {
viewModel.update(trackId, this.metricUnits, this.selectedInterval);
}
}
}