Dashboard view (#3478)

This adds a new dashboard-type view to Gadgetbridge. The new dashboard activity displays several widgets with aggregated statistics from multiple devices. New preferences are added to allow configuration of the dashboard and its widgets. A new bottom navigation bar is added to switch between the Dashboard and Devices views.

Some issues that prompted this feature and provided inspiration for the implementation:
- https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/301 (More Intuitive User Interface)
- https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/3074 (Ability to merge historical data from several devices)

Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/3478
Reviewed-by: José Rebelo <joserebelo@noreply.codeberg.org>
Co-authored-by: Arjan Schrijver <a_gadgetbridge@anymore.nl>
Co-committed-by: Arjan Schrijver <a_gadgetbridge@anymore.nl>
This commit is contained in:
Arjan Schrijver 2024-04-04 19:28:04 +00:00 committed by Arjan Schrijver
parent e4cac887cc
commit 43fddd0110
47 changed files with 3235 additions and 280 deletions

View File

@ -222,12 +222,14 @@ dependencies {
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.preference:preference:1.2.1"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.recyclerview:recyclerview:1.3.1"
implementation "androidx.recyclerview:recyclerview:1.3.2"
implementation "androidx.legacy:legacy-support-v4:1.0.0"
implementation "androidx.gridlayout:gridlayout:1.0.0"
implementation "androidx.palette:palette:1.0.0"
implementation "androidx.activity:activity:1.7.2"
implementation "androidx.fragment:fragment:1.6.1"
implementation "androidx.fragment:fragment:1.6.2"
implementation "androidx.navigation:navigation-fragment:2.6.0"
implementation "androidx.navigation:navigation-ui:2.6.0"
implementation "com.google.android.material:material:1.9.0"
implementation 'com.google.android.flexbox:flexbox:3.0.0'

View File

@ -128,6 +128,10 @@
android:name=".activities.SettingsActivity"
android:label="@string/title_activity_settings"
android:parentActivityName=".activities.ControlCenterv2" />
<activity
android:name=".activities.DashboardPreferencesActivity"
android:label="@string/dashboard_settings"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.AboutUserPreferencesActivity"
android:label="@string/activity_prefs_about_you"
@ -848,6 +852,11 @@
android:name=".externalevents.opentracks.OpenTracksController"
android:label="OpenTracks controller and intent receiver"
android:exported="true"/>
<activity
android:name=".activities.dashboard.DashboardCalendarActivity"
android:label="@string/menuitem_calendar"
android:parentActivityName=".activities.ControlCenterv2" />
</application>
</manifest>

View File

@ -25,7 +25,6 @@ import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.widget.RemoteViews;

View File

@ -20,7 +20,6 @@
package nodomain.freeyourgadget.gadgetbridge.activities;
import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_CONNECT;
import static nodomain.freeyourgadget.gadgetbridge.util.GB.toast;
import android.Manifest;
import android.annotation.TargetApi;
@ -53,7 +52,6 @@ import androidx.annotation.RequiresApi;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.appcompat.view.menu.MenuItemImpl;
import androidx.core.app.ActivityCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
@ -61,12 +59,14 @@ import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.DialogFragment;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.navigation.NavController;
import androidx.navigation.NavGraph;
import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.NavigationUI;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.navigation.NavigationView;
import org.slf4j.Logger;
@ -74,9 +74,6 @@ import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@ -87,31 +84,26 @@ import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
import nodomain.freeyourgadget.gadgetbridge.adapter.GBDeviceAdapterv2;
import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.DailyTotals;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GBChangeLog;
import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.GBChangeLog;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
//TODO: extend AbstractGBActivity, but it requires actionbar that is not available
public class ControlCenterv2 extends AppCompatActivity
implements NavigationView.OnNavigationItemSelectedListener, GBActivity {
private static final Logger LOG = LoggerFactory.getLogger(ControlCenterv2.class);
public static final int MENU_REFRESH_CODE = 1;
public static final String ACTION_REQUEST_PERMISSIONS
= "nodomain.freeyourgadget.gadgetbridge.activities.controlcenter.requestpermissions";
public static final String ACTION_REQUEST_LOCATION_PERMISSIONS
= "nodomain.freeyourgadget.gadgetbridge.activities.controlcenter.requestlocationpermissions";
private boolean isLanguageInvalid = false;
private boolean isThemeInvalid = false;
private static PhoneStateListener fakeStateListener;
//needed for KK compatibility
@ -119,15 +111,6 @@ public class ControlCenterv2 extends AppCompatActivity
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
}
private DeviceManager deviceManager;
private GBDeviceAdapterv2 mGBDeviceAdapter;
private RecyclerView deviceListView;
private FloatingActionButton fab;
private boolean isLanguageInvalid = false;
private boolean isThemeInvalid = false;
List<GBDevice> deviceList;
private HashMap<String,long[]> deviceActivityHashMap = new HashMap();
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
@ -142,12 +125,6 @@ public class ControlCenterv2 extends AppCompatActivity
case GBApplication.ACTION_QUIT:
finish();
break;
case DeviceManager.ACTION_DEVICES_CHANGED:
case GBApplication.ACTION_NEW_DATA:
createRefreshTask("get activity data", getApplication()).execute();
mGBDeviceAdapter.rebuildFolders();
refreshPairedDevices();
break;
case DeviceService.ACTION_REALTIME_SAMPLES:
handleRealtimeSample(intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE));
break;
@ -157,7 +134,6 @@ public class ControlCenterv2 extends AppCompatActivity
case ACTION_REQUEST_LOCATION_PERMISSIONS:
checkAndRequestLocationPermissions();
break;
}
}
};
@ -171,7 +147,6 @@ public class ControlCenterv2 extends AppCompatActivity
private void setCurrentHRSample(ActivitySample sample) {
if (HeartRateUtils.getInstance().isValidHeartRateValue(sample.getHeartRate())) {
currentHRSample = sample;
refreshPairedDevices();
}
}
@ -185,11 +160,42 @@ public class ControlCenterv2 extends AppCompatActivity
@Override
protected void onCreate(Bundle savedInstanceState) {
AbstractGBActivity.init(this, AbstractGBActivity.NO_ACTIONBAR);
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_controlcenterv2);
setContentView(R.layout.activity_main);
Prefs prefs = GBApplication.getPrefs();
boolean activityTrackerAvailable = false;
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
for (GBDevice dev : devices) {
if (dev.getDeviceCoordinator().supportsActivityTracking()) {
activityTrackerAvailable = true;
break;
}
}
NavHostFragment navHostFragment = (NavHostFragment)
getSupportFragmentManager().findFragmentById(R.id.fragment_container);
NavController navController = navHostFragment.getNavController();
if (!prefs.getBoolean("dashboard_as_default_view", true) || !activityTrackerAvailable) {
NavGraph navGraph = navController.getNavInflater().inflate(R.navigation.main);
navGraph.setStartDestination(R.id.bottom_nav_devices);
navController.setGraph(navGraph);
}
BottomNavigationView navigationView = findViewById(R.id.bottom_nav_bar);
NavigationUI.setupWithNavController(navigationView, navController);
navigationView.setVisibility(activityTrackerAvailable ? View.VISIBLE : View.GONE);
NavigationView drawerNavigationView = findViewById(R.id.nav_view);
drawerNavigationView.setNavigationItemSelectedListener(this);
MaterialToolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
DrawerLayout drawer = findViewById(R.id.drawer_layout);
ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
this, drawer, toolbar, R.string.controlcenter_navigation_drawer_open, R.string.controlcenter_navigation_drawer_close);
drawer.setDrawerListener(toggle);
toggle.syncState();
if (GBApplication.areDynamicColorsEnabled()) {
TypedValue typedValue = new TypedValue();
@ -202,103 +208,18 @@ public class ControlCenterv2 extends AppCompatActivity
toolbar.setTitleTextColor(getResources().getColor(android.R.color.white));
}
DrawerLayout drawer = findViewById(R.id.drawer_layout);
ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
this, drawer, toolbar, R.string.controlcenter_navigation_drawer_open, R.string.controlcenter_navigation_drawer_close);
drawer.setDrawerListener(toggle);
toggle.syncState();
/* This sucks but for the play store we're not allowed a donation link. Instead for
the Bangle.js Play Store app we put a message in the About dialog via @string/about_description */
if (BuildConfig.FLAVOR == "banglejs") {
MenuItemImpl v = (MenuItemImpl) ((NavigationView) drawer.getChildAt(1)).getMenu().findItem(R.id.donation_link);
if (v != null) v.setVisible(false);
}
NavigationView navigationView = findViewById(R.id.nav_view);
navigationView.setNavigationItemSelectedListener(this);
//end of material design boilerplate
deviceManager = ((GBApplication) getApplication()).getDeviceManager();
deviceListView = findViewById(R.id.deviceListView);
deviceListView.setHasFixedSize(true);
deviceListView.setLayoutManager(new LinearLayoutManager(this));
deviceList = deviceManager.getDevices();
mGBDeviceAdapter = new GBDeviceAdapterv2(this, deviceList, deviceActivityHashMap);
mGBDeviceAdapter.setHasStableIds(true);
// get activity data asynchronously, this fills the deviceActivityHashMap
// and calls refreshPairedDevices() notifyDataSetChanged
createRefreshTask("get activity data", getApplication()).execute();
deviceListView.setAdapter(this.mGBDeviceAdapter);
fab = findViewById(R.id.fab);
fab.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
launchDiscoveryActivity();
}
});
showFabIfNeccessary();
/* uncomment to enable fixed-swipe to reveal more actions
ItemTouchHelper swipeToDismissTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(
ItemTouchHelper.LEFT , ItemTouchHelper.RIGHT) {
@Override
public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
if(dX>50)
dX = 50;
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
GB.toast(getBaseContext(), "onMove", Toast.LENGTH_LONG, GB.ERROR);
return false;
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
GB.toast(getBaseContext(), "onSwiped", Toast.LENGTH_LONG, GB.ERROR);
}
@Override
public void onChildDrawOver(Canvas c, RecyclerView recyclerView,
RecyclerView.ViewHolder viewHolder, float dX, float dY,
int actionState, boolean isCurrentlyActive) {
}
});
swipeToDismissTouchHelper.attachToRecyclerView(deviceListView);
*/
registerForContextMenu(deviceListView);
IntentFilter filterLocal = new IntentFilter();
filterLocal.addAction(GBApplication.ACTION_LANGUAGE_CHANGE);
filterLocal.addAction(GBApplication.ACTION_THEME_CHANGE);
filterLocal.addAction(GBApplication.ACTION_QUIT);
filterLocal.addAction(GBApplication.ACTION_NEW_DATA);
filterLocal.addAction(DeviceManager.ACTION_DEVICES_CHANGED);
filterLocal.addAction(DeviceService.ACTION_REALTIME_SAMPLES);
filterLocal.addAction(ACTION_REQUEST_PERMISSIONS);
filterLocal.addAction(ACTION_REQUEST_LOCATION_PERMISSIONS);
LocalBroadcastManager.getInstance(this).registerReceiver(mReceiver, filterLocal);
refreshPairedDevices();
/*
* Ask for permission to intercept notifications on first run.
*/
Prefs prefs = GBApplication.getPrefs();
pesterWithPermissions = prefs.getBoolean("permission_pestering", true);
boolean displayPermissionDialog = !prefs.getBoolean("permission_dialog_displayed", false);
@ -315,7 +236,7 @@ public class ControlCenterv2 extends AppCompatActivity
}
}
/* We not put up dialogs explaining why we need permissions (Polite, but also Play Store policy).
/* We not put up dialogs explaining why we need permissions (Polite, but also Play Store policy).
Rather than chaining the calls, we just open a bunch of dialogs. Last in this list = first
on the page, and as they are accepted the permissions are requested in turn.
@ -364,21 +285,15 @@ public class ControlCenterv2 extends AppCompatActivity
checkAndRequestPermissions();
}
GBChangeLog cl = createChangeLog();
final boolean showChangelog = prefs.getBoolean("show_changelog", true);
GBChangeLog cl = GBChangeLog.createChangeLog(this);
boolean showChangelog = prefs.getBoolean("show_changelog", true);
if (showChangelog && cl.isFirstRun() && cl.hasChanges(cl.isFirstRunEver())) {
try {
cl.getMaterialLogDialog().show();
} catch (Exception ignored) {
GB.toast(getBaseContext(), "Error showing Changelog", Toast.LENGTH_LONG, GB.ERROR);
GB.toast(this, getString(R.string.error_showing_changelog), Toast.LENGTH_LONG, GB.ERROR);
}
}
if (GB.isBluetoothEnabled() && deviceList.isEmpty() && Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
launchDiscoveryActivity();
} else {
GBApplication.deviceService().requestDeviceInfo();
}
}
@Override
@ -394,30 +309,10 @@ public class ControlCenterv2 extends AppCompatActivity
@Override
protected void onDestroy() {
unregisterForContextMenu(deviceListView);
LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver);
super.onDestroy();
}
@Override
public void onBackPressed() {
DrawerLayout drawer = findViewById(R.id.drawer_layout);
if (drawer.isDrawerOpen(GravityCompat.START)) {
drawer.closeDrawer(GravityCompat.START);
} else {
super.onBackPressed();
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == MENU_REFRESH_CODE) {
showFabIfNeccessary();
}
}
@Override
public boolean onNavigationItemSelected(@NonNull MenuItem item) {
@ -461,7 +356,7 @@ public class ControlCenterv2 extends AppCompatActivity
cl.getMaterialFullLogDialog().show();
}
} catch (Exception ignored) {
GB.toast(getBaseContext(), "Error showing Changelog", Toast.LENGTH_LONG, GB.ERROR);
GB.toast(getBaseContext(), getString(R.string.error_showing_changelog), Toast.LENGTH_LONG, GB.ERROR);
}
return false;
case R.id.about:
@ -485,18 +380,14 @@ public class ControlCenterv2 extends AppCompatActivity
startActivity(new Intent(this, DiscoveryActivityV2.class));
}
private void refreshPairedDevices() {
mGBDeviceAdapter.notifyDataSetChanged();
}
private void showFabIfNeccessary() {
if (GBApplication.getPrefs().getBoolean("display_add_device_fab", true)) {
fab.show();
} else {
if (deviceManager.getDevices().size() < 1) {
fab.show();
} else {
fab.hide();
private void handleShortcut(Intent intent) {
if(ACTION_CONNECT.equals(intent.getAction())) {
String btDeviceAddress = intent.getStringExtra("device");
if(btDeviceAddress!=null){
GBDevice candidate = DeviceHelper.getInstance().findAvailableDevice(btDeviceAddress, this);
if (candidate != null && !candidate.isConnected()) {
GBApplication.deviceService(candidate).connect();
}
}
}
}
@ -504,7 +395,7 @@ public class ControlCenterv2 extends AppCompatActivity
private void checkAndRequestLocationPermissions() {
if (ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED) {
LOG.error("No permission to access background location!");
toast(ControlCenterv2.this, getString(R.string.error_no_location_access), Toast.LENGTH_SHORT, GB.ERROR);
GB.toast(getString(R.string.error_no_location_access), Toast.LENGTH_SHORT, GB.ERROR);
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_BACKGROUND_LOCATION}, 0);
}
}
@ -647,51 +538,6 @@ public class ControlCenterv2 extends AppCompatActivity
AndroidUtils.setLanguage(this, language);
}
private long[] getSteps(GBDevice device, DBHandler db) {
Calendar day = GregorianCalendar.getInstance();
DailyTotals ds = new DailyTotals();
return ds.getDailyTotalsForDevice(device, day, db);
}
protected RefreshTask createRefreshTask(String task, Context context) {
return new RefreshTask(task, context);
}
private void handleShortcut(Intent intent) {
if(ACTION_CONNECT.equals(intent.getAction())) {
String btDeviceAddress = intent.getStringExtra("device");
if(btDeviceAddress!=null){
GBDevice candidate = DeviceHelper.getInstance().findAvailableDevice(btDeviceAddress, this);
if (candidate != null && !candidate.isConnected()) {
GBApplication.deviceService(candidate).connect();
}
}
}
}
public class RefreshTask extends DBAccess {
public RefreshTask(String task, Context context) {
super(task, context);
}
@Override
protected void doInBackground(DBHandler db) {
for (GBDevice gbDevice : deviceList) {
final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
if (coordinator.supportsActivityTracking()) {
long[] stepsAndSleepData = getSteps(gbDevice, db);
deviceActivityHashMap.put(gbDevice.getAddress(), stepsAndSleepData);
}
}
}
@Override
protected void onPostExecute(Object o) {
refreshPairedDevices();
}
}
/// Called from onCreate - this puts up a dialog explaining we need permissions, and goes to the correct Activity
public static class NotifyPolicyPermissionsDialogFragment extends DialogFragment {
@Override
@ -700,8 +546,8 @@ public class ControlCenterv2 extends AppCompatActivity
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity());
final Context context = getContext();
builder.setMessage(context.getString(R.string.permission_notification_policy_access,
getContext().getString(R.string.app_name),
getContext().getString(R.string.ok)))
getContext().getString(R.string.app_name),
getContext().getString(R.string.ok)))
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
@RequiresApi(api = Build.VERSION_CODES.M)
public void onClick(DialogInterface dialog, int id) {
@ -724,8 +570,8 @@ public class ControlCenterv2 extends AppCompatActivity
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity());
final Context context = getContext();
builder.setMessage(context.getString(R.string.permission_notification_listener,
getContext().getString(R.string.app_name),
getContext().getString(R.string.ok)))
getContext().getString(R.string.app_name),
getContext().getString(R.string.ok)))
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
try {

View File

@ -0,0 +1,479 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://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.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentContainerView;
import androidx.gridlayout.widget.GridLayout;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.google.android.material.card.MaterialCardView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.GregorianCalendar;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.AbstractDashboardWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardActiveTimeWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardCalendarActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardDistanceWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardGoalsWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardSleepWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardStepsWidget;
import nodomain.freeyourgadget.gadgetbridge.activities.dashboard.DashboardTodayWidget;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceCommunicationService;
import nodomain.freeyourgadget.gadgetbridge.util.DashboardUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class DashboardFragment extends Fragment {
private static final Logger LOG = LoggerFactory.getLogger(DashboardFragment.class);
private Calendar day = GregorianCalendar.getInstance();
private TextView textViewDate;
private TextView arrowLeft;
private TextView arrowRight;
private GridLayout gridLayout;
private SwipeRefreshLayout swipeLayout;
private DashboardTodayWidget todayWidget;
private DashboardGoalsWidget goalsWidget;
private DashboardStepsWidget stepsWidget;
private DashboardDistanceWidget distanceWidget;
private DashboardActiveTimeWidget activeTimeWidget;
private DashboardSleepWidget sleepWidget;
private DashboardData dashboardData = new DashboardData();
private boolean isConfigChanged = false;
public static final String ACTION_CONFIG_CHANGE = "nodomain.freeyourgadget.gadgetbridge.activities.dashboardfragment.action.config_change";
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action == null) return;
switch (action) {
case GBDevice.ACTION_DEVICE_CHANGED:
GBDevice dev = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
if (dev != null && !dev.isBusy()) {
refresh();
}
break;
case ACTION_CONFIG_CHANGE:
isConfigChanged = true;
break;
}
}
};
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState);
View dashboardView = inflater.inflate(R.layout.fragment_dashboard, container, false);
setHasOptionsMenu(true);
textViewDate = dashboardView.findViewById(R.id.dashboard_date);
gridLayout = dashboardView.findViewById(R.id.dashboard_gridlayout);
swipeLayout = dashboardView.findViewById(R.id.dashboard_swipe_layout);
swipeLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
@Override
public void onRefresh() {
// Signal DeviceCommunicationService to fetch activity for all connected devices
Intent intent = new Intent(requireContext(), DeviceCommunicationService.class);
intent.setAction(DeviceService.ACTION_FETCH_RECORDED_DATA)
.putExtra(DeviceService.EXTRA_RECORDED_DATA_TYPES, ActivityKind.TYPE_ACTIVITY);
requireContext().startService(intent);
// Hide 'refreshing' animation immediately if no health devices are connected
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
for (GBDevice dev : devices) {
if (dev.getDeviceCoordinator().supportsActivityTracking() && dev.isInitialized()) {
return;
}
}
swipeLayout.setRefreshing(false);
GB.toast(getString(R.string.info_no_devices_connected), Toast.LENGTH_LONG, GB.WARN);
}
});
// Increase column count on landscape, tablets and open foldables
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
if (displayMetrics.widthPixels / displayMetrics.density >= 600) {
gridLayout.setColumnCount(4);
}
arrowLeft = dashboardView.findViewById(R.id.arrow_left);
arrowLeft.setOnClickListener(v -> {
day.add(Calendar.DAY_OF_MONTH, -1);
refresh();
});
arrowRight = dashboardView.findViewById(R.id.arrow_right);
arrowRight.setOnClickListener(v -> {
Calendar today = GregorianCalendar.getInstance();
if (!DateTimeUtils.isSameDay(today, day)) {
day.add(Calendar.DAY_OF_MONTH, 1);
refresh();
}
});
if (savedInstanceState != null && savedInstanceState.containsKey("dashboard_data") && dashboardData.isEmpty()) {
dashboardData = (DashboardData) savedInstanceState.getSerializable("dashboard_data");
}
// Make sure the widget fragments are (re)instantiated when drawing the dashboard
todayWidget = null;
goalsWidget = null;
stepsWidget = null;
distanceWidget = null;
activeTimeWidget = null;
sleepWidget = null;
IntentFilter filterLocal = new IntentFilter();
filterLocal.addAction(GBDevice.ACTION_DEVICE_CHANGED);
filterLocal.addAction(ACTION_CONFIG_CHANGE);
LocalBroadcastManager.getInstance(requireContext()).registerReceiver(mReceiver, filterLocal);
return dashboardView;
}
@Override
public void onResume() {
super.onResume();
draw();
if (isConfigChanged) {
isConfigChanged = false;
fullRefresh();
} else if (dashboardData.isEmpty() || todayWidget == null) {
refresh();
}
}
@Override
public void onDestroy() {
LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mReceiver);
super.onDestroy();
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putSerializable("dashboard_data", dashboardData);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.dashboard_menu, menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
switch (item.getItemId()) {
case R.id.dashboard_show_calendar:
Intent intent = new Intent(requireActivity(), DashboardCalendarActivity.class);
intent.putExtra(DashboardCalendarActivity.EXTRA_TIMESTAMP, day.getTimeInMillis());
startActivityForResult(intent, 0);
return false;
}
return super.onOptionsItemSelected(item);
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == 0 && resultCode == DashboardCalendarActivity.RESULT_OK && data != null) {
long timeMillis = data.getLongExtra(DashboardCalendarActivity.EXTRA_TIMESTAMP, 0);
if (timeMillis != 0) {
day.setTimeInMillis(timeMillis);
fullRefresh();
}
}
}
private void fullRefresh() {
gridLayout.removeAllViews();
todayWidget = null;
goalsWidget = null;
stepsWidget = null;
distanceWidget = null;
activeTimeWidget = null;
sleepWidget = null;
refresh();
}
private void refresh() {
swipeLayout.setRefreshing(false);
day.set(Calendar.HOUR_OF_DAY, 23);
day.set(Calendar.MINUTE, 59);
day.set(Calendar.SECOND, 59);
dashboardData.clear();
Prefs prefs = GBApplication.getPrefs();
dashboardData.showAllDevices = prefs.getBoolean("dashboard_devices_all", true);
dashboardData.showDeviceList = prefs.getStringSet("dashboard_devices_multiselect", new HashSet<>());
dashboardData.hrIntervalSecs = prefs.getInt("dashboard_widget_today_hr_interval", 1) * 60;
dashboardData.timeTo = (int) (day.getTimeInMillis() / 1000);
dashboardData.timeFrom = DateTimeUtils.shiftDays(dashboardData.timeTo, -1);
draw();
}
private void draw() {
Prefs prefs = GBApplication.getPrefs();
String defaultWidgetsOrder = String.join(",", getResources().getStringArray(R.array.pref_dashboard_widgets_order_values));
String widgetsOrderPref = prefs.getString("pref_dashboard_widgets_order", defaultWidgetsOrder);
List<String> widgetsOrder = Arrays.asList(widgetsOrderPref.split(","));
Calendar today = GregorianCalendar.getInstance();
if (DateTimeUtils.isSameDay(today, day)) {
textViewDate.setText(getContext().getString(R.string.activity_summary_today));
arrowRight.setAlpha(0.5f);
} else {
textViewDate.setText(DateTimeUtils.formatDate(day.getTime()));
arrowRight.setAlpha(1);
}
boolean cardsEnabled = prefs.getBoolean("dashboard_cards_enabled", true);
for (String widgetName : widgetsOrder) {
switch (widgetName) {
case "today":
if (todayWidget == null) {
todayWidget = DashboardTodayWidget.newInstance(dashboardData);
createWidget(todayWidget, cardsEnabled, prefs.getBoolean("dashboard_widget_today_2columns", true) ? 2 : 1);
} else {
todayWidget.update();
}
break;
case "goals":
if (goalsWidget == null) {
goalsWidget = DashboardGoalsWidget.newInstance(dashboardData);
createWidget(goalsWidget, cardsEnabled, prefs.getBoolean("dashboard_widget_goals_2columns", true) ? 2 : 1);
} else {
goalsWidget.update();
}
break;
case "steps":
if (stepsWidget == null) {
stepsWidget = DashboardStepsWidget.newInstance(dashboardData);
createWidget(stepsWidget, cardsEnabled, 1);
} else {
stepsWidget.update();
}
break;
case "distance":
if (distanceWidget == null) {
distanceWidget = DashboardDistanceWidget.newInstance(dashboardData);
createWidget(distanceWidget, cardsEnabled, 1);
} else {
distanceWidget.update();
}
break;
case "activetime":
if (activeTimeWidget == null) {
activeTimeWidget = DashboardActiveTimeWidget.newInstance(dashboardData);
createWidget(activeTimeWidget, cardsEnabled, 1);
} else {
activeTimeWidget.update();
}
break;
case "sleep":
if (sleepWidget == null) {
sleepWidget = DashboardSleepWidget.newInstance(dashboardData);
createWidget(sleepWidget, cardsEnabled, 1);
} else {
sleepWidget.update();
}
break;
}
}
}
private void createWidget(AbstractDashboardWidget widgetObj, boolean cardsEnabled, int columnSpan) {
final float scale = requireContext().getResources().getDisplayMetrics().density;
FragmentContainerView fragment = new FragmentContainerView(requireActivity());
int fragmentId = View.generateViewId();
fragment.setId(fragmentId);
getParentFragmentManager()
.beginTransaction()
.replace(fragmentId, widgetObj)
.commit();
GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL,1f),
GridLayout.spec(GridLayout.UNDEFINED, columnSpan, GridLayout.FILL,1f)
);
layoutParams.width = 0;
int pixels_8dp = (int) (8 * scale + 0.5f);
layoutParams.setMargins(pixels_8dp, pixels_8dp, pixels_8dp, pixels_8dp);
if (cardsEnabled) {
MaterialCardView card = new MaterialCardView(requireActivity());
int pixels_4dp = (int) (4 * scale + 0.5f);
card.setRadius(pixels_4dp);
card.setCardElevation(pixels_4dp);
card.setContentPadding(pixels_4dp, pixels_4dp, pixels_4dp, pixels_4dp);
card.setLayoutParams(layoutParams);
card.addView(fragment);
gridLayout.addView(card);
} else {
fragment.setLayoutParams(layoutParams);
gridLayout.addView(fragment);
}
}
/**
* This class serves as a data collection object for all data points used by the various
* dashboard widgets. Since retrieving this data can be costly, this class makes sure it will
* only be done once. It will be passed to every widget, making sure they have the necessary
* data available.
*/
public static class DashboardData implements Serializable {
public boolean showAllDevices;
public Set<String> showDeviceList;
public int hrIntervalSecs;
public int timeFrom;
public int timeTo;
public final List<GeneralizedActivity> generalizedActivities = Collections.synchronizedList(new ArrayList<>());
private int stepsTotal;
private float stepsGoalFactor;
private long sleepTotalMinutes;
private float sleepGoalFactor;
private float distanceTotalMeters;
private float distanceGoalFactor;
private long activeMinutesTotal;
private float activeMinutesGoalFactor;
public void clear() {
stepsTotal = 0;
stepsGoalFactor = 0;
sleepTotalMinutes = 0;
sleepGoalFactor = 0;
distanceTotalMeters = 0;
distanceGoalFactor = 0;
activeMinutesTotal = 0;
activeMinutesGoalFactor = 0;
generalizedActivities.clear();
}
public boolean isEmpty() {
return (stepsTotal == 0 &&
stepsGoalFactor == 0 &&
sleepTotalMinutes == 0 &&
sleepGoalFactor == 0 &&
distanceTotalMeters == 0 &&
distanceGoalFactor == 0 &&
activeMinutesTotal == 0 &&
activeMinutesGoalFactor == 0 &&
generalizedActivities.isEmpty());
}
public synchronized int getStepsTotal() {
if (stepsTotal == 0)
stepsTotal = DashboardUtils.getStepsTotal(this);
return stepsTotal;
}
public synchronized float getStepsGoalFactor() {
if (stepsGoalFactor == 0)
stepsGoalFactor = DashboardUtils.getStepsGoalFactor(this);
return stepsGoalFactor;
}
public synchronized float getDistanceTotal() {
if (distanceTotalMeters == 0)
distanceTotalMeters = DashboardUtils.getDistanceTotal(this);
return distanceTotalMeters;
}
public synchronized float getDistanceGoalFactor() {
if (distanceGoalFactor == 0)
distanceGoalFactor = DashboardUtils.getDistanceGoalFactor(this);
return distanceGoalFactor;
}
public synchronized long getActiveMinutesTotal() {
if (activeMinutesTotal == 0)
activeMinutesTotal = DashboardUtils.getActiveMinutesTotal(this);
return activeMinutesTotal;
}
public synchronized float getActiveMinutesGoalFactor() {
if (activeMinutesGoalFactor == 0)
activeMinutesGoalFactor = DashboardUtils.getActiveMinutesGoalFactor(this);
return activeMinutesGoalFactor;
}
public synchronized long getSleepMinutesTotal() {
if (sleepTotalMinutes == 0)
sleepTotalMinutes = DashboardUtils.getSleepMinutesTotal(this);
return sleepTotalMinutes;
}
public synchronized float getSleepMinutesGoalFactor() {
if (sleepGoalFactor == 0)
sleepGoalFactor = DashboardUtils.getSleepMinutesGoalFactor(this);
return sleepGoalFactor;
}
public static class GeneralizedActivity implements Serializable {
public int activityKind;
public long timeFrom;
public long timeTo;
public GeneralizedActivity(int activityKind, long timeFrom, long timeTo) {
this.activityKind = activityKind;
this.timeFrom = timeFrom;
this.timeTo = timeTo;
}
@NonNull
@Override
public String toString() {
return "Generalized activity: timeFrom=" + timeFrom + ", timeTo=" + timeTo + ", activityKind=" + activityKind + ", calculated duration: " + (timeTo - timeFrom) + " seconds";
}
}
}
}

View File

@ -0,0 +1,101 @@
/* Copyright (C) 2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputType;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.preference.MultiSelectListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class DashboardPreferencesActivity extends AbstractSettingsActivityV2 {
@Override
protected String fragmentTag() {
return DashboardPreferencesFragment.FRAGMENT_TAG;
}
@Override
protected PreferenceFragmentCompat newFragment() {
return new DashboardPreferencesFragment();
}
public static class DashboardPreferencesFragment extends AbstractPreferenceFragment {
static final String FRAGMENT_TAG = "DASHBOARD_PREFERENCES_FRAGMENT";
@Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
setPreferencesFromResource(R.xml.dashboard_preferences, rootKey);
setInputTypeFor("dashboard_widget_today_hr_interval", InputType.TYPE_CLASS_NUMBER);
final MultiSelectListPreference dashboardDevices = findPreference("dashboard_devices_multiselect");
if (dashboardDevices != null) {
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
List<String> deviceMACs = new ArrayList<>();
List<String> deviceNames = new ArrayList<>();
for (GBDevice dev : devices) {
deviceMACs.add(dev.getAddress());
deviceNames.add(dev.getAliasOrName());
}
dashboardDevices.setEntryValues(deviceMACs.toArray(new String[0]));
dashboardDevices.setEntries(deviceNames.toArray(new String[0]));
}
List<String> dashboardPrefs = Arrays.asList(
"dashboard_cards_enabled",
"pref_dashboard_widgets_order",
"dashboard_widget_today_24h",
"dashboard_widget_today_2columns",
"dashboard_widget_today_legend",
"dashboard_widget_today_hr_interval",
"dashboard_widget_goals_2columns",
"dashboard_widget_goals_legend",
"dashboard_devices_all",
"dashboard_devices_multiselect"
);
Preference pref;
for (String dashboardPref : dashboardPrefs) {
pref = findPreference(dashboardPref);
if (pref != null) {
pref.setOnPreferenceChangeListener((preference, autoExportEnabled) -> {
sendDashboardConfigChangedIntent();
return true;
});
}
}
}
/**
* Signal dashboard that its config has changed
*/
private void sendDashboardConfigChangedIntent() {
Intent intent = new Intent();
intent.setAction(DashboardFragment.ACTION_CONFIG_CHANGE);
LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent);
}
}
}

View File

@ -0,0 +1,251 @@
/* Copyright (C) 2016-2024 Andreas Shimokawa, Andrzej Surowiec, Arjan
Schrijver, Carsten Pfeiffer, Daniel Dakhno, Daniele Gobbetti, Ganblejs,
gfwilliams, Gordon Williams, Johannes Tysiak, José Rebelo, marco.altomonte,
Petr Vaněk, Taavi Eomäe
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.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import java.io.Serializable;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.discovery.DiscoveryActivityV2;
import nodomain.freeyourgadget.gadgetbridge.adapter.GBDeviceAdapterv2;
import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.DailyTotals;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class DevicesFragment extends Fragment {
private DeviceManager deviceManager;
private GBDeviceAdapterv2 mGBDeviceAdapter;
private RecyclerView deviceListView;
private FloatingActionButton fab;
List<GBDevice> deviceList;
private HashMap<String,long[]> deviceActivityHashMap = new HashMap();
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
switch (Objects.requireNonNull(action)) {
case DeviceManager.ACTION_DEVICES_CHANGED:
case GBApplication.ACTION_NEW_DATA:
createRefreshTask("get activity data", requireContext()).execute();
mGBDeviceAdapter.rebuildFolders();
refreshPairedDevices();
break;
case DeviceService.ACTION_REALTIME_SAMPLES:
handleRealtimeSample(intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE));
break;
}
}
};
private void handleRealtimeSample(Serializable extra) {
if (extra instanceof ActivitySample) {
ActivitySample sample = (ActivitySample) extra;
if (HeartRateUtils.getInstance().isValidHeartRateValue(sample.getHeartRate())) {
refreshPairedDevices();
}
}
}
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
View currentView = inflater.inflate(R.layout.fragment_devices, container, false);
deviceManager = ((GBApplication) getActivity().getApplication()).getDeviceManager();
deviceListView = currentView.findViewById(R.id.deviceListView);
deviceListView.setHasFixedSize(true);
deviceListView.setLayoutManager(new LinearLayoutManager(currentView.getContext()));
deviceList = deviceManager.getDevices();
mGBDeviceAdapter = new GBDeviceAdapterv2(currentView.getContext(), deviceList, deviceActivityHashMap);
mGBDeviceAdapter.setHasStableIds(true);
deviceListView.setAdapter(this.mGBDeviceAdapter);
// get activity data asynchronously, this fills the deviceActivityHashMap
// and calls refreshPairedDevices() notifyDataSetChanged
deviceListView.post(new Runnable() {
@Override
public void run() {
if (getContext() != null) {
createRefreshTask("get activity data", getContext()).execute();
}
}
});
fab = currentView.findViewById(R.id.fab);
fab.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
launchDiscoveryActivity();
}
});
showFabIfNeccessary();
/* uncomment to enable fixed-swipe to reveal more actions
ItemTouchHelper swipeToDismissTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(
ItemTouchHelper.LEFT , ItemTouchHelper.RIGHT) {
@Override
public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
if(dX>50)
dX = 50;
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
GB.toast(getBaseContext(), "onMove", Toast.LENGTH_LONG, GB.ERROR);
return false;
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
GB.toast(getBaseContext(), "onSwiped", Toast.LENGTH_LONG, GB.ERROR);
}
@Override
public void onChildDrawOver(Canvas c, RecyclerView recyclerView,
RecyclerView.ViewHolder viewHolder, float dX, float dY,
int actionState, boolean isCurrentlyActive) {
}
});
swipeToDismissTouchHelper.attachToRecyclerView(deviceListView);
*/
registerForContextMenu(deviceListView);
IntentFilter filterLocal = new IntentFilter();
filterLocal.addAction(GBApplication.ACTION_NEW_DATA);
filterLocal.addAction(DeviceManager.ACTION_DEVICES_CHANGED);
filterLocal.addAction(DeviceService.ACTION_REALTIME_SAMPLES);
LocalBroadcastManager.getInstance(requireContext()).registerReceiver(mReceiver, filterLocal);
refreshPairedDevices();
if (GB.isBluetoothEnabled() && deviceList.isEmpty() && Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
startActivity(new Intent(getActivity(), DiscoveryActivityV2.class));
} else {
GBApplication.deviceService().requestDeviceInfo();
}
return currentView;
}
private void launchDiscoveryActivity() {
startActivity(new Intent(getActivity(), DiscoveryActivityV2.class));
}
private void showFabIfNeccessary() {
if (GBApplication.getPrefs().getBoolean("display_add_device_fab", true)) {
fab.show();
} else {
if (deviceManager.getDevices().size() < 1) {
fab.show();
} else {
fab.hide();
}
}
}
@Override
public void onDestroy() {
if (deviceListView != null) unregisterForContextMenu(deviceListView);
LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mReceiver);
super.onDestroy();
}
private long[] getSteps(GBDevice device, DBHandler db) {
Calendar day = GregorianCalendar.getInstance();
DailyTotals ds = new DailyTotals();
return ds.getDailyTotalsForDevice(device, day, db);
}
public void refreshPairedDevices() {
if (mGBDeviceAdapter != null) {
mGBDeviceAdapter.notifyDataSetChanged();
mGBDeviceAdapter.rebuildFolders();
}
}
public RefreshTask createRefreshTask(String task, Context context) {
return new RefreshTask(task, context);
}
public class RefreshTask extends DBAccess {
public RefreshTask(String task, Context context) {
super(task, context);
}
@Override
protected void doInBackground(DBHandler db) {
for (GBDevice gbDevice : deviceList) {
final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
if (coordinator.supportsActivityTracking()) {
long[] stepsAndSleepData = getSteps(gbDevice, db);
deviceActivityHashMap.put(gbDevice.getAddress(), stepsAndSleepData);
}
}
}
@Override
protected void onPostExecute(Object o) {
refreshPairedDevices();
}
}
}

View File

@ -45,6 +45,7 @@ import android.widget.Toast;
import androidx.core.app.ActivityCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.preference.ListPreference;
import androidx.preference.MultiSelectListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
@ -55,6 +56,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@ -72,6 +75,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleSettingsActivit
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.ConfigActivity;
import nodomain.freeyourgadget.gadgetbridge.devices.zetime.ZeTimePreferenceActivity;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.Weather;
import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
@ -397,6 +401,15 @@ public class SettingsActivity extends AbstractSettingsActivityV2 {
audioPlayer.setDefaultValue(newValues[0]);
}
pref = findPreference("pref_category_dashboard");
if (pref != null) {
pref.setOnPreferenceClickListener(preference -> {
Intent enableIntent = new Intent(requireContext(), DashboardPreferencesActivity.class);
startActivity(enableIntent);
return true;
});
}
final Preference theme = findPreference("pref_key_theme");
final Preference amoled_black = findPreference("pref_key_theme_amoled_black");

View File

@ -0,0 +1,92 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import androidx.annotation.ColorInt;
import androidx.fragment.app.Fragment;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
public abstract class AbstractDashboardWidget extends Fragment {
private static final Logger LOG = LoggerFactory.getLogger(AbstractDashboardWidget.class);
protected static String ARG_DASHBOARD_DATA = "dashboard_widget_argument_data";
protected DashboardFragment.DashboardData dashboardData;
protected @ColorInt int color_unknown = Color.argb(25, 128, 128, 128);
protected @ColorInt int color_not_worn = Color.BLACK;
protected @ColorInt int color_worn = Color.rgb(128, 128, 128);
protected @ColorInt int color_activity = Color.GREEN;
protected @ColorInt int color_exercise = Color.rgb(255, 128, 0);
protected @ColorInt int color_deep_sleep = Color.BLUE;
protected @ColorInt int color_light_sleep = Color.rgb(150, 150, 255);
protected @ColorInt int color_rem_sleep = Color.rgb(182, 191, 255);
protected @ColorInt int color_distance = Color.BLUE;
protected @ColorInt int color_active_time = Color.rgb(170, 0, 255);
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getArguments() != null) {
dashboardData = (DashboardFragment.DashboardData) getArguments().getSerializable(ARG_DASHBOARD_DATA);
}
}
public void update() {
fillData();
}
protected abstract void fillData();
/**
* @param width Bitmap width in pixels
* @param barWidth Gauge bar width in pixels
* @param filledColor Color of the filled part of the gauge
* @param filledFactor Factor between 0 and 1 that determines the amount of the gauge that should be filled
* @return Bitmap containing the gauge
*/
Bitmap drawGauge(int width, int barWidth, @ColorInt int filledColor, float filledFactor) {
int height = width / 2;
int barMargin = (int) Math.ceil(barWidth / 2f);
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStrokeWidth(barWidth * 0.75f);
paint.setColor(color_unknown);
canvas.drawArc(barMargin, barMargin, width - barMargin, width - barMargin, 180 + 180 * filledFactor, 180 - 180 * filledFactor, false, paint);
paint.setStrokeWidth(barWidth);
paint.setColor(filledColor);
canvas.drawArc(barMargin, barMargin, width - barMargin, width - barMargin, 180, 180 * filledFactor, false, paint);
return bitmap;
}
}

View File

@ -0,0 +1,113 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
/**
* A simple {@link AbstractDashboardWidget} subclass.
* Use the {@link DashboardActiveTimeWidget#newInstance} factory method to
* create an instance of this fragment.
*/
public class DashboardActiveTimeWidget extends AbstractDashboardWidget {
private static final Logger LOG = LoggerFactory.getLogger(DashboardActiveTimeWidget.class);
private TextView activeTime;
private ImageView activeTimeGauge;
public DashboardActiveTimeWidget() {
// Required empty public constructor
}
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param dashboardData An instance of DashboardFragment.DashboardData.
* @return A new instance of fragment DashboardActiveTimeWidget.
*/
public static DashboardActiveTimeWidget newInstance(DashboardFragment.DashboardData dashboardData) {
DashboardActiveTimeWidget fragment = new DashboardActiveTimeWidget();
Bundle args = new Bundle();
args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
fragment.setArguments(args);
return fragment;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View fragmentView = inflater.inflate(R.layout.dashboard_widget_active_time, container, false);
activeTime = fragmentView.findViewById(R.id.activetime_text);
activeTimeGauge = fragmentView.findViewById(R.id.activetime_gauge);
fillData();
return fragmentView;
}
@Override
public void onResume() {
super.onResume();
if (activeTime != null && activeTimeGauge != null) fillData();
}
@Override
protected void fillData() {
if (activeTimeGauge == null) return;
activeTimeGauge.post(new Runnable() {
@Override
public void run() {
FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
myAsyncTask.execute();
}
});
}
private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
dashboardData.getActiveMinutesTotal();
dashboardData.getActiveMinutesGoalFactor();
return null;
}
@Override
protected void onPostExecute(Void unused) {
super.onPostExecute(unused);
// Update text representation
long totalActiveMinutes = dashboardData.getActiveMinutesTotal();
String activeHours = String.format("%d", (int) Math.floor(totalActiveMinutes / 60f));
String activeMinutes = String.format("%02d", (int) (totalActiveMinutes % 60f));
activeTime.setText(activeHours + ":" + activeMinutes);
// Draw gauge
activeTimeGauge.setImageBitmap(drawGauge(200, 15, color_active_time, dashboardData.getActiveMinutesGoalFactor()));
}
}
}

View File

@ -0,0 +1,262 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.LayerDrawable;
import android.os.AsyncTask;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.gridlayout.widget.GridLayout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
import nodomain.freeyourgadget.gadgetbridge.util.DashboardUtils;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class DashboardCalendarActivity extends AbstractGBActivity {
private static final Logger LOG = LoggerFactory.getLogger(DashboardCalendarActivity.class);
public static String EXTRA_TIMESTAMP = "dashboard_calendar_chosen_day";
private final ConcurrentHashMap<Calendar, TextView> dayCells = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Integer, Integer> dayColors = new ConcurrentHashMap<>();
private boolean showAllDevices;
private Set<String> showDeviceList;
TextView monthTextView;
TextView arrowLeft;
TextView arrowRight;
GridLayout calendarGrid;
Calendar currentDay;
Calendar cal;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_dashboard_calendar);
monthTextView = findViewById(R.id.calendar_month);
calendarGrid = findViewById(R.id.dashboard_calendar_grid);
currentDay = Calendar.getInstance();
cal = Calendar.getInstance();
long receivedTimestamp = getIntent().getLongExtra(EXTRA_TIMESTAMP, 0);
if (receivedTimestamp != 0) {
currentDay.setTimeInMillis(receivedTimestamp);
cal.setTimeInMillis(receivedTimestamp);
}
Prefs prefs = GBApplication.getPrefs();
showAllDevices = prefs.getBoolean("dashboard_devices_all", true);
showDeviceList = prefs.getStringSet("dashboard_devices_multiselect", new HashSet<>());
arrowLeft = findViewById(R.id.arrow_left);
arrowLeft.setOnClickListener(v -> {
cal.add(Calendar.MONTH, -1);
draw();
});
arrowRight = findViewById(R.id.arrow_right);
arrowRight.setOnClickListener(v -> {
Calendar today = GregorianCalendar.getInstance();
if (!DateTimeUtils.isSameMonth(today, cal)) {
cal.add(Calendar.MONTH, 1);
draw();
}
});
draw();
}
private void displayColorsAsync() {
calendarGrid.post(new Runnable() {
@Override
public void run() {
FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
myAsyncTask.execute();
}
});
}
private void draw() {
// Remove previous calendar days
dayCells.clear();
dayColors.clear();
calendarGrid.removeAllViews();
// Update month display
SimpleDateFormat monthFormat = new SimpleDateFormat("LLLL yyyy", Locale.getDefault());
monthTextView.setText(monthFormat.format(cal.getTime()));
Calendar today = GregorianCalendar.getInstance();
today.set(Calendar.HOUR, 23);
today.set(Calendar.MINUTE, 59);
today.set(Calendar.SECOND, 59);
if (DateTimeUtils.isSameMonth(today, cal)) {
arrowRight.setAlpha(0.5f);
} else {
arrowRight.setAlpha(1);
}
// Calculate grid cell size for dates
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
int screenWidth = displayMetrics.widthPixels;
int cellSize = screenWidth / 7;
// Determine first day that should be displayed
Calendar drawCal = (Calendar) cal.clone();
drawCal.set(Calendar.DAY_OF_MONTH, 1);
int displayMonth = drawCal.get(Calendar.MONTH);
int firstDayOfWeek = cal.getFirstDayOfWeek();
int daysToFirstDay = (drawCal.get(Calendar.DAY_OF_WEEK) - firstDayOfWeek + 7) % 7;
drawCal.add(Calendar.DAY_OF_MONTH, -daysToFirstDay);
// Determine last day that should be displayed
Calendar lastDay = (Calendar) cal.clone();
lastDay.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH));
lastDay.add(Calendar.DAY_OF_MONTH, (firstDayOfWeek + 7 - lastDay.get(Calendar.DAY_OF_WEEK)) % 7);
// Add day names header
SimpleDateFormat dayFormat = new SimpleDateFormat("E", Locale.getDefault());
Calendar weekdays = Calendar.getInstance();
for (int i=0; i<7; i++) {
int currentDayOfWeek = (firstDayOfWeek + i - 1) % 7 + 1;
weekdays.set(Calendar.DAY_OF_WEEK, currentDayOfWeek);
createWeekdayCell(dayFormat.format(weekdays.getTime()), cellSize);
}
// Loop through month days and create grid cells for them
while (!DateTimeUtils.isSameDay(drawCal, lastDay)) {
boolean clickable = drawCal.get(Calendar.MONTH) == displayMonth;
if (drawCal.after(today)) clickable = false;
createDateCell(drawCal, cellSize, clickable);
drawCal.add(Calendar.DAY_OF_MONTH, 1);
}
// Asynchronously determine and display goal colors
displayColorsAsync();
}
private TextView prepareGridElement(int cellSize) {
GridLayout.LayoutParams layoutParams = new GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED, GridLayout.FILL,1f),
GridLayout.spec(GridLayout.UNDEFINED, 1, GridLayout.FILL,1f)
);
int margin = cellSize / 10;
layoutParams.width = 0;
layoutParams.height = cellSize - 2 * margin;
layoutParams.setMargins(margin, margin, margin, margin);
TextView text = new TextView(this);
text.setLayoutParams(layoutParams);
text.setGravity(Gravity.CENTER);
return text;
}
private void createWeekdayCell(String day, int cellSize) {
TextView text = prepareGridElement(cellSize);
text.setText(day);
calendarGrid.addView(text);
}
private void createDateCell(Calendar day, int cellSize, boolean clickable) {
TextView text = prepareGridElement(cellSize);
text.setText(String.valueOf(day.get(Calendar.DAY_OF_MONTH)));
if (clickable) {
// Save textview for later coloring
dayCells.put((Calendar) day.clone(), text);
}
calendarGrid.addView(text);
}
private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
for (Calendar day : dayCells.keySet()) {
// Determine day color by the amount of the steps goal reached
DashboardFragment.DashboardData dashboardData = new DashboardFragment.DashboardData();
dashboardData.showAllDevices = showAllDevices;
dashboardData.showDeviceList = showDeviceList;
dashboardData.timeTo = (int) (day.getTimeInMillis() / 1000);
dashboardData.timeFrom = DateTimeUtils.shiftDays(dashboardData.timeTo, -1);
float goalFactor = DashboardUtils.getStepsGoalFactor(dashboardData);
@ColorInt int dayColor;
if (goalFactor >= 1) {
dayColor = Color.argb(128, 0, 255, 0); // Green
} else if (goalFactor >= 0.75) {
dayColor = Color.argb(128, 0, 128, 0); // Dark green
} else if (goalFactor >= 0.5) {
dayColor = Color.argb(128, 255, 255, 0); // Yellow
} else if (goalFactor > 0.25) {
dayColor = Color.argb(128, 255, 128, 0); // Orange
} else if (goalFactor > 0) {
dayColor = Color.argb(128, 255, 0, 0); // Red
} else {
dayColor = Color.argb(50, 128, 128, 128);
}
dayColors.put(day.get(Calendar.DAY_OF_MONTH), dayColor);
}
return null;
}
@Override
protected void onPostExecute(Void unused) {
super.onPostExecute(unused);
for (Map.Entry<Calendar, TextView> entry : dayCells.entrySet()) {
Calendar day = entry.getKey();
TextView text = entry.getValue();
@ColorInt int dayColor;
try {
dayColor = dayColors.get(day.get(Calendar.DAY_OF_MONTH));
} catch (NullPointerException e) {
continue;
}
final long timestamp = day.getTimeInMillis();
// Draw colored circle
GradientDrawable backgroundDrawable = new GradientDrawable();
backgroundDrawable.setShape(GradientDrawable.OVAL);
backgroundDrawable.setColor(dayColor);
if (DateTimeUtils.isSameDay(day, currentDay)) {
GradientDrawable borderDrawable = new GradientDrawable();
borderDrawable.setShape(GradientDrawable.OVAL);
borderDrawable.setColor(Color.TRANSPARENT);
borderDrawable.setStroke(5, GBApplication.getTextColor(getApplicationContext()));
LayerDrawable layerDrawable = new LayerDrawable(new Drawable[]{backgroundDrawable, borderDrawable});
text.setBackground(layerDrawable);
} else {
text.setBackground(backgroundDrawable);
}
text.setOnClickListener(v -> {
Intent resultIntent = new Intent();
resultIntent.putExtra(EXTRA_TIMESTAMP, timestamp);
setResult(RESULT_OK, resultIntent);
finish();
});
}
}
}
}

View File

@ -0,0 +1,113 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
import nodomain.freeyourgadget.gadgetbridge.util.FormatUtils;
/**
* A simple {@link AbstractDashboardWidget} subclass.
* Use the {@link DashboardDistanceWidget#newInstance} factory method to
* create an instance of this fragment.
*/
public class DashboardDistanceWidget extends AbstractDashboardWidget {
private static final Logger LOG = LoggerFactory.getLogger(DashboardDistanceWidget.class);
private TextView distanceText;
private ImageView distanceGauge;
public DashboardDistanceWidget() {
// Required empty public constructor
}
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param dashboardData An instance of DashboardFragment.DashboardData.
* @return A new instance of fragment DashboardDistanceWidget.
*/
public static DashboardDistanceWidget newInstance(DashboardFragment.DashboardData dashboardData) {
DashboardDistanceWidget fragment = new DashboardDistanceWidget();
Bundle args = new Bundle();
args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
fragment.setArguments(args);
return fragment;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View fragmentView = inflater.inflate(R.layout.dashboard_widget_distance, container, false);
distanceText = fragmentView.findViewById(R.id.distance_text);
distanceGauge = fragmentView.findViewById(R.id.distance_gauge);
fillData();
return fragmentView;
}
@Override
public void onResume() {
super.onResume();
if (distanceText != null && distanceGauge != null) fillData();
}
@Override
protected void fillData() {
if (distanceGauge == null) return;
distanceGauge.post(new Runnable() {
@Override
public void run() {
FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
myAsyncTask.execute();
}
});
}
private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
dashboardData.getDistanceTotal();
dashboardData.getDistanceGoalFactor();
return null;
}
@Override
protected void onPostExecute(Void unused) {
super.onPostExecute(unused);
// Update text representation
String distanceFormatted = FormatUtils.getFormattedDistanceLabel(dashboardData.getDistanceTotal());
distanceText.setText(distanceFormatted);
// Draw gauge
distanceGauge.setImageBitmap(drawGauge(200, 15, color_distance, dashboardData.getDistanceGoalFactor()));
}
}
}

View File

@ -0,0 +1,171 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
/**
* A simple {@link AbstractDashboardWidget} subclass.
* Use the {@link DashboardGoalsWidget#newInstance} factory method to
* create an instance of this fragment.
*/
public class DashboardGoalsWidget extends AbstractDashboardWidget {
private static final Logger LOG = LoggerFactory.getLogger(DashboardGoalsWidget.class);
private View goalsView;
private ImageView goalsChart;
public DashboardGoalsWidget() {
// Required empty public constructor
}
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param dashboardData An instance of DashboardFragment.DashboardData.
* @return A new instance of fragment DashboardGoalsWidget.
*/
public static DashboardGoalsWidget newInstance(DashboardFragment.DashboardData dashboardData) {
DashboardGoalsWidget fragment = new DashboardGoalsWidget();
Bundle args = new Bundle();
args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
fragment.setArguments(args);
return fragment;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
goalsView = inflater.inflate(R.layout.dashboard_widget_goals, container, false);
goalsChart = goalsView.findViewById(R.id.dashboard_goals_chart);
// Initialize legend
TextView legend = goalsView.findViewById(R.id.dashboard_goals_legend);
SpannableString l_steps = new SpannableString("" + getString(R.string.steps));
l_steps.setSpan(new ForegroundColorSpan(color_activity), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_distance = new SpannableString("" + getString(R.string.distance));
l_distance.setSpan(new ForegroundColorSpan(color_distance), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_active_time = new SpannableString("" + getString(R.string.activity_list_summary_active_time));
l_active_time.setSpan(new ForegroundColorSpan(color_active_time), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_sleep = new SpannableString("" + getString(R.string.menuitem_sleep));
l_sleep.setSpan(new ForegroundColorSpan(color_light_sleep), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableStringBuilder legendBuilder = new SpannableStringBuilder();
legend.setText(legendBuilder.append(l_steps).append(" ").append(l_distance).append("\n").append(l_active_time).append(" ").append(l_sleep));
Prefs prefs = GBApplication.getPrefs();
legend.setVisibility(prefs.getBoolean("dashboard_widget_goals_legend", true) ? View.VISIBLE : View.GONE);
fillData();
return goalsView;
}
@Override
public void onResume() {
super.onResume();
if (goalsChart != null) fillData();
}
@Override
protected void fillData() {
if (goalsView == null) return;
goalsView.post(new Runnable() {
@Override
public void run() {
FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
myAsyncTask.execute();
}
});
}
private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
private Bitmap goalsBitmap;
@Override
protected Void doInBackground(Void... params) {
int width = 500;
int height = 500;
int barWidth = 20;
int barMargin = (int) Math.ceil(barWidth / 2f);
goalsBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(goalsBitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStrokeWidth(barWidth * 0.75f);
paint.setColor(color_unknown);
canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360, false, paint);
paint.setStrokeWidth(barWidth);
paint.setColor(color_activity);
canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360 * dashboardData.getStepsGoalFactor(), false, paint);
barMargin += barWidth * 1.5;
paint.setStrokeWidth(barWidth * 0.75f);
paint.setColor(color_unknown);
canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360, false, paint);
paint.setStrokeWidth(barWidth);
paint.setColor(color_distance);
canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360 * dashboardData.getDistanceGoalFactor(), false, paint);
barMargin += barWidth * 1.5;
paint.setStrokeWidth(barWidth * 0.75f);
paint.setColor(color_unknown);
canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360, false, paint);
paint.setStrokeWidth(barWidth);
paint.setColor(color_active_time);
canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360 * dashboardData.getActiveMinutesGoalFactor(), false, paint);
barMargin += barWidth * 1.5;
paint.setStrokeWidth(barWidth * 0.75f);
paint.setColor(color_unknown);
canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360, false, paint);
paint.setStrokeWidth(barWidth);
paint.setColor(color_light_sleep);
canvas.drawArc(barMargin, barMargin, width - barMargin, height - barMargin, 270, 360 * dashboardData.getSleepMinutesGoalFactor(), false, paint);
return null;
}
@Override
protected void onPostExecute(Void unused) {
super.onPostExecute(unused);
goalsChart.setImageBitmap(goalsBitmap);
}
}
}

View File

@ -0,0 +1,113 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
/**
* A simple {@link AbstractDashboardWidget} subclass.
* Use the {@link DashboardSleepWidget#newInstance} factory method to
* create an instance of this fragment.
*/
public class DashboardSleepWidget extends AbstractDashboardWidget {
private static final Logger LOG = LoggerFactory.getLogger(DashboardSleepWidget.class);
private TextView sleepAmount;
private ImageView sleepGauge;
public DashboardSleepWidget() {
// Required empty public constructor
}
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param dashboardData An instance of DashboardFragment.DashboardData.
* @return A new instance of fragment DashboardSleepWidget.
*/
public static DashboardSleepWidget newInstance(DashboardFragment.DashboardData dashboardData) {
DashboardSleepWidget fragment = new DashboardSleepWidget();
Bundle args = new Bundle();
args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
fragment.setArguments(args);
return fragment;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View fragmentView = inflater.inflate(R.layout.dashboard_widget_sleep, container, false);
sleepAmount = fragmentView.findViewById(R.id.sleep_text);
sleepGauge = fragmentView.findViewById(R.id.sleep_gauge);
fillData();
return fragmentView;
}
@Override
public void onResume() {
super.onResume();
if (sleepAmount != null && sleepGauge != null) fillData();
}
@Override
protected void fillData() {
if (sleepGauge == null) return;
sleepGauge.post(new Runnable() {
@Override
public void run() {
FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
myAsyncTask.execute();
}
});
}
private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
dashboardData.getSleepMinutesTotal();
dashboardData.getSleepMinutesGoalFactor();
return null;
}
@Override
protected void onPostExecute(Void unused) {
super.onPostExecute(unused);
// Update text representation
long totalSleepMinutes = dashboardData.getSleepMinutesTotal();
String sleepHours = String.format("%d", (int) Math.floor(totalSleepMinutes / 60f));
String sleepMinutes = String.format("%02d", (int) (totalSleepMinutes % 60f));
sleepAmount.setText(sleepHours + ":" + sleepMinutes);
// Draw gauge
sleepGauge.setImageBitmap(drawGauge(200, 15, color_light_sleep, dashboardData.getSleepMinutesGoalFactor()));
}
}
}

View File

@ -0,0 +1,109 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.os.AsyncTask;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
/**
* A simple {@link AbstractDashboardWidget} subclass.
* Use the {@link DashboardStepsWidget#newInstance} factory method to
* create an instance of this fragment.
*/
public class DashboardStepsWidget extends AbstractDashboardWidget {
private static final Logger LOG = LoggerFactory.getLogger(DashboardStepsWidget.class);
private TextView stepsCount;
private ImageView stepsGauge;
public DashboardStepsWidget() {
// Required empty public constructor
}
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param dashboardData An instance of DashboardFragment.DashboardData.
* @return A new instance of fragment DashboardStepsWidget.
*/
public static DashboardStepsWidget newInstance(DashboardFragment.DashboardData dashboardData) {
DashboardStepsWidget fragment = new DashboardStepsWidget();
Bundle args = new Bundle();
args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
fragment.setArguments(args);
return fragment;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View fragmentView = inflater.inflate(R.layout.dashboard_widget_steps, container, false);
stepsCount = fragmentView.findViewById(R.id.steps_count);
stepsGauge = fragmentView.findViewById(R.id.steps_gauge);
fillData();
return fragmentView;
}
@Override
public void onResume() {
super.onResume();
if (stepsCount != null && stepsGauge != null) fillData();
}
@Override
protected void fillData() {
if (stepsGauge == null) return;
stepsGauge.post(new Runnable() {
@Override
public void run() {
FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
myAsyncTask.execute();
}
});
}
private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
dashboardData.getStepsTotal();
dashboardData.getStepsGoalFactor();
return null;
}
@Override
protected void onPostExecute(Void unused) {
super.onPostExecute(unused);
// Update text representation
stepsCount.setText(String.valueOf(dashboardData.getStepsTotal()));
// Draw gauge
stepsGauge.setImageBitmap(drawGauge(200, 15, color_activity, dashboardData.getStepsGoalFactor()));
}
}
}

View File

@ -0,0 +1,475 @@
/* Copyright (C) 2023-2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.dashboard;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.format.DateFormat;
import android.text.style.ForegroundColorSpan;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.StepAnalysis;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySession;
import nodomain.freeyourgadget.gadgetbridge.util.DashboardUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
/**
* A simple {@link AbstractDashboardWidget} subclass.
* Use the {@link DashboardTodayWidget#newInstance} factory method to
* create an instance of this fragment.
*/
public class DashboardTodayWidget extends AbstractDashboardWidget {
private static final Logger LOG = LoggerFactory.getLogger(DashboardTodayWidget.class);
private View todayView;
private ImageView todayChart;
private boolean mode_24h;
public DashboardTodayWidget() {
// Required empty public constructor
}
/**
* Use this factory method to create a new instance of
* this fragment using the provided parameters.
*
* @param dashboardData An instance of DashboardFragment.DashboardData.
* @return A new instance of fragment DashboardTodayWidget.
*/
public static DashboardTodayWidget newInstance(DashboardFragment.DashboardData dashboardData) {
DashboardTodayWidget fragment = new DashboardTodayWidget();
Bundle args = new Bundle();
args.putSerializable(ARG_DASHBOARD_DATA, dashboardData);
fragment.setArguments(args);
return fragment;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
todayView = inflater.inflate(R.layout.dashboard_widget_today, container, false);
todayChart = todayView.findViewById(R.id.dashboard_today_chart);
// Determine whether to draw a single or a double chart. In case 24h mode is selected,
// use just the outer chart (chart_12_24) for all data.
Prefs prefs = GBApplication.getPrefs();
mode_24h = prefs.getBoolean("dashboard_widget_today_24h", false);
// Initialize legend
TextView legend = todayView.findViewById(R.id.dashboard_piechart_legend);
SpannableString l_not_worn = new SpannableString("" + getString(R.string.abstract_chart_fragment_kind_not_worn));
l_not_worn.setSpan(new ForegroundColorSpan(color_not_worn), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_worn = new SpannableString("" + getString(R.string.activity_type_worn));
l_worn.setSpan(new ForegroundColorSpan(color_worn), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_activity = new SpannableString("" + getString(R.string.activity_type_activity));
l_activity.setSpan(new ForegroundColorSpan(color_activity), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_exercise = new SpannableString("" + getString(R.string.activity_type_exercise));
l_exercise.setSpan(new ForegroundColorSpan(color_exercise), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_deep_sleep = new SpannableString("" + getString(R.string.activity_type_deep_sleep));
l_deep_sleep.setSpan(new ForegroundColorSpan(color_deep_sleep), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_light_sleep = new SpannableString("" + getString(R.string.activity_type_light_sleep));
l_light_sleep.setSpan(new ForegroundColorSpan(color_light_sleep), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableString l_rem_sleep = new SpannableString("" + getString(R.string.abstract_chart_fragment_kind_rem_sleep));
l_rem_sleep.setSpan(new ForegroundColorSpan(color_rem_sleep), 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
SpannableStringBuilder legendBuilder = new SpannableStringBuilder();
legend.setText(legendBuilder.append(l_not_worn).append(" ").append(l_worn).append("\n").append(l_activity).append(" ").append(l_exercise).append("\n").append(l_light_sleep).append(" ").append(l_deep_sleep).append(" ").append(l_rem_sleep));
legend.setVisibility(prefs.getBoolean("dashboard_widget_today_legend", true) ? View.VISIBLE : View.GONE);
if (dashboardData.generalizedActivities.isEmpty()) {
fillData();
} else {
draw();
}
return todayView;
}
@Override
public void onResume() {
super.onResume();
if (todayChart != null) fillData();
}
private void draw() {
// Prepare circular chart
long midDaySecond = dashboardData.timeFrom + (12 * 60 * 60);
int width = 500;
int height = 500;
int barWidth = 40;
int hourTextSp = 12;
float hourTextPixels = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, hourTextSp, requireContext().getResources().getDisplayMetrics());
float outerCircleMargin = mode_24h ? barWidth / 2f : barWidth / 2f + hourTextPixels * 1.3f;
float innerCircleMargin = outerCircleMargin + barWidth * 1.3f;
float degreeFactor = mode_24h ? 240 : 120;
Bitmap todayBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(todayBitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.STROKE);
// Draw clock stripes
float clockMargin = outerCircleMargin + (mode_24h ? barWidth : barWidth*2.3f);
int clockStripesInterval = mode_24h ? 15 : 30;
float clockStripesWidth = barWidth / 3f;
paint.setStrokeWidth(clockStripesWidth);
paint.setColor(color_worn);
for (int i=0; i<360; i+=clockStripesInterval) {
canvas.drawArc(clockMargin, clockMargin, width - clockMargin, height - clockMargin, i, 1, false, paint);
}
// Draw hours
boolean normalClock = DateFormat.is24HourFormat(getContext());
Map<Integer, String> hours = new HashMap<Integer, String>() {
{
put(3, "3");
put(6, normalClock ? "6" : "6am");
put(9, "9");
put(12, normalClock ? "12" : "12pm");
put(15, normalClock ? "15" : "3");
put(18, normalClock ? "18" : "6pm");
put(21, normalClock ? "21" : "9");
put(24, normalClock ? "24" : "12am");
}
};
Paint textPaint = new Paint();
textPaint.setAntiAlias(true);
textPaint.setColor(color_worn);
textPaint.setTextSize(hourTextPixels);
textPaint.setTextAlign(Paint.Align.CENTER);
Rect textBounds = new Rect();
if (mode_24h) {
textPaint.getTextBounds(hours.get(6), 0, hours.get(6).length(), textBounds);
canvas.drawText(hours.get(6), width - (clockMargin + clockStripesWidth + textBounds.width()), height / 2f + textBounds.height() / 2f, textPaint);
textPaint.getTextBounds(hours.get(12), 0, hours.get(12).length(), textBounds);
canvas.drawText(hours.get(12), width / 2f, height - (clockMargin + clockStripesWidth), textPaint);
textPaint.getTextBounds(hours.get(18), 0, hours.get(18).length(), textBounds);
canvas.drawText(hours.get(18), clockMargin + clockStripesWidth + textBounds.width() / 2f, height / 2f + textBounds.height() / 2f, textPaint);
textPaint.getTextBounds(hours.get(24), 0, hours.get(24).length(), textBounds);
canvas.drawText(hours.get(24), width / 2f, clockMargin + clockStripesWidth + textBounds.height(), textPaint);
} else {
textPaint.getTextBounds(hours.get(3), 0, hours.get(3).length(), textBounds);
canvas.drawText(hours.get(3), width - (clockMargin + clockStripesWidth + textBounds.width()), height / 2f + textBounds.height() / 2f, textPaint);
textPaint.getTextBounds(hours.get(6), 0, hours.get(6).length(), textBounds);
canvas.drawText(hours.get(6), width / 2f, height - (clockMargin + clockStripesWidth), textPaint);
textPaint.getTextBounds(hours.get(9), 0, hours.get(9).length(), textBounds);
canvas.drawText(hours.get(9), clockMargin + clockStripesWidth + textBounds.width() / 2f, height / 2f + textBounds.height() / 2f, textPaint);
textPaint.getTextBounds(hours.get(12), 0, hours.get(12).length(), textBounds);
canvas.drawText(hours.get(12), width / 2f, clockMargin + clockStripesWidth + textBounds.height(), textPaint);
textPaint.getTextBounds(hours.get(15), 0, hours.get(15).length(), textBounds);
canvas.drawText(hours.get(15), (float) (width - Math.ceil(textBounds.width() / 2f)), height / 2f + textBounds.height() / 2f, textPaint);
textPaint.getTextBounds(hours.get(18), 0, hours.get(18).length(), textBounds);
canvas.drawText(hours.get(18), width / 2f, height - textBounds.height() / 2f, textPaint);
textPaint.setTextAlign(Paint.Align.LEFT);
textPaint.getTextBounds(hours.get(21), 0, hours.get(21).length(), textBounds);
canvas.drawText(hours.get(21), 1, height / 2f + textBounds.height() / 2f, textPaint);
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.getTextBounds(hours.get(24), 0, hours.get(24).length(), textBounds);
canvas.drawText(hours.get(24), width / 2f, textBounds.height(), textPaint);
}
// Draw generalized activities on circular chart
long secondIndex = dashboardData.timeFrom;
long currentTime = Calendar.getInstance().getTimeInMillis() / 1000;
synchronized (dashboardData.generalizedActivities) {
for (DashboardFragment.DashboardData.GeneralizedActivity activity : dashboardData.generalizedActivities) {
// Determine margin depending on 24h/12h mode
float margin = (mode_24h || activity.timeFrom >= midDaySecond) ? outerCircleMargin : innerCircleMargin;
// Draw inactive slices
if (!mode_24h && secondIndex < midDaySecond && activity.timeFrom >= midDaySecond) {
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (midDaySecond - secondIndex) / degreeFactor, false, paint);
secondIndex = midDaySecond;
}
if (activity.timeFrom > secondIndex) {
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(margin, margin, width - margin, height - margin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (activity.timeFrom - secondIndex) / degreeFactor, false, paint);
}
float start_angle = 270 + (activity.timeFrom - dashboardData.timeFrom) / degreeFactor;
float sweep_angle = (activity.timeTo - activity.timeFrom) / degreeFactor;
if (activity.activityKind == ActivityKind.TYPE_NOT_MEASURED) {
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_worn);
canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
} else if (activity.activityKind == ActivityKind.TYPE_NOT_WORN) {
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_not_worn);
canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
} else if (activity.activityKind == ActivityKind.TYPE_LIGHT_SLEEP || activity.activityKind == ActivityKind.TYPE_SLEEP) {
paint.setStrokeWidth(barWidth);
paint.setColor(color_light_sleep);
canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
} else if (activity.activityKind == ActivityKind.TYPE_REM_SLEEP) {
paint.setStrokeWidth(barWidth);
paint.setColor(color_rem_sleep);
canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
} else if (activity.activityKind == ActivityKind.TYPE_DEEP_SLEEP) {
paint.setStrokeWidth(barWidth);
paint.setColor(color_deep_sleep);
canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
} else if (activity.activityKind == ActivityKind.TYPE_EXERCISE) {
paint.setStrokeWidth(barWidth);
paint.setColor(color_exercise);
canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
} else {
paint.setStrokeWidth(barWidth);
paint.setColor(color_activity);
canvas.drawArc(margin, margin, width - margin, height - margin, start_angle, sweep_angle, false, paint);
}
secondIndex = activity.timeTo;
}
}
// Fill remaining time until current time in 12h mode before midday
if (!mode_24h && currentTime < midDaySecond) {
// Fill inner bar up until current time
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (currentTime - secondIndex) / degreeFactor, false, paint);
// Fill inner bar up until midday
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, 270 + (currentTime - dashboardData.timeFrom) / degreeFactor, (midDaySecond - currentTime) / degreeFactor, false, paint);
// Fill outer bar up until midnight
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, 0, 360, false, paint);
}
// Fill remaining time until current time in 24h mode or in 12h mode after midday
if ((mode_24h || currentTime >= midDaySecond) && currentTime < dashboardData.timeTo) {
// Fill inner bar up until midday
if (!mode_24h && secondIndex < midDaySecond) {
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (midDaySecond - secondIndex) / degreeFactor, false, paint);
secondIndex = midDaySecond;
}
// Fill outer bar up until current time
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (currentTime - secondIndex) / degreeFactor, false, paint);
// Fill outer bar up until midnight
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, 270 + (currentTime - dashboardData.timeFrom) / degreeFactor, (dashboardData.timeTo - currentTime) / degreeFactor, false, paint);
}
// Only when displaying a past day
if (secondIndex < dashboardData.timeTo && currentTime > dashboardData.timeTo) {
// Fill outer bar up until midnight
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (dashboardData.timeTo - secondIndex) / degreeFactor, false, paint);
}
todayChart.setImageBitmap(todayBitmap);
}
protected void fillData() {
if (todayView == null) return;
todayView.post(new Runnable() {
@Override
public void run() {
FillDataAsyncTask myAsyncTask = new FillDataAsyncTask();
myAsyncTask.execute();
}
});
}
private class FillDataAsyncTask extends AsyncTask<Void, Void, Void> {
private final TreeMap<Long, Integer> activityTimestamps = new TreeMap<>();
private void addActivity(long timeFrom, long timeTo, int activityKind) {
for (long i = timeFrom; i<=timeTo; i++) {
// If the current timestamp isn't saved yet, do so immediately
if (activityTimestamps.get(i) == null) {
activityTimestamps.put(i, activityKind);
continue;
}
// If the current timestamp is already saved, compare the activity kinds and
// keep the most 'important' one
switch (activityTimestamps.get(i)) {
case ActivityKind.TYPE_EXERCISE:
break;
case ActivityKind.TYPE_ACTIVITY:
if (activityKind == ActivityKind.TYPE_EXERCISE)
activityTimestamps.put(i, activityKind);
break;
case ActivityKind.TYPE_DEEP_SLEEP:
if (activityKind == ActivityKind.TYPE_EXERCISE ||
activityKind == ActivityKind.TYPE_ACTIVITY)
activityTimestamps.put(i, activityKind);
break;
case ActivityKind.TYPE_LIGHT_SLEEP:
if (activityKind == ActivityKind.TYPE_EXERCISE ||
activityKind == ActivityKind.TYPE_ACTIVITY ||
activityKind == ActivityKind.TYPE_DEEP_SLEEP)
activityTimestamps.put(i, activityKind);
break;
case ActivityKind.TYPE_REM_SLEEP:
if (activityKind == ActivityKind.TYPE_EXERCISE ||
activityKind == ActivityKind.TYPE_ACTIVITY ||
activityKind == ActivityKind.TYPE_DEEP_SLEEP ||
activityKind == ActivityKind.TYPE_LIGHT_SLEEP)
activityTimestamps.put(i, activityKind);
break;
case ActivityKind.TYPE_SLEEP:
if (activityKind == ActivityKind.TYPE_EXERCISE ||
activityKind == ActivityKind.TYPE_ACTIVITY ||
activityKind == ActivityKind.TYPE_DEEP_SLEEP ||
activityKind == ActivityKind.TYPE_LIGHT_SLEEP ||
activityKind == ActivityKind.TYPE_REM_SLEEP)
activityTimestamps.put(i, activityKind);
break;
default:
activityTimestamps.put(i, activityKind);
break;
}
}
}
private void calculateWornSessions(List<ActivitySample> samples) {
int firstTimestamp = 0;
int lastTimestamp = 0;
for (ActivitySample sample : samples) {
if (sample.getHeartRate() < 10 && firstTimestamp == 0) continue;
if (firstTimestamp == 0) firstTimestamp = sample.getTimestamp();
if (lastTimestamp == 0) lastTimestamp = sample.getTimestamp();
if ((sample.getHeartRate() < 10 || sample.getTimestamp() > lastTimestamp + dashboardData.hrIntervalSecs) && firstTimestamp != lastTimestamp) {
LOG.info("Registered worn session from " + firstTimestamp + " to " + lastTimestamp);
addActivity(firstTimestamp, lastTimestamp, ActivityKind.TYPE_NOT_MEASURED);
if (sample.getHeartRate() < 10) {
firstTimestamp = 0;
lastTimestamp = 0;
} else {
firstTimestamp = sample.getTimestamp();
lastTimestamp = sample.getTimestamp();
}
continue;
}
lastTimestamp = sample.getTimestamp();
}
if (firstTimestamp != lastTimestamp) {
LOG.info("Registered worn session from " + firstTimestamp + " to " + lastTimestamp);
addActivity(firstTimestamp, lastTimestamp, ActivityKind.TYPE_NOT_MEASURED);
}
}
private void createGeneralizedActivities() {
DashboardFragment.DashboardData.GeneralizedActivity previous = null;
long midDaySecond = dashboardData.timeFrom + (12 * 60 * 60);
for (Map.Entry<Long, Integer> activity : activityTimestamps.entrySet()) {
long timestamp = activity.getKey();
int activityKind = activity.getValue();
if (previous == null || previous.activityKind != activityKind || (!mode_24h && timestamp == midDaySecond) || previous.timeTo < timestamp - 60) {
previous = new DashboardFragment.DashboardData.GeneralizedActivity(activityKind, timestamp, timestamp);
dashboardData.generalizedActivities.add(previous);
} else {
previous.timeTo = timestamp;
}
}
}
@Override
protected Void doInBackground(Void... params) {
// Retrieve activity data
dashboardData.generalizedActivities.clear();
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
List<ActivitySample> allActivitySamples = new ArrayList<>();
List<ActivitySession> stepSessions = new ArrayList<>();
List<BaseActivitySummary> activitySummaries = null;
try (DBHandler dbHandler = GBApplication.acquireDB()) {
for (GBDevice dev : devices) {
if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsActivityTracking()) {
List<? extends ActivitySample> activitySamples = DashboardUtils.getAllSamples(dbHandler, dev, dashboardData);
allActivitySamples.addAll(activitySamples);
StepAnalysis stepAnalysis = new StepAnalysis();
stepSessions.addAll(stepAnalysis.calculateStepSessions(activitySamples));
}
}
activitySummaries = DashboardUtils.getWorkoutSamples(dbHandler, dashboardData);
} catch (Exception e) {
LOG.warn("Could not retrieve activity amounts: ", e);
}
Collections.sort(allActivitySamples, (lhs, rhs) -> Integer.valueOf(lhs.getTimestamp()).compareTo(rhs.getTimestamp()));
// Determine worn sessions from heart rate samples
calculateWornSessions(allActivitySamples);
// Integrate various data from multiple devices
for (ActivitySample sample : allActivitySamples) {
// Handle only TYPE_NOT_WORN and TYPE_SLEEP (including variants) here
if (sample.getKind() != ActivityKind.TYPE_NOT_WORN && (sample.getKind() == ActivityKind.TYPE_NOT_MEASURED || (sample.getKind() & ActivityKind.TYPE_SLEEP) == 0))
continue;
// Add to day results
addActivity(sample.getTimestamp(), sample.getTimestamp() + 60, sample.getKind());
}
if (activitySummaries != null) {
for (BaseActivitySummary baseActivitySummary : activitySummaries) {
addActivity(baseActivitySummary.getStartTime().getTime() / 1000, baseActivitySummary.getEndTime().getTime() / 1000, ActivityKind.TYPE_EXERCISE);
}
}
for (ActivitySession session : stepSessions) {
addActivity(session.getStartTime().getTime() / 1000, session.getEndTime().getTime() / 1000, ActivityKind.TYPE_ACTIVITY);
}
createGeneralizedActivities();
return null;
}
@Override
protected void onPostExecute(Void unused) {
super.onPostExecute(unused);
try {
draw();
} catch (IllegalStateException e) {
LOG.warn("calling draw() failed: " + e.getMessage());
}
}
}
}

View File

@ -845,7 +845,7 @@ public class GBDeviceAdapterv2 extends ListAdapter<GBDevice, GBDeviceAdapterv2.V
boolean deviceConnected = device.getState() != GBDevice.State.NOT_CONNECTED;
PopupMenu menu = new PopupMenu(v.getContext(), v);
menu.inflate(R.menu.activity_controlcenterv2_device_submenu);
menu.inflate(R.menu.fragment_devices_device_submenu);
final boolean detailsShown = expandedDeviceAddress.equals(device.getAddress());
boolean showInfoIcon = device.hasDeviceInfos() && !device.isBusy();

View File

@ -37,7 +37,6 @@ import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.unknown.UnknownDeviceSupport;

View File

@ -18,7 +18,6 @@
package nodomain.freeyourgadget.gadgetbridge.model;
import android.content.Context;
import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -33,8 +32,6 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class DailyTotals {
@ -78,17 +75,17 @@ public class DailyTotals {
}
public long[] getDailyTotalsForDevice(GBDevice device, Calendar day, DBHandler handler) {
ActivityAnalysis analysis = new ActivityAnalysis();
ActivityAmounts amountsSteps;
ActivityAmounts amountsSleep;
ActivityAnalysis analysis = new ActivityAnalysis();
ActivityAmounts amountsSteps;
ActivityAmounts amountsSleep;
amountsSteps = analysis.calculateActivityAmounts(getSamplesOfDay(handler, day, 0, device));
amountsSleep = analysis.calculateActivityAmounts(getSamplesOfDay(handler, day, -12, device));
amountsSteps = analysis.calculateActivityAmounts(getSamplesOfDay(handler, day, 0, device));
amountsSleep = analysis.calculateActivityAmounts(getSamplesOfDay(handler, day, -12, device));
long[] sleep = getTotalsSleepForActivityAmounts(amountsSleep);
long steps = getTotalsStepsForActivityAmounts(amountsSteps);
long[] sleep = getTotalsSleepForActivityAmounts(amountsSleep);
long steps = getTotalsStepsForActivityAmounts(amountsSteps);
return new long[]{steps, sleep[0] + sleep[1] + sleep[2]};
return new long[]{steps, sleep[0] + sleep[1] + sleep[2]};
}
private long[] getTotalsSleepForActivityAmounts(ActivityAmounts activityAmounts) {

View File

@ -0,0 +1,191 @@
/* Copyright (C) 2024 Arjan Schrijver
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
import nodomain.freeyourgadget.gadgetbridge.activities.charts.StepAnalysis;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySession;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
import nodomain.freeyourgadget.gadgetbridge.model.DailyTotals;
public class DashboardUtils {
private static final Logger LOG = LoggerFactory.getLogger(DashboardUtils.class);
public static long getSteps(GBDevice device, DBHandler db, int timeTo) {
Calendar day = GregorianCalendar.getInstance();
day.setTimeInMillis(timeTo * 1000L);
DailyTotals ds = new DailyTotals();
return ds.getDailyTotalsForDevice(device, day, db)[0];
}
public static int getStepsTotal(DashboardFragment.DashboardData dashboardData) {
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
int totalSteps = 0;
try (DBHandler dbHandler = GBApplication.acquireDB()) {
for (GBDevice dev : devices) {
if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsActivityTracking()) {
totalSteps += getSteps(dev, dbHandler, dashboardData.timeTo);
}
}
} catch (Exception e) {
LOG.warn("Could not calculate total amount of steps: ", e);
}
return totalSteps;
}
public static float getStepsGoalFactor(DashboardFragment.DashboardData dashboardData) {
ActivityUser activityUser = new ActivityUser();
float stepsGoal = activityUser.getStepsGoal();
float goalFactor = getStepsTotal(dashboardData) / stepsGoal;
if (goalFactor > 1) goalFactor = 1;
return goalFactor;
}
public static long getSleep(GBDevice device, DBHandler db, int timeTo) {
Calendar day = GregorianCalendar.getInstance();
day.setTimeInMillis(timeTo * 1000L);
DailyTotals ds = new DailyTotals();
return ds.getDailyTotalsForDevice(device, day, db)[1];
}
public static long getSleepMinutesTotal(DashboardFragment.DashboardData dashboardData) {
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
long totalSleepMinutes = 0;
try (DBHandler dbHandler = GBApplication.acquireDB()) {
for (GBDevice dev : devices) {
if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsActivityTracking()) {
totalSleepMinutes += getSleep(dev, dbHandler, dashboardData.timeTo);
}
}
} catch (Exception e) {
LOG.warn("Could not calculate total amount of sleep: ", e);
}
return totalSleepMinutes;
}
public static float getSleepMinutesGoalFactor(DashboardFragment.DashboardData dashboardData) {
ActivityUser activityUser = new ActivityUser();
int sleepMinutesGoal = activityUser.getSleepDurationGoal() * 60;
float goalFactor = (float) getSleepMinutesTotal(dashboardData) / sleepMinutesGoal;
if (goalFactor > 1) goalFactor = 1;
return goalFactor;
}
public static float getDistanceTotal(DashboardFragment.DashboardData dashboardData) {
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
long totalSteps = 0;
try (DBHandler dbHandler = GBApplication.acquireDB()) {
for (GBDevice dev : devices) {
if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsActivityTracking()) {
totalSteps += getSteps(dev, dbHandler, dashboardData.timeTo);
}
}
} catch (Exception e) {
LOG.warn("Could not calculate total distance: ", e);
}
ActivityUser activityUser = new ActivityUser();
int stepLength = activityUser.getStepLengthCm();
return totalSteps * stepLength * 0.01f;
}
public static float getDistanceGoalFactor(DashboardFragment.DashboardData dashboardData) {
ActivityUser activityUser = new ActivityUser();
int distanceGoal = activityUser.getDistanceGoalMeters();
float goalFactor = getDistanceTotal(dashboardData) / distanceGoal;
if (goalFactor > 1) goalFactor = 1;
return goalFactor;
}
public static long getActiveMinutesTotal(DashboardFragment.DashboardData dashboardData) {
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
long totalActiveMinutes = 0;
try (DBHandler dbHandler = GBApplication.acquireDB()) {
for (GBDevice dev : devices) {
if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsActivityTracking()) {
totalActiveMinutes += getActiveMinutes(dev, dbHandler, dashboardData);
}
}
} catch (Exception e) {
LOG.warn("Could not calculate total amount of activity: ", e);
}
return totalActiveMinutes;
}
public static float getActiveMinutesGoalFactor(DashboardFragment.DashboardData dashboardData) {
ActivityUser activityUser = new ActivityUser();
int activeTimeGoal = activityUser.getActiveTimeGoalMinutes();
float goalFactor = (float) getActiveMinutesTotal(dashboardData) / activeTimeGoal;
if (goalFactor > 1) goalFactor = 1;
return goalFactor;
}
public static long getActiveMinutes(GBDevice gbDevice, DBHandler db, DashboardFragment.DashboardData dashboardData) {
ActivitySession stepSessionsSummary = new ActivitySession();
List<ActivitySession> stepSessions;
List<? extends ActivitySample> activitySamples = getAllSamples(db, gbDevice, dashboardData);
StepAnalysis stepAnalysis = new StepAnalysis();
boolean isEmptySummary = false;
if (activitySamples != null) {
stepSessions = stepAnalysis.calculateStepSessions(activitySamples);
if (stepSessions.toArray().length == 0) {
isEmptySummary = true;
}
stepSessionsSummary = stepAnalysis.calculateSummary(stepSessions, isEmptySummary);
}
long duration = stepSessionsSummary.getEndTime().getTime() - stepSessionsSummary.getStartTime().getTime();
return duration / 1000 / 60;
}
public static List<? extends ActivitySample> getAllSamples(DBHandler db, GBDevice device, DashboardFragment.DashboardData dashboardData) {
SampleProvider<? extends ActivitySample> provider = getProvider(db, device);
return provider.getAllActivitySamples(dashboardData.timeFrom, dashboardData.timeTo);
}
protected static SampleProvider<? extends AbstractActivitySample> getProvider(DBHandler db, GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
return coordinator.getSampleProvider(device, db.getDaoSession());
}
public static List<BaseActivitySummary> getWorkoutSamples(DBHandler db, DashboardFragment.DashboardData dashboardData) {
return db.getDaoSession().getBaseActivitySummaryDao().queryBuilder().where(
BaseActivitySummaryDao.Properties.StartTime.gt(new Date(dashboardData.timeFrom * 1000L)),
BaseActivitySummaryDao.Properties.EndTime.lt(new Date(dashboardData.timeTo * 1000L))
).build().list();
}
}

View File

@ -21,7 +21,6 @@ import android.text.format.DateUtils;
import com.github.pfichtner.durationformatter.DurationFormatter;
import java.io.IOException;
import java.text.FieldPosition;
import java.text.ParseException;
import java.text.ParsePosition;
@ -206,12 +205,9 @@ public class DateTimeUtils {
* @param days
*/
public static int shiftDays(int time, int days) {
int newTime = time + ((24 * 3600) - 1) * days;
Calendar day = Calendar.getInstance();
day.setTimeInMillis(newTime * 1000L);
day.set(Calendar.HOUR_OF_DAY, 0);
day.set(Calendar.MINUTE, 0);
day.set(Calendar.SECOND, 0);
day.setTimeInMillis(time * 1000L);
day.add(Calendar.DAY_OF_YEAR, days);
return (int) (day.getTimeInMillis() / 1000);
}
@ -225,4 +221,28 @@ public class DateTimeUtils {
return (int) TimeUnit.MILLISECONDS.toDays((time2 - time1) * 1000L);
}
/**
* Determine whether two Calendar instances are on the same day
*
* @param calendar1 The first calendar to compare
* @param calendar2 The second calendar to compare
* @return true if the Calendar instances are on the same day
*/
public static boolean isSameDay(Calendar calendar1, Calendar calendar2) {
return calendar1.get(Calendar.YEAR) == calendar2.get(Calendar.YEAR)
&& calendar1.get(Calendar.MONTH) == calendar2.get(Calendar.MONTH)
&& calendar1.get(Calendar.DAY_OF_MONTH) == calendar2.get(Calendar.DAY_OF_MONTH);
}
/**
* Determine whether two Calendar instances are in the same month
*
* @param calendar1 The first calendar to compare
* @param calendar2 The second calendar to compare
* @return true if the Calendar instances are in the same month
*/
public static boolean isSameMonth(Calendar calendar1, Calendar calendar2) {
return calendar1.get(Calendar.YEAR) == calendar2.get(Calendar.YEAR)
&& calendar1.get(Calendar.MONTH) == calendar2.get(Calendar.MONTH);
}
}

View File

@ -104,4 +104,12 @@ public class GBChangeLog extends ChangeLog {
return builder.create();
}
public static GBChangeLog createChangeLog(Context context) {
String css = GBChangeLog.DEFAULT_CSS;
css += "body { "
+ "color: " + AndroidUtils.getTextColorHex(context) + "; "
+ "}";
return new GBChangeLog(context, css);
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#7E7E7E"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#000000"
android:pathData="M3,13h8L11,3L3,3v10zM3,21h8v-6L3,15v6zM13,21h8L21,11h-8v10zM13,3v6h8L21,3h-8z" />
</vector>

View File

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
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"
android:fitsSystemWindows="true"
tools:context="nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimaryDark" />
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/activity_controlcenterv2_content_main" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
android:layout_gravity="bottom|end"
app:srcCompat="@drawable/ic_add"
android:layout_margin="16dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,48 @@
<android.widget.LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".activities.dashboard.DashboardCalendarActivity">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/calendar_month"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/calendar_month"
android:textStyle="bold"
android:textSize="30sp" />
<TextView
android:id="@+id/arrow_left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u003C"
android:textStyle="bold"
android:textSize="40sp"
android:paddingHorizontal="8dp"
android:layout_toLeftOf="@+id/arrow_right" />
<TextView
android:id="@+id/arrow_right"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u003E"
android:textStyle="bold"
android:textSize="40sp"
android:paddingHorizontal="8dp"
android:layout_alignParentEnd="true"/>
</RelativeLayout>
<androidx.gridlayout.widget.GridLayout
android:id="@+id/dashboard_calendar_grid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
app:columnCount="7" />
</android.widget.LinearLayout>

View File

@ -6,10 +6,11 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:openDrawer="start">
tools:openDrawer="start"
tools:context=".activities.ControlCenterv2">
<include
layout="@layout/activity_controlcenterv2_app_bar_main"
layout="@layout/activity_main_app_bar"
android:layout_width="match_parent"
android:layout_height="match_parent" />
@ -19,7 +20,7 @@
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header_main"
app:menu="@menu/activity_controlcenterv2_main_drawer" />
app:headerLayout="@layout/main_drawer_header"
app:menu="@menu/activity_main_drawer" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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"
android:fitsSystemWindows="true"
tools:context=".activities.ControlCenterv2">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbarlayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimaryDark" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/main"
app:defaultNavHost="true"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/appbarlayout"
app:layout_constraintBottom_toTopOf="@id/bottom_nav_bar" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="0dp"
android:layout_marginEnd="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:menu="@menu/bottom_nav_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:gravity="center_vertical"
tools:context=".activities.dashboard.DashboardActiveTimeWidget">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical"
android:id="@+id/card_layout">
<ImageView
android:layout_width="150dp"
android:layout_height="75dp"
android:layout_centerHorizontal="true"
android:scaleType="fitStart"
android:id="@+id/activetime_gauge" />
<TextView
android:id="@+id/activetime_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_centerHorizontal="true"
android:text="0:00"
android:textSize="30dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/activetime_text"
android:layout_centerHorizontal="true"
android:text="@string/activity_list_summary_active_time" />
</RelativeLayout>
</LinearLayout>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:gravity="center_vertical"
tools:context=".activities.dashboard.DashboardDistanceWidget">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical"
android:id="@+id/card_layout">
<ImageView
android:layout_width="150dp"
android:layout_height="75dp"
android:layout_centerHorizontal="true"
android:scaleType="fitStart"
android:id="@+id/distance_gauge" />
<TextView
android:id="@+id/distance_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_centerHorizontal="true"
android:text="0.0km"
android:textSize="30dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/distance_text"
android:layout_centerHorizontal="true"
android:text="@string/distance" />
</RelativeLayout>
</LinearLayout>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center_vertical"
tools:context=".activities.dashboard.DashboardGoalsWidget">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/dashboard_goals_chart"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="15dp"
android:layout_marginVertical="5dp"
android:gravity="center"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:scaleType="fitXY" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/dashboard_goals_legend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:textAlignment="center" />
</LinearLayout>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:gravity="center_vertical"
tools:context=".activities.dashboard.DashboardSleepWidget">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical"
android:id="@+id/card_layout">
<ImageView
android:layout_width="150dp"
android:layout_height="75dp"
android:layout_centerHorizontal="true"
android:scaleType="fitStart"
android:id="@+id/sleep_gauge" />
<TextView
android:id="@+id/sleep_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_centerHorizontal="true"
android:text="0:00"
android:textSize="30dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/sleep_text"
android:layout_centerHorizontal="true"
android:text="@string/menuitem_sleep" />
</RelativeLayout>
</LinearLayout>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:gravity="center_vertical"
tools:context=".activities.dashboard.DashboardStepsWidget">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="vertical"
android:id="@+id/card_layout">
<ImageView
android:layout_width="150dp"
android:layout_height="75dp"
android:layout_centerHorizontal="true"
android:scaleType="fitStart"
android:id="@+id/steps_gauge" />
<TextView
android:id="@+id/steps_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_centerHorizontal="true"
android:text="0"
android:textSize="30dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/steps_count"
android:layout_centerHorizontal="true"
android:text="@string/steps" />
</RelativeLayout>
</LinearLayout>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center_vertical"
tools:context=".activities.dashboard.DashboardTodayWidget">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/dashboard_today_chart"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="15dp"
android:layout_marginVertical="5dp"
android:gravity="center"
app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:scaleType="fitXY" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/dashboard_piechart_legend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:textAlignment="center" />
</LinearLayout>

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
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"
tools:context=".activities.DashboardFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/dashboard_swipe_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/dashboard_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/activity_summary_today"
android:textStyle="bold"
android:textSize="30sp" />
<TextView
android:id="@+id/arrow_left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u003C"
android:textStyle="bold"
android:textSize="40sp"
android:paddingHorizontal="8dp"
android:layout_toLeftOf="@+id/arrow_right" />
<TextView
android:id="@+id/arrow_right"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="\u003E"
android:textStyle="bold"
android:textSize="40sp"
android:paddingHorizontal="8dp"
android:layout_alignParentEnd="true"/>
</RelativeLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.gridlayout.widget.GridLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/dashboard_gridlayout"
app:columnCount="2" />
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</RelativeLayout>

View File

@ -6,9 +6,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:context="nodomain.freeyourgadget.gadgetbridge.activities.ControlCenterv2"
tools:showIn="@layout/activity_controlcenterv2_app_bar_main">
tools:context=".activities.DevicesFragment">
<ImageView
android:id="@+id/no_items_bg"
@ -26,4 +24,13 @@
android:layout_centerHorizontal="true"
android:divider="@null" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
app:srcCompat="@drawable/ic_add"
android:layout_margin="16dp" />
</RelativeLayout>

View File

@ -140,4 +140,4 @@
</RelativeLayout>
</com.google.android.material.card.MaterialCardView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:title="@string/bottom_nav_dashboard"
android:id="@+id/bottom_nav_dashboard"
android:icon="@drawable/ic_dashboard"/>
<item
android:title="@string/bottom_nav_devices"
android:id="@+id/bottom_nav_devices"
android:icon="@drawable/ic_devices_other"/>
</menu>

View File

@ -0,0 +1,12 @@
<menu 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"
tools:context=".activities.DashboardFragment">
<item
android:id="@+id/dashboard_show_calendar"
android:icon="@drawable/ic_calendar_from"
android:title="@string/menuitem_calendar"
app:iconTint="?attr/actionmenu_icon_color"
app:showAsAction="ifRoom" />
</menu>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation 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/main"
app:startDestination="@id/bottom_nav_dashboard">
<fragment
android:id="@+id/bottom_nav_dashboard"
android:name="nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment"
android:label="fragment_dashboard"
tools:layout="@layout/fragment_dashboard">
<action
android:id="@+id/dashboard_to_devices"
app:destination="@id/bottom_nav_devices" />
</fragment>
<fragment
android:id="@+id/bottom_nav_devices"
android:name="nodomain.freeyourgadget.gadgetbridge.activities.DevicesFragment"
android:label="fragment_devices"
tools:layout="@layout/fragment_devices">
<action
android:id="@+id/devices_to_dashboard"
app:destination="@id/bottom_nav_dashboard" />
</fragment>
</navigation>

View File

@ -3843,4 +3843,22 @@
<item>@string/pref_force_connection_type_ble_value</item>
<item>@string/pref_force_connection_type_bt_classic_value</item>
</string-array>
<string-array name="pref_dashboard_widgets_order">
<item>@string/pref_dashboard_widget_today_title</item>
<item>@string/pref_dashboard_widget_goals_chart_title</item>
<item>@string/steps</item>
<item>@string/distance</item>
<item>@string/active_time</item>
<item>@string/menuitem_sleep</item>
</string-array>
<string-array name="pref_dashboard_widgets_order_values">
<item>today</item>
<item>goals</item>
<item>steps</item>
<item>distance</item>
<item>activetime</item>
<item>sleep</item>
</string-array>
</resources>

View File

@ -2417,7 +2417,7 @@
<string name="huawei_reparse_workout_data">"Reparse workout data"</string>
<string name="huawei_reparse_workout_data_description">"This will only do something after certain updates"</string>
<string name="info_no_devices_connected">no devices connected</string>
<string name="info_no_devices_connected">No devices connected</string>
<string name="info_connected_count">%d devices connected</string>
<string name="controlcenter_set_parent_folder">Set parent folder</string>
<string name="controlcenter_set_preferences">Set preferences</string>
@ -2748,4 +2748,30 @@
<string name="devicesetting_scannable_debounce_summary">After being scanned, the device will stick as scanned and ignored for the specified amount of time</string>
<string name="devicesetting_scannable_minimum_unseen_summary">After being scanned, the device has to be unseen for this amount of time before being registered again</string>
<string name="devicesetting_scannable_rssi_summary">The minimum RSSI threshold for detection</string>
<string name="error_showing_changelog">Error showing Changelog</string>
<string name="activity_type_worn">Worn</string>
<string name="dashboard_settings">Dashboard settings</string>
<string name="bottom_nav_dashboard">Dashboard</string>
<string name="bottom_nav_devices">Devices</string>
<string name="pref_dashboard_first_title">Show dashboard first</string>
<string name="pref_dashboard_first_summary">Show the dashboard when Gadgetbridge starts, instead of the devices screen</string>
<string name="pref_dashboard_cards_title">Show widgets on cards</string>
<string name="pref_dashboard_cards_summary">Draw cards around the widgets on the dashboard</string>
<string name="pref_dashboard_widget_settings">Widget settings</string>
<string name="pref_dashboard_widget_today_title">Activity chart</string>
<string name="pref_dashboard_widget_today_24h_title">24h mode</string>
<string name="pref_dashboard_widget_double_size_title">Double size</string>
<string name="pref_dashboard_widget_show_legend_title">Show legend</string>
<string name="pref_dashboard_widget_goals_chart_title">Goals chart</string>
<string name="pref_dashboard_devices_to_include">Devices to include</string>
<string name="pref_dashboard_all_devices_title">All devices</string>
<string name="pref_dashboard_select_devices_title">Select devices...</string>
<string name="pref_dashboard_widgets_order_summary">Select which widgets are enabled and in what order they are displayed on the dashboard</string>
<string name="pref_dashboard_widget_today_24h_summary">Show the activity in a single 24h circle instead of a double 12h circle</string>
<string name="pref_dashboard_widget_double_size_summary">Allow the widget to take up two columns on the dashboard</string>
<string name="pref_dashboard_widget_show_legend_summary">Show a legend below the widget explaining the colors</string>
<string name="pref_dashboard_all_devices_summary">Combine activity data from all added devices for the totals on the dashboard</string>
<string name="pref_dashboard_select_devices_summary">Combine activity data from specific devices for the totals on the dashboard</string>
<string name="pref_dashboard_widget_today_hr_interval_title">Heart rate interval</string>
<string name="pref_dashboard_widget_today_hr_interval_summary">The amount of minutes the chart shows \'worn\' after each successful heart rate measurement</string>
</resources>

View File

@ -145,6 +145,7 @@
</style>
<style name="GadgetbridgeThemeDynamic.ActionBar" parent="Widget.Material3.ActionBar.Solid">
<item name="background">?attr/colorSurface</item>
<item name="elevation">0dp</item>
</style>
<style name="GadgetbridgeTheme.DrawerButtonStyle" parent="@style/Widget.AppCompat.DrawerArrowToggle">
<item name="spinBars">true</item>

View File

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:key="pref_key_dashboard_general"
android:title="@string/pref_header_general"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dashboard_as_default_view"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_dashboard_first_title"
android:summary="@string/pref_dashboard_first_summary"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dashboard_cards_enabled"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_dashboard_cards_title"
android:summary="@string/pref_dashboard_cards_summary"
app:iconSpaceReserved="false" />
<com.mobeta.android.dslv.DragSortListPreference
android:defaultValue="@array/pref_dashboard_widgets_order_values"
android:dialogTitle="@string/menuitem_widgets"
android:entries="@array/pref_dashboard_widgets_order"
android:entryValues="@array/pref_dashboard_widgets_order_values"
android:key="pref_dashboard_widgets_order"
android:persistent="true"
android:summary="@string/pref_dashboard_widgets_order_summary"
android:title="@string/menuitem_widgets"
app:iconSpaceReserved="false" />
</PreferenceCategory>
<PreferenceCategory
android:key="pref_key_dashboard_widgets"
android:title="@string/pref_dashboard_widget_settings"
app:iconSpaceReserved="false">
<PreferenceScreen
android:key="pref_key_dashboard_today"
android:title="@string/pref_dashboard_widget_today_title"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="dashboard_widget_today_24h"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_dashboard_widget_today_24h_title"
android:summary="@string/pref_dashboard_widget_today_24h_summary"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dashboard_widget_today_2columns"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_dashboard_widget_double_size_title"
android:summary="@string/pref_dashboard_widget_double_size_summary"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dashboard_widget_today_legend"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_dashboard_widget_show_legend_title"
android:summary="@string/pref_dashboard_widget_show_legend_summary"
app:iconSpaceReserved="false" />
<EditTextPreference
android:defaultValue="1"
android:inputType="number"
android:key="dashboard_widget_today_hr_interval"
android:maxLength="4"
android:summary="@string/pref_dashboard_widget_today_hr_interval_summary"
android:title="@string/pref_dashboard_widget_today_hr_interval_title"
app:iconSpaceReserved="false" />
</PreferenceScreen>
<PreferenceScreen
android:key="pref_key_dashboard_goals"
android:title="@string/pref_dashboard_widget_goals_chart_title"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dashboard_widget_goals_2columns"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_dashboard_widget_double_size_title"
android:summary="@string/pref_dashboard_widget_double_size_summary"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dashboard_widget_goals_legend"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_dashboard_widget_show_legend_title"
android:summary="@string/pref_dashboard_widget_show_legend_summary"
app:iconSpaceReserved="false" />
</PreferenceScreen>
</PreferenceCategory>
<PreferenceCategory
android:key="pref_key_dashboard_devices"
android:title="@string/pref_dashboard_devices_to_include"
app:iconSpaceReserved="false">
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dashboard_devices_all"
android:layout="@layout/preference_checkbox"
android:title="@string/pref_dashboard_all_devices_title"
android:summary="@string/pref_dashboard_all_devices_summary"
android:disableDependentsState="true"
app:iconSpaceReserved="false" />
<MultiSelectListPreference
android:dependency="dashboard_devices_all"
android:dialogTitle="@string/pref_dashboard_select_devices_title"
android:entries="@array/empty_array"
android:entryValues="@array/empty_array"
android:key="dashboard_devices_multiselect"
android:title="@string/pref_dashboard_select_devices_title"
android:summary="@string/pref_dashboard_select_devices_summary"
app:iconSpaceReserved="false" />
</PreferenceCategory>
</PreferenceScreen>

View File

@ -58,6 +58,11 @@
android:title="@string/pref_title_audio_player"
app:iconSpaceReserved="false" />
<Preference
android:key="pref_category_dashboard"
android:title="@string/bottom_nav_dashboard"
app:iconSpaceReserved="false" />
<PreferenceScreen
android:key="pref_screen_theme"
android:title="@string/pref_title_theme"