Refactor workout details

This commit is contained in:
José Rebelo
2025-08-16 15:19:38 +02:00
committed by José Rebelo
parent f76d64b20d
commit e59ea27051
37 changed files with 2501 additions and 1743 deletions
+1 -1
View File
@@ -657,7 +657,7 @@
android:parentActivityName=".activities.ControlCenterv2" />
<activity
android:name=".activities.ActivitySummaryDetail"
android:name=".activities.workouts.WorkoutDetailsActivity"
android:label="@string/activity_summary_detail"
android:parentActivityName=".activities.ActivitySummariesActivity"
android:windowSoftInputMode="stateHidden" />
@@ -24,15 +24,12 @@ import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.net.Uri;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.DatePicker;
import android.widget.Toast;
import androidx.core.content.FileProvider;
@@ -57,6 +54,7 @@ import java.util.Objects;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutDetailsActivity;
import nodomain.freeyourgadget.gadgetbridge.adapter.ActivitySummariesAdapter;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
@@ -90,38 +88,30 @@ public class ActivitySummariesActivity extends AbstractListActivity<BaseActivity
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
switch (Objects.requireNonNull(action)) {
case GBDevice.ACTION_DEVICE_CHANGED:
GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
if (device == null) {
LOG.error("Got device changed without device");
return;
}
if (!device.equals(mGBDevice)) {
return;
}
if (device.isBusy()) {
swipeLayout.setRefreshing(true);
} else {
boolean wasBusy = swipeLayout.isRefreshing();
swipeLayout.setRefreshing(false);
if (wasBusy) {
refresh();
}
}
break;
if (!GBDevice.ACTION_DEVICE_CHANGED.equals(intent.getAction())) {
LOG.warn("Got unexpected action {}", intent.getAction());
return;
}
final GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
if (device == null) {
LOG.error("Got device changed without device");
return;
}
if (!device.equals(mGBDevice)) {
return;
}
if (device.isBusy()) {
swipeLayout.setRefreshing(true);
} else {
boolean wasBusy = swipeLayout.isRefreshing();
swipeLayout.setRefreshing(false);
if (wasBusy) {
refresh();
}
}
}
};
public static int getBackgroundColor(Context context) {
TypedValue typedValue = new TypedValue();
Resources.Theme theme = context.getTheme();
theme.resolveAttribute(R.attr.sports_activity_summary_background, typedValue, true);
return typedValue.data;
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
@@ -152,13 +142,14 @@ public class ActivitySummariesActivity extends AbstractListActivity<BaseActivity
@Override
public void onActivityResult(int requestCode, int resultCode, Intent resultData) {
super.onActivityResult(requestCode, resultCode, resultData);
if (requestCode == ACTIVITY_FILTER && resultData != null) {
if (requestCode == ACTIVITY_FILTER && resultData != null && resultData.getExtras() != null) {
Bundle bundle = resultData.getExtras();
activityFilter = bundle.getInt("activityFilter", 0);
dateFromFilter = bundle.getLong("dateFromFilter", 0);
dateToFilter = bundle.getLong("dateToFilter", 0);
deviceFilter = bundle.getLong("deviceFilter", 0);
nameContainsFilter = bundle.getString("nameContainsFilter");
//noinspection unchecked
itemsFilter = (List<Long>) bundle.getSerializable("itemsFilter");
setActivityKindFilter(activityFilter);
setDateFromFilter(dateFromFilter);
@@ -206,7 +197,7 @@ public class ActivitySummariesActivity extends AbstractListActivity<BaseActivity
ActivitySummary summary = activitySummariesAdapter.getItem(position);
if (summary != null) {
try {
showActivityDetail(position);
showActivityDetail(position - 1);
} catch (Exception e) {
GB.toast(getApplicationContext(), "Unable to display Activity Detail, maybe the activity is not available yet: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
}
@@ -284,11 +275,7 @@ public class ActivitySummariesActivity extends AbstractListActivity<BaseActivity
.setTitle(ActivitySummariesActivity.this.getString(R.string.sports_activity_confirm_delete_title, toDelete.size()))
.setMessage(ActivitySummariesActivity.this.getString(R.string.sports_activity_confirm_delete_description, toDelete.size()))
.setIcon(R.drawable.ic_delete_forever)
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
public void onClick(final DialogInterface dialog, final int whichButton) {
deleteItems(toDelete);
}
})
.setPositiveButton(android.R.string.yes, (dialog, whichButton) -> deleteItems(toDelete))
.setNegativeButton(android.R.string.no, null)
.show();
@@ -370,18 +357,15 @@ public class ActivitySummariesActivity extends AbstractListActivity<BaseActivity
public void resetFetchTimestampToChosenDate() {
final Calendar currentDate = Calendar.getInstance();
new DatePickerDialog(this, new DatePickerDialog.OnDateSetListener() {
@Override
public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) {
Calendar date = Calendar.getInstance();
date.set(year, monthOfYear, dayOfMonth);
new DatePickerDialog(this, (view, year, monthOfYear, dayOfMonth) -> {
Calendar date = Calendar.getInstance();
date.set(year, monthOfYear, dayOfMonth);
long timestamp = date.getTimeInMillis() - 1000;
SharedPreferences.Editor editor = GBApplication.getDeviceSpecificSharedPrefs(mGBDevice.getAddress()).edit();
editor.remove("lastSportsActivityTimeMillis"); //FIXME: key reconstruction is BAD
editor.putLong("lastSportsActivityTimeMillis", timestamp);
editor.apply();
}
long timestamp = date.getTimeInMillis() - 1000;
SharedPreferences.Editor editor = GBApplication.getDeviceSpecificSharedPrefs(mGBDevice.getAddress()).edit();
editor.remove("lastSportsActivityTimeMillis"); //FIXME: key reconstruction is BAD
editor.putLong("lastSportsActivityTimeMillis", timestamp);
editor.apply();
}, currentDate.get(Calendar.YEAR), currentDate.get(Calendar.MONTH), currentDate.get(Calendar.DATE)).show();
}
@@ -433,23 +417,20 @@ public class ActivitySummariesActivity extends AbstractListActivity<BaseActivity
}
}
private void showActivityDetail(int position) {
Intent ActivitySummaryDetailIntent = new Intent(this, ActivitySummaryDetail.class);
Bundle bundle = new Bundle();
private void showActivityDetail(final int position) {
final Intent intent = WorkoutDetailsActivity.Companion.createFilteredListIntent(
this,
mGBDevice,
position,
activityFilter,
dateFromFilter,
dateToFilter,
nameContainsFilter,
deviceFilter,
itemsFilter
);
bundle.putInt("position", position);
bundle.putSerializable("activityKindMap", activityKindMap);
bundle.putSerializable("itemsFilter", (Serializable) itemsFilter);
bundle.putInt("activityFilter", activityFilter);
bundle.putLong("dateFromFilter", dateFromFilter);
bundle.putLong("dateToFilter", dateToFilter);
bundle.putLong("deviceFilter", deviceFilter);
bundle.putLong("initial_deviceFilter", getDeviceId(mGBDevice));
bundle.putString("nameContainsFilter", nameContainsFilter);
ActivitySummaryDetailIntent.putExtras(bundle);
ActivitySummaryDetailIntent.putExtra(GBDevice.EXTRA_DEVICE, mGBDevice);
startActivityForResult(ActivitySummaryDetailIntent, ACTIVITY_DETAIL);
startActivityForResult(intent, ACTIVITY_DETAIL);
}
private void runFilterActivity() {
@@ -467,11 +448,12 @@ public class ActivitySummariesActivity extends AbstractListActivity<BaseActivity
startActivityForResult(filterIntent, ACTIVITY_FILTER);
}
private long getDeviceId(GBDevice device) {
private long getDeviceId(final GBDevice device) {
try (DBHandler handler = GBApplication.acquireDB()) {
Device dbDevice = DBHelper.findDevice(device, handler.getDaoSession());
return dbDevice.getId();
final Device dbDevice = DBHelper.findDevice(device, handler.getDaoSession());
return Objects.requireNonNull(dbDevice).getId();
} catch (Exception e) {
LOG.error("Failed to get device id", e);
}
return 0;
}
@@ -51,6 +51,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.charts.ChartsHost;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.DefaultChartsData;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.SampleXLabelFormatter;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.TimestampTranslation;
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackViewModel;
import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@@ -72,6 +73,8 @@ public class ActivitySummariesChartFragment extends AbstractActivityChartFragmen
private int startTime;
private int endTime;
private boolean chartsSetUp;
@Override
protected void onReceive(final Context context, final Intent intent) {
// FIXME: We need to override this, or we crash
@@ -90,6 +93,7 @@ public class ActivitySummariesChartFragment extends AbstractActivityChartFragmen
this.endTime = (int) endTime;
this.gbDevice = gbDevice;
if (this.view != null) {
setupChart();
createLocalRefreshTask("getting hr and activity", getActivity()).execute();
}
}
@@ -124,6 +128,9 @@ public class ActivitySummariesChartFragment extends AbstractActivityChartFragmen
}
private void setupChart() {
if (chartsSetUp) {
return;
}
mChart.setBackgroundColor(BACKGROUND_COLOR);
mChart.getDescription().setTextColor(DESCRIPTION_COLOR);
configureBarLineChartDefaults(mChart);
@@ -156,6 +163,7 @@ public class ActivitySummariesChartFragment extends AbstractActivityChartFragmen
yAxisRight.setAxisMaximum(HeartRateUtils.getInstance().getMaxHeartRate());
yAxisRight.setAxisMinimum(HeartRateUtils.getInstance().getMinHeartRate());
chartsSetUp = true;
}
@Override
@@ -219,7 +227,7 @@ public class ActivitySummariesChartFragment extends AbstractActivityChartFragmen
final DefaultChartsData<LineData> activitySamplesData = buildChartFromSamples(handler);
if (trackFile != null) {
final List<ActivityPoint> activityPoints = ActivitySummariesGpsFragment.getActivityPoints(trackFile)
final List<ActivityPoint> activityPoints = MapsTrackViewModel.Companion.getActivityPoints(trackFile)
.stream()
.filter(ap -> ap.getHeartRate() > 0)
.collect(Collectors.toList());
@@ -1,252 +0,0 @@
/* Copyright (C) 2020-2024 José Rebelo, Petr Vaněk, a0z
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.mapsforge.core.model.BoundingBox;
import org.mapsforge.core.model.Dimension;
import org.mapsforge.core.model.LatLong;
import org.mapsforge.core.model.MapPosition;
import org.mapsforge.core.util.LatLongUtils;
import org.mapsforge.map.android.view.MapView;
import org.mapsforge.map.model.Model;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.Objects;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsFragment;
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackActivity;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParseException;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser;
import nodomain.freeyourgadget.gadgetbridge.util.maps.MapsManager;
public class ActivitySummariesGpsFragment extends AbstractGBFragment {
private static final Logger LOG = LoggerFactory.getLogger(ActivitySummariesGpsFragment.class);
private MapView mapView;
private File inputFile;
private MapsManager mapsManager;
private boolean mapValid = true;
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, final Intent intent) {
if (MapsSettingsFragment.ACTION_SETTING_CHANGE.equals(intent.getAction())) {
if (!isResumed()) {
mapValid = false;
} else {
mapsManager.reload();
}
}
}
};
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_gps, container, false);
TextView gpsWarning = rootView.findViewById(R.id.gpsWarning);
mapView = rootView.findViewById(R.id.activitygpsview);
mapView.setBuiltInZoomControls(false);
mapsManager = new MapsManager(requireContext(), mapView);
mapsManager.loadMaps();
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(MapsSettingsFragment.ACTION_SETTING_CHANGE);
LocalBroadcastManager.getInstance(requireActivity()).registerReceiver(mReceiver, intentFilter);
if (mapsManager.isMapLoaded()) {
gpsWarning.setVisibility(View.GONE);
}
if (inputFile != null) {
processInBackgroundThread();
}
return rootView;
}
@Override
public void onResume() {
super.onResume();
if (!mapValid) {
mapsManager.reload();
}
}
@Override
public void onDestroyView() {
LocalBroadcastManager.getInstance(requireActivity()).unregisterReceiver(mReceiver);
mapsManager.onDestroy();
super.onDestroyView();
}
public void set_data(File inputFile) {
this.inputFile = inputFile;
if (mapView != null) { //first fragment inflate is AFTER this is called
processInBackgroundThread();
}
}
private void processInBackgroundThread() {
new Thread(() -> {
final List<GPSCoordinate> points = getActivityPoints(inputFile)
.stream()
.map(ActivityPoint::getLocation)
.filter(Objects::nonNull)
.collect(Collectors.toList());
if (!points.isEmpty()) {
drawTrack(mapView, (ArrayList<? extends GPSCoordinate>) points);
}
}).start();
}
public static List<ActivityPoint> getActivityPoints(final File trackFile) {
final List<ActivityPoint> points = new ArrayList<>();
if (trackFile == null) {
return points;
}
if (trackFile.getName().endsWith(".gpx")) {
try (FileInputStream inputStream = new FileInputStream(trackFile)) {
final GpxParser gpxParser = new GpxParser(inputStream);
points.addAll(gpxParser.getGpxFile().getActivityPoints());
} catch (final IOException e) {
LOG.error("Failed to open {}", trackFile, e);
} catch (final GpxParseException e) {
LOG.error("Failed to parse gpx file", e);
}
} else if (trackFile.getName().endsWith(".fit")) {
try {
FitFile fitFile = FitFile.parseIncoming(trackFile);
for (final RecordData record : fitFile.getRecords()) {
if (record instanceof FitRecord) {
points.add(((FitRecord) record).toActivityPoint());
}
}
} catch (final IOException e) {
LOG.error("Failed to open {}", trackFile, e);
} catch (final Exception e) {
LOG.error("Failed to parse fit file", e);
}
} else {
LOG.warn("Unknown file type {}", trackFile.getName());
}
return points;
}
private void drawTrack(MapView mapView, final ArrayList<? extends GPSCoordinate> trackPoints) {
double maxLat = (Collections.max(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
double minLat = (Collections.min(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
double maxLon = (Collections.max(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
double minLon = (Collections.min(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
List<LatLong> latlongs = trackPoints.stream()
.map(p -> new LatLong(p.getLatitude(), p.getLongitude()))
.collect(Collectors.toList());
mapsManager.setTrack(latlongs);
final Model model = mapView.getModel();
// FIXME: We need to offset the min latitude so the track gets centered - not sure why
final BoundingBox boundingBox = new BoundingBox(minLat - (maxLat - minLat) / 4, minLon, maxLat, maxLon);
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
int screenWidth = displayMetrics.widthPixels;
int height = (int) (300 * displayMetrics.density);
byte zoom = LatLongUtils.zoomForBounds(new Dimension(screenWidth, height), boundingBox, model.displayModel.getTileSize());
model.mapViewPosition.setMapPosition(new MapPosition(boundingBox.getCenterPoint(), zoom));
final View.OnTouchListener controlTouchListener = new View.OnTouchListener() {
private float startX, startY;
private static final int TAP_THRESHOLD = 10;
private static final int LONG_PRESS_TIME = 300;
private long pressTime;
private boolean isDrag = false;
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isDrag = false;
startX = event.getX();
startY = event.getY();
pressTime = System.currentTimeMillis();
break;
case MotionEvent.ACTION_MOVE:
if (Math.abs(startX - event.getX()) > TAP_THRESHOLD || Math.abs(startY - event.getY()) > TAP_THRESHOLD) {
isDrag = true;
}
break;
case MotionEvent.ACTION_UP:
if (!isDrag && System.currentTimeMillis() - pressTime < LONG_PRESS_TIME) {
openMapActivity();
}
break;
case MotionEvent.ACTION_CANCEL:
break;
}
return true;
}
private void openMapActivity() {
Intent intent = new Intent(getContext(), MapsTrackActivity.class);
intent.putExtra("file", inputFile);
startActivity(intent);
}
};
mapView.setOnTouchListener(controlTouchListener);
mapView.invalidate();
}
@Nullable
@Override
protected CharSequence getTitle() {
return null;
}
}
@@ -1,743 +0,0 @@
/* Copyright (C) 2020-2024 Andreas Shimokawa, Arjan Schrijver, Daniel Dakhno,
José Rebelo, Petr Vaněk
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.INTERNAL_HAS_GPS;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Bundle;
import android.text.InputType;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TableRow;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.FileProvider;
import androidx.gridlayout.widget.GridLayout;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileFilter;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.fit.FitViewerActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.WorkoutValueFormatter;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryItems;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryJsonSummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.util.ActivitySummaryUtils;
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.SwipeEvents;
public class ActivitySummaryDetail extends AbstractGBActivity {
private static final Logger LOG = LoggerFactory.getLogger(ActivitySummaryDetail.class);
BaseActivitySummary currentItem = null;
private GBDevice gbDevice;
private Menu mOptionsMenu;
List<String> filesGpxList = new ArrayList<>();
int selectedGpxIndex;
String selectedGpxFile;
File export_path_root = null; // original gpx file location, could still contain old gpx files
File export_path_gpx = null; // new gpx file location
private ActivitySummariesChartFragment activitySummariesChartFragment;
private ActivitySummariesGpsFragment activitySummariesGpsFragment;
private final WorkoutValueFormatter workoutValueFormatter = new WorkoutValueFormatter();
public static int getAlternateColor(Context context) {
TypedValue typedValue = new TypedValue();
Resources.Theme theme = context.getTheme();
theme.resolveAttribute(R.attr.alternate_row_background, typedValue, true);
return typedValue.data;
}
public static Bitmap getScreenShot(View view, int height, int width, Context context) {
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.drawColor(GBApplication.getWindowBackgroundColor(context));
view.draw(canvas);
return bitmap;
}
@SuppressLint("ClickableViewAccessibility")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Context appContext = this.getApplicationContext();
if (appContext instanceof GBApplication) {
setContentView(R.layout.activity_summary_details);
}
Intent intent = getIntent();
Bundle bundle = intent.getExtras();
gbDevice = bundle.getParcelable(GBDevice.EXTRA_DEVICE);
final int position = bundle.getInt("position", 0);
final int activityFilter = bundle.getInt("activityFilter", 0);
final long dateFromFilter = bundle.getLong("dateFromFilter", 0);
final long dateToFilter = bundle.getLong("dateToFilter", 0);
final long deviceFilter = bundle.getLong("deviceFilter", 0);
final String nameContainsFilter = bundle.getString("nameContainsFilter");
final List<Long> itemsFilter = (List<Long>) bundle.getSerializable("itemsFilter");
final ActivitySummaryItems items = new ActivitySummaryItems(this, gbDevice, activityFilter, dateFromFilter, dateToFilter, nameContainsFilter, deviceFilter, itemsFilter);
final ScrollView layout = findViewById(R.id.activity_summary_detail_scroll_layout);
//final LinearLayout layout = findViewById(R.id.activity_summary_detail_relative_layout);
final Animation animFadeRight;
final Animation animFadeLeft;
final Animation animBounceLeft;
final Animation animBounceRight;
animFadeRight = AnimationUtils.loadAnimation(
this,
R.anim.flyright);
animFadeLeft = AnimationUtils.loadAnimation(
this,
R.anim.flyleft);
animBounceLeft = AnimationUtils.loadAnimation(
this,
R.anim.bounceleft);
animBounceRight = AnimationUtils.loadAnimation(
this,
R.anim.bounceright);
activitySummariesChartFragment = new ActivitySummariesChartFragment();
activitySummariesGpsFragment = new ActivitySummariesGpsFragment();
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.chartsFragmentHolder, activitySummariesChartFragment)
.replace(R.id.gpsFragmentHolder, activitySummariesGpsFragment)
.commit();
layout.setOnTouchListener(new SwipeEvents(this) {
@Override
public void onSwipeRight() {
BaseActivitySummary newItem = items.getNextItem();
if (newItem != null) {
currentItem = newItem;
refreshFromCurrentItem();
layout.startAnimation(animFadeRight);
} else {
layout.startAnimation(animBounceRight);
}
}
@Override
public void onSwipeLeft() {
BaseActivitySummary newItem = items.getPrevItem();
if (newItem != null) {
currentItem = newItem;
refreshFromCurrentItem();
layout.startAnimation(animFadeLeft);
} else {
layout.startAnimation(animBounceLeft);
}
}
});
currentItem = items.getItem(position);
if (currentItem != null) {
refreshFromCurrentItem();
}
//allows long-press.switch of data being in raw form or recalculated
ImageView activity_icon = findViewById(R.id.item_image);
activity_icon.setOnLongClickListener(new View.OnLongClickListener() {
public boolean onLongClick(View v) {
workoutValueFormatter.toggleRawData();
if (currentItem != null) {
makeSummaryHeader(currentItem);
makeSummaryContent(currentItem);
}
return false;
}
});
}
private void makeSummaryHeader(BaseActivitySummary item) {
//make view of data from main part of item
String activitykindname = ActivityKind.fromCode(item.getActivityKind()).getLabel(getApplicationContext());
String activityname = item.getName();
Date starttime = item.getStartTime();
Date endtime = item.getEndTime();
String durationhms = DateTimeUtils.formatDurationHoursMinutes((endtime.getTime() - starttime.getTime()), TimeUnit.MILLISECONDS);
ImageView activity_icon = findViewById(R.id.item_image);
activity_icon.setImageResource(ActivityKind.fromCode(item.getActivityKind()).getIcon());
final ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setTitle(activitykindname);
}
TextView activity_name = findViewById(R.id.activityname);
activity_name.setText(activityname);
if (StringUtils.isBlank(activityname)) {
activity_name.setVisibility(View.GONE);
} else {
activity_name.setVisibility(View.VISIBLE);
}
TextView activityDateTextView = findViewById(R.id.activitydate);
final Context context = activityDateTextView.getContext();
final String timeString;
if (DateTimeUtils.isSameDay(starttime, endtime)) {
timeString = context.getString(
R.string.date_placeholders__start_time__end_time,
DateTimeUtils.formatDateTimeRelative(context, starttime),
DateTimeUtils.formatTime(endtime.getHours(), endtime.getMinutes())
);
} else {
timeString = context.getString(
R.string.date_placeholders__start_time__end_time,
DateTimeUtils.formatDateTimeRelative(context, starttime),
DateTimeUtils.formatDateTimeRelative(context, endtime)
);
}
activityDateTextView.setText(timeString);
TextView activityTimeTextView = findViewById(R.id.activityduration);
activityTimeTextView.setText(durationhms);
}
private void refreshFromCurrentItem() {
makeSummaryHeader(currentItem);
makeSummaryContent(currentItem);
activitySummariesChartFragment.setDateAndGetData(
getTrackFile(),
getGBDevice(currentItem.getDevice()),
currentItem.getStartTime().getTime() / 1000,
currentItem.getEndTime().getTime() / 1000
);
if (itemHasGps()) {
showGpsCanvas();
activitySummariesGpsFragment.set_data(getTrackFile());
} else {
hideGpsCanvas();
}
updateMenuItems();
}
private File get_path() {
File path = null;
try {
path = FileUtils.getExternalFilesDir();
} catch (IOException e) {
LOG.error("Error getting path", e);
}
return path;
}
private List<String> get_gpx_file_list() {
List<File> files = new ArrayList<>();
FileFilter gpxFileFilter = file -> file.isFile() && file.getPath().toLowerCase().endsWith(".gpx");
if (export_path_root.isDirectory()) {
File[] rootFiles = export_path_root.listFiles(gpxFileFilter);
if (rootFiles != null)
Collections.addAll(files, rootFiles);
}
if (export_path_gpx.isDirectory()) {
File[] gpxFiles = export_path_gpx.listFiles(gpxFileFilter);
if (gpxFiles != null)
Collections.addAll(files, gpxFiles);
}
files.sort((file1, file2) -> {
long lastModified1 = file1.lastModified();
long lastModified2 = file2.lastModified();
return Long.compare(lastModified2, lastModified1); // Descending order
});
List<String> list = new ArrayList<>();
list.add(getString(R.string.activity_summary_detail_clear_gpx_track));
for (File file : files) {
list.add(file.getName());
}
return list;
}
private void makeSummaryContent(BaseActivitySummary item) {
final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
final ActivitySummaryParser summaryParser = coordinator.getActivitySummaryParser(gbDevice, this);
//make view of data from summaryData of item
LinearLayout fieldLayout = findViewById(R.id.summaryDetails);
fieldLayout.removeAllViews(); //remove old widgets
ActivitySummaryJsonSummary activitySummaryJsonSummary = new ActivitySummaryJsonSummary(summaryParser, item);
Map<String, List<Pair<String, ActivitySummaryEntry>>> data = activitySummaryJsonSummary.getSummaryGroupedList(); //get list, grouped by groups
if (data == null) return;
for (final Map.Entry<String, List<Pair<String, ActivitySummaryEntry>>> group : data.entrySet()) {
final String groupKey = group.getKey();
final List<Pair<String, ActivitySummaryEntry>> entries = group.getValue();
// Do not add a header to the first ACTIVITY group, it's redundant
if (!ActivitySummaryEntries.GROUP_ACTIVITY.equals(groupKey)) {
TableRow label_row = new TableRow(ActivitySummaryDetail.this);
TextView label_field = new TextView(ActivitySummaryDetail.this);
label_field.setId(View.generateViewId());
label_field.setTextSize(18);
label_field.setGravity(Gravity.CENTER);
label_field.setPaddingRelative(dpToPx(16), dpToPx(16), dpToPx(16), dpToPx(16));
label_field.setTypeface(null, Typeface.BOLD);
label_field.setText(workoutValueFormatter.getStringResourceByName(groupKey));
label_row.addView(label_field);
fieldLayout.addView(label_row);
}
GridLayout gridLayout = new GridLayout(ActivitySummaryDetail.this);
gridLayout.setBackgroundColor(getResources().getColor(R.color.gauge_line_color));
gridLayout.setColumnCount(2);
gridLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
int totalCells = entries.stream().mapToInt(e -> e.getRight().getColumnSpan()).sum();
totalCells += totalCells & 1; // round up to nearest even number, since we have 2 columns
int cellNumber = 0;
for (final Pair<String, ActivitySummaryEntry> entry : entries) {
final int columnSpan = entry.getRight().getColumnSpan();
if (columnSpan == 2 && cellNumber % 2 != 0) {
// This entry needs 2 columns, so let's move to the next row
cellNumber++;
}
LinearLayout linearLayout = generateLinearLayout(cellNumber, cellNumber + 2 >= totalCells, columnSpan);
entry.getRight().populate(entry.getLeft(), linearLayout, workoutValueFormatter);
gridLayout.addView(linearLayout);
cellNumber += columnSpan;
}
if (gridLayout.getChildCount() > 0) {
if (cellNumber % 2 != 0) {
final LinearLayout emptyLayout = generateLinearLayout(cellNumber, true, 1);
new ActivitySummarySimpleEntry(null, "", "string").populate("", emptyLayout, workoutValueFormatter);
gridLayout.addView(emptyLayout);
}
fieldLayout.addView(gridLayout);
}
}
}
public LinearLayout generateLinearLayout(int i, boolean lastRow, int columnSize) {
LinearLayout linearLayout = new LinearLayout(ActivitySummaryDetail.this);
GridLayout.LayoutParams columnParams = new GridLayout.LayoutParams();
columnParams.columnSpec = GridLayout.spec(i % 2 == 0 ? 0 : 1, 1);
GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f),
GridLayout.spec(GridLayout.UNDEFINED, columnSize, GridLayout.FILL, 1f)
);
layoutParams.width = 0;
linearLayout.setLayoutParams(layoutParams);
linearLayout.setOrientation(LinearLayout.VERTICAL);
linearLayout.setGravity(Gravity.CENTER);
linearLayout.setPadding(dpToPx(15), dpToPx(15), dpToPx(15), dpToPx(15));
linearLayout.setBackgroundColor(GBApplication.getWindowBackgroundColor(ActivitySummaryDetail.this));
int marginLeft = 0;
int marginTop = 2;
int marginBottom = 0;
int marginRight = 0;
if (i % 2 == 0) {
marginRight = 1;
}
if (i % 2 == 1) {
marginLeft = 1;
}
if (lastRow) {
marginBottom = 2;
}
layoutParams.setMargins(dpToPx(marginLeft), dpToPx(marginTop), dpToPx(marginRight), dpToPx(marginBottom));
return linearLayout;
}
public int dpToPx(int dp) {
float density = getResources().getDisplayMetrics().density;
return Math.round(dp * density);
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
final int itemId = item.getItemId();
if (itemId == android.R.id.home) {
// back button
finish();
return true;
} else if (itemId == R.id.activity_action_take_screenshot) {
take_share_screenshot(ActivitySummaryDetail.this);
return true;
} else if (itemId == R.id.activity_action_show_gpx) {
viewGpxTrack(ActivitySummaryDetail.this);
return true;
} else if (itemId == R.id.activity_action_share_gpx) {
shareGpxTrack(ActivitySummaryDetail.this);
return true;
} else if (itemId == R.id.activity_action_dev_inspect_file) {
final Intent inspectFileIntent = new Intent(ActivitySummaryDetail.this, FitViewerActivity.class);
inspectFileIntent.putExtra(FitViewerActivity.EXTRA_PATH, currentItem.getRawDetailsPath());
startActivity(inspectFileIntent);
return true;
} else if (itemId == R.id.activity_action_dev_share_raw_summary) {
shareRawSummary(ActivitySummaryDetail.this, currentItem);
return true;
} else if (itemId == R.id.activity_action_dev_share_raw_details) {
shareRawDetails(ActivitySummaryDetail.this, currentItem);
return true;
} else if (itemId == R.id.activity_action_dev_share_json_details) {
shareJsonDetails(ActivitySummaryDetail.this, currentItem);
return true;
} else if (itemId == R.id.activity_summary_detail_action_edit_name) {
editLabel();
return true;
} else if (itemId == R.id.activity_summary_detail_action_edit_gps) {
editGps();
return true;
}
return super.onOptionsItemSelected(item);
}
private void editLabel() {
final EditText input = new EditText(ActivitySummaryDetail.this);
input.setInputType(InputType.TYPE_CLASS_TEXT);
String name = currentItem.getName();
input.setText((name != null) ? name : "");
FrameLayout container = new FrameLayout(ActivitySummaryDetail.this);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.leftMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
params.rightMargin = getResources().getDimensionPixelSize(R.dimen.dialog_margin);
input.setLayoutParams(params);
container.addView(input);
new MaterialAlertDialogBuilder(ActivitySummaryDetail.this)
.setView(container)
.setCancelable(true)
.setTitle(ActivitySummaryDetail.this.getString(R.string.activity_summary_edit_name_title))
.setPositiveButton(R.string.ok, (dialog, which) -> {
String name1 = input.getText().toString();
if (name1.isEmpty()) name1 = null;
currentItem.setName(name1);
currentItem.update();
makeSummaryHeader(currentItem);
makeSummaryContent(currentItem);
})
.setNegativeButton(R.string.Cancel, (dialog, which) -> {
// do nothing
})
.show();
}
private void editGps() {
export_path_root = get_path();
export_path_gpx = new File(get_path(), "gpx");
filesGpxList = get_gpx_file_list();
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(ActivitySummaryDetail.this);
builder.setTitle(R.string.activity_summary_detail_select_gpx_track);
ArrayAdapter<String> directory_listing = new ArrayAdapter<>(ActivitySummaryDetail.this, android.R.layout.simple_list_item_1, filesGpxList);
builder.setSingleChoiceItems(directory_listing, 0, (dialog, which) -> {
selectedGpxIndex = which;
String selectedFilename = filesGpxList.get(selectedGpxIndex);
if (new File(export_path_gpx, selectedFilename).isFile()) {
// Note: if selectedFilename exists in both export_path_gpx and export_path_root,
// this code will always choose the one in export_path_gpx. This is acceptable
// because export_path_gpx is where all new files end up, and gpx files tend to
// have a unique name anyway because it usually contains some timestamp.
selectedGpxFile = new File(export_path_gpx, selectedFilename).getPath();
} else {
selectedGpxFile = new File(export_path_root, selectedFilename).getPath();
}
String message = String.format("%s %s?", getString(R.string.set), filesGpxList.get(selectedGpxIndex));
if (selectedGpxIndex == 0) {
selectedGpxFile = null;
message = String.format("%s?", getString(R.string.activity_summary_detail_clear_gpx_track));
}
new MaterialAlertDialogBuilder(ActivitySummaryDetail.this)
.setCancelable(true)
.setIcon(R.drawable.ic_warning)
.setTitle(R.string.activity_summary_detail_editing_gpx_track)
.setMessage(message)
.setPositiveButton(R.string.ok, (dialog1, which1) -> {
currentItem.setGpxTrack(selectedGpxFile);
currentItem.update();
if (itemHasGps()) {
showGpsCanvas();
activitySummariesGpsFragment.set_data(getTrackFile());
} else {
hideGpsCanvas();
}
})
.setNegativeButton(R.string.Cancel, (dialog2, which2) -> {
})
.show();
dialog.dismiss();
});
AlertDialog dialog = builder.create();
dialog.show();
}
private void take_share_screenshot(Context context) {
final ScrollView layout = findViewById(R.id.activity_summary_detail_scroll_layout);
final int width = layout.getChildAt(0).getWidth();
final int height = layout.getChildAt(0).getHeight();
final Bitmap screenShot = getScreenShot(layout, height, width, context);
final String fileName = FileUtils.makeValidFileName("Screenshot-" + ActivityKind.fromCode(currentItem.getActivityKind()).getLabel(context).toLowerCase() + "-" + DateTimeUtils.formatIso8601(currentItem.getStartTime()) + ".png");
try {
final File targetFile = new File(FileUtils.getExternalFilesDir(), fileName);
final FileOutputStream fOut = new FileOutputStream(targetFile);
screenShot.compress(Bitmap.CompressFormat.PNG, 85, fOut);
fOut.flush();
fOut.close();
shareScreenshot(targetFile, context);
GB.toast(getApplicationContext(), "Screenshot saved", Toast.LENGTH_LONG, GB.INFO);
} catch (IOException e) {
LOG.error("Error getting screenshot", e);
}
}
private void shareScreenshot(File targetFile, Context context) {
Uri contentUri = FileProvider.getUriForFile(context,
context.getApplicationContext().getPackageName() + ".screenshot_provider", targetFile);
Intent sharingIntent = new Intent(android.content.Intent.ACTION_SEND);
sharingIntent.setType("image/*");
String shareBody = "Sports Activity";
sharingIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, "Sports Activity");
sharingIntent.putExtra(android.content.Intent.EXTRA_TEXT, shareBody);
sharingIntent.putExtra(Intent.EXTRA_STREAM, contentUri);
try {
startActivity(Intent.createChooser(sharingIntent, "Share via"));
} catch (ActivityNotFoundException e) {
Toast.makeText(context, R.string.activity_error_no_app_for_png, Toast.LENGTH_LONG).show();
}
}
private void viewGpxTrack(Context context) {
final File gpxFile = ActivitySummaryUtils.getGpxFile(currentItem);
if (gpxFile == null) {
GB.toast(getApplicationContext(), "No GPX track in this activity", Toast.LENGTH_LONG, GB.INFO);
return;
}
try {
AndroidUtils.viewFile(gpxFile.getPath(), "application/gpx+xml", context);
} catch (final Exception e) {
GB.toast(getApplicationContext(), "Unable to display GPX track: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
}
}
private void shareGpxTrack(final Context context) {
final File gpxFile = ActivitySummaryUtils.getGpxFile(currentItem);
if (gpxFile == null) {
GB.toast(getApplicationContext(), "No GPX track in this activity", Toast.LENGTH_LONG, GB.INFO);
return;
}
try {
AndroidUtils.shareFile(context, gpxFile);
} catch (final Exception e) {
GB.toast(context, "Unable to share GPX track: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
}
}
private static void shareRawSummary(final Context context, final BaseActivitySummary summary) {
if (summary.getRawSummaryData() == null) {
GB.toast(context, "No raw summary in this activity", Toast.LENGTH_LONG, GB.WARN);
return;
}
final String rawSummaryFilename = FileUtils.makeValidFileName(String.format("%s_summary.bin", DateTimeUtils.formatIso8601(summary.getStartTime())));
try {
AndroidUtils.shareBytesAsFile(context, rawSummaryFilename, summary.getRawSummaryData());
} catch (final Exception e) {
GB.toast(context, "Unable to share GPX track: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
}
}
private static void shareRawDetails(final Context context, final BaseActivitySummary summary) {
if (summary.getRawDetailsPath() == null) {
GB.toast(context, "No raw details in this activity", Toast.LENGTH_LONG, GB.WARN);
return;
}
try {
AndroidUtils.shareFile(context, new File(summary.getRawDetailsPath()));
} catch (final Exception e) {
GB.toast(context, "Unable to share raw details: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
}
}
private static void shareJsonDetails(final Context context, final BaseActivitySummary summary) {
if (summary.getSummaryData() == null) {
GB.toast(context, "No json details in this activity", Toast.LENGTH_LONG, GB.WARN);
return;
}
final String jsonSummaryFilename = FileUtils.makeValidFileName(String.format("%s.json", DateTimeUtils.formatIso8601(summary.getStartTime())));
try {
AndroidUtils.shareBytesAsFile(context, jsonSummaryFilename, summary.getSummaryData().getBytes(StandardCharsets.UTF_8));
} catch (final Exception e) {
GB.toast(context, "Unable to share json details: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
}
}
private void updateMenuItems() {
boolean hasGpx = false;
boolean hasRawSummary = false;
boolean hasRawDetails = false;
if (currentItem != null) {
hasGpx = itemHasGps();
hasRawSummary = currentItem.getRawSummaryData() != null;
final String rawDetailsPath = currentItem.getRawDetailsPath();
if (rawDetailsPath != null) {
hasRawDetails = new File(rawDetailsPath).exists();
}
}
if (mOptionsMenu != null) {
final SubMenu overflowMenu = mOptionsMenu.findItem(R.id.activity_detail_overflowMenu).getSubMenu();
if (overflowMenu != null) {
overflowMenu.findItem(R.id.activity_action_show_gpx).setVisible(hasGpx);
overflowMenu.findItem(R.id.activity_action_share_gpx).setVisible(hasGpx);
overflowMenu.findItem(R.id.activity_action_dev_inspect_file).setVisible(hasRawDetails && currentItem.getRawDetailsPath().toLowerCase(Locale.ROOT).endsWith(".fit"));
overflowMenu.findItem(R.id.activity_action_dev_share_raw_summary).setVisible(hasRawSummary);
overflowMenu.findItem(R.id.activity_action_dev_share_raw_details).setVisible(hasRawDetails);
final MenuItem devToolsMenu = overflowMenu.findItem(R.id.activity_action_dev_tools);
final SubMenu devToolsSubMenu = devToolsMenu.getSubMenu();
devToolsMenu.setVisible(devToolsSubMenu != null && devToolsSubMenu.hasVisibleItems());
}
}
}
private void showGpsCanvas() {
final View gpsView = findViewById(R.id.gpsFragmentHolder);
final ViewGroup.LayoutParams params = gpsView.getLayoutParams();
params.height = (int) (300 * getApplicationContext().getResources().getDisplayMetrics().density);
gpsView.setLayoutParams(params);
}
private void hideGpsCanvas() {
final View gpsView = findViewById(R.id.gpsFragmentHolder);
final ViewGroup.LayoutParams params = gpsView.getLayoutParams();
params.height = 0;
gpsView.setLayoutParams(params);
}
private boolean itemHasGps() {
if (currentItem.getGpxTrack() != null) {
final File existing = FileUtils.tryFixPath(new File(currentItem.getGpxTrack()));
if (existing != null && existing.canRead()) {
return true;
}
}
final String summaryDataJson = currentItem.getSummaryData();
if (summaryDataJson != null && summaryDataJson.contains(INTERNAL_HAS_GPS)) {
final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summaryDataJson);
return summaryData != null && summaryData.getBoolean(INTERNAL_HAS_GPS, false);
}
return false;
}
@Nullable
private File getTrackFile() {
return ActivitySummaryUtils.getTrackFile(currentItem);
}
@Override
public boolean onCreateOptionsMenu(final Menu menu) {
super.onCreateOptionsMenu(menu);
mOptionsMenu = menu;
getMenuInflater().inflate(R.menu.activity_take_screenshot_menu, menu);
updateMenuItems();
return true;
}
@Nullable
private static GBDevice getGBDevice(final Device findDevice) {
return GBApplication.app().getDeviceManager().getDevices()
.stream()
.filter(d -> d.getAddress().equalsIgnoreCase(findDevice.getIdentifier()))
.findFirst()
.orElse(null);
}
}
@@ -14,9 +14,7 @@ import android.widget.Toast
import androidx.core.app.NavUtils
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
@@ -29,10 +27,6 @@ import nodomain.freeyourgadget.gadgetbridge.model.ItemWithDetails
import nodomain.freeyourgadget.gadgetbridge.util.GB
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile
import nodomain.freeyourgadget.gadgetbridge.util.maps.MapsManager
import org.mapsforge.core.model.BoundingBox
import org.mapsforge.core.model.Dimension
import org.mapsforge.core.model.LatLong
import org.mapsforge.core.util.LatLongUtils
class GpxRouteInstallerActivity : AbstractGBActivity(), InstallActivity {
companion object {
@@ -262,7 +256,7 @@ class GpxRouteInstallerActivity : AbstractGBActivity(), InstallActivity {
}
setInstallEnabled(false)
installHandler?.onStartInstall(device)
installHandler.onStartInstall(device)
val bundle = Bundle().apply {
putString(GpxRouteInstallHandler.EXTRA_TRACK_NAME, trackName)
@@ -285,36 +279,10 @@ class GpxRouteInstallerActivity : AbstractGBActivity(), InstallActivity {
private fun loadMapTrack(gpxFile: GpxFile) {
lifecycleScope.launch {
val (center, zoom, trackPoints) = withContext(Dispatchers.Default) {
val points = gpxFile.activityPoints.mapNotNull { it.location }
val points = gpxFile.activityPoints.mapNotNull { it.location }
if (points.isEmpty()) {
return@withContext Triple(null, null, emptyList<LatLong>())
}
val latitudes = points.map { it.latitude }
val longitudes = points.map { it.longitude }
val maxLat = latitudes.maxOrNull() ?: 0.0
val minLat = latitudes.minOrNull() ?: 0.0
val maxLon = longitudes.maxOrNull() ?: 0.0
val minLon = longitudes.minOrNull() ?: 0.0
val latLongs = points.map { LatLong(it.latitude, it.longitude) }
val mapCenter = LatLong(minLat + (maxLat - minLat) / 2, minLon + (maxLon - minLon) / 2)
val mapZoom = LatLongUtils.zoomForBounds(
Dimension(resources.displayMetrics.widthPixels, resources.displayMetrics.heightPixels),
BoundingBox(minLat, minLon, maxLat, maxLon),
binding.gpxMapView.model.displayModel.tileSize
)
Triple(mapCenter, mapZoom, latLongs)
}
if (trackPoints.isNotEmpty() && center != null && zoom != null) {
mapsManager?.setTrack(trackPoints)
binding.gpxMapView.setCenter(center)
binding.gpxMapView.setZoomLevel(zoom)
if (points.isNotEmpty()) {
mapsManager?.setTrack(points)
} else {
binding.gpxMapView.visibility = View.GONE
}
@@ -1,175 +0,0 @@
/* Copyright (C) 2024 José Rebelo, a0z
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.maps;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.core.view.MenuProvider;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.mapsforge.core.model.BoundingBox;
import org.mapsforge.core.model.Dimension;
import org.mapsforge.core.model.LatLong;
import org.mapsforge.core.util.LatLongUtils;
import org.mapsforge.map.android.view.MapView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesGpsFragment;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.util.maps.MapsManager;
public class MapsTrackActivity extends AbstractGBActivity implements MenuProvider {
private static final Logger LOG = LoggerFactory.getLogger(MapsTrackActivity.class);
private MapView mapView;
private File file;
public static boolean isActivityOpen = false;
private MapsManager mapsManager;
private boolean mapValid = true;
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, final Intent intent) {
if (MapsSettingsFragment.ACTION_SETTING_CHANGE.equals(intent.getAction())) {
mapValid = false;
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_maps_track);
if (isActivityOpen) {
finish();
return;
}
isActivityOpen = true;
addMenuProvider(this);
mapView = findViewById(R.id.activitygpsview);
mapsManager = new MapsManager(this, mapView);
mapsManager.loadMaps();
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(MapsSettingsFragment.ACTION_SETTING_CHANGE);
LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, intentFilter);
file = (File) getIntent().getExtras().get("file");
final List<GPSCoordinate> trackPoints = ActivitySummariesGpsFragment.getActivityPoints(file)
.stream()
.map(ActivityPoint::getLocation)
.filter(Objects::nonNull)
.collect(Collectors.toList());
if (trackPoints.isEmpty()) {
return;
}
double maxLat = (Collections.max(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
double minLat = (Collections.min(trackPoints, new GPSCoordinate.compareLatitude())).getLatitude();
double maxLon = (Collections.max(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
double minLon = (Collections.min(trackPoints, new GPSCoordinate.compareLongitude())).getLongitude();
List<LatLong> latlongs = trackPoints.stream()
.map(p -> new LatLong(p.getLatitude(), p.getLongitude()))
.collect(Collectors.toList());
mapsManager.setTrack(latlongs);
mapView.setCenter(new LatLong(minLat + (maxLat - minLat) / 2, minLon + (maxLon - minLon) / 2));
byte zoom = LatLongUtils.zoomForBounds(new Dimension(this.getResources().getDisplayMetrics().widthPixels, this.getResources().getDisplayMetrics().heightPixels), new BoundingBox(minLat, minLon, maxLat, maxLon), mapView.getModel().displayModel.getTileSize());
mapView.setZoomLevel(zoom);
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
final int itemId = item.getItemId();
if (itemId == android.R.id.home) {
// back button
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
protected void onSaveInstanceState(@NonNull final Bundle state) {
super.onSaveInstanceState(state);
}
@Override
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
}
@Override
protected void onDestroy() {
super.onDestroy();
synchronized (MapsTrackActivity.class) {
isActivityOpen = false;
}
LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
mapsManager.onDestroy();
}
@Override
protected void onResume() {
super.onResume();
if (!mapValid) {
mapsManager.reload();
}
}
@Override
public void onCreateMenu(@NonNull final Menu menu, @NonNull final MenuInflater menuInflater) {
menuInflater.inflate(R.menu.maps_track_menu, menu);
}
@Override
public boolean onMenuItemSelected(@NonNull final MenuItem menuItem) {
final int itemId = menuItem.getItemId();
if (itemId == R.id.maps_settings) {
final Intent enableIntent = new Intent(this, MapsSettingsActivity.class);
startActivity(enableIntent);
return true;
}
return false;
}
}
@@ -0,0 +1,147 @@
/* Copyright (C) 2024-2025 José Rebelo, a0z
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.maps
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.view.MenuProvider
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
import nodomain.freeyourgadget.gadgetbridge.databinding.ActivityMapsTrackBinding
import nodomain.freeyourgadget.gadgetbridge.util.GB
import nodomain.freeyourgadget.gadgetbridge.util.maps.MapsManager
import org.slf4j.LoggerFactory
import java.io.File
class MapsTrackActivity : AbstractGBActivity(), MenuProvider {
private lateinit var binding: ActivityMapsTrackBinding
private val viewModel: MapsTrackViewModel by viewModels()
private lateinit var mapsManager: MapsManager
private var mapValid = true
private val mReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (MapsSettingsFragment.ACTION_SETTING_CHANGE == intent.action) {
mapValid = false
return
}
LOG.warn("Unknown action: {}", intent.action)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMapsTrackBinding.inflate(layoutInflater)
setContentView(binding.root)
val file = intent.extras?.getSerializable("file") as? File
if (file == null) {
GB.toast(this, "No file provided", Toast.LENGTH_LONG, GB.ERROR)
finish()
return
}
addMenuProvider(this)
mapsManager = MapsManager(this, binding.mapView)
mapsManager.loadMaps()
LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, IntentFilter().apply {
addAction(MapsSettingsFragment.ACTION_SETTING_CHANGE)
})
viewModel.loadTrackData(file)
observeViewModel()
}
private fun observeViewModel() {
viewModel.trackPoints.observe(this) { points ->
if (points.isNotEmpty()) {
mapsManager.setTrack(points)
} else {
LOG.warn("No track points to display or file was empty.")
}
}
viewModel.isLoading.observe(this) { isLoading ->
// TODO loading spinner?
}
viewModel.error.observe(this) { error ->
error?.let {
GB.toast(this, it.localizedMessage, Toast.LENGTH_LONG, GB.ERROR)
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onDestroy() {
super.onDestroy()
LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver)
mapsManager.onDestroy()
}
override fun onResume() {
super.onResume()
if (!mapValid) {
mapsManager.reload()
mapValid = true
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.maps_track_menu, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.maps_settings -> {
val enableIntent = Intent(this, MapsSettingsActivity::class.java)
startActivity(enableIntent)
true
}
else -> false
}
}
companion object {
private val LOG = LoggerFactory.getLogger(MapsTrackActivity::class.java)
}
}
@@ -0,0 +1,92 @@
package nodomain.freeyourgadget.gadgetbridge.activities.maps
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParseException
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileInputStream
import java.io.IOException
class MapsTrackViewModel : ViewModel() {
private val _trackPoints = MutableLiveData<List<GPSCoordinate>>()
val trackPoints: LiveData<List<GPSCoordinate>> = _trackPoints
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> = _isLoading
private val _error = MutableLiveData<Exception?>()
val error: LiveData<Exception?> = _error
fun loadTrackData(trackFile: File) {
_isLoading.value = true
viewModelScope.launch {
try {
val points = fetchTrackPoints(trackFile)
if (points.isNotEmpty()) {
_trackPoints.postValue(points)
} else {
LOG.warn("No track points found in file: ${trackFile.name}")
_trackPoints.postValue(emptyList())
}
} catch (e: Exception) {
LOG.error("Error loading track points", e)
_error.postValue(e)
} finally {
_isLoading.postValue(false)
}
}
}
private suspend fun fetchTrackPoints(trackFile: File): List<GPSCoordinate> {
return withContext(Dispatchers.IO) {
getActivityPoints(trackFile).mapNotNull { it.location }
}
}
companion object {
private val LOG = LoggerFactory.getLogger(MapsTrackViewModel::class.java)
fun getActivityPoints(trackFile: File): List<ActivityPoint> {
try {
when {
trackFile.name.endsWith(".gpx") -> {
FileInputStream(trackFile).use { inputStream ->
val gpxParser = GpxParser(inputStream)
return gpxParser.gpxFile.activityPoints
}
}
trackFile.name.endsWith(".fit") -> {
val fitFile = FitFile.parseIncoming(trackFile)
return fitFile.records
.filterIsInstance<FitRecord>()
.map { it.toActivityPoint() }
}
else -> {
LOG.warn("Unknown file type: ${trackFile.name}")
}
}
} catch (e: IOException) {
LOG.error("Failed to open $trackFile", e)
} catch (e: GpxParseException) {
LOG.error("Failed to parse gpx file", e)
} catch (e: Exception) {
LOG.error("Failed to parse fit file", e)
}
return emptyList()
}
}
}
@@ -0,0 +1,155 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.ViewPager2
import kotlinx.coroutines.launch
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
import nodomain.freeyourgadget.gadgetbridge.databinding.ActivityWorkoutDetailsBinding
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
class WorkoutDetailsActivity : AbstractGBActivity() {
private val viewModel: WorkoutDetailsViewModel by viewModels()
private lateinit var binding: ActivityWorkoutDetailsBinding
private lateinit var pagerAdapter: WorkoutViewPagerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityWorkoutDetailsBinding.inflate(layoutInflater)
setContentView(binding.root)
setupViewPager()
setupViewModel()
}
private fun setupViewPager() {
pagerAdapter = WorkoutViewPagerAdapter(this)
binding.viewPager.adapter = pagerAdapter
binding.viewPager.offscreenPageLimit = 1
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
viewModel.setCurrentPosition(position)
val summary = viewModel.workouts.value?.get(position)
summary?.activityKind?.let {
val activityKindName = ActivityKind.fromCode(it).getLabel(this@WorkoutDetailsActivity)
// Action bar title
supportActionBar?.title = activityKindName
}
}
})
}
private fun setupViewModel() {
val bundle = intent.extras ?: return
val gbDevice = bundle.getParcelable<GBDevice>(GBDevice.EXTRA_DEVICE)
val singleWorkoutId = bundle.getLong(EXTRA_WORKOUT_ID, -1L)
if (singleWorkoutId != -1L) {
// Single workout
lifecycleScope.launch {
viewModel.loadSingleWorkout(singleWorkoutId)
}
} else {
// Filtered list
val position = bundle.getInt("position", 0)
val activityFilter = bundle.getInt("activityFilter", 0)
val dateFromFilter = bundle.getLong("dateFromFilter", 0)
val dateToFilter = bundle.getLong("dateToFilter", 0)
val deviceFilter = bundle.getLong("deviceFilter", 0)
val nameContainsFilter = bundle.getString("nameContainsFilter")
@Suppress("UNCHECKED_CAST")
val itemsFilter = bundle.getSerializable("itemsFilter") as? List<Long>
lifecycleScope.launch {
viewModel.loadFilteredWorkouts(
gbDevice,
activityFilter,
dateFromFilter,
dateToFilter,
nameContainsFilter,
deviceFilter,
itemsFilter,
position
)
}
}
viewModel.workouts.observe(this) { workouts ->
pagerAdapter.updateWorkouts(workouts)
}
viewModel.currentPosition.observe(this) { position ->
if (binding.viewPager.currentItem != position) {
binding.viewPager.setCurrentItem(position, false)
}
}
viewModel.isLoading.observe(this) { isLoading ->
showLoading(isLoading)
}
viewModel.error.observe(this) { error ->
error?.let { showError(it) }
}
}
private fun showLoading(isLoading: Boolean) {
binding.loadingSpinner.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.viewPager.visibility = if (isLoading) View.GONE else View.VISIBLE
binding.errorMessage.visibility = View.GONE
}
private fun showError(message: String) {
binding.loadingSpinner.visibility = View.GONE
binding.viewPager.visibility = View.GONE
binding.errorMessage.visibility = View.VISIBLE
binding.errorMessage.text = message
}
companion object {
const val EXTRA_WORKOUT_ID = "workout_id"
fun createSingleWorkoutIntent(
context: Context,
workoutId: Long,
gbDevice: GBDevice
): Intent {
return Intent(context, WorkoutDetailsActivity::class.java).apply {
putExtra(EXTRA_WORKOUT_ID, workoutId)
putExtra(GBDevice.EXTRA_DEVICE, gbDevice)
}
}
fun createFilteredListIntent(
context: Context,
gbDevice: GBDevice,
position: Int = 0,
activityFilter: Int = 0,
dateFromFilter: Long = 0,
dateToFilter: Long = 0,
nameContainsFilter: String? = null,
deviceFilter: Long = 0,
itemsFilter: List<Long>? = null
): Intent {
return Intent(context, WorkoutDetailsActivity::class.java).apply {
putExtra(GBDevice.EXTRA_DEVICE, gbDevice)
putExtra("position", position)
putExtra("activityFilter", activityFilter)
putExtra("dateFromFilter", dateFromFilter)
putExtra("dateToFilter", dateToFilter)
nameContainsFilter?.let { putExtra("nameContainsFilter", it) }
putExtra("deviceFilter", deviceFilter)
itemsFilter?.let { putExtra("itemsFilter", ArrayList(it)) }
}
}
}
}
@@ -0,0 +1,719 @@
/* Copyright (C) 2020-2025 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.workouts
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Typeface
import android.os.Bundle
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TableRow
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.view.MenuProvider
import androidx.core.view.isNotEmpty
import androidx.fragment.app.Fragment
import androidx.gridlayout.widget.GridLayout
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.ActivitySummariesChartFragment
import nodomain.freeyourgadget.gadgetbridge.activities.fit.FitViewerActivity
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryGroup
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummarySimpleEntry
import nodomain.freeyourgadget.gadgetbridge.databinding.FragmentWorkoutDetailsBinding
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries
import nodomain.freeyourgadget.gadgetbridge.model.workout.Workout
import nodomain.freeyourgadget.gadgetbridge.model.workout.WorkoutChart
import nodomain.freeyourgadget.gadgetbridge.util.ActivitySummaryUtils
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils
import nodomain.freeyourgadget.gadgetbridge.util.GB
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.tuple.Pair
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.concurrent.TimeUnit
class WorkoutDetailsFragment : Fragment(), MenuProvider {
private var workoutId: Long = -1
private var currentWorkout: Workout? = null
private var gbDevice: GBDevice? = null
private lateinit var binding: FragmentWorkoutDetailsBinding
private var chartFragment: ActivitySummariesChartFragment? = null
private var gpsFragment: WorkoutGpsFragment? = null
private lateinit var workoutEditor: WorkoutEditor
private val workoutValueFormatter = WorkoutValueFormatter()
private var menu: Menu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
workoutEditor = WorkoutEditor(requireContext())
arguments?.let {
workoutId = it.getLong(ARG_WORKOUT_ID, -1)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentWorkoutDetailsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
chartFragment = ActivitySummariesChartFragment()
gpsFragment = WorkoutGpsFragment()
childFragmentManager.beginTransaction()
.replace(R.id.chartsFragmentHolder, chartFragment!!)
.replace(R.id.gpsFragmentHolder, gpsFragment!!)
.commit()
// Toggle raw data when long pressing the workout image
binding.itemImage.setOnLongClickListener {
workoutValueFormatter.toggleRawData()
currentWorkout?.let { workout ->
updateWorkoutHeader(workout.summary)
updateWorkoutDetails(workout)
}
false
}
loadWorkoutData()
}
override fun onResume() {
super.onResume()
currentWorkout?.summary?.activityKind?.let {
val activityKindName = ActivityKind.fromCode(it).getLabel(requireContext())
// Action bar title
(activity as? AppCompatActivity)?.supportActionBar?.title = activityKindName
}
}
private fun loadWorkoutData() {
if (workoutId == -1L) return
showLoading(true)
lifecycleScope.launch {
try {
currentWorkout = withContext(Dispatchers.IO) {
val summary = GBApplication.acquireDB().use { dbHandler ->
dbHandler.daoSession.baseActivitySummaryDao.load(workoutId)
}
gbDevice = getGBDevice(summary.device)
return@withContext gbDevice!!.deviceCoordinator.getActivitySummaryParser(gbDevice, requireContext())
.parseWorkout(summary, true)
}
requireActivity().addMenuProvider(this@WorkoutDetailsFragment, viewLifecycleOwner, Lifecycle.State.RESUMED)
currentWorkout?.let { workout ->
updateWorkoutHeader(workout.summary)
updateWorkoutDetails(workout)
updateFragments(workout)
showLoading(false)
} ?: run {
showError("Workout not found")
}
} catch (e: Exception) {
LOG.error("Error loading workout data", e)
showError("Failed to load workout: ${e.localizedMessage}")
}
}
}
private fun showLoading(isLoading: Boolean) {
binding.loadingSpinner.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.scrollView.visibility = if (isLoading) View.GONE else View.VISIBLE
binding.errorMessage.visibility = View.GONE
}
private fun showError(message: String) {
binding.loadingSpinner.visibility = View.GONE
binding.scrollView.visibility = View.GONE
binding.errorMessage.visibility = View.VISIBLE
binding.errorMessage.text = message
}
private fun updateWorkoutHeader(summary: BaseActivitySummary) {
val activityName = summary.name
val startTime = summary.startTime
val endTime = summary.endTime
val durationHms = DateTimeUtils.formatDurationHoursMinutes(
endTime.time - startTime.time, TimeUnit.MILLISECONDS
)
view?.let { view ->
binding.itemImage.setImageResource(
ActivityKind.fromCode(summary.activityKind).icon
)
// Activity name
binding.activityname.apply {
text = activityName
visibility = if (StringUtils.isBlank(activityName)) View.GONE else View.VISIBLE
}
// Date
binding.activitydate.apply {
val timeString = if (DateTimeUtils.isSameDay(startTime, endTime)) {
context.getString(
R.string.date_placeholders__start_time__end_time,
DateTimeUtils.formatDateTimeRelative(context, startTime),
DateTimeUtils.formatTime(endTime.hours, endTime.minutes)
)
} else {
context.getString(
R.string.date_placeholders__start_time__end_time,
DateTimeUtils.formatDateTimeRelative(context, startTime),
DateTimeUtils.formatDateTimeRelative(context, endTime)
)
}
text = timeString
}
// Duration
binding.activityduration.text = durationHms
}
}
private fun updateWorkoutDetails(workout: Workout) {
binding.summaryDetails.removeAllViews()
val groups = ActivitySummaryGroup.buildGroupedList(workout.data)
for ((groupKey, entries) in groups) {
if (ActivitySummaryEntries.GROUP_ACTIVITY != groupKey) {
addGroupHeader(groupKey)
}
addGroupContent(entries)
}
}
private fun addGroupHeader(groupKey: String) {
val labelRow = TableRow(context)
val labelField = TextView(context).apply {
id = View.generateViewId()
textSize = 18f
gravity = Gravity.CENTER
setPaddingRelative(dpToPx(16), dpToPx(16), dpToPx(16), dpToPx(16))
typeface = Typeface.DEFAULT_BOLD
text = workoutValueFormatter.getStringResourceByName(groupKey)
}
labelRow.addView(labelField)
binding.summaryDetails.addView(labelRow)
}
private fun addGroupContent(entries: List<Pair<String, ActivitySummaryEntry>>) {
val gridLayout = GridLayout(context).apply {
setBackgroundColor(resources.getColor(R.color.gauge_line_color))
columnCount = 2
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
val totalCells = entries.sumOf { it.right.columnSpan }.let { it + (it and 1) }
var cellNumber = 0
for ((key, entry) in entries) {
val columnSpan = entry.columnSpan
if (columnSpan == 2 && cellNumber % 2 != 0) {
cellNumber++
}
val linearLayout = generateLinearLayout(cellNumber, cellNumber + 2 >= totalCells, columnSpan)
entry.populate(key, linearLayout, workoutValueFormatter)
gridLayout.addView(linearLayout)
cellNumber += columnSpan
}
if (gridLayout.isNotEmpty()) {
if (cellNumber % 2 != 0) {
val emptyLayout = generateLinearLayout(cellNumber, true, 1)
ActivitySummarySimpleEntry(null, "", "string").populate("", emptyLayout, workoutValueFormatter)
gridLayout.addView(emptyLayout)
}
binding.summaryDetails.addView(gridLayout)
}
}
private fun generateLinearLayout(i: Int, lastRow: Boolean, columnSize: Int): LinearLayout {
return LinearLayout(context).apply {
val layoutParams = GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL, 1f),
GridLayout.spec(GridLayout.UNDEFINED, columnSize, GridLayout.FILL, 1f)
)
layoutParams.width = 0
this.layoutParams = layoutParams
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setPadding(dpToPx(15), dpToPx(15), dpToPx(15), dpToPx(15))
setBackgroundColor(GBApplication.getWindowBackgroundColor(context))
val marginLeft = if (i % 2 == 0) 0 else 1
val marginRight = if (i % 2 == 0) 1 else 0
val marginTop = 2
val marginBottom = if (lastRow) 2 else 0
layoutParams.setMargins(dpToPx(marginLeft), dpToPx(marginTop), dpToPx(marginRight), dpToPx(marginBottom))
}
}
private fun dpToPx(dp: Int): Int {
val density = resources.displayMetrics.density
return (dp * density).toInt()
}
private fun updateFragments(workout: Workout) {
val trackFile = ActivitySummaryUtils.getTrackFile(workout.summary)
chartFragment?.setDateAndGetData(
trackFile,
gbDevice,
workout.summary.startTime.time / 1000,
workout.summary.endTime.time / 1000
)
binding.dynamicCharts.removeAllViews()
for (chart in workout.charts) {
addChart(chart)
}
if (workoutHasGps(workout.summary)) {
showGpsCanvas()
gpsFragment?.setTrackData(trackFile)
} else {
hideGpsCanvas()
}
}
private fun addChart(chart: WorkoutChart) {
val separator1 = View(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(2 * resources.displayMetrics.density).toInt()
)
val typedValue = TypedValue()
context.theme.resolveAttribute(R.attr.row_separator, typedValue, true)
setBackgroundColor(ContextCompat.getColor(context, typedValue.resourceId))
}
binding.dynamicCharts.addView(separator1)
val chartTitle = TextView(context).apply {
id = View.generateViewId()
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
text = chart.title
gravity = Gravity.CENTER
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
typeface = Typeface.create("sans-serif-black", Typeface.NORMAL)
val paddingPx = (16 * resources.displayMetrics.density).toInt()
setPadding(paddingPx, paddingPx, paddingPx, paddingPx)
}
binding.dynamicCharts.addView(chartTitle)
val separator2 = View(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(2 * resources.displayMetrics.density).toInt()
)
val typedValue = TypedValue()
context.theme.resolveAttribute(R.attr.row_separator, typedValue, true)
setBackgroundColor(ContextCompat.getColor(context, typedValue.resourceId))
}
binding.dynamicCharts.addView(separator2)
val chartsFragmentHolder = FrameLayout(requireContext()).apply {
id = View.generateViewId()
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
(300 * resources.displayMetrics.density).toInt()
)
}
// TODO add chartData
binding.dynamicCharts.addView(chartsFragmentHolder)
}
private fun workoutHasGps(summary: BaseActivitySummary): Boolean {
summary.gpxTrack?.let { gpxTrack ->
val existing = FileUtils.tryFixPath(File(gpxTrack))
if (existing != null && existing.canRead()) {
return true
}
}
val summaryDataJson = summary.summaryData
if (summaryDataJson != null && summaryDataJson.contains(ActivitySummaryEntries.INTERNAL_HAS_GPS)) {
val summaryData = ActivitySummaryData.fromJson(summaryDataJson)
return summaryData.getBoolean(ActivitySummaryEntries.INTERNAL_HAS_GPS, false)
}
return false
}
private fun showGpsCanvas() {
val params = binding.gpsFragmentHolder.layoutParams
params.height = (300 * requireContext().resources.displayMetrics.density).toInt()
binding.gpsFragmentHolder.layoutParams = params
}
private fun hideGpsCanvas() {
val params = binding.gpsFragmentHolder.layoutParams
params.height = 0
binding.gpsFragmentHolder.layoutParams = params
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.clear()
menuInflater.inflate(R.menu.activity_take_screenshot_menu, menu)
this.menu = menu
updateMenuItems(menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
val workout = currentWorkout ?: return false
return when (menuItem.itemId) {
R.id.activity_action_take_screenshot -> {
takeSharedScreenshot()
true
}
R.id.activity_action_show_gpx -> {
viewGpxTrack()
true
}
R.id.activity_action_share_gpx -> {
shareGpxTrack()
true
}
R.id.activity_action_dev_inspect_file -> {
val intent = Intent(requireContext(), FitViewerActivity::class.java).apply {
putExtra(FitViewerActivity.EXTRA_PATH, workout.summary.rawDetailsPath)
}
startActivity(intent)
true
}
R.id.activity_action_dev_share_raw_summary -> {
shareRawSummary(workout)
true
}
R.id.activity_action_dev_share_raw_details -> {
shareRawDetails(workout)
true
}
R.id.activity_action_dev_share_json_details -> {
shareJsonDetails(workout)
true
}
R.id.activity_summary_detail_action_edit_name -> {
currentWorkout?.let {
workoutEditor.editWorkoutName(it, object : WorkoutEditor.Callback {
override fun onWorkoutUpdated() {
updateWorkoutHeader(workout.summary)
}
})
}
true
}
R.id.activity_summary_detail_action_edit_gps -> {
currentWorkout?.let {
workoutEditor.editGpsTrack(it, object : WorkoutEditor.Callback {
override fun onWorkoutUpdated() {
if (workoutHasGps(workout.summary)) {
showGpsCanvas()
gpsFragment?.setTrackData(ActivitySummaryUtils.getTrackFile(it.summary))
} else {
hideGpsCanvas()
}
}
})
}
true
}
android.R.id.home -> {
requireActivity().finish()
true
}
else -> false
}
}
private fun updateMenuItems(menu: Menu) {
val workout = currentWorkout ?: return
val hasGpx = workoutHasGps(workout.summary)
val hasRawSummary = workout.summary.rawSummaryData != null
val hasRawDetails = workout.summary.rawDetailsPath?.let { File(it).exists() } ?: false
val overflowMenu = menu.findItem(R.id.activity_detail_overflowMenu)?.subMenu
if (overflowMenu != null) {
overflowMenu.findItem(R.id.activity_action_show_gpx)?.isVisible = hasGpx
overflowMenu.findItem(R.id.activity_action_share_gpx)?.isVisible = hasGpx
overflowMenu.findItem(R.id.activity_action_dev_inspect_file)?.isVisible =
hasRawDetails && workout.summary.rawDetailsPath?.lowercase(Locale.ROOT)?.endsWith(".fit") == true
overflowMenu.findItem(R.id.activity_action_dev_share_raw_summary)?.isVisible = hasRawSummary
overflowMenu.findItem(R.id.activity_action_dev_share_raw_details)?.isVisible = hasRawDetails
val devToolsMenu = overflowMenu.findItem(R.id.activity_action_dev_tools)
val devToolsSubMenu = devToolsMenu?.subMenu
devToolsMenu?.isVisible = devToolsSubMenu != null && devToolsSubMenu.hasVisibleItems()
}
}
private fun takeSharedScreenshot() {
lifecycleScope.launch(Dispatchers.IO) {
try {
val workout = currentWorkout ?: return@launch
val width = binding.scrollView.getChildAt(0).width
val height = binding.scrollView.getChildAt(0).height
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
withContext(Dispatchers.Main) {
canvas.drawColor(GBApplication.getWindowBackgroundColor(requireContext()))
binding.scrollView.draw(canvas)
}
val fileName = FileUtils.makeValidFileName(
"Screenshot-${
ActivityKind.fromCode(workout.summary.activityKind).getLabel(requireContext()).lowercase()
}-${DateTimeUtils.formatIso8601(workout.summary.startTime)}.png"
)
val targetFile = File(FileUtils.getExternalFilesDir(), fileName)
val fOut = FileOutputStream(targetFile)
bitmap.compress(Bitmap.CompressFormat.PNG, 85, fOut)
fOut.flush()
fOut.close()
withContext(Dispatchers.Main) {
shareScreenshot(targetFile)
GB.toast(requireContext(), "Screenshot saved", Toast.LENGTH_LONG, GB.INFO)
}
} catch (e: IOException) {
LOG.error("Error taking screenshot", e)
}
}
}
private fun shareScreenshot(targetFile: File) {
val subject = currentWorkout?.summary?.name?.let { "Sports Activity" }
val contentUri = FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.screenshot_provider",
targetFile
)
val sharingIntent = Intent(Intent.ACTION_SEND).apply {
type = "image/*"
putExtra(Intent.EXTRA_SUBJECT, subject)
putExtra(Intent.EXTRA_TEXT, subject)
putExtra(Intent.EXTRA_STREAM, contentUri)
}
try {
startActivity(Intent.createChooser(sharingIntent, "Share via"))
} catch (e: Exception) {
LOG.error("Failed to share screenshot", e)
Toast.makeText(requireContext(), R.string.activity_error_no_app_for_png, Toast.LENGTH_LONG).show()
}
}
private fun viewGpxTrack() {
val workout = currentWorkout ?: return
val gpxFile = ActivitySummaryUtils.getGpxFile(workout.summary)
if (gpxFile == null) {
GB.toast(requireContext(), "No GPX track in this activity", Toast.LENGTH_LONG, GB.INFO)
return
}
try {
AndroidUtils.viewFile(gpxFile.path, "application/gpx+xml", requireContext())
} catch (e: Exception) {
GB.toast(
requireContext(),
"Unable to display GPX track: ${e.localizedMessage}",
Toast.LENGTH_LONG,
GB.ERROR,
e
)
}
}
private fun shareGpxTrack() {
val workout = currentWorkout ?: return
val gpxFile = ActivitySummaryUtils.getGpxFile(workout.summary)
if (gpxFile == null) {
GB.toast(requireContext(), "No GPX track in this activity", Toast.LENGTH_LONG, GB.INFO)
return
}
try {
AndroidUtils.shareFile(requireContext(), gpxFile)
} catch (e: Exception) {
GB.toast(
requireContext(),
"Unable to share GPX track: ${e.localizedMessage}",
Toast.LENGTH_LONG,
GB.ERROR,
e
)
}
}
private fun shareRawSummary(workout: Workout) {
if (workout.summary.rawSummaryData == null) {
GB.toast(requireContext(), "No raw summary in this activity", Toast.LENGTH_LONG, GB.WARN)
return
}
val filename =
FileUtils.makeValidFileName("${DateTimeUtils.formatIso8601(workout.summary.startTime)}_summary.bin")
try {
AndroidUtils.shareBytesAsFile(requireContext(), filename, workout.summary.rawSummaryData)
} catch (e: Exception) {
GB.toast(
requireContext(),
"Unable to share raw summary: ${e.localizedMessage}",
Toast.LENGTH_LONG,
GB.ERROR,
e
)
}
}
private fun shareRawDetails(workout: Workout) {
if (workout.summary.rawDetailsPath == null) {
GB.toast(requireContext(), "No raw details in this activity", Toast.LENGTH_LONG, GB.WARN)
return
}
try {
AndroidUtils.shareFile(requireContext(), File(workout.summary.rawDetailsPath))
} catch (e: Exception) {
GB.toast(
requireContext(),
"Unable to share raw details: ${e.localizedMessage}",
Toast.LENGTH_LONG,
GB.ERROR,
e
)
}
}
private fun shareJsonDetails(workout: Workout) {
val filename = FileUtils.makeValidFileName("${DateTimeUtils.formatIso8601(workout.summary.startTime)}.json")
try {
AndroidUtils.shareBytesAsFile(
requireContext(),
filename,
workout.data.toString().toByteArray(StandardCharsets.UTF_8)
)
} catch (e: Exception) {
GB.toast(
requireContext(),
"Unable to share json details: ${e.localizedMessage}",
Toast.LENGTH_LONG,
GB.ERROR,
e
)
}
}
private fun getGBDevice(device: nodomain.freeyourgadget.gadgetbridge.entities.Device?): GBDevice? {
return device?.let { findDevice ->
GBApplication.app().deviceManager.devices
.firstOrNull { it.address.equals(findDevice.identifier, ignoreCase = true) }
}
}
companion object {
private val LOG = LoggerFactory.getLogger(WorkoutDetailsFragment::class.java)
private const val ARG_WORKOUT_ID = "workout_id"
fun newInstance(workoutId: Long): WorkoutDetailsFragment {
return WorkoutDetailsFragment().apply {
arguments = Bundle().apply {
putLong(ARG_WORKOUT_ID, workoutId)
}
}
}
}
}
@@ -0,0 +1,160 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import org.slf4j.LoggerFactory
import java.util.Date
class WorkoutDetailsViewModel : ViewModel() {
private val _workouts = MutableLiveData<List<BaseActivitySummary>>()
val workouts: LiveData<List<BaseActivitySummary>> = _workouts
private val _currentPosition = MutableLiveData<Int>()
val currentPosition: LiveData<Int> = _currentPosition
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> = _isLoading
private val _error = MutableLiveData<String?>()
val error: LiveData<String?> = _error
suspend fun loadSingleWorkout(workoutId: Long) {
_isLoading.value = true
_error.value = null
try {
val workout = withContext(Dispatchers.IO) {
GBApplication.acquireDB().use { dbHandler ->
val summaryDao = dbHandler.daoSession.baseActivitySummaryDao
summaryDao.load(workoutId)
}
}
if (workout != null) {
_workouts.value = listOf(workout)
_currentPosition.value = 0
} else {
_error.value = "Workout not found"
}
} catch (e: Exception) {
LOG.error("Error loading single workout", e)
_error.value = "Failed to load workout: ${e.message}"
} finally {
_isLoading.value = false
}
}
/**
* Load filtered workouts for paging
*/
suspend fun loadFilteredWorkouts(
gbDevice: GBDevice?,
activityKindFilter: Int,
dateFromFilter: Long,
dateToFilter: Long,
nameContainsFilter: String?,
deviceFilter: Long,
itemsFilter: List<Long>?,
initialPosition: Int
) {
_isLoading.value = true
_error.value = null
try {
val workouts = withContext(Dispatchers.IO) {
loadWorkoutsFromDatabase(
gbDevice,
activityKindFilter,
dateFromFilter,
dateToFilter,
nameContainsFilter,
deviceFilter,
itemsFilter
)
}
_workouts.value = workouts
// Set initial position, but ensure it's valid
val validPosition = if (initialPosition > 0 && initialPosition < workouts.size) {
initialPosition
} else {
0
}
_currentPosition.value = validPosition
} catch (e: Exception) {
LOG.error("Error loading filtered workouts", e)
_error.value = "Failed to load workouts: ${e.message}"
} finally {
_isLoading.value = false
}
}
private fun loadWorkoutsFromDatabase(
gbDevice: GBDevice?,
activityKindFilter: Int,
dateFromFilter: Long,
dateToFilter: Long,
nameContainsFilter: String?,
deviceFilter: Long,
itemsFilter: List<Long>?
): List<BaseActivitySummary> {
return GBApplication.acquireDB().use { dbHandler ->
val summaryDao = dbHandler.daoSession.baseActivitySummaryDao
val dbDevice = gbDevice?.let { DBHelper.findDevice(it, dbHandler.daoSession) }
val queryBuilder = summaryDao.queryBuilder()
// Apply device filter
when {
deviceFilter != 0L && deviceFilter != ALL_DEVICES -> {
queryBuilder.where(BaseActivitySummaryDao.Properties.DeviceId.eq(deviceFilter))
}
dbDevice != null -> {
queryBuilder.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.id))
}
}
queryBuilder.orderDesc(BaseActivitySummaryDao.Properties.StartTime)
if (activityKindFilter != 0) {
queryBuilder.where(BaseActivitySummaryDao.Properties.ActivityKind.eq(activityKindFilter))
}
if (dateFromFilter != 0L) {
queryBuilder.where(BaseActivitySummaryDao.Properties.StartTime.gt(Date(dateFromFilter)))
}
if (dateToFilter != 0L) {
queryBuilder.where(BaseActivitySummaryDao.Properties.EndTime.lt(Date(dateToFilter)))
}
if (!nameContainsFilter.isNullOrEmpty()) {
queryBuilder.where(BaseActivitySummaryDao.Properties.Name.like("%${nameContainsFilter}%"))
}
if (!itemsFilter.isNullOrEmpty()) {
queryBuilder.where(BaseActivitySummaryDao.Properties.Id.`in`(itemsFilter))
}
queryBuilder.list()
}
}
fun setCurrentPosition(position: Int) {
_currentPosition.value = position
}
companion object {
private val LOG = LoggerFactory.getLogger(WorkoutDetailsViewModel::class.java)
const val ALL_DEVICES = 999L
}
}
@@ -0,0 +1,149 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts
import android.content.Context
import android.content.DialogInterface
import android.text.InputType
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.model.workout.Workout
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils
import org.slf4j.LoggerFactory
import java.io.File
import java.io.FileFilter
import java.io.IOException
import java.util.Collections
import java.util.Locale
class WorkoutEditor(private val context: Context) {
var filesGpxList: MutableList<String> = ArrayList()
var selectedGpxIndex: Int = 0
var selectedGpxFile: String? = null
var exportPathRoot: File? = null // original gpx file location, could still contain old gpx files
var exportPathGpx: File? = null // new gpx file location
fun editWorkoutName(workout: Workout, callback: Callback) {
val input = EditText(context).apply {
inputType = InputType.TYPE_CLASS_TEXT
setText(workout.summary.name ?: "")
}
val container = FrameLayout(context)
val params = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = context.resources.getDimensionPixelSize(R.dimen.dialog_margin)
rightMargin = context.resources.getDimensionPixelSize(R.dimen.dialog_margin)
}
input.layoutParams = params
container.addView(input)
MaterialAlertDialogBuilder(context)
.setView(container)
.setCancelable(true)
.setTitle(R.string.activity_summary_edit_name_title)
.setPositiveButton(R.string.ok) { _, _ ->
val newName = input.text.toString().takeIf { it.isNotEmpty() }
workout.summary.name = newName
workout.summary.update()
callback.onWorkoutUpdated()
}
.setNegativeButton(R.string.Cancel, null)
.show()
}
private fun buildGpxFileList(): MutableList<String> {
val files: MutableList<File> = ArrayList()
val gpxFileFilter = FileFilter { file: File? ->
file!!.isFile() && file.path.lowercase(Locale.getDefault()).endsWith(".gpx")
}
exportPathRoot?.let {
val rootFiles: Array<File?>? = it.listFiles(gpxFileFilter)
if (rootFiles != null) Collections.addAll<File?>(files, *rootFiles)
}
exportPathGpx?.let {
val gpxFiles: Array<File?>? = it.listFiles(gpxFileFilter)
if (gpxFiles != null) Collections.addAll<File?>(files, *gpxFiles)
}
files.sortWith { file1: File?, file2: File? ->
val lastModified1 = file1!!.lastModified()
val lastModified2 = file2!!.lastModified()
lastModified2.compareTo(lastModified1) // Descending order
}
val list: MutableList<String> = ArrayList()
list.add(context.getString(R.string.activity_summary_detail_clear_gpx_track))
for (file in files) {
list.add(file.getName())
}
return list
}
private fun getPath(): File? {
var path: File? = null
try {
path = FileUtils.getExternalFilesDir()
} catch (e: IOException) {
LOG.error("Error getting path", e)
}
return path
}
fun editGpsTrack(workout: Workout, callback: Callback) {
exportPathRoot = getPath()
exportPathGpx = File(getPath(), "gpx")
filesGpxList = buildGpxFileList()
val builder = MaterialAlertDialogBuilder(context)
builder.setTitle(R.string.activity_summary_detail_select_gpx_track)
val directoryListing = ArrayAdapter(context, android.R.layout.simple_list_item_1, filesGpxList)
builder.setSingleChoiceItems(directoryListing, 0) { dialog: DialogInterface?, which: Int ->
selectedGpxIndex = which
val selectedFilename = filesGpxList[selectedGpxIndex]
if (File(exportPathGpx, selectedFilename).isFile()) {
// Note: if selectedFilename exists in both exportPathGpx and exportPathRoot,
// this code will always choose the one in exportPathGpx. This is acceptable
// because exportPathGpx is where all new files end up, and gpx files tend to
// have a unique name anyway because it usually contains some timestamp.
selectedGpxFile = File(exportPathGpx, selectedFilename).getPath()
} else {
selectedGpxFile = File(exportPathRoot, selectedFilename).getPath()
}
var message = String.format("%s %s?", context.getString(R.string.set), filesGpxList.get(selectedGpxIndex))
if (selectedGpxIndex == 0) {
selectedGpxFile = null
message = String.format("%s?", context.getString(R.string.activity_summary_detail_clear_gpx_track))
}
MaterialAlertDialogBuilder(context)
.setCancelable(true)
.setIcon(R.drawable.ic_warning)
.setTitle(R.string.activity_summary_detail_editing_gpx_track)
.setMessage(message)
.setPositiveButton(R.string.ok) { dialog1, which1 ->
workout.summary.gpxTrack = selectedGpxFile
workout.summary.update()
callback.onWorkoutUpdated()
}
.setNegativeButton(R.string.Cancel) { dialog2, which2 -> }
.show()
dialog!!.dismiss()
}
val dialog = builder.create()
dialog.show()
}
interface Callback {
fun onWorkoutUpdated()
}
companion object {
private val LOG = LoggerFactory.getLogger(WorkoutEditor::class.java)
}
}
@@ -0,0 +1,195 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsSettingsFragment
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackActivity
import nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackViewModel
import nodomain.freeyourgadget.gadgetbridge.databinding.FragmentWorkoutGpsBinding
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate
import nodomain.freeyourgadget.gadgetbridge.util.maps.MapsManager
import org.slf4j.LoggerFactory
import java.io.File
class WorkoutGpsFragment : Fragment() {
private val viewModel: MapsTrackViewModel by viewModels()
private lateinit var binding: FragmentWorkoutGpsBinding
private lateinit var mapsManager: MapsManager
private var mapValid = true
private var inputFile: File? = null
private val mReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (MapsSettingsFragment.ACTION_SETTING_CHANGE == intent.action) {
if (!isResumed) {
mapValid = false
} else {
mapsManager.reload()
}
return
}
LOG.warn("Unknown action {}", intent.action)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentWorkoutGpsBinding.inflate(inflater, container, false)
binding.mapView.setBuiltInZoomControls(false)
mapsManager = MapsManager(requireContext(), binding.mapView)
mapsManager.loadMaps()
if (mapsManager.isMapLoaded) {
binding.missingMapsWarning.visibility = View.GONE
}
LocalBroadcastManager.getInstance(requireActivity()).registerReceiver(mReceiver, IntentFilter().apply {
addAction(MapsSettingsFragment.ACTION_SETTING_CHANGE)
})
inputFile?.let { viewModel.loadTrackData(it) }
observeViewModel()
return binding.root
}
private fun observeViewModel() {
viewModel.trackPoints.observe(viewLifecycleOwner) { points ->
if (points.isNotEmpty()) {
lifecycleScope.launch {
drawTrack(points)
}
} else {
LOG.warn("No track points to display or file was empty.")
}
}
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
showLoading(isLoading)
}
viewModel.error.observe(viewLifecycleOwner) { error ->
error?.let {
showError(it.localizedMessage ?: it.javaClass.simpleName)
}
}
}
override fun onResume() {
super.onResume()
if (!mapValid) {
mapsManager.reload()
mapValid = true
}
}
override fun onDestroyView() {
LocalBroadcastManager.getInstance(requireActivity()).unregisterReceiver(mReceiver)
mapsManager.onDestroy()
super.onDestroyView()
}
fun setTrackData(inputFile: File?) {
this.inputFile = inputFile
if (mapsManager.isMapLoaded && inputFile != null) {
viewModel.loadTrackData(inputFile)
}
}
private fun showLoading(isLoading: Boolean) {
binding.loadingSpinner.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.mapView.visibility = if (isLoading) View.INVISIBLE else View.VISIBLE
binding.errorMessage.visibility = View.GONE
}
private fun showError(message: String) {
binding.loadingSpinner.visibility = View.GONE
binding.mapView.visibility = View.INVISIBLE
binding.errorMessage.visibility = View.VISIBLE
binding.errorMessage.text = message
}
private suspend fun drawTrack(trackPoints: List<GPSCoordinate>) {
withContext(Dispatchers.Main) {
try {
mapsManager.setTrack(trackPoints)
setupMapTouchListener()
} catch (e: Exception) {
LOG.error("Error drawing track", e)
showError(e.localizedMessage ?: e.message ?: "Error drawing track")
}
}
}
private fun setupMapTouchListener() {
binding.mapView.setOnTouchListener(object : View.OnTouchListener {
private var startX = 0f
private var startY = 0f
private var pressTime = 0L
private var isDrag = false
private val TAP_THRESHOLD = 10
private val LONG_PRESS_TIME = 300
override fun onTouch(v: View?, event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
isDrag = false
startX = event.x
startY = event.y
pressTime = System.currentTimeMillis()
}
MotionEvent.ACTION_MOVE -> {
if (kotlin.math.abs(startX - event.x) > TAP_THRESHOLD ||
kotlin.math.abs(startY - event.y) > TAP_THRESHOLD
) {
isDrag = true
}
}
MotionEvent.ACTION_UP -> {
if (!isDrag && System.currentTimeMillis() - pressTime < LONG_PRESS_TIME) {
openMapActivity()
}
}
}
return true
}
private fun openMapActivity() {
val intent = Intent(getContext(), MapsTrackActivity::class.java)
intent.putExtra("file", inputFile)
startActivity(intent)
}
})
}
companion object {
private val LOG = LoggerFactory.getLogger(WorkoutGpsFragment::class.java)
}
}
@@ -0,0 +1,31 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts
import android.annotation.SuppressLint
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary
class WorkoutViewPagerAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
private var workouts: List<BaseActivitySummary> = emptyList()
override fun getItemCount(): Int = workouts.size
override fun createFragment(position: Int): Fragment {
return WorkoutDetailsFragment.newInstance(workouts[position].id)
}
override fun getItemId(position: Int): Long {
return workouts[position].id
}
override fun containsItem(itemId: Long): Boolean {
return workouts.any { it.id == itemId }
}
@SuppressLint("NotifyDataSetChanged")
fun updateWorkouts(newWorkouts: List<BaseActivitySummary>) {
workouts = newWorkouts
notifyDataSetChanged()
}
}
@@ -0,0 +1,243 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries
import org.apache.commons.lang3.tuple.Pair
object ActivitySummaryGroup {
fun buildGroupedList(activitySummaryData: ActivitySummaryData): Map<String, List<Pair<String, ActivitySummaryEntry>>> {
// Initialize activeGroups with the initial expected order and empty arrays
val activeGroups = DEFAULT_GROUPS.keys
.associateWith { mutableListOf<Pair<String, ActivitySummaryEntry>>() }
.toMutableMap()
activitySummaryData.keys
.filterNot { it.startsWith("internal") }
.forEach { key ->
val item = activitySummaryData[key]
// Use the group if specified in the entry, otherwise fallback to the default mapping from getDefaultGroup
val groupName = item.group ?: getDefaultGroup(key)
// If the group is not defined the default groups, it will be added to the end
val group = activeGroups.getOrPut(groupName) { mutableListOf() }
group.add(Pair.of<String, ActivitySummaryEntry>(key, item))
}
// activeGroups is already ordered, discard empty ones
return activeGroups.filterValues { it.isNotEmpty() }
}
/**
* Find the default group key for a given entry. Defaults to Activity if not found.
*/
private fun getDefaultGroup(searchItem: String): String {
return DEFAULT_GROUPS.entries
.firstOrNull { (_, items) -> items.contains(searchItem) }
?.key
?: ActivitySummaryEntries.GROUP_ACTIVITY
}
/**
* A map of group key to list of entries that should be in that group by default.
*/
val DEFAULT_GROUPS: Map<String, List<String>> = object : LinkedHashMap<String, List<String>>() {
init {
// NB: Default group Activity must be present in this definition, otherwise it wouldn't
// be shown.
put(
ActivitySummaryEntries.GROUP_ACTIVITY, listOf<String>(
ActivitySummaryEntries.DISTANCE_METERS,
ActivitySummaryEntries.STEPS,
ActivitySummaryEntries.STEP_RATE_SUM,
ActivitySummaryEntries.ACTIVE_SECONDS,
ActivitySummaryEntries.CALORIES_BURNT,
ActivitySummaryEntries.STRIDE_TOTAL,
ActivitySummaryEntries.HR_AVG,
ActivitySummaryEntries.HR_MAX,
ActivitySummaryEntries.HR_MIN,
ActivitySummaryEntries.STRIDE_AVG,
ActivitySummaryEntries.STRIDE_MAX,
ActivitySummaryEntries.STRIDE_MIN,
ActivitySummaryEntries.STEP_LENGTH_AVG,
ActivitySummaryEntries.STANDING_TIME,
ActivitySummaryEntries.STANDING_COUNT,
ActivitySummaryEntries.AVG_POWER,
ActivitySummaryEntries.MAX_POWER,
ActivitySummaryEntries.NORMALIZED_POWER,
)
)
// Speed
put(
ActivitySummaryEntries.GROUP_SPEED, listOf<String>(
ActivitySummaryEntries.SPEED_AVG,
ActivitySummaryEntries.SPEED_MAX,
ActivitySummaryEntries.SPEED_MIN,
ActivitySummaryEntries.PACE_AVG_SECONDS_KM,
ActivitySummaryEntries.PACE_MIN,
ActivitySummaryEntries.PACE_MAX,
"averageSpeed2",
ActivitySummaryEntries.CADENCE_AVG,
ActivitySummaryEntries.CADENCE_MAX,
ActivitySummaryEntries.CADENCE_MIN,
ActivitySummaryEntries.STEP_RATE_AVG,
ActivitySummaryEntries.STEP_RATE_MAX,
)
)
// Elevation
put(
ActivitySummaryEntries.GROUP_ELEVATION, listOf<String>(
ActivitySummaryEntries.TOTAL_ASCENT,
ActivitySummaryEntries.TOTAL_DESCENT,
ActivitySummaryEntries.ASCENT_METERS,
ActivitySummaryEntries.DESCENT_METERS,
ActivitySummaryEntries.ALTITUDE_MAX,
ActivitySummaryEntries.ALTITUDE_MIN,
ActivitySummaryEntries.ALTITUDE_AVG,
ActivitySummaryEntries.ALTITUDE_BASE,
ActivitySummaryEntries.ASCENT_SECONDS,
ActivitySummaryEntries.DESCENT_SECONDS,
ActivitySummaryEntries.FLAT_SECONDS,
ActivitySummaryEntries.ASCENT_DISTANCE,
ActivitySummaryEntries.DESCENT_DISTANCE,
ActivitySummaryEntries.FLAT_DISTANCE,
ActivitySummaryEntries.ELEVATION_GAIN,
ActivitySummaryEntries.ELEVATION_LOSS,
)
)
// Strokes
put(
ActivitySummaryEntries.GROUP_STROKES, listOf<String>(
ActivitySummaryEntries.STROKE_DISTANCE_AVG,
ActivitySummaryEntries.STROKE_AVG_PER_SECOND,
ActivitySummaryEntries.STROKES,
ActivitySummaryEntries.STROKE_RATE_AVG,
ActivitySummaryEntries.STROKE_RATE_MAX,
)
)
// Jumps
put(
ActivitySummaryEntries.GROUP_JUMPS, listOf<String>(
ActivitySummaryEntries.JUMPS,
ActivitySummaryEntries.JUMP_RATE_AVG,
ActivitySummaryEntries.JUMP_RATE_MAX,
)
)
// Swimming
put(
ActivitySummaryEntries.GROUP_SWIMMING, listOf<String>(
ActivitySummaryEntries.POOL_LENGTH,
ActivitySummaryEntries.SWIM_AVG_CADENCE,
ActivitySummaryEntries.SWOLF_INDEX,
ActivitySummaryEntries.SWOLF_AVG,
ActivitySummaryEntries.SWOLF_MAX,
ActivitySummaryEntries.SWOLF_MIN,
ActivitySummaryEntries.SWIM_STYLE,
)
)
// Cycling
put(
ActivitySummaryEntries.GROUP_CYCLING, listOf<String>(
ActivitySummaryEntries.LEFT_RIGHT_BALANCE,
ActivitySummaryEntries.AVG_PEDAL_SMOOTHNESS,
ActivitySummaryEntries.AVG_TORQUE_EFFECTIVENESS,
ActivitySummaryEntries.AVG_LEFT_PCO,
ActivitySummaryEntries.AVG_LEFT_POWER_PHASE,
ActivitySummaryEntries.AVG_LEFT_POWER_PHASE_PEAK,
ActivitySummaryEntries.AVG_RIGHT_PCO,
ActivitySummaryEntries.AVG_RIGHT_POWER_PHASE,
ActivitySummaryEntries.AVG_RIGHT_POWER_PHASE_PEAK,
ActivitySummaryEntries.AVG_POWER_STANDING,
ActivitySummaryEntries.MAX_POWER_STANDING,
ActivitySummaryEntries.AVG_POWER_SEATING,
ActivitySummaryEntries.MAX_POWER_SEATING,
ActivitySummaryEntries.AVG_CADENCE_STANDING,
ActivitySummaryEntries.AVG_CADENCE_SEATING,
ActivitySummaryEntries.MAX_CADENCE_STANDING,
ActivitySummaryEntries.MAX_CADENCE_SEATING,
ActivitySummaryEntries.FRONT_GEAR_SHIFTS,
ActivitySummaryEntries.REAR_GEAR_SHIFTS,
)
)
// Training effect
put(
ActivitySummaryEntries.GROUP_TRAINING_EFFECT, listOf<String>(
ActivitySummaryEntries.TRAINING_EFFECT_AEROBIC,
ActivitySummaryEntries.TRAINING_EFFECT_ANAEROBIC,
ActivitySummaryEntries.WORKOUT_LOAD,
ActivitySummaryEntries.TRAINING_LOAD,
ActivitySummaryEntries.INTENSITY_FACTOR,
ActivitySummaryEntries.TRAINING_STRESS_SCORE,
ActivitySummaryEntries.MAXIMUM_OXYGEN_UPTAKE,
ActivitySummaryEntries.RECOVERY_TIME,
ActivitySummaryEntries.LACTATE_THRESHOLD_HR,
)
)
// Laps
put(
ActivitySummaryEntries.GROUP_LAPS, listOf<String>(
ActivitySummaryEntries.LAP_PACE_AVERAGE,
ActivitySummaryEntries.LAPS,
ActivitySummaryEntries.LANE_LENGTH,
)
)
// Pace
put(ActivitySummaryEntries.GROUP_PACE, listOf<String>())
// Running form
put(
ActivitySummaryEntries.GROUP_RUNNING_FORM, listOf<String>(
ActivitySummaryEntries.GROUND_CONTACT_TIME_AVG,
ActivitySummaryEntries.IMPACT_AVG,
ActivitySummaryEntries.IMPACT_MAX,
ActivitySummaryEntries.SWING_ANGLE_AVG,
ActivitySummaryEntries.FORE_FOOT_LANDINGS,
ActivitySummaryEntries.MID_FOOT_LANDINGS,
ActivitySummaryEntries.BACK_FOOT_LANDINGS,
ActivitySummaryEntries.EVERSION_ANGLE_AVG,
ActivitySummaryEntries.EVERSION_ANGLE_MAX,
)
)
// Heart rate zones
put(
ActivitySummaryEntries.GROUP_HEART_RATE_ZONES, listOf<String>(
ActivitySummaryEntries.HR_ZONE_NA,
ActivitySummaryEntries.HR_ZONE_WARM_UP,
ActivitySummaryEntries.HR_ZONE_FAT_BURN,
ActivitySummaryEntries.HR_ZONE_EASY,
ActivitySummaryEntries.HR_ZONE_AEROBIC,
ActivitySummaryEntries.HR_ZONE_ANAEROBIC,
ActivitySummaryEntries.HR_ZONE_THRESHOLD,
ActivitySummaryEntries.HR_ZONE_EXTREME,
ActivitySummaryEntries.HR_ZONE_MAXIMUM,
)
)
// Diving
put(
ActivitySummaryEntries.GROUP_DIVING, listOf<String>(
ActivitySummaryEntries.AVG_DEPTH,
ActivitySummaryEntries.MAX_DEPTH,
ActivitySummaryEntries.START_CNS,
ActivitySummaryEntries.END_CNS,
ActivitySummaryEntries.START_N2,
ActivitySummaryEntries.END_N2,
ActivitySummaryEntries.DIVE_NUMBER,
ActivitySummaryEntries.BOTTOM_TIME,
)
)
// Sets
put(ActivitySummaryEntries.SETS, listOf<String>())
}
}
}
@@ -19,7 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.adapter;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
@@ -34,6 +33,7 @@ import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import de.greenrobot.dao.query.QueryBuilder;
@@ -67,14 +67,14 @@ import com.google.gson.GsonBuilder;
public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<BaseActivitySummary> {
protected static final Logger LOG = LoggerFactory.getLogger(ActivitySummariesAdapter.class);
private final GBDevice device;
long dateFromFilter = 0;
long dateToFilter = 0;
long dateFromFilter;
long dateToFilter;
long deviceFilter;
String nameContainsFilter;
List<Long> itemsFilter;
private int activityKindFilter;
public ActivitySummariesAdapter(Context context, GBDevice device, int activityKindFilter, long dateFromFilter, long dateToFilter, String nameContainsFilter, long deviceFilter, List itemsFilter) {
public ActivitySummariesAdapter(Context context, GBDevice device, int activityKindFilter, long dateFromFilter, long dateToFilter, String nameContainsFilter, long deviceFilter, List<Long> itemsFilter) {
super(context);
this.device = device;
this.activityKindFilter = activityKindFilter;
@@ -91,6 +91,7 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<Bas
try (DBHandler handler = GBApplication.acquireDB()) {
BaseActivitySummaryDao summaryDao = handler.getDaoSession().getBaseActivitySummaryDao();
Device dbDevice = DBHelper.findDevice(device, handler.getDaoSession());
assert dbDevice != null;
QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
@@ -149,14 +150,13 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<Bas
@NonNull
@Override
public AbstractActivityListingViewHolder<BaseActivitySummary> onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
switch (viewType) {
case 0: // dashboard
return new DashboardViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_summary_dashboard_item, parent, false));
case 2: // item
return new ActivityItemViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_list_item, parent, false));
}
return super.onCreateViewHolder(parent, viewType);
return switch (viewType) {
case 0 -> // dashboard
new DashboardViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_summary_dashboard_item, parent, false));
case 2 -> // item
new ActivityItemViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.activity_list_item, parent, false));
default -> super.onCreateViewHolder(parent, viewType);
};
}
public void setActivityKindFilter(int filter) {
@@ -183,7 +183,7 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<Bas
this.deviceFilter = device;
}
public int gettActivityKindFilter() {
public int getActivityKindFilter() {
return this.activityKindFilter;
}
@@ -205,7 +205,7 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<Bas
hasGps = true;
} else if (summary.getSummaryData() != null && summary.getSummaryData().contains(ActivitySummaryEntries.INTERNAL_HAS_GPS)) {
final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summary.getSummaryData());
hasGps = summaryData != null && summaryData.getBoolean(INTERNAL_HAS_GPS, false);
hasGps = summaryData.getBoolean(INTERNAL_HAS_GPS, false);
} else {
hasGps = false;
}
@@ -281,8 +281,8 @@ public class ActivitySummariesAdapter extends AbstractActivityListingAdapter<Bas
activeSecondsSumView.setText(String.format("%s", DateTimeUtils.formatDurationHoursMinutes((long) stats.activeSecondsSum, TimeUnit.SECONDS)));
activitiesCountView.setText(String.valueOf(activitiesCount));
String activityName = getContext().getString(R.string.activity_summaries_all_activities);
if (gettActivityKindFilter() != 0) {
ActivityKind activityKind = ActivityKind.fromCode(gettActivityKindFilter());
if (getActivityKindFilter() != 0) {
ActivityKind activityKind = ActivityKind.fromCode(getActivityKindFilter());
activityName = activityKind.getLabel(getContext());
activityIconView.setImageResource(activityKind.getIcon());
activityIconBigView.setImageResource(activityKind.getIcon());
@@ -1,6 +1,101 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.*;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_CADENCE_SEATING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_CADENCE_STANDING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_DEPTH;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_GROUND_CONTACT_TIME;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_GROUND_CONTACT_TIME_BALANCE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_LEFT_PCO;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_LEFT_POWER_PHASE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_LEFT_POWER_PHASE_PEAK;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_PEDAL_SMOOTHNESS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_POWER;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_POWER_SEATING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_POWER_STANDING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_RIGHT_PCO;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_RIGHT_POWER_PHASE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_RIGHT_POWER_PHASE_PEAK;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_TORQUE_EFFECTIVENESS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_VERTICAL_OSCILLATION;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.AVG_VERTICAL_RATIO;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.BOTTOM_TIME;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CALORIES_ACTIVE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CALORIES_BURNT;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CALORIES_RESTING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.DISTANCE_METERS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.DIVE_NUMBER;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.END_CNS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.END_N2;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ESTIMATED_SWEAT_LOSS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.FRONT_GEAR_SHIFTS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.GAS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.GROUP_INTERVALS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HRV_RMSSD;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HRV_SDRR;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_MAX;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_AEROBIC;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_EASY;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_MAXIMUM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_NA;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_THRESHOLD;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_WARM_UP;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.INTENSITY_FACTOR;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.INTERNAL_HAS_GPS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.LACTATE_THRESHOLD_HR;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.LEFT_RIGHT_BALANCE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAXIMUM_OXYGEN_UPTAKE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAX_CADENCE_SEATING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAX_CADENCE_STANDING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAX_DEPTH;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAX_POWER;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAX_POWER_SEATING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAX_POWER_STANDING;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.NORMALIZED_POWER;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.PACE_AVG_SECONDS_KM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.PACE_MAX;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.POOL_LENGTH;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.REAR_GEAR_SHIFTS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.RECOVERY_TIME;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.RESPIRATION_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.RESPIRATION_MAX;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.RESPIRATION_MIN;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SETS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPEED_MAX;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SPO2_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STANDING_COUNT;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STANDING_TIME;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.START_CNS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.START_N2;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STEP_LENGTH_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STRESS_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SWIM_AVG_CADENCE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.SWOLF_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TOTAL_ASCENT;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TOTAL_DESCENT;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TRAINING_EFFECT_AEROBIC;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TRAINING_EFFECT_ANAEROBIC;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TRAINING_LOAD;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TRAINING_STRESS_SCORE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_BPM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_BREATHS_PER_MIN;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KCAL;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KMPH;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_LB;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MILLISECONDS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_ML;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_ML_KG_MIN;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_MM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_NONE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_PERCENTAGE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_RPM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_STROKES_PER_LENGTH;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_WATT;
import android.content.Context;
@@ -14,7 +109,6 @@ import java.util.Arrays;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import nodomain.freeyourgadget.gadgetbridge.R;
@@ -25,8 +119,9 @@ import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.workout.Workout;
import nodomain.freeyourgadget.gadgetbridge.model.workout.WorkoutChart;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums.GarminSport;
@@ -34,6 +129,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.exception
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionExerciseCategory.ExerciseCategory;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDiveGas;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDiveSummary;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitLap;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitPhysiologicalMetrics;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord;
@@ -42,7 +138,6 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTimeInZone;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitUserProfile;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDiveSummary;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
@@ -67,10 +162,16 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
}
@Override
public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary, final boolean forDetails) {
public BaseActivitySummary parseBinaryData(BaseActivitySummary summary, boolean forDetails) {
// FIXME Do not use this
return parseWorkout(summary, forDetails).getSummary();
}
@Override
public Workout parseWorkout(BaseActivitySummary summary, final boolean forDetails) {
if (!forDetails) {
// Our parsing is too slow, especially without a RecyclerView
return summary;
return new Workout(summary, ActivitySummaryData.fromJson(summary.getSummaryData()));
}
final long nanoStart = System.nanoTime();
@@ -80,12 +181,12 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
final String rawDetailsPath = summary.getRawDetailsPath();
if (rawDetailsPath == null) {
LOG.warn("No rawDetailsPath");
return summary;
return new Workout(summary, ActivitySummaryData.fromJson(summary.getSummaryData()));
}
final File file = FileUtils.tryFixPath(new File(rawDetailsPath));
if (file == null || !file.isFile() || !file.canRead()) {
LOG.warn("Unable to read {}", rawDetailsPath);
return summary;
return new Workout(summary, ActivitySummaryData.fromJson(summary.getSummaryData()));
}
final FitFile fitFile;
@@ -93,20 +194,28 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
fitFile = FitFile.parseIncoming(file);
} catch (final IOException | FitParseException e) {
LOG.error("Failed to parse fit file", e);
return summary;
return new Workout(summary, ActivitySummaryData.fromJson(summary.getSummaryData()));
}
for (final RecordData record : fitFile.getRecords()) {
handleRecord(record);
}
updateSummary(summary);
final ActivitySummaryData activitySummaryData = updateSummary(summary);
final List<WorkoutChart> charts = new LinkedList<>();
// TODO charts
final long nanoEnd = System.nanoTime();
final long executionTime = (nanoEnd - nanoStart) / 1000000;
LOG.trace("Updating summary took {}ms", executionTime);
return summary;
return new Workout(
summary,
activitySummaryData,
charts
);
}
public void reset() {
@@ -177,14 +286,14 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
return true;
}
public void updateSummary(final BaseActivitySummary summary) {
public ActivitySummaryData updateSummary(final BaseActivitySummary summary) {
final ActivitySummaryData summaryData = new ActivitySummaryData();
if (session == null) {
LOG.error("Got workout, but no session");
return;
return summaryData;
}
final ActivitySummaryData summaryData = new ActivitySummaryData();
final ActivityKind activityKind;
if (sport != null) {
if (StringUtils.isNullOrEmpty(summary.getName())) {
@@ -660,6 +769,8 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
);
summary.setSummaryData(summaryData.toString());
return summaryData;
}
public Number safeRound(final Number number) {
@@ -140,10 +140,10 @@ public class ActivitySummaryData {
return Boolean.parseBoolean((String) value);
}
@Nullable
@NonNull
public static ActivitySummaryData fromJson(final String string) {
if (StringUtils.isBlank(string)) {
return null;
return new ActivitySummaryData();
}
final Type type = new TypeToken<LinkedHashMap<String, ActivitySummaryEntry>>(){}.getType();
@@ -1,67 +0,0 @@
/* Copyright (C) 2020-2024 Petr Vaněk
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import android.content.Context;
import androidx.annotation.Nullable;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.adapter.ActivitySummariesAdapter;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class ActivitySummaryItems {
ActivitySummariesAdapter itemsAdapter;
private int current_position = 0;
public ActivitySummaryItems(Context context, GBDevice device, int activityKindFilter, long dateFromFilter, long dateToFilter, String nameContainsFilter, long deviceFilter, List itemsFilter) {
this.itemsAdapter = new ActivitySummariesAdapter(context, device, activityKindFilter, dateFromFilter, dateToFilter, nameContainsFilter, deviceFilter, itemsFilter);
}
@Nullable
public BaseActivitySummary getItem(int position) {
if (position == 0) return null;
current_position = position;
return itemsAdapter.getItem(position);
}
public int getPosition(BaseActivitySummary item) {
return itemsAdapter.getPosition(item);
}
@Nullable
public BaseActivitySummary getNextItem() {
// last one is empty to avoid items behind fab
if (current_position + 2 < itemsAdapter.getItemCount()) {
current_position += 1;
return itemsAdapter.getItem(current_position);
}
return null;
}
@Nullable
public BaseActivitySummary getPrevItem() {
if (current_position - 1 >= 1) { //0 is empty item for summary dashboard
current_position -= 1;
return itemsAdapter.getItem(current_position);
}
return null;
}
}
@@ -19,25 +19,14 @@ package nodomain.freeyourgadget.gadgetbridge.model;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.*;
import org.apache.commons.lang3.tuple.Pair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryEntry;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
public class ActivitySummaryJsonSummary {
private static final Logger LOG = LoggerFactory.getLogger(ActivitySummaryJsonSummary.class);
private Map<String, List<String>> groupData;
private ActivitySummaryData summaryData;
private Map<String, List<Pair<String, ActivitySummaryEntry>>> summaryGroupedList;
private final ActivitySummaryParser summaryParser;
private final BaseActivitySummary baseActivitySummary;
@@ -48,9 +37,6 @@ public class ActivitySummaryJsonSummary {
private ActivitySummaryData setSummaryData(BaseActivitySummary item, final boolean forDetails) {
final ActivitySummaryData summary = ActivitySummaryData.fromJson(getCorrectSummary(item, forDetails));
if (summary == null) {
return null;
}
//add additionally computed values here
if (item.getBaseAltitude() != null && item.getBaseAltitude() != -20000 && !summary.has("baseAltitude")) {
@@ -89,148 +75,4 @@ public class ActivitySummaryJsonSummary {
}
return item.getSummaryData();
}
public Map<String, List<Pair<String, ActivitySummaryEntry>>> getSummaryGroupedList() {
//returns list grouped by activity groups as per createActivitySummaryGroups
if (summaryData == null) summaryData = setSummaryData(baseActivitySummary, true);
if (summaryGroupedList == null) summaryGroupedList = setSummaryGroupedList(summaryData);
return summaryGroupedList;
}
private Map<String, List<Pair<String, ActivitySummaryEntry>>> setSummaryGroupedList(ActivitySummaryData activitySummaryData) {
this.groupData = createActivitySummaryGroups(); //structure for grouping activities into groups, when visualizing
if (activitySummaryData == null) return null;
Iterator<String> keys = activitySummaryData.getKeys().iterator();
final Map<String, List<Pair<String, ActivitySummaryEntry>>> activeGroups = new LinkedHashMap<>();
// Initialize activeGroups with the initial expected order and empty arrays
for (final String key : this.groupData.keySet()) {
activeGroups.put(key, new LinkedList<>());
}
while (keys.hasNext()) {
String key = keys.next();
if (key.startsWith("internal")) {
continue;
}
ActivitySummaryEntry item = activitySummaryData.get(key);
// Use the group if specified in the entry, otherwise fallback to the array below
String groupName = item.getGroup() != null ? item.getGroup() : getDefaultGroup(key);
List<Pair<String, ActivitySummaryEntry>> group = activeGroups.get(groupName);
if (group == null) {
// This group is not defined in createActivitySummaryGroups - add it to the end
group = new LinkedList<>();
activeGroups.put(groupName, group);
}
group.add(Pair.of(key, item));
}
// Convert activeGroups to the expected JSONObject
// activeGroups is already ordered
final Map<String, List<Pair<String, ActivitySummaryEntry>>> grouped = new LinkedHashMap<>();
for (final Map.Entry<String, List<Pair<String, ActivitySummaryEntry>>> entry : activeGroups.entrySet()) {
if (entry.getValue().isEmpty()) {
// empty group
continue;
}
grouped.put(entry.getKey(), entry.getValue());
}
return grouped;
}
private String getDefaultGroup(final String searchItem) {
final String defaultGroup = GROUP_ACTIVITY;
if (groupData == null) return defaultGroup;
for (final String groupKey : groupData.keySet()) {
final List<String> itemList = groupData.get(groupKey);
if (itemList == null) {
continue;
}
for (final String itemKey : itemList) {
if (itemKey.equals(searchItem)) {
return groupKey;
}
}
}
// NB: Default group must be present in group JSONObject created by createActivitySummaryGroups
return defaultGroup;
}
/**
* @noinspection ArraysAsListWithZeroOrOneArgument
*/
private static Map<String, List<String>> createActivitySummaryGroups() {
return new LinkedHashMap<String, List<String>>() {{
// NB: Default group Activity must be present in this definition, otherwise it wouldn't
// be shown.
put(GROUP_ACTIVITY, Arrays.asList(
DISTANCE_METERS, STEPS, STEP_RATE_SUM, ACTIVE_SECONDS, CALORIES_BURNT,
STRIDE_TOTAL, HR_AVG, HR_MAX, HR_MIN, STRIDE_AVG, STRIDE_MAX, STRIDE_MIN,
STEP_LENGTH_AVG,
STANDING_TIME, STANDING_COUNT,
AVG_POWER, MAX_POWER, NORMALIZED_POWER
));
put(GROUP_SPEED, Arrays.asList(
SPEED_AVG, SPEED_MAX, SPEED_MIN, PACE_AVG_SECONDS_KM, PACE_MIN,
PACE_MAX, "averageSpeed2", CADENCE_AVG, CADENCE_MAX, CADENCE_MIN,
STEP_RATE_AVG, STEP_RATE_MAX
));
put(GROUP_ELEVATION, Arrays.asList(
ASCENT_METERS, DESCENT_METERS, ALTITUDE_MAX, ALTITUDE_MIN, ALTITUDE_AVG,
ALTITUDE_BASE, ASCENT_SECONDS, DESCENT_SECONDS, FLAT_SECONDS, ASCENT_DISTANCE,
DESCENT_DISTANCE, FLAT_DISTANCE, ELEVATION_GAIN, ELEVATION_LOSS
));
put(GROUP_STROKES, Arrays.asList(
STROKE_DISTANCE_AVG, STROKE_AVG_PER_SECOND, STROKES,
STROKE_RATE_AVG, STROKE_RATE_MAX
));
put(GROUP_JUMPS, Arrays.asList(
JUMPS, JUMP_RATE_AVG, JUMP_RATE_MAX
));
put(GROUP_SWIMMING, Arrays.asList(
POOL_LENGTH, SWIM_AVG_CADENCE,
SWOLF_INDEX, SWOLF_AVG, SWOLF_MAX, SWOLF_MIN, SWIM_STYLE
));
put(GROUP_CYCLING, Arrays.asList(
LEFT_RIGHT_BALANCE,
AVG_PEDAL_SMOOTHNESS, AVG_TORQUE_EFFECTIVENESS,
AVG_LEFT_PCO, AVG_LEFT_POWER_PHASE, AVG_LEFT_POWER_PHASE_PEAK,
AVG_RIGHT_PCO, AVG_RIGHT_POWER_PHASE, AVG_RIGHT_POWER_PHASE_PEAK,
AVG_POWER_STANDING, MAX_POWER_STANDING,
AVG_POWER_SEATING, MAX_POWER_SEATING,
AVG_CADENCE_STANDING, AVG_CADENCE_SEATING,
MAX_CADENCE_STANDING, MAX_CADENCE_SEATING,
FRONT_GEAR_SHIFTS, REAR_GEAR_SHIFTS
));
put(GROUP_TRAINING_EFFECT, Arrays.asList(
TRAINING_EFFECT_AEROBIC, TRAINING_EFFECT_ANAEROBIC, WORKOUT_LOAD,
TRAINING_LOAD,
INTENSITY_FACTOR, TRAINING_STRESS_SCORE,
MAXIMUM_OXYGEN_UPTAKE, RECOVERY_TIME, LACTATE_THRESHOLD_HR
));
put(GROUP_LAPS, Arrays.asList(
LAP_PACE_AVERAGE, LAPS, LANE_LENGTH
));
put(GROUP_PACE, Arrays.asList(
));
put(GROUP_RUNNING_FORM, Arrays.asList(
GROUND_CONTACT_TIME_AVG, IMPACT_AVG, IMPACT_MAX, SWING_ANGLE_AVG,
FORE_FOOT_LANDINGS, MID_FOOT_LANDINGS, BACK_FOOT_LANDINGS,
EVERSION_ANGLE_AVG, EVERSION_ANGLE_MAX
));
put(GROUP_HEART_RATE_ZONES, Arrays.asList(
HR_ZONE_NA, HR_ZONE_WARM_UP, HR_ZONE_FAT_BURN, HR_ZONE_EASY, HR_ZONE_AEROBIC, HR_ZONE_ANAEROBIC,
HR_ZONE_THRESHOLD, HR_ZONE_EXTREME, HR_ZONE_MAXIMUM
));
put(GROUP_DIVING, Arrays.asList(
AVG_DEPTH, MAX_DEPTH, START_CNS, END_CNS, START_N2, END_N2, DIVE_NUMBER, BOTTOM_TIME
));
put(SETS, Arrays.asList(
));
}};
}
}
@@ -16,6 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@@ -27,6 +28,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.workout.Workout;
public interface ActivitySummaryParser {
/**
@@ -37,10 +39,21 @@ public interface ActivitySummaryParser {
* @param forDetails whether the parsing is for the details page. If this is false, the parser
* should avoid slow operations such as reading and parsing raw files from
* storage.
* @return the update {@link BaseActivitySummary}
* @return the {@link Workout}
* @deprecated Use/implement {@link #parseWorkout(BaseActivitySummary, boolean)} instead.
*/
@Deprecated
BaseActivitySummary parseBinaryData(BaseActivitySummary summary, final boolean forDetails);
default Workout parseWorkout(BaseActivitySummary summary, final boolean forDetails) {
final BaseActivitySummary baseActivitySummary = parseBinaryData(summary, forDetails);
return new Workout(
baseActivitySummary,
ActivitySummaryData.fromJson(baseActivitySummary.getSummaryData()),
Collections.emptyList()
);
}
static BaseActivitySummary findOrCreateBaseActivitySummary(final DaoSession session,
final GBDevice gbDevice,
final int timestampSeconds) {
@@ -0,0 +1,10 @@
package nodomain.freeyourgadget.gadgetbridge.model.workout
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData
data class Workout @JvmOverloads constructor(
val summary: BaseActivitySummary,
val data: ActivitySummaryData,
val charts: List<WorkoutChart> = emptyList()
)
@@ -0,0 +1,8 @@
package nodomain.freeyourgadget.gadgetbridge.model.workout
import com.github.mikephil.charting.data.ChartData
data class WorkoutChart(
val title: String,
val chartData: ChartData<*>
)
@@ -95,7 +95,6 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
@@ -0,0 +1,22 @@
package nodomain.freeyourgadget.gadgetbridge.util
open class LiveDataEvent<out T>(private val content: T) {
private var hasBeenHandled = false
/**
* Returns the content if it hasn't been handled already.
*/
fun getContentIfNotHandled(): T? {
return if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
}
/**
* Returns the content, even if it's already been handled.
*/
fun peekContent(): T = content
}
@@ -1,4 +1,4 @@
/* Copyright (C) 2024 José Rebelo
/* Copyright (C) 2024-2025 José Rebelo
This file is part of Gadgetbridge.
@@ -18,12 +18,16 @@ package nodomain.freeyourgadget.gadgetbridge.util.maps;
import android.content.Context;
import android.net.Uri;
import android.util.DisplayMetrics;
import androidx.documentfile.provider.DocumentFile;
import org.mapsforge.core.graphics.Paint;
import org.mapsforge.core.graphics.Style;
import org.mapsforge.core.model.BoundingBox;
import org.mapsforge.core.model.Dimension;
import org.mapsforge.core.model.LatLong;
import org.mapsforge.core.util.LatLongUtils;
import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
import org.mapsforge.map.android.util.AndroidUtil;
import org.mapsforge.map.android.view.MapView;
@@ -39,9 +43,12 @@ import java.io.FileInputStream;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.util.Accumulator;
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
public final class MapsManager {
@@ -171,7 +178,22 @@ public final class MapsManager {
isMapLoaded = false;
}
public void setTrack(final List<LatLong> points) {
public void setTrack(final List<? extends GPSCoordinate> trackPoints) {
final Accumulator latitudeAccumulator = new Accumulator();
final Accumulator longitudeAccumulator = new Accumulator();
for (GPSCoordinate trackPoint : trackPoints) {
latitudeAccumulator.add(trackPoint.getLatitude());
longitudeAccumulator.add(trackPoint.getLongitude());
}
final double maxLat = latitudeAccumulator.getMax();
final double minLat = latitudeAccumulator.getMin();
final double maxLon = longitudeAccumulator.getMax();
final double minLon = longitudeAccumulator.getMin();
final List<LatLong> points = trackPoints.stream()
.map(p -> new LatLong(p.getLatitude(), p.getLongitude()))
.collect(Collectors.toList());
if (polyline == null) {
final Paint paint = AndroidGraphicFactory.INSTANCE.createPaint();
final int trackColor = GBApplication.getPrefs().getInt(MapsManager.PREF_TRACK_COLOR, mContext.getResources().getColor(R.color.map_track_default));
@@ -184,6 +206,15 @@ public final class MapsManager {
}
polyline.setPoints(points);
mapView.getLayerManager().redrawLayers();
mapView.setCenter(new LatLong(minLat + (maxLat - minLat) / 2, minLon + (maxLon - minLon) / 2));
final DisplayMetrics displayMetrics = mContext.getResources().getDisplayMetrics();
final byte zoom = LatLongUtils.zoomForBounds(
new Dimension(displayMetrics.widthPixels, displayMetrics.heightPixels),
new BoundingBox(minLat, minLon, maxLat, maxLon),
mapView.getModel().displayModel.getTileSize()
);
mapView.setZoomLevel(zoom);
}
public void reload() {
@@ -2,12 +2,14 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/windowBackground"
android:orientation="vertical"
tools:context="nodomain.freeyourgadget.gadgetbridge.activities.maps.MapsTrackActivity">
<org.mapsforge.map.android.view.MapView
android:id="@+id/activitygpsview"
android:id="@+id/map_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" />
</RelativeLayout>
@@ -1,128 +0,0 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_summary_detail_scroll_layout"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:scrollbars="vertical">
<LinearLayout
android:id="@+id/activity_summary_detail_relative_layout"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_marginBottom="8dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<TextView
android:id="@+id/activityname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:fontFamily="sans-serif-black"
android:scrollHorizontally="false"
android:text="Activity Name"
android:textSize="16sp"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/activitydate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:layout_weight="1"
android:gravity="start"
android:text="Today, 10:00 - 12:00"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/activityduration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:text="30min"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:id="@+id/top_layout2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foregroundGravity="top"
android:gravity="end"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<ImageView
android:id="@+id/item_image"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:contentDescription="@string/icon_placeholder"
app:srcCompat="@drawable/ic_activity_unknown" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/bottom_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/summaryDetails"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/textView4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:gravity="center"
android:padding="16dp"
android:text="@string/heart_rate"
android:textSize="18sp" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="?attr/row_separator" />
<FrameLayout
android:id="@+id/chartsFragmentHolder"
android:layout_width="match_parent"
android:layout_height="300dp" />
</LinearLayout>
<FrameLayout
android:id="@+id/gpsFragmentHolder"
android:layout_width="match_parent"
android:layout_height="300dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ProgressBar
android:id="@+id/loading_spinner"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:text="@string/agps_status_error"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />
</FrameLayout>
-54
View File
@@ -1,54 +0,0 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="nodomain.freeyourgadget.gadgetbridge.activities.charts.ActivityChartsActivity$PlaceholderFragment">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="?attr/row_separator" />
<TextView
android:id="@+id/textView4"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:gravity="center"
android:padding="16dp"
android:text="@string/gps_track"
android:textSize="18sp" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="?attr/row_separator" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/transparent"
android:orientation="vertical">
<TextView
android:id="@+id/gpsWarning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/maps_not_found" />
<org.mapsforge.map.android.view.MapView
android:id="@+id/activitygpsview"
android:layout_width="match_parent"
android:layout_height="300dp"
android:orientation="vertical" />
</LinearLayout>
</LinearLayout>
</RelativeLayout>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:gravity="center"
android:padding="16dp"
android:text="@string/unknown"
android:textSize="18sp" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="?attr/row_separator" />
<com.github.mikephil.charting.charts.LineChart
android:id="@+id/chart"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<ProgressBar
android:id="@+id/loading_spinner"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:text="@string/agps_status_error"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- header -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_marginBottom="8dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<TextView
android:id="@+id/activityname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:fontFamily="sans-serif-black"
android:scrollHorizontally="false"
android:text="Activity Name"
android:textSize="16sp"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/activitydate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:layout_weight="1"
android:gravity="start"
android:text="Today, 10:00 - 12:00"
tools:ignore="HardcodedText" />
<TextView
android:id="@+id/activityduration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:text="30min"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:id="@+id/top_layout2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foregroundGravity="top"
android:gravity="end"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<ImageView
android:id="@+id/item_image"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:contentDescription="@string/icon_placeholder"
app:srcCompat="@drawable/ic_activity_unknown" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- details -->
<LinearLayout
android:id="@+id/summaryDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<!--
Heart Rate chart
TODO: Merge this into the dynamic charts
-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:gravity="center"
android:padding="16dp"
android:text="@string/heart_rate"
android:textSize="18sp" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="?attr/row_separator" />
<FrameLayout
android:id="@+id/chartsFragmentHolder"
android:layout_width="match_parent"
android:layout_height="300dp" />
</LinearLayout>
<!-- Dynamic charts, provided by the device -->
<LinearLayout
android:id="@+id/dynamic_charts"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" />
<!-- Map for gpx track -->
<FrameLayout
android:id="@+id/gpsFragmentHolder"
android:layout_width="match_parent"
android:layout_height="300dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<ProgressBar
android:id="@+id/loading_spinner"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:text="@string/agps_status_error"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-black"
android:gravity="center"
android:padding="16dp"
android:text="@string/gps_track"
android:textSize="18sp" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="?attr/row_separator" />
<TextView
android:id="@+id/missing_maps_warning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:padding="16dp"
android:text="@string/maps_not_found"
android:textColor="?android:attr/textColorSecondary"
android:visibility="visible" />
<org.mapsforge.map.android.view.MapView
android:id="@+id/map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<ProgressBar
android:id="@+id/loading_spinner"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:text="@string/unknown_error"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />
</FrameLayout>
+6 -16
View File
@@ -7,22 +7,12 @@
android:icon="@drawable/ic_filter_alt"
android:title="@string/pref_header_filter"
app:iconTint="?attr/actionmenu_icon_color"
app:showAsAction="always" />
app:showAsAction="ifRoom" />
<item
android:id="@+id/overflowMenu"
android:icon="@drawable/ic_menu"
android:title="@string/menuitem_more"
app:iconTint="?attr/actionmenu_icon_color"
app:showAsAction="always">
android:id="@+id/activity_action_manage_timestamp"
android:icon="@drawable/ic_history"
android:title="@string/reset_index"
app:showAsAction="never" />
<menu>
<item
android:id="@+id/activity_action_manage_timestamp"
android:icon="@drawable/ic_history"
android:title="@string/reset_index"
app:showAsAction="never" />
</menu>
</item>
</menu>
</menu>
-1
View File
@@ -17,7 +17,6 @@
<attr name="chart_not_worn" format="color" />
<attr name="training_acute_load" format="color" />
<attr name="alternate_row_background" format="color" />
<attr name="sports_activity_summary_background" format="color" />
<attr name="deviceIconPrimary" format="color" />
<attr name="deviceIconOnPrimary" format="color" />
-4
View File
@@ -33,7 +33,6 @@
<item name="training_acute_load">@color/training_load</item>
<item name="alternate_row_background">@color/alternate_row_background_light</item>
<item name="row_separator">@color/row_separator_light</item>
<item name="sports_activity_summary_background">@color/chart_heartrate_fill</item>
<item name="deviceIconPrimary">#2196f3</item>
<item name="deviceIconOnPrimary">#ffffff</item>
@@ -79,7 +78,6 @@
<item name="training_acute_load">@color/training_load_dark</item>
<item name="alternate_row_background">@color/alternate_row_background_dark</item>
<item name="row_separator">@color/row_separator_dark</item>
<item name="sports_activity_summary_background">@color/alternate_row_background_dark</item>
<item name="deviceIconPrimary">#2196f3</item>
<item name="deviceIconOnPrimary">#ffffff</item>
@@ -110,7 +108,6 @@
<item name="actionmenu_icon_color">?attr/colorOnSurface</item>
<item name="alternate_row_background">?attr/colorSecondaryContainer</item>
<item name="row_separator">@color/row_separator_light</item>
<item name="sports_activity_summary_background">?attr/colorSurface</item>
<item name="preferenceTheme">@style/AppPreferenceThemeOverlay</item>
<item name="android:navigationBarColor">?attr/colorSurface</item>
<!-- FIXME: SDK 21 to 27 has light navbar on light background -->
@@ -132,7 +129,6 @@
<item name="actionmenu_icon_color">?attr/colorOnSurface</item>
<item name="alternate_row_background">?attr/colorSecondaryContainer</item>
<item name="row_separator">@color/row_separator_dark</item>
<item name="sports_activity_summary_background">?attr/colorSurface</item>
<item name="preferenceTheme">@style/AppPreferenceThemeOverlay</item>
<item name="android:navigationBarColor">?attr/colorSurface</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>