From 43fddd01102e2bea0eb59567e826a751a29fe310 Mon Sep 17 00:00:00 2001 From: Arjan Schrijver Date: Thu, 4 Apr 2024 19:28:04 +0000 Subject: [PATCH] Dashboard view (#3478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Arjan Schrijver Co-committed-by: Arjan Schrijver --- app/build.gradle | 6 +- app/src/main/AndroidManifest.xml | 9 + .../freeyourgadget/gadgetbridge/Widget.java | 1 - .../activities/ControlCenterv2.java | 272 +++------- .../activities/DashboardFragment.java | 479 ++++++++++++++++++ .../DashboardPreferencesActivity.java | 101 ++++ .../activities/DevicesFragment.java | 251 +++++++++ .../activities/SettingsActivity.java | 13 + .../dashboard/AbstractDashboardWidget.java | 92 ++++ .../dashboard/DashboardActiveTimeWidget.java | 113 +++++ .../dashboard/DashboardCalendarActivity.java | 262 ++++++++++ .../dashboard/DashboardDistanceWidget.java | 113 +++++ .../dashboard/DashboardGoalsWidget.java | 171 +++++++ .../dashboard/DashboardSleepWidget.java | 113 +++++ .../dashboard/DashboardStepsWidget.java | 109 ++++ .../dashboard/DashboardTodayWidget.java | 475 +++++++++++++++++ .../adapter/GBDeviceAdapterv2.java | 2 +- .../devices/UnknownDeviceCoordinator.java | 1 - .../gadgetbridge/model/DailyTotals.java | 19 +- .../gadgetbridge/util/DashboardUtils.java | 191 +++++++ .../gadgetbridge/util/DateTimeUtils.java | 32 +- .../gadgetbridge/util/GBChangeLog.java | 8 + app/src/main/res/drawable/ic_dashboard.xml | 10 + .../activity_controlcenterv2_app_bar_main.xml | 36 -- .../layout/activity_dashboard_calendar.xml | 48 ++ ..._controlcenterv2.xml => activity_main.xml} | 9 +- .../main/res/layout/activity_main_app_bar.xml | 44 ++ .../layout/dashboard_widget_active_time.xml | 42 ++ .../res/layout/dashboard_widget_distance.xml | 42 ++ .../res/layout/dashboard_widget_goals.xml | 35 ++ .../res/layout/dashboard_widget_sleep.xml | 42 ++ .../res/layout/dashboard_widget_steps.xml | 42 ++ .../res/layout/dashboard_widget_today.xml | 35 ++ .../main/res/layout/fragment_dashboard.xml | 64 +++ ..._content_main.xml => fragment_devices.xml} | 13 +- app/src/main/res/layout/item_alarm.xml | 2 +- ...header_main.xml => main_drawer_header.xml} | 0 ...in_drawer.xml => activity_main_drawer.xml} | 0 app/src/main/res/menu/bottom_nav_menu.xml | 13 + app/src/main/res/menu/dashboard_menu.xml | 12 + ...ml => fragment_devices_device_submenu.xml} | 0 app/src/main/res/navigation/main.xml | 26 + app/src/main/res/values/arrays.xml | 18 + app/src/main/res/values/strings.xml | 28 +- app/src/main/res/values/styles.xml | 1 + .../main/res/xml/dashboard_preferences.xml | 115 +++++ app/src/main/res/xml/preferences.xml | 5 + 47 files changed, 3235 insertions(+), 280 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardPreferencesActivity.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardWidget.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardActiveTimeWidget.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCalendarActivity.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardDistanceWidget.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardGoalsWidget.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepWidget.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStepsWidget.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardTodayWidget.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DashboardUtils.java create mode 100644 app/src/main/res/drawable/ic_dashboard.xml delete mode 100644 app/src/main/res/layout/activity_controlcenterv2_app_bar_main.xml create mode 100644 app/src/main/res/layout/activity_dashboard_calendar.xml rename app/src/main/res/layout/{activity_controlcenterv2.xml => activity_main.xml} (78%) create mode 100644 app/src/main/res/layout/activity_main_app_bar.xml create mode 100644 app/src/main/res/layout/dashboard_widget_active_time.xml create mode 100644 app/src/main/res/layout/dashboard_widget_distance.xml create mode 100644 app/src/main/res/layout/dashboard_widget_goals.xml create mode 100644 app/src/main/res/layout/dashboard_widget_sleep.xml create mode 100644 app/src/main/res/layout/dashboard_widget_steps.xml create mode 100644 app/src/main/res/layout/dashboard_widget_today.xml create mode 100644 app/src/main/res/layout/fragment_dashboard.xml rename app/src/main/res/layout/{activity_controlcenterv2_content_main.xml => fragment_devices.xml} (70%) rename app/src/main/res/layout/{nav_header_main.xml => main_drawer_header.xml} (100%) rename app/src/main/res/menu/{activity_controlcenterv2_main_drawer.xml => activity_main_drawer.xml} (100%) create mode 100644 app/src/main/res/menu/bottom_nav_menu.xml create mode 100644 app/src/main/res/menu/dashboard_menu.xml rename app/src/main/res/menu/{activity_controlcenterv2_device_submenu.xml => fragment_devices_device_submenu.xml} (100%) create mode 100644 app/src/main/res/navigation/main.xml create mode 100644 app/src/main/res/xml/dashboard_preferences.xml diff --git a/app/build.gradle b/app/build.gradle index 3ba35b00b..063944947 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cc014a9dc..54661a5b8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -128,6 +128,10 @@ android:name=".activities.SettingsActivity" android:label="@string/title_activity_settings" android:parentActivityName=".activities.ControlCenterv2" /> + + + diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Widget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Widget.java index 42e64b636..7725e5d8a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Widget.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/Widget.java @@ -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; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java index 756539e56..1296e2583 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ControlCenterv2.java @@ -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 deviceList; - private HashMap 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 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 { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java new file mode 100644 index 000000000..bbfdfc69d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardFragment.java @@ -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 . */ +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 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 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 showDeviceList; + public int hrIntervalSecs; + public int timeFrom; + public int timeTo; + public final List 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"; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardPreferencesActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardPreferencesActivity.java new file mode 100644 index 000000000..cf2744761 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DashboardPreferencesActivity.java @@ -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 . */ +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 devices = GBApplication.app().getDeviceManager().getDevices(); + List deviceMACs = new ArrayList<>(); + List 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 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); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java new file mode 100644 index 000000000..102eee042 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java @@ -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 . */ +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 deviceList; + private HashMap 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(); + } + + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java index b4f15caa2..c45be8b7f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java @@ -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"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardWidget.java new file mode 100644 index 000000000..42aaaf146 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/AbstractDashboardWidget.java @@ -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 . */ +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; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardActiveTimeWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardActiveTimeWidget.java new file mode 100644 index 000000000..4daac6b72 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardActiveTimeWidget.java @@ -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 . */ +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 { + @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())); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCalendarActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCalendarActivity.java new file mode 100644 index 000000000..29458ed95 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardCalendarActivity.java @@ -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 . */ +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 dayCells = new ConcurrentHashMap<>(); + private final ConcurrentHashMap dayColors = new ConcurrentHashMap<>(); + + private boolean showAllDevices; + private Set 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 { + @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 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(); + }); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardDistanceWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardDistanceWidget.java new file mode 100644 index 000000000..a6801c22a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardDistanceWidget.java @@ -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 . */ +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 { + @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())); + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardGoalsWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardGoalsWidget.java new file mode 100644 index 000000000..eb5a24a4b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardGoalsWidget.java @@ -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 . */ +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 { + 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); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepWidget.java new file mode 100644 index 000000000..31e9c1a53 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardSleepWidget.java @@ -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 . */ +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 { + @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())); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStepsWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStepsWidget.java new file mode 100644 index 000000000..66f37fc20 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardStepsWidget.java @@ -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 . */ +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 { + @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())); + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardTodayWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardTodayWidget.java new file mode 100644 index 000000000..646db2f9f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardTodayWidget.java @@ -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 . */ +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 hours = new HashMap() { + { + 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 { + private final TreeMap 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 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 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 devices = GBApplication.app().getDeviceManager().getDevices(); + List allActivitySamples = new ArrayList<>(); + List stepSessions = new ArrayList<>(); + List activitySummaries = null; + try (DBHandler dbHandler = GBApplication.acquireDB()) { + for (GBDevice dev : devices) { + if ((dashboardData.showAllDevices || dashboardData.showDeviceList.contains(dev.getAddress())) && dev.getDeviceCoordinator().supportsActivityTracking()) { + List 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()); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java index 455eaa649..0b7ead89d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java @@ -845,7 +845,7 @@ public class GBDeviceAdapterv2 extends ListAdapter. */ +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 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 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 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 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 stepSessions; + List 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 getAllSamples(DBHandler db, GBDevice device, DashboardFragment.DashboardData dashboardData) { + SampleProvider provider = getProvider(db, device); + return provider.getAllActivitySamples(dashboardData.timeFrom, dashboardData.timeTo); + } + + protected static SampleProvider getProvider(DBHandler db, GBDevice device) { + DeviceCoordinator coordinator = device.getDeviceCoordinator(); + return coordinator.getSampleProvider(device, db.getDaoSession()); + } + + public static List 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(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java index 8fd06f0fd..840e11652 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java @@ -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); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBChangeLog.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBChangeLog.java index 863ff7bfb..0fea1db49 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBChangeLog.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBChangeLog.java @@ -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); + } } diff --git a/app/src/main/res/drawable/ic_dashboard.xml b/app/src/main/res/drawable/ic_dashboard.xml new file mode 100644 index 000000000..be3329b05 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_controlcenterv2_app_bar_main.xml b/app/src/main/res/layout/activity_controlcenterv2_app_bar_main.xml deleted file mode 100644 index 7d2a0e143..000000000 --- a/app/src/main/res/layout/activity_controlcenterv2_app_bar_main.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_dashboard_calendar.xml b/app/src/main/res/layout/activity_dashboard_calendar.xml new file mode 100644 index 000000000..e7eb7cb34 --- /dev/null +++ b/app/src/main/res/layout/activity_dashboard_calendar.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_controlcenterv2.xml b/app/src/main/res/layout/activity_main.xml similarity index 78% rename from app/src/main/res/layout/activity_controlcenterv2.xml rename to app/src/main/res/layout/activity_main.xml index 3246e894a..451698fd0 100644 --- a/app/src/main/res/layout/activity_controlcenterv2.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -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"> @@ -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" /> diff --git a/app/src/main/res/layout/activity_main_app_bar.xml b/app/src/main/res/layout/activity_main_app_bar.xml new file mode 100644 index 000000000..3dbd9a247 --- /dev/null +++ b/app/src/main/res/layout/activity_main_app_bar.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dashboard_widget_active_time.xml b/app/src/main/res/layout/dashboard_widget_active_time.xml new file mode 100644 index 000000000..44879cc2d --- /dev/null +++ b/app/src/main/res/layout/dashboard_widget_active_time.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dashboard_widget_distance.xml b/app/src/main/res/layout/dashboard_widget_distance.xml new file mode 100644 index 000000000..eb37b1654 --- /dev/null +++ b/app/src/main/res/layout/dashboard_widget_distance.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dashboard_widget_goals.xml b/app/src/main/res/layout/dashboard_widget_goals.xml new file mode 100644 index 000000000..dade05f21 --- /dev/null +++ b/app/src/main/res/layout/dashboard_widget_goals.xml @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dashboard_widget_sleep.xml b/app/src/main/res/layout/dashboard_widget_sleep.xml new file mode 100644 index 000000000..438845c30 --- /dev/null +++ b/app/src/main/res/layout/dashboard_widget_sleep.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dashboard_widget_steps.xml b/app/src/main/res/layout/dashboard_widget_steps.xml new file mode 100644 index 000000000..e4e40e04e --- /dev/null +++ b/app/src/main/res/layout/dashboard_widget_steps.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dashboard_widget_today.xml b/app/src/main/res/layout/dashboard_widget_today.xml new file mode 100644 index 000000000..0e942aa4c --- /dev/null +++ b/app/src/main/res/layout/dashboard_widget_today.xml @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dashboard.xml b/app/src/main/res/layout/fragment_dashboard.xml new file mode 100644 index 000000000..61d90906c --- /dev/null +++ b/app/src/main/res/layout/fragment_dashboard.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_controlcenterv2_content_main.xml b/app/src/main/res/layout/fragment_devices.xml similarity index 70% rename from app/src/main/res/layout/activity_controlcenterv2_content_main.xml rename to app/src/main/res/layout/fragment_devices.xml index 6dcd9ba27..fefa3109c 100644 --- a/app/src/main/res/layout/activity_controlcenterv2_content_main.xml +++ b/app/src/main/res/layout/fragment_devices.xml @@ -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"> + + diff --git a/app/src/main/res/layout/item_alarm.xml b/app/src/main/res/layout/item_alarm.xml index 6dd427c82..c19961fa6 100644 --- a/app/src/main/res/layout/item_alarm.xml +++ b/app/src/main/res/layout/item_alarm.xml @@ -140,4 +140,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/nav_header_main.xml b/app/src/main/res/layout/main_drawer_header.xml similarity index 100% rename from app/src/main/res/layout/nav_header_main.xml rename to app/src/main/res/layout/main_drawer_header.xml diff --git a/app/src/main/res/menu/activity_controlcenterv2_main_drawer.xml b/app/src/main/res/menu/activity_main_drawer.xml similarity index 100% rename from app/src/main/res/menu/activity_controlcenterv2_main_drawer.xml rename to app/src/main/res/menu/activity_main_drawer.xml diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml new file mode 100644 index 000000000..7b71f1363 --- /dev/null +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/dashboard_menu.xml b/app/src/main/res/menu/dashboard_menu.xml new file mode 100644 index 000000000..57d1974d8 --- /dev/null +++ b/app/src/main/res/menu/dashboard_menu.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/menu/activity_controlcenterv2_device_submenu.xml b/app/src/main/res/menu/fragment_devices_device_submenu.xml similarity index 100% rename from app/src/main/res/menu/activity_controlcenterv2_device_submenu.xml rename to app/src/main/res/menu/fragment_devices_device_submenu.xml diff --git a/app/src/main/res/navigation/main.xml b/app/src/main/res/navigation/main.xml new file mode 100644 index 000000000..d33a89371 --- /dev/null +++ b/app/src/main/res/navigation/main.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index b10da12b1..654537aab 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -3843,4 +3843,22 @@ @string/pref_force_connection_type_ble_value @string/pref_force_connection_type_bt_classic_value + + + @string/pref_dashboard_widget_today_title + @string/pref_dashboard_widget_goals_chart_title + @string/steps + @string/distance + @string/active_time + @string/menuitem_sleep + + + + today + goals + steps + distance + activetime + sleep + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9a1805359..e409626cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2417,7 +2417,7 @@ "Reparse workout data" "This will only do something after certain updates" - no devices connected + No devices connected %d devices connected Set parent folder Set preferences @@ -2748,4 +2748,30 @@ After being scanned, the device will stick as scanned and ignored for the specified amount of time After being scanned, the device has to be unseen for this amount of time before being registered again The minimum RSSI threshold for detection + Error showing Changelog + Worn + Dashboard settings + Dashboard + Devices + Show dashboard first + Show the dashboard when Gadgetbridge starts, instead of the devices screen + Show widgets on cards + Draw cards around the widgets on the dashboard + Widget settings + Activity chart + 24h mode + Double size + Show legend + Goals chart + Devices to include + All devices + Select devices... + Select which widgets are enabled and in what order they are displayed on the dashboard + Show the activity in a single 24h circle instead of a double 12h circle + Allow the widget to take up two columns on the dashboard + Show a legend below the widget explaining the colors + Combine activity data from all added devices for the totals on the dashboard + Combine activity data from specific devices for the totals on the dashboard + Heart rate interval + The amount of minutes the chart shows \'worn\' after each successful heart rate measurement diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f9f011614..06cb52e2e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -145,6 +145,7 @@