diff --git a/src/main/java/de/dennisguse/opentracks/data/models/SkiLift.java b/src/main/java/de/dennisguse/opentracks/data/models/SkiLift.java index 99c9b10f9..f88fbc139 100644 --- a/src/main/java/de/dennisguse/opentracks/data/models/SkiLift.java +++ b/src/main/java/de/dennisguse/opentracks/data/models/SkiLift.java @@ -85,4 +85,3 @@ public boolean isUserRidingSkiLift() { return false; } } - diff --git a/src/main/java/de/dennisguse/opentracks/data/models/SkiRun.java b/src/main/java/de/dennisguse/opentracks/data/models/SkiRun.java new file mode 100644 index 000000000..688b62d17 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/data/models/SkiRun.java @@ -0,0 +1,5 @@ +package de.dennisguse.opentracks.data.models; + +public class SkiRun { + //TODO: Add class attributes +} diff --git a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedDayStatisticsAdapter.java b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedDayStatisticsAdapter.java new file mode 100644 index 000000000..7be6bf5a9 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedDayStatisticsAdapter.java @@ -0,0 +1,192 @@ +package de.dennisguse.opentracks.ui.aggregatedStatistics; + +import android.content.Context; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.data.models.ActivityType; +import de.dennisguse.opentracks.data.models.DistanceFormatter; +import de.dennisguse.opentracks.data.models.SpeedFormatter; +import de.dennisguse.opentracks.databinding.AggregatedDailyStatsListItemBinding; +import de.dennisguse.opentracks.databinding.AggregatedStatsListItemBinding; +import de.dennisguse.opentracks.settings.PreferencesUtils; +import de.dennisguse.opentracks.settings.UnitSystem; +import de.dennisguse.opentracks.util.StringUtils; + +public class AggregatedDayStatisticsAdapter extends RecyclerView.Adapter { + + private AggregatedStatistics aggregatedStatistics; + private SimpleDateFormat formatter = new SimpleDateFormat("MM dd yyy"); + + private final Context context; + + public AggregatedDayStatisticsAdapter(Context context, AggregatedStatistics aggregatedStatistics) { + this.context = context; + this.aggregatedStatistics = aggregatedStatistics; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(AggregatedDailyStatsListItemBinding.inflate(LayoutInflater.from(parent.getContext()))); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + ViewHolder viewHolder = (ViewHolder) holder; + + AggregatedStatistics.AggregatedStatistic aggregatedStatistic = aggregatedStatistics.getItem(position); + + String type = aggregatedStatistic.getActivityTypeLocalized(); + if (ActivityType.findByLocalizedString(context, type).isShowSpeedPreferred()) { + viewHolder.setSpeed(aggregatedStatistic); + } else { + viewHolder.setPace(aggregatedStatistic); + } + } + + @Override + public int getItemCount() { + if (aggregatedStatistics == null) { + return 0; + } + return aggregatedStatistics.getCount(); + } + + public void swapData(AggregatedStatistics aggregatedStatistics) { + this.aggregatedStatistics = aggregatedStatistics; + this.notifyDataSetChanged(); + } + + public List getDays() { + List days = new ArrayList<>(); + for (int i = 0; i < aggregatedStatistics.getCount(); i++) { + Date day = Date.from(aggregatedStatistics.getItem(i).getTrackStatistics().getStopTime()); + days.add(formatter.format(day)); + } + return days; + } + + private class ViewHolder extends RecyclerView.ViewHolder { + private final AggregatedDailyStatsListItemBinding viewBinding; + private UnitSystem unitSystem = UnitSystem.defaultUnitSystem(); + private boolean reportSpeed; + + public ViewHolder(AggregatedDailyStatsListItemBinding viewBinding) { + super(viewBinding.getRoot()); + this.viewBinding = viewBinding; + } + + public void setSpeed(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { + setCommonValues(aggregatedStatistic); + + SpeedFormatter formatter = SpeedFormatter.Builder().setUnit(unitSystem).setReportSpeedOrPace(reportSpeed).build(context); + { + //TODO Fill in the infomation here + Pair parts = formatter.getSpeedParts(aggregatedStatistic.getTrackStatistics().getAverageMovingSpeed()); +// viewBinding.aggregatedStatsAvgRate.setText(parts.first); +// viewBinding.aggregatedStatsAvgRateUnit.setText(parts.second); +// viewBinding.aggregatedStatsAvgRateLabel.setText(context.getString(R.string.stats_average_moving_speed)); + // Average Run Speed + viewBinding.dailyRunAvgSpeed.setText(parts.first); + viewBinding.dailyRunAvgSpeedUnit.setText(parts.second); + viewBinding.dailyRunAvgSpeedLabel.setText(context.getString(R.string.daily_run_avg_speed_label)); + } + + { + //TODO Fill in the information here +// Pair parts = formatter.getSpeedParts(aggregatedStatistic.getTrackStatistics().getMaxSpeed()); +// viewBinding.aggregatedStatsMaxRate.setText(parts.first); +// viewBinding.aggregatedStatsMaxRateUnit.setText(parts.second); +// viewBinding.aggregatedStatsMaxRateLabel.setText(context.getString(R.string.stats_max_speed)); + } + } + + public void setPace(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { + setCommonValues(aggregatedStatistic); + + SpeedFormatter formatter = SpeedFormatter.Builder().setUnit(unitSystem).setReportSpeedOrPace(reportSpeed).build(context); + { + //TODO Fill in the information here +// Pair parts = formatter.getSpeedParts(aggregatedStatistic.getTrackStatistics().getAverageMovingSpeed()); +// viewBinding.aggregatedStatsAvgRate.setText(parts.first); +// viewBinding.aggregatedStatsAvgRateUnit.setText(parts.second); +// viewBinding.aggregatedStatsAvgRateLabel.setText(context.getString(R.string.stats_average_moving_pace)); + } + + { + //TODO Fill in the information here +// Pair parts = formatter.getSpeedParts(aggregatedStatistic.getTrackStatistics().getMaxSpeed()); +// viewBinding.aggregatedStatsMaxRate.setText(parts.first); +// viewBinding.aggregatedStatsMaxRateUnit.setText(parts.second); +// viewBinding.aggregatedStatsMaxRateLabel.setText(R.string.stats_fastest_pace); + } + } + + //TODO Check preference handling. + private void setCommonValues(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { + String activityType = aggregatedStatistic.getActivityTypeLocalized(); + String day = aggregatedStatistic.getDay(); + + reportSpeed = PreferencesUtils.isReportSpeed(activityType); + unitSystem = PreferencesUtils.getUnitSystem(); + + viewBinding.activityIcon.setImageResource(getIcon(aggregatedStatistic)); + viewBinding.aggregatedStatsDayLabel.setText(day); + viewBinding.aggregatedStatsNumDayTracks.setText(StringUtils.valueInParentheses(String.valueOf(aggregatedStatistic.getCountTracks()))); + + Pair parts = DistanceFormatter.Builder() + .setUnit(unitSystem) + .build(context).getDistanceParts(aggregatedStatistic.getTrackStatistics().getTotalDistance()); + viewBinding.dailyTotalDistanceNumber.setText(parts.first); + viewBinding.dailyTotalDistanceUnit.setText(context.getString(R.string.daily_total_distance_unit)); + viewBinding.dailyTotalDistanceLabel.setText(context.getString(R.string.daily_total_distance_label)); + +// viewBinding.aggregatedStatsTime.setText(StringUtils.formatElapsedTime(aggregatedStatistic.getTrackStatistics().getMovingTime())); + // Number of Lifts + viewBinding.dailyLiftNumber.setText(String.valueOf(aggregatedStatistic.getCountTracks())); + viewBinding.dailyLiftNumberUnit.setText(context.getString(R.string.daily_lift_number_unit)); + viewBinding.dailyLiftNumberLabel.setText(context.getString(R.string.daily_lift_number_label)); + // Lift Total Time + viewBinding.dailyLiftTotalTime.setText(String.valueOf(aggregatedStatistic.getTotalTime())); + viewBinding.dailyLiftTotalTimeLabel.setText(context.getString(R.string.daily_lift_total_time_label)); + // Lift Moving Time + viewBinding.dailyLiftMovingTime.setText(String.valueOf(aggregatedStatistic.getMovingTime())); + viewBinding.dailyLiftMovingTimeLabel.setText(context.getString(R.string.daily_lift_moving_time_label)); + + // Number of Runs + viewBinding.dailyRunNumber.setText(String.valueOf(aggregatedStatistic.getCountTracks())); + viewBinding.dailyRunNumberUnit.setText(context.getString(R.string.daily_run_number_unit)); + viewBinding.dailyRunNumberLabel.setText(context.getString(R.string.daily_run_number_label)); + + // Run Elevation + viewBinding.dailyRunMaxVertical.setText(String.valueOf(aggregatedStatistic.getMaxVertical())); + viewBinding.dailyRunMaxVerticalUnit.setText(context.getString(R.string.daily_run_max_vertical_unit)); + viewBinding.dailyRunMaxVerticalLabel.setText(context.getString(R.string.daily_run_max_vertical_label)); + + // Max Speed + viewBinding.dailyTotalDistanceNumber.setText(parts.first); + viewBinding.dailyTotalDistanceUnit.setText(context.getString(R.string.daily_max_speed_unit)); + viewBinding.dailyTotalDistanceLabel.setText(context.getString(R.string.daily_max_speed_label)); + + //Activity type + viewBinding.activityTypeLabel.setText(String.valueOf(aggregatedStatistic.getActivityTypeLocalized())); + } + + private int getIcon(AggregatedStatistics.AggregatedStatistic aggregatedStatistic) { + String localizedActivityType = aggregatedStatistic.getActivityTypeLocalized(); + return ActivityType.findByLocalizedString(context, localizedActivityType) + .getIconDrawableId(); + } + } +} diff --git a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatistics.java b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatistics.java index 8de17659d..ed1594902 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatistics.java +++ b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatistics.java @@ -1,13 +1,23 @@ package de.dennisguse.opentracks.ui.aggregatedStatistics; +import static java.lang.Math.round; + import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.time.Duration; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import de.dennisguse.opentracks.data.models.Distance; +import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.stats.TrackStatistics; @@ -31,13 +41,55 @@ public AggregatedStatistics(@NonNull List tracks) { }); } + public AggregatedStatistics(@NonNull List tracks, Boolean isDaily) { + for (Track track : tracks) { + if (isDaily) { + aggregateDays(track); + } else { + aggregate(track); + } + } + + if(isDaily) { + dataList.addAll(dataMap.values()); + dataList.sort((o1, o2) -> { + if (o1.getCountTracks() == o2.getCountTracks()) { + return o1.getDay().compareTo(o2.getDay()); + } + return (o1.getCountTracks() < o2.getCountTracks() ? 1 : -1); + }); + } else { + dataList.addAll(dataMap.values()); + dataList.sort((o1, o2) -> { + if (o1.getCountTracks() == o2.getCountTracks()) { + return o1.getActivityTypeLocalized().compareTo(o2.getActivityTypeLocalized()); + } + return (o1.getCountTracks() < o2.getCountTracks() ? 1 : -1); + }); + } + } + @VisibleForTesting public void aggregate(@NonNull Track track) { String activityTypeLocalized = track.getActivityTypeLocalized(); if (dataMap.containsKey(activityTypeLocalized)) { dataMap.get(activityTypeLocalized).add(track.getTrackStatistics()); } else { - dataMap.put(activityTypeLocalized, new AggregatedStatistic(activityTypeLocalized, track.getTrackStatistics())); + dataMap.put(activityTypeLocalized, + new AggregatedStatistic(activityTypeLocalized, track.getTrackStatistics())); + } + } + + @VisibleForTesting + public void aggregateDays(@NonNull Track track) { + String activityTypeLocalized = track.getActivityTypeLocalized(); + SimpleDateFormat formatter = new SimpleDateFormat("MM dd yyy"); + String day = formatter.format(Date.from(track.getTrackStatistics().getStopTime())); + String combinedKey = day + " - " + activityTypeLocalized; + if (dataMap.containsKey(combinedKey)) { + dataMap.get(combinedKey).add(track.getTrackStatistics()); + } else { + dataMap.put(combinedKey, new AggregatedStatistic(track.getActivityTypeLocalized(), track.getTrackStatistics(), day)); } } @@ -55,6 +107,8 @@ public AggregatedStatistic getItem(int position) { public static class AggregatedStatistic { private final String activityTypeLocalized; + + private String day; private final TrackStatistics trackStatistics; private int countTracks = 1; @@ -63,10 +117,20 @@ public AggregatedStatistic(String activityTypeLocalized, TrackStatistics trackSt this.trackStatistics = trackStatistics; } + public AggregatedStatistic(String activityTypeLocalized, TrackStatistics trackStatistics, String day) { + this.day = day; + this.activityTypeLocalized = activityTypeLocalized; + this.trackStatistics = trackStatistics; + } + public String getActivityTypeLocalized() { return activityTypeLocalized; } + public String getDay() { + return day; + } + public TrackStatistics getTrackStatistics() { return trackStatistics; } @@ -79,5 +143,50 @@ void add(TrackStatistics statistics) { trackStatistics.merge(statistics); countTracks++; } + + public String getTotalTime() { + Duration duration = trackStatistics.getTotalTime(); + String formattedTime = formatDuration(duration); + return formattedTime; + } + + public String getMovingTime() { + Duration duration = trackStatistics.getMovingTime(); + String formattedTime = formatDuration(duration); + return formattedTime; + } + + public Distance getTotalDistance() { + return trackStatistics.getTotalDistance(); + } + + public Speed getMaxSpeed() { + return trackStatistics.getMaxSpeed(); + + } + + public double getMaxVertical() { + double value = trackStatistics.getMaxAltitude(); + double roundedValue = round(value, 2); + return roundedValue; + } + + public static double round(double value, int places) { + if (places < 0) throw new IllegalArgumentException(); + + BigDecimal bd = BigDecimal.valueOf(value); + bd = bd.setScale(places, RoundingMode.HALF_UP); + return bd.doubleValue(); + } + + private String formatDuration(Duration duration) { + long seconds = duration.getSeconds(); + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + long secs = seconds % 60; + + // Format hours, minutes and seconds to ensure they are in 00:00:00 format + return String.format("%02d:%02d:%02d", hours, minutes, secs); + } } } diff --git a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsActivity.java b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsActivity.java index afb33ef24..12007a958 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsActivity.java +++ b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsActivity.java @@ -4,6 +4,7 @@ import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.widget.Switch; import androidx.annotation.NonNull; import androidx.lifecycle.ViewModelProvider; @@ -28,6 +29,7 @@ public class AggregatedStatisticsActivity extends AbstractActivity implements Fi private AggregatedStatsBinding viewBinding; private AggregatedStatisticsAdapter adapter; + private AggregatedDayStatisticsAdapter dayAdapter; private AggregatedStatisticsModel viewModel; private final TrackSelection selection = new TrackSelection(); @@ -36,6 +38,8 @@ public class AggregatedStatisticsActivity extends AbstractActivity implements Fi private MenuItem filterItem; private MenuItem clearFilterItem; + private boolean isDailyView = false; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -49,23 +53,42 @@ protected void onCreate(Bundle savedInstanceState) { LinearLayoutManager layoutManager = new LinearLayoutManager(this); adapter = new AggregatedStatisticsAdapter(this, null); + dayAdapter = new AggregatedDayStatisticsAdapter(this, null); viewBinding.aggregatedStatsList.setLayoutManager(layoutManager); - viewBinding.aggregatedStatsList.setAdapter(adapter); 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.swapData(aggregatedStatistics); - } - checkListEmpty(); + + Switch dailySwitch = findViewById(R.id.aggregated_stats_daily_switch); + dailySwitch.setOnCheckedChangeListener((compoundButton, switchState) -> { + isDailyView = switchState; + toggleAdapter(); }); + toggleAdapter(); setSupportActionBar(viewBinding.bottomAppBarLayout.bottomAppBar); } + private void toggleAdapter() { + if (isDailyView) { + viewBinding.aggregatedStatsList.setAdapter(dayAdapter); + viewModel.getAggregatedDailyStats(selection).observe(this, aggregatedStats -> { + if (aggregatedStats != null) { + dayAdapter.swapData(aggregatedStats); + } + checkListEmpty(); + }); + } else { + viewBinding.aggregatedStatsList.setAdapter(adapter); + viewModel.getAggregatedStats(selection).observe(this, aggregatedStats -> { + if (aggregatedStats != null) { + adapter.swapData(aggregatedStats); + } + checkListEmpty(); + }); + } + + } + private void checkListEmpty() { if (adapter.getItemCount() == 0) { viewBinding.aggregatedStatsList.setVisibility(View.GONE); @@ -101,7 +124,11 @@ public boolean onCreateOptionsMenu(Menu menu) { public boolean onOptionsItemSelected(@NonNull MenuItem item) { if (item.getItemId() == R.id.aggregated_statistics_filter) { ArrayList filterItems = new ArrayList<>(); - adapter.getCategories().stream().forEach(activityType -> filterItems.add(new FilterDialogFragment.FilterItem(activityType, activityType, true))); + if(isDailyView) { + dayAdapter.getDays().stream().forEach(day -> filterItems.add(new FilterDialogFragment.FilterItem(day, day, true))); + } else { + adapter.getCategories().stream().forEach(activityType -> filterItems.add(new FilterDialogFragment.FilterItem(activityType, activityType, true))); + } FilterDialogFragment.showDialog(getSupportFragmentManager(), filterItems); return true; } diff --git a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsModel.java b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsModel.java index 5115a00c5..f67c606b7 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsModel.java +++ b/src/main/java/de/dennisguse/opentracks/ui/aggregatedStatistics/AggregatedStatisticsModel.java @@ -17,6 +17,7 @@ public class AggregatedStatisticsModel extends AndroidViewModel { private MutableLiveData aggregatedStats; + private MutableLiveData aggregatedDayStats; public AggregatedStatisticsModel(@NonNull Application application) { super(application); @@ -30,6 +31,14 @@ public LiveData getAggregatedStats(@Nullable TrackSelectio return aggregatedStats; } + public LiveData getAggregatedDailyStats(@Nullable TrackSelection selection) { + if (aggregatedDayStats == null) { + aggregatedDayStats = new MutableLiveData<>(); + loadAggregatedStats(selection, true); + } + return aggregatedDayStats; + } + public void updateSelection(TrackSelection selection) { loadAggregatedStats(selection); } @@ -43,9 +52,25 @@ private void loadAggregatedStats(TrackSelection selection) { ContentProviderUtils contentProviderUtils = new ContentProviderUtils(getApplication().getApplicationContext()); List tracks = selection != null ? contentProviderUtils.getTracks(selection) : contentProviderUtils.getTracks(); + // This is where we need to define our custom sorting AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks); aggregatedStats.postValue(aggregatedStatistics); }).start(); } + + private void loadAggregatedStats(TrackSelection selection, Boolean isDaily) { + new Thread(() -> { + ContentProviderUtils contentProviderUtils = new ContentProviderUtils(getApplication().getApplicationContext()); + List tracks = selection != null ? contentProviderUtils.getTracks(selection) : contentProviderUtils.getTracks(); + + // This is where we need to define our custom sorting + AggregatedStatistics aggregatedStatistics = new AggregatedStatistics(tracks, isDaily); + if(isDaily) { + aggregatedDayStats.postValue(aggregatedStatistics); + } else { + aggregatedStats.postValue(aggregatedStatistics); + } + }).start(); + } } diff --git a/src/main/res/layout/aggregated_daily_stats_list_item.xml b/src/main/res/layout/aggregated_daily_stats_list_item.xml new file mode 100644 index 000000000..c221925ef --- /dev/null +++ b/src/main/res/layout/aggregated_daily_stats_list_item.xml @@ -0,0 +1,445 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/layout/aggregated_stats.xml b/src/main/res/layout/aggregated_stats.xml index 6f2fc52e7..74f37a09a 100644 --- a/src/main/res/layout/aggregated_stats.xml +++ b/src/main/res/layout/aggregated_stats.xml @@ -1,19 +1,39 @@ - + android:layout_height="?attr/actionBarSize"> - + android:layout_height="match_parent" + android:orientation="horizontal" + android:gravity="center_vertical"> + + + + + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 953de4933..3d5ef0721 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -575,6 +575,8 @@ limitations under the License. Cadence Power Clock + Day\ + Daily View Track %1$d Location accuracy: %1$s @@ -794,7 +796,29 @@ limitations under the License. Always OpenTracks itself does not provide a map. Please install OSMDashboard to view your recordings on a map. + average lift moving speed + LIFT MOVING TIME + Lift Total Time + Lift(s) + km/h + h + NUMBER OF LIFTS + LIFT WAITING TIME + LIFT MAX SPEED + km/h + NUMBER OF RUNS + Run(s) LeaderboardActivity Tab 1 Tab 2 - \ No newline at end of file + RUN MAX VERTICAL + m + Skiing + TOTAL DISTANCE + 0 + km + AVERAGE SPEED + MAX SPEED + 0 + km/h +