diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcda90c38..41bdf43b2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -593,6 +593,10 @@ android:name=".activities.ConfigureContacts" android:label="@string/title_activity_set_contacts" android:parentActivityName=".activities.ControlCenterv2" /> + + { + final Intent intent = new Intent(getContext(), WidgetScreensListActivity.class); + intent.putExtra(GBDevice.EXTRA_DEVICE, device); + startActivity(intent); + return true; + }); + } + final Preference calendarBlacklist = findPreference("blacklist_calendars"); if (calendarBlacklist != null) { calendarBlacklist.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenDetailsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenDetailsActivity.java new file mode 100644 index 000000000..4dcff2f89 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenDetailsActivity.java @@ -0,0 +1,274 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.activities.widgets; + +import android.app.Activity; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetLayout; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPartSubtype; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetType; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class WidgetScreenDetailsActivity extends AbstractGBActivity { + private static final Logger LOG = LoggerFactory.getLogger(WidgetScreenDetailsActivity.class); + + private WidgetScreen widgetScreen; + private WidgetManager widgetManager; + + private View cardWidgetTopLeft; + private View cardWidgetTopRight; + private View cardWidgetCenter; + private View cardWidgetBotLeft; + private View cardWidgetBotRight; + + private TextView labelWidgetScreenLayout; + private TextView labelWidgetTopLeft; + private TextView labelWidgetTopRight; + private TextView labelWidgetCenter; + private TextView labelWidgetBotLeft; + private TextView labelWidgetBotRight; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_widget_screen_details); + + final GBDevice device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE); + if (device == null) { + LOG.error("device must not be null"); + finish(); + return; + } + + widgetScreen = (WidgetScreen) getIntent().getSerializableExtra(WidgetScreen.EXTRA_WIDGET_SCREEN); + if (widgetScreen == null) { + GB.toast("No widget screen provided to WidgetScreenDetailsActivity", Toast.LENGTH_LONG, GB.ERROR); + finish(); + return; + } + + widgetManager = device.getDeviceCoordinator().getWidgetManager(device); + + // Save button + final FloatingActionButton fab = findViewById(R.id.fab_save); + fab.setOnClickListener(view -> { + for (final WidgetPart part : widgetScreen.getParts()) { + if (part.getId() == null) { + GB.toast(getBaseContext().getString(R.string.widget_missing_parts), Toast.LENGTH_LONG, GB.WARN); + return; + } + } + + updateWidgetScreen(); + WidgetScreenDetailsActivity.this.setResult(Activity.RESULT_OK); + finish(); + }); + + // Layouts + final List supportedLayouts = widgetManager.getSupportedWidgetLayouts(); + final String[] layoutStrings = new String[supportedLayouts.size()]; + for (int i = 0; i < supportedLayouts.size(); i++) { + layoutStrings[i] = getBaseContext().getString(supportedLayouts.get(i).getName()); + } + final ArrayAdapter layoutAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, layoutStrings); + + final View cardLayout = findViewById(R.id.card_layout); + cardLayout.setOnClickListener(view -> { + new MaterialAlertDialogBuilder(WidgetScreenDetailsActivity.this).setAdapter(layoutAdapter, (dialogInterface, i) -> { + if (widgetScreen.getLayout() != supportedLayouts.get(i)) { + final ArrayList defaultParts = new ArrayList<>(); + for (final WidgetType widgetType : supportedLayouts.get(i).getWidgetTypes()) { + defaultParts.add(new WidgetPart(null, "", widgetType)); + } + widgetScreen.setParts(defaultParts); + } + widgetScreen.setLayout(supportedLayouts.get(i)); + updateUiFromWidget(); + }).setTitle(R.string.widget_layout).create().show(); + }); + + cardWidgetTopLeft = findViewById(R.id.card_widget_top_left); + cardWidgetTopRight = findViewById(R.id.card_widget_top_right); + cardWidgetCenter = findViewById(R.id.card_widget_center); + cardWidgetBotLeft = findViewById(R.id.card_widget_bottom_left); + cardWidgetBotRight = findViewById(R.id.card_widget_bottom_right); + + labelWidgetScreenLayout = findViewById(R.id.widget_screen_layout); + labelWidgetTopLeft = findViewById(R.id.widget_top_left); + labelWidgetTopRight = findViewById(R.id.widget_top_right); + labelWidgetCenter = findViewById(R.id.widget_center); + labelWidgetBotLeft = findViewById(R.id.widget_bottom_left); + labelWidgetBotRight = findViewById(R.id.widget_bottom_right); + + updateUiFromWidget(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // back button + // TODO confirm when exiting without saving + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void updateWidgetScreen() { + widgetManager.saveScreen(widgetScreen); + } + + @Override + protected void onSaveInstanceState(@NonNull final Bundle state) { + super.onSaveInstanceState(state); + state.putSerializable("widgetScreen", widgetScreen); + } + + @Override + protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + widgetScreen = (WidgetScreen) savedInstanceState.getSerializable("widgetScreen"); + updateUiFromWidget(); + } + + private void updateUiFromWidget() { + labelWidgetScreenLayout.setText(widgetScreen.getLayout().getName()); + + switch (widgetScreen.getLayout()) { + case TOP_1_BOT_2: + updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, -1); + updateWidget(cardWidgetTopRight, labelWidgetTopRight, -1); + updateWidget(cardWidgetCenter, labelWidgetCenter, 0); + updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, 1); + updateWidget(cardWidgetBotRight, labelWidgetBotRight, 2); + break; + case TOP_2_BOT_1: + updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, 0); + updateWidget(cardWidgetTopRight, labelWidgetTopRight, 1); + updateWidget(cardWidgetCenter, labelWidgetCenter, 2); + updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, -1); + updateWidget(cardWidgetBotRight, labelWidgetBotRight, -1); + break; + case TOP_2_BOT_2: + updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, 0); + updateWidget(cardWidgetTopRight, labelWidgetTopRight, 1); + updateWidget(cardWidgetCenter, labelWidgetCenter, -1); + updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, 2); + updateWidget(cardWidgetBotRight, labelWidgetBotRight, 3); + break; + case SINGLE: + updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, -1); + updateWidget(cardWidgetTopRight, labelWidgetTopRight, -1); + updateWidget(cardWidgetCenter, labelWidgetCenter, 0); + updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, -1); + updateWidget(cardWidgetBotRight, labelWidgetBotRight, -1); + break; + case TWO: + updateWidget(cardWidgetTopLeft, labelWidgetTopLeft, 0); + updateWidget(cardWidgetTopRight, labelWidgetTopRight, 1); + updateWidget(cardWidgetCenter, labelWidgetCenter, -1); + updateWidget(cardWidgetBotLeft, labelWidgetBotLeft, -1); + updateWidget(cardWidgetBotRight, labelWidgetBotRight, -1); + break; + default: + throw new IllegalStateException("Unknown layout " + widgetScreen.getLayout()); + } + } + + private void updateWidget(final View card, final TextView label, final int partIdx) { + final boolean validPart = partIdx >= 0 && partIdx < widgetScreen.getParts().size(); + + card.setVisibility(validPart ? View.VISIBLE : View.GONE); + + if (!validPart) { + card.setOnClickListener(null); + label.setText(R.string.not_set); + return; + } + + final WidgetPart widgetPart = widgetScreen.getParts().get(partIdx); + + if (widgetPart.getId() == null) { + label.setText(R.string.not_set); + } else { + label.setText(widgetPart.getFullName()); + } + + // Select widget part + + final List supportedParts = widgetManager.getSupportedWidgetParts(widgetPart.getType()); + final String[] layoutStrings = new String[supportedParts.size()]; + for (int i = 0; i < supportedParts.size(); i++) { + layoutStrings[i] = supportedParts.get(i).getName(); + } + final ArrayAdapter partAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, layoutStrings); + + card.setOnClickListener(view -> { + new MaterialAlertDialogBuilder(WidgetScreenDetailsActivity.this).setAdapter(partAdapter, (dialogInterface, i) -> { + final WidgetPart selectedPart = supportedParts.get(i); + + final List supportedSubtypes = selectedPart.getSupportedSubtypes(); + if (supportedSubtypes.isEmpty()) { + // No subtypes selected + + widgetScreen.getParts().set(partIdx, selectedPart); + updateUiFromWidget(); + return; + } + + // If the selected part supports subtypes, the user must select a subtype + + final String[] subtypeStrings = new String[supportedSubtypes.size()]; + for (int j = 0; j < supportedSubtypes.size(); j++) { + subtypeStrings[j] = supportedSubtypes.get(j).getName(); + } + final ArrayAdapter subtypesAdapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, subtypeStrings); + + new MaterialAlertDialogBuilder(WidgetScreenDetailsActivity.this).setAdapter(subtypesAdapter, (dialogInterface1, j) -> { + final WidgetPartSubtype selectedSubtype = supportedSubtypes.get(j); + selectedPart.setSubtype(selectedSubtype); + widgetScreen.getParts().set(partIdx, selectedPart); + updateUiFromWidget(); + }).setTitle(R.string.widget_subtype).create().show(); + }).setTitle(R.string.widget).create().show(); + }); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenListAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenListAdapter.java new file mode 100644 index 000000000..18403ec8c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreenListAdapter.java @@ -0,0 +1,123 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.activities.widgets; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen; + +public class WidgetScreenListAdapter extends RecyclerView.Adapter { + + private final Context mContext; + private ArrayList widgetScreenList; + + public WidgetScreenListAdapter(Context context) { + this.mContext = context; + } + + public void setWidgetScreenList(List widgetScreens) { + this.widgetScreenList = new ArrayList<>(widgetScreens); + } + + public ArrayList getWidgetScreenList() { + return widgetScreenList; + } + + @NonNull + @Override + public WidgetScreenListAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_widget_screen, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, final int position) { + final WidgetScreen widgetScreen = widgetScreenList.get(position); + + holder.container.setOnClickListener(v -> ((WidgetScreensListActivity) mContext).configureWidgetScreen(widgetScreen)); + + holder.container.setOnLongClickListener(v -> { + // TODO move up + // TODO move down + new MaterialAlertDialogBuilder(v.getContext()) + .setTitle(R.string.widget_screen_delete_confirm_title) + .setMessage(mContext.getString( + R.string.widget_screen_delete_confirm_description, + mContext.getString(R.string.widget_screen_x, widgetScreen.getId()) + )) + .setIcon(R.drawable.ic_warning) + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> { + ((WidgetScreensListActivity) mContext).deleteWidgetScreen(widgetScreen); + }) + .setNegativeButton(android.R.string.no, null) + .show(); + + return true; + }); + + holder.widgetScreenName.setText(mContext.getString(R.string.widget_screen_x, widgetScreen.getId())); + final List widgetNames = new ArrayList<>(); + + for (final WidgetPart part : widgetScreen.getParts()) { + if (part.getId() != null) { + widgetNames.add(part.getFullName()); + } + } + + if (!widgetNames.isEmpty()) { + holder.widgetScreenDescription.setText(String.join(", ", widgetNames)); + } else { + holder.widgetScreenDescription.setText(R.string.unknown); + } + } + + @Override + public int getItemCount() { + return widgetScreenList.size(); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + final MaterialCardView container; + + final TextView widgetScreenName; + final TextView widgetScreenDescription; + + ViewHolder(View view) { + super(view); + + container = view.findViewById(R.id.card_widget_screen); + + widgetScreenName = view.findViewById(R.id.widget_screen_name); + widgetScreenDescription = view.findViewById(R.id.widget_screen_description); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreensListActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreensListActivity.java new file mode 100644 index 000000000..f8f1b17b3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/widgets/WidgetScreensListActivity.java @@ -0,0 +1,183 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.activities.widgets; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetLayout; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetType; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class WidgetScreensListActivity extends AbstractGBActivity { + private static final Logger LOG = LoggerFactory.getLogger(WidgetScreensListActivity.class); + + private WidgetScreenListAdapter mGBWidgetScreenListAdapter; + private GBDevice gbDevice; + private WidgetManager widgetManager; + + private ActivityResultLauncher configureWidgetScreenLauncher; + private final ActivityResultCallback configureWidgetCallback = result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + updateWidgetScreensFromManager(); + sendWidgetsToDevice(); + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_widget_screens_list); + + gbDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE); + if (gbDevice == null) { + LOG.error("gbDevice must not be null"); + finish(); + return; + } + + widgetManager = gbDevice.getDeviceCoordinator().getWidgetManager(gbDevice); + if (widgetManager == null) { + LOG.error("widgetManager must not be null"); + finish(); + return; + } + + configureWidgetScreenLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + configureWidgetCallback + ); + + mGBWidgetScreenListAdapter = new WidgetScreenListAdapter(this); + + final RecyclerView widgetsRecyclerView = findViewById(R.id.widget_screens_list); + widgetsRecyclerView.setHasFixedSize(true); + widgetsRecyclerView.setLayoutManager(new LinearLayoutManager(this)); + widgetsRecyclerView.setAdapter(mGBWidgetScreenListAdapter); + updateWidgetScreensFromManager(); + + final FloatingActionButton fab = findViewById(R.id.fab); + fab.setOnClickListener(v -> { + int deviceSlots = widgetManager.getMaxScreens(); + + if (mGBWidgetScreenListAdapter.getItemCount() >= deviceSlots) { + // No more free slots + new MaterialAlertDialogBuilder(v.getContext()) + .setTitle(R.string.reminder_no_free_slots_title) + .setMessage(getBaseContext().getString(R.string.widget_screen_no_free_slots_description, String.format(Locale.getDefault(), "%d", deviceSlots))) + .setIcon(R.drawable.ic_warning) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> { + }) + .show(); + return; + } + + final WidgetLayout defaultLayout = widgetManager.getSupportedWidgetLayouts().get(0); + final ArrayList defaultParts = new ArrayList<>(); + for (final WidgetType widgetType : defaultLayout.getWidgetTypes()) { + defaultParts.add(new WidgetPart(null, "", widgetType)); + } + final WidgetScreen widgetScreen = new WidgetScreen( + null, + defaultLayout, + defaultParts + ); + + configureWidgetScreen(widgetScreen); + }); + } + + /** + * Reads the available widgets from the database and updates the view afterwards. + */ + @SuppressLint("NotifyDataSetChanged") + private void updateWidgetScreensFromManager() { + final List widgetScreens = widgetManager.getWidgetScreens(); + + mGBWidgetScreenListAdapter.setWidgetScreenList(widgetScreens); + mGBWidgetScreenListAdapter.notifyDataSetChanged(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // back button + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + public void configureWidgetScreen(final WidgetScreen widgetScreen) { + final Intent startIntent = new Intent(getApplicationContext(), WidgetScreenDetailsActivity.class); + startIntent.putExtra(GBDevice.EXTRA_DEVICE, gbDevice); + startIntent.putExtra(WidgetScreen.EXTRA_WIDGET_SCREEN, widgetScreen); + configureWidgetScreenLauncher.launch(startIntent); + } + + public void deleteWidgetScreen(final WidgetScreen widgetScreen) { + if (mGBWidgetScreenListAdapter.getItemCount() - 1 < widgetManager.getMinScreens()) { + // Under minimum slots + new MaterialAlertDialogBuilder(this.getBaseContext()) + .setTitle(R.string.widget_screen_delete_confirm_title) + .setMessage(getBaseContext().getString(R.string.widget_screen_min_screens, String.format(Locale.getDefault(), "%d", widgetManager.getMinScreens()))) + .setIcon(R.drawable.ic_warning) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> { + }) + .show(); + return; + } + + widgetManager.deleteScreen(widgetScreen); + + updateWidgetScreensFromManager(); + sendWidgetsToDevice(); + } + + private void sendWidgetsToDevice() { + if (gbDevice.isInitialized()) { + widgetManager.sendToDevice(); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetLayout.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetLayout.java new file mode 100644 index 000000000..d10331d38 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetLayout.java @@ -0,0 +1,48 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets; + +import androidx.annotation.StringRes; + +import nodomain.freeyourgadget.gadgetbridge.R; + +public enum WidgetLayout { + TOP_1_BOT_2(R.string.widget_layout_top_1_bot_2, WidgetType.WIDE, WidgetType.SMALL, WidgetType.SMALL), + TOP_2_BOT_1(R.string.widget_layout_top_2_bot_1, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL), + TOP_2_BOT_2(R.string.widget_layout_top_2_bot_2, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL, WidgetType.SMALL), + SINGLE(R.string.widget_layout_single, WidgetType.TALL), + TWO(R.string.widget_layout_two, WidgetType.SMALL, WidgetType.SMALL), + ; + + @StringRes + private final int name; + private final WidgetType[] widgetTypes; + + WidgetLayout(final int name, final WidgetType... widgetTypes) { + this.name = name; + this.widgetTypes = widgetTypes; + } + + @StringRes + public int getName() { + return name; + } + + public WidgetType[] getWidgetTypes() { + return widgetTypes; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetManager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetManager.java new file mode 100644 index 000000000..1ac23da96 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetManager.java @@ -0,0 +1,70 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets; + +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +/** + * Provide an interface to manage a device's widgets in a device-independent manner. + */ +public interface WidgetManager { + /** + * The widget layouts supported by this device. + */ + List getSupportedWidgetLayouts(); + + /** + * The widget parts that can be used to build a widget screen, for a specific widget type.x + */ + List getSupportedWidgetParts(WidgetType targetWidgetType); + + /** + * The currently configured widget screens on this device. + */ + List getWidgetScreens(); + + GBDevice getDevice(); + + /** + * The minimum number of screens that must be present - deleting screens won't be allowed + * past this number. + */ + int getMinScreens(); + + /** + * The maximum number of screens that can be created. + */ + int getMaxScreens(); + + /** + * Saves a screen. If the screen is new, the ID will be null, otherwise it matches the + * screen that is being updated. + */ + void saveScreen(WidgetScreen widgetScreen); + + /** + * Deletes a screen. + */ + void deleteScreen(WidgetScreen widgetScreen); + + /** + * Send the currently configured screens to the device. + */ + void sendToDevice(); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPart.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPart.java new file mode 100644 index 000000000..54c10ae8c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPart.java @@ -0,0 +1,97 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets; + +import androidx.annotation.Nullable; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * A widget part is a single widget in a widget screen. + */ +public class WidgetPart implements Serializable { + // Null when not selected + @Nullable + private String id; + + // The human-readable part name + private String name; + + private WidgetType type; + + // Null if it has no specific subtype + @Nullable + private WidgetPartSubtype subtype; + + // The list of subtypes supported by this part, if any + private final List supportedSubtypes = new ArrayList<>(); + + public WidgetPart(@Nullable final String id, final String name, final WidgetType type) { + this.id = id; + this.name = name; + this.type = type; + } + + @Nullable + public String getId() { + return id; + } + + public void setId(@Nullable final String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public String getFullName() { + if (subtype != null) { + return String.format(Locale.ROOT, "%s (%s)", name, subtype.getName()); + } + + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public WidgetType getType() { + return type; + } + + public void setType(final WidgetType type) { + this.type = type; + } + + @Nullable + public WidgetPartSubtype getSubtype() { + return subtype; + } + + public void setSubtype(@Nullable final WidgetPartSubtype subtype) { + this.subtype = subtype; + } + + public List getSupportedSubtypes() { + return supportedSubtypes; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPartSubtype.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPartSubtype.java new file mode 100644 index 000000000..7655ed020 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetPartSubtype.java @@ -0,0 +1,48 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets; + +import java.io.Serializable; + +/** + * A widget part can have an optional subtype (eg. a workout type). + */ +public class WidgetPartSubtype implements Serializable { + private String id; + private String name; + + public WidgetPartSubtype(final String id, final String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return id; + } + + public void setId(final String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetScreen.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetScreen.java new file mode 100644 index 000000000..c7394d8fc --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetScreen.java @@ -0,0 +1,65 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets; + +import androidx.annotation.Nullable; + +import java.io.Serializable; +import java.util.List; + +public class WidgetScreen implements Serializable { + public static final String EXTRA_WIDGET_SCREEN = "widget_screen"; + + // Null when creating a new screen + @Nullable + private String id; + private WidgetLayout layout; + + // The list of parts must match what the WidgetLayout expects + private List parts; + + public WidgetScreen(@Nullable final String id, final WidgetLayout layout, final List parts) { + this.id = id; + this.layout = layout; + this.parts = parts; + } + + @Nullable + public String getId() { + return id; + } + + public void setId(@Nullable final String id) { + this.id = id; + } + + public WidgetLayout getLayout() { + return layout; + } + + public void setLayout(final WidgetLayout layout) { + this.layout = layout; + } + + public List getParts() { + return parts; + } + + public void setParts(final List parts) { + this.parts = parts; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetType.java new file mode 100644 index 000000000..14be6f4f8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/widgets/WidgetType.java @@ -0,0 +1,24 @@ +/* Copyright (C) 2022 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.capabilities.widgets; + +public enum WidgetType { + SMALL, // 1x1 + TALL, // 1x2 + WIDE, // 2x1 + ; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLEDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLEDeviceCoordinator.java index 9ea31e016..18500a4b0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLEDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractBLEDeviceCoordinator.java @@ -1,6 +1,6 @@ package nodomain.freeyourgadget.gadgetbridge.devices; -public abstract class AbstractBLEDeviceCoordinator extends AbstractDeviceCoordinator{ +public abstract class AbstractBLEDeviceCoordinator extends AbstractDeviceCoordinator { @Override public ConnectionType getConnectionType() { return ConnectionType.BLE; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index e5d58e595..66ca3dfb2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -51,6 +51,7 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability; import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; @@ -586,6 +587,17 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { ); } + @Override + public boolean supportsWidgets(final GBDevice device) { + return false; + } + + @Nullable + @Override + public WidgetManager getWidgetManager(final GBDevice device) { + return null; + } + public boolean supportsNavigation() { return false; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index 618e5fa6f..d39090e32 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -38,6 +38,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBException; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability; import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; @@ -550,6 +551,17 @@ public interface DeviceCoordinator { List getHeartRateMeasurementIntervals(); + /** + * Whether the device supports screens with configurable widgets. + */ + boolean supportsWidgets(GBDevice device); + + /** + * Gets the {@link WidgetManager} for this device. Must not be null if supportsWidgets is true. + */ + @Nullable + WidgetManager getWidgetManager(GBDevice device); + boolean supportsNavigation(); int getOrderPriority(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java index 5617ce1a0..d3164cb49 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiCoordinator.java @@ -42,6 +42,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActi import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability; import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; @@ -371,6 +372,9 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator { if (supports(device, FEAT_DISPLAY_ITEMS)) { settings.add(R.xml.devicesettings_xiaomi_displayitems); } + if (this.supportsWidgets(device)) { + settings.add(R.xml.devicesettings_widgets); + } if (supports(device, FEAT_PASSWORD)) { settings.add(R.xml.devicesettings_password); } @@ -514,6 +518,16 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator { ); } + @Override + public boolean supportsWidgets(final GBDevice device) { + return getPrefs(device).getBoolean(XiaomiPreferences.FEAT_WIDGETS, false); + } + + @Override + public WidgetManager getWidgetManager(final GBDevice device) { + return new XiaomiWidgetManager(device); + } + protected static Prefs getPrefs(final GBDevice device) { return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress())); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWidgetManager.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWidgetManager.java new file mode 100644 index 000000000..f0d87862c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWidgetManager.java @@ -0,0 +1,421 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi; + +import androidx.annotation.Nullable; + +import com.google.protobuf.InvalidProtocolBufferException; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetLayout; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetManager; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPart; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetPartSubtype; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetScreen; +import nodomain.freeyourgadget.gadgetbridge.capabilities.widgets.WidgetType; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; +import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class XiaomiWidgetManager implements WidgetManager { + private static final Logger LOG = LoggerFactory.getLogger(XiaomiWidgetManager.class); + + private final GBDevice device; + + public XiaomiWidgetManager(final GBDevice device) { + this.device = device; + } + + @Override + public List getSupportedWidgetLayouts() { + final List layouts = new ArrayList<>(); + final Set partTypes = new HashSet<>(); + + final XiaomiProto.WidgetParts rawWidgetParts = getRawWidgetParts(); + + for (final XiaomiProto.WidgetPart widgetPart : rawWidgetParts.getWidgetPartList()) { + partTypes.add(fromRawWidgetType(widgetPart.getType())); + } + + if (partTypes.contains(WidgetType.WIDE) && partTypes.contains(WidgetType.SMALL)) { + layouts.add(WidgetLayout.TOP_1_BOT_2); + layouts.add(WidgetLayout.TOP_2_BOT_1); + layouts.add(WidgetLayout.TOP_2_BOT_2); + } + + if (partTypes.contains(WidgetType.TALL)) { + layouts.add(WidgetLayout.SINGLE); + + if (partTypes.contains(WidgetType.SMALL)) { + layouts.add(WidgetLayout.TWO); + } + } + + return layouts; + } + + @Override + public List getSupportedWidgetParts(final WidgetType targetWidgetType) { + final List parts = new LinkedList<>(); + + final XiaomiProto.WidgetParts rawWidgetParts = getRawWidgetParts(); + + for (final XiaomiProto.WidgetPart widgetPart : rawWidgetParts.getWidgetPartList()) { + final WidgetType type = fromRawWidgetType(widgetPart.getType()); + + if (type != null && type.equals(targetWidgetType)) { + final WidgetPart newPart = new WidgetPart( + String.valueOf(widgetPart.getId()), + widgetPart.getTitle(), + type + ); + + // FIXME are there others? + if (widgetPart.getId() == 2321) { + if (StringUtils.isBlank(newPart.getName())) { + newPart.setName(GBApplication.getContext().getString(R.string.menuitem_workout)); + } + + final List workoutTypes = XiaomiPreferences.getWorkoutTypes(getDevice()); + for (final XiaomiWorkoutType workoutType : workoutTypes) { + newPart.getSupportedSubtypes().add( + new WidgetPartSubtype( + String.valueOf(workoutType.getCode()), + workoutType.getName() + ) + ); + Collections.sort(newPart.getSupportedSubtypes(), (p1, p2) -> p1.getName().compareToIgnoreCase(p2.getName())); + } + } + + parts.add(newPart); + } + } + + return parts; + } + + @Override + public List getWidgetScreens() { + final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens(); + + final List ret = new ArrayList<>(rawWidgetScreens.getWidgetScreenCount()); + + final List workoutTypes = XiaomiPreferences.getWorkoutTypes(getDevice()); + + for (final XiaomiProto.WidgetScreen widgetScreen : rawWidgetScreens.getWidgetScreenList()) { + final WidgetLayout layout = fromRawLayout(widgetScreen.getLayout()); + + final List parts = new ArrayList<>(widgetScreen.getWidgetPartCount()); + + for (final XiaomiProto.WidgetPart widgetPart : widgetScreen.getWidgetPartList()) { + final WidgetType type = fromRawWidgetType(widgetPart.getType()); + + final WidgetPart newPart = new WidgetPart( + String.valueOf(widgetPart.getId()), + "Unknown (" + widgetPart.getId() + ")", + type + ); + + // Find the name + final XiaomiProto.WidgetPart rawPart1 = findRawPart(widgetPart.getType(), widgetPart.getId()); + if (rawPart1 != null) { + newPart.setName(rawPart1.getTitle()); + } + + // FIXME are there others? + if (widgetPart.getId() == 2321) { + if (StringUtils.isBlank(newPart.getName())) { + newPart.setName(GBApplication.getContext().getString(R.string.menuitem_workout)); + } + } + + // Get the proper subtype, if any + if (widgetPart.getSubType() != 0) { + for (final XiaomiWorkoutType workoutType : workoutTypes) { + if (workoutType.getCode() == widgetPart.getSubType()) { + newPart.setSubtype(new WidgetPartSubtype( + String.valueOf(workoutType.getCode()), + workoutType.getName() + )); + } + } + } + + parts.add(newPart); + } + + ret.add(new WidgetScreen( + String.valueOf(widgetScreen.getId()), + layout, + parts + )); + } + + return ret; + } + + @Override + public GBDevice getDevice() { + return device; + } + + @Override + public int getMinScreens() { + return getRawWidgetScreens().getWidgetsCapabilities().getMinWidgets(); + } + + @Override + public int getMaxScreens() { + return getRawWidgetScreens().getWidgetsCapabilities().getMaxWidgets(); + } + + @Override + public void saveScreen(final WidgetScreen widgetScreen) { + final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens(); + + final int layoutNum; + switch (widgetScreen.getLayout()) { + case TOP_2_BOT_2: + layoutNum = 1; + break; + case TOP_1_BOT_2: + layoutNum = 2; + break; + case TOP_2_BOT_1: + layoutNum = 4; + break; + case TWO: + layoutNum = 256; + break; + case SINGLE: + layoutNum = 512; + break; + default: + LOG.warn("Unknown widget screens layout {}", widgetScreen.getLayout()); + return; + } + + XiaomiProto.WidgetScreen.Builder rawScreen = null; + if (widgetScreen.getId() == null) { + // new screen + rawScreen = XiaomiProto.WidgetScreen.newBuilder() + .setId(rawWidgetScreens.getWidgetScreenCount() + 1); // ids start at 1 + } else { + for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) { + if (String.valueOf(screen.getId()).equals(widgetScreen.getId())) { + rawScreen = XiaomiProto.WidgetScreen.newBuilder(screen); + break; + } + + LOG.warn("Failed to find original screen for {}", widgetScreen.getId()); + } + + if (rawScreen == null) { + rawScreen = XiaomiProto.WidgetScreen.newBuilder() + .setId(rawWidgetScreens.getWidgetScreenCount() + 1); + } + } + + rawScreen.setLayout(layoutNum); + rawScreen.clearWidgetPart(); + + for (final WidgetPart newPart : widgetScreen.getParts()) { + // Find the existing raw part + final XiaomiProto.WidgetPart knownRawPart = findRawPart( + toRawWidgetType(newPart.getType()), + Integer.parseInt(Objects.requireNonNull(newPart.getId())) + ); + + final XiaomiProto.WidgetPart.Builder newRawPartBuilder = XiaomiProto.WidgetPart.newBuilder(knownRawPart); + + if (newPart.getSubtype() != null) { + // Get the workout type as subtype + final List workoutTypes = XiaomiPreferences.getWorkoutTypes(getDevice()); + for (final XiaomiWorkoutType workoutType : workoutTypes) { + if (newPart.getSubtype().getId().equals(String.valueOf(workoutType.getCode()))) { + newRawPartBuilder.setSubType(workoutType.getCode()); + break; + } + } + } + + rawScreen.addWidgetPart(newRawPartBuilder); + } + + final XiaomiProto.WidgetScreens.Builder builder = XiaomiProto.WidgetScreens.newBuilder(rawWidgetScreens); + if (rawScreen.getId() == rawWidgetScreens.getWidgetScreenCount() + 1) { + // Append at the end + builder.addWidgetScreen(rawScreen); + } else { + // Replace existing + builder.clearWidgetScreen(); + + for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) { + if (screen.getId() == rawScreen.getId()) { + builder.addWidgetScreen(rawScreen); + } else { + builder.addWidgetScreen(screen); + } + } + } + + getPrefs().getPreferences().edit() + .putString(XiaomiPreferences.PREF_WIDGET_SCREENS, GB.hexdump(builder.build().toByteArray())) + .apply(); + } + + @Override + public void deleteScreen(final WidgetScreen widgetScreen) { + if (widgetScreen.getId() == null) { + LOG.warn("Can't delete screen without id"); + return; + } + + final XiaomiProto.WidgetScreens rawWidgetScreens = getRawWidgetScreens(); + + final XiaomiProto.WidgetScreens.Builder builder = XiaomiProto.WidgetScreens.newBuilder(rawWidgetScreens) + .clearWidgetScreen(); + + for (final XiaomiProto.WidgetScreen screen : rawWidgetScreens.getWidgetScreenList()) { + if (String.valueOf(screen.getId()).equals(widgetScreen.getId())) { + continue; + } + + builder.addWidgetScreen(screen); + } + + getPrefs().getPreferences().edit() + .putString(XiaomiPreferences.PREF_WIDGET_SCREENS, GB.hexdump(builder.build().toByteArray())) + .apply(); + } + + @Override + public void sendToDevice() { + GBApplication.deviceService(getDevice()).onSendConfiguration(DeviceSettingsPreferenceConst.PREF_WIDGETS); + } + + private Prefs getPrefs() { + return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress())); + } + + @Nullable + private WidgetType fromRawWidgetType(final int rawType) { + switch (rawType) { + case 1: + return WidgetType.SMALL; + case 2: + return WidgetType.WIDE; + case 3: + return WidgetType.TALL; + default: + LOG.warn("Unknown widget type {}", rawType); + return null; + } + } + + private int toRawWidgetType(final WidgetType widgetType) { + switch (widgetType) { + case SMALL: + return 1; + case WIDE: + return 2; + case TALL: + return 3; + default: + throw new IllegalArgumentException("Unknown widget type " + widgetType); + } + } + + @Nullable + private WidgetLayout fromRawLayout(final int rawLayout) { + switch (rawLayout) { + case 1: + return WidgetLayout.TOP_2_BOT_2; + case 2: + return WidgetLayout.TOP_1_BOT_2; + case 4: + return WidgetLayout.TOP_2_BOT_1; + case 256: + return WidgetLayout.TWO; + case 512: + return WidgetLayout.SINGLE; + default: + LOG.warn("Unknown widget screens layout {}", rawLayout); + return null; + } + } + + @Nullable + private XiaomiProto.WidgetPart findRawPart(final int type, final int id) { + final XiaomiProto.WidgetParts rawWidgetParts = getRawWidgetParts(); + + for (final XiaomiProto.WidgetPart rawPart : rawWidgetParts.getWidgetPartList()) { + if (rawPart.getType() == type && rawPart.getId() == id) { + return rawPart; + } + } + + return null; + } + + private XiaomiProto.WidgetScreens getRawWidgetScreens() { + final String hex = getPrefs().getString(XiaomiPreferences.PREF_WIDGET_SCREENS, null); + if (hex == null) { + LOG.warn("raw widget screens hex is null"); + return XiaomiProto.WidgetScreens.newBuilder().build(); + } + + try { + return XiaomiProto.WidgetScreens.parseFrom(GB.hexStringToByteArray(hex)); + } catch (final InvalidProtocolBufferException e) { + LOG.warn("failed to parse raw widget screns hex"); + return XiaomiProto.WidgetScreens.newBuilder().build(); + } + } + + private XiaomiProto.WidgetParts getRawWidgetParts() { + final String hex = getPrefs().getString(XiaomiPreferences.PREF_WIDGET_PARTS, null); + if (hex == null) { + LOG.warn("raw widget parts hex is null"); + return XiaomiProto.WidgetParts.newBuilder().build(); + } + + try { + return XiaomiProto.WidgetParts.parseFrom(GB.hexStringToByteArray(hex)); + } catch (final InvalidProtocolBufferException e) { + LOG.warn("failed to parse raw widget parts hex"); + return XiaomiProto.WidgetParts.newBuilder().build(); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWorkoutType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWorkoutType.java new file mode 100644 index 000000000..f9aa663d4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/xiaomi/XiaomiWorkoutType.java @@ -0,0 +1,61 @@ +/* Copyright (C) 2023 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi; + +import androidx.annotation.StringRes; + +import nodomain.freeyourgadget.gadgetbridge.R; + +public class XiaomiWorkoutType { + private final int code; + private final String name; + + public XiaomiWorkoutType(final int code, final String name) { + this.code = code; + this.name = name; + } + + public int getCode() { + return code; + } + + public String getName() { + return name; + } + + @StringRes + public static int mapWorkoutName(final int code) { + switch (code) { + case 1: + return R.string.activity_type_outdoor_running; + case 2: + return R.string.activity_type_walking; + case 3: + return R.string.activity_type_hiking; + case 4: + return R.string.activity_type_trekking; + case 5: + return R.string.activity_type_trail_run; + case 6: + return R.string.activity_type_outdoor_cycling; + case 501: + return R.string.activity_type_wrestling; + } + + return -1; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiPreferences.java index 6ef16b89c..0324716ce 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiPreferences.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/XiaomiPreferences.java @@ -16,13 +16,18 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi; +import java.util.ArrayList; import java.util.Calendar; +import java.util.Collections; import java.util.Date; import java.util.GregorianCalendar; +import java.util.List; import java.util.Locale; import java.util.TimeZone; import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiWorkoutType; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; @@ -32,6 +37,9 @@ public final class XiaomiPreferences { public static final String PREF_REMINDER_SLOTS = "reminder_slots"; public static final String PREF_CANNED_MESSAGES_MIN = "canned_messages_min"; public static final String PREF_CANNED_MESSAGES_MAX = "canned_messages_max"; + public static final String PREF_WORKOUT_TYPES = "workout_types"; + public static final String PREF_WIDGET_SCREENS = "widget_screens"; // FIXME the value is a protobuf hex string + public static final String PREF_WIDGET_PARTS = "widget_parts"; // FIXME the value is a protobuf hex string public static final String FEAT_WEAR_MODE = "feat_wear_mode"; public static final String FEAT_DEVICE_ACTIONS = "feat_device_actions"; @@ -45,6 +53,7 @@ public final class XiaomiPreferences { public static final String FEAT_VITALITY_SCORE = "feat_vitality_score"; public static final String FEAT_SCREEN_ON_ON_NOTIFICATIONS = "feat_screen_on_on_notifications"; public static final String FEAT_CAMERA_REMOTE = "feat_camera_remote"; + public static final String FEAT_WIDGETS = "feat_widgets"; private XiaomiPreferences() { // util class @@ -80,4 +89,22 @@ public final class XiaomiPreferences { final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); return prefs.getBoolean("keep_activity_data_on_device", false); } + + // FIXME this function should not be here + public static List getWorkoutTypes(final GBDevice gbDevice) { + final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); + final List codes = prefs.getList(PREF_WORKOUT_TYPES, Collections.emptyList()); + final List ret = new ArrayList<>(codes.size()); + for (final String code : codes) { + final int codeInt = Integer.parseInt(code); + final int codeNameStringRes = XiaomiWorkoutType.mapWorkoutName(codeInt); + ret.add(new XiaomiWorkoutType( + codeInt, + codeNameStringRes != -1 ? + GBApplication.getContext().getString(codeNameStringRes) : + GBApplication.getContext().getString(R.string.widget_unknown_workout, code) + )); + } + return ret; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiSystemService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiSystemService.java index 2be56f7d0..9b88806f3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiSystemService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiSystemService.java @@ -16,6 +16,8 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services; +import com.google.protobuf.InvalidProtocolBufferException; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -83,10 +85,14 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi public static final int CMD_PASSWORD_SET = 21; public static final int CMD_DISPLAY_ITEMS_GET = 29; public static final int CMD_DISPLAY_ITEMS_SET = 30; + public static final int CMD_WORKOUT_TYPES_GET = 39; public static final int CMD_MISC_SETTING_SET_FROM_BAND = 42; public static final int CMD_SILENT_MODE_GET = 43; public static final int CMD_SILENT_MODE_SET_FROM_PHONE = 44; public static final int CMD_SILENT_MODE_SET_FROM_WATCH = 45; + public static final int CMD_WIDGET_SCREENS_GET = 51; + public static final int CMD_WIDGET_SCREENS_SET = 52; + public static final int CMD_WIDGET_PARTS_GET = 53; public static final int CMD_DEVICE_STATE_GET = 78; public static final int CMD_DEVICE_STATE = 79; @@ -111,6 +117,9 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi getSupport().sendCommand("get password", COMMAND_TYPE, CMD_PASSWORD_GET); getSupport().sendCommand("get display items", COMMAND_TYPE, CMD_DISPLAY_ITEMS_GET); getSupport().sendCommand("get camera remote", COMMAND_TYPE, CMD_CAMERA_REMOTE_GET); + getSupport().sendCommand("get widgets", COMMAND_TYPE, CMD_WIDGET_SCREENS_GET); + getSupport().sendCommand("get widget parts", COMMAND_TYPE, CMD_WIDGET_PARTS_GET); + getSupport().sendCommand("get workout types", COMMAND_TYPE, CMD_WORKOUT_TYPES_GET); } @Override @@ -159,6 +168,9 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi case CMD_DISPLAY_ITEMS_GET: handleDisplayItems(cmd.getSystem().getDisplayItems()); return; + case CMD_WORKOUT_TYPES_GET: + handleWorkoutTypes(cmd.getSystem().getWorkoutTypes()); + return; case CMD_MISC_SETTING_SET_FROM_BAND: handleMiscSettingSet(cmd.getSystem().getMiscSettingSet()); return; @@ -168,6 +180,15 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi case CMD_SILENT_MODE_SET_FROM_WATCH: handlePhoneSilentModeSet(cmd.getSystem().getPhoneSilentModeSet()); return; + case CMD_WIDGET_SCREENS_GET: + handleWidgetScreens(cmd.getSystem().getWidgetScreens()); + return; + case CMD_WIDGET_SCREENS_SET: + LOG.debug("Got widget screens set ack, status={}", cmd.getStatus()); + return; + case CMD_WIDGET_PARTS_GET: + handleWidgetParts(cmd.getSystem().getWidgetParts()); + return; case CMD_DEVICE_STATE_GET: handleBasicDeviceState(cmd.getSystem().hasBasicDeviceState() ? cmd.getSystem().getBasicDeviceState() @@ -205,6 +226,9 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi case HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE: setDisplayItems(); return true; + case DeviceSettingsPreferenceConst.PREF_WIDGETS: + setWidgets(); + return true; } return super.onSendConfiguration(config, prefs); @@ -592,6 +616,20 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); } + private void handleWorkoutTypes(final XiaomiProto.WorkoutTypes workoutTypes) { + LOG.debug("Got {} workout types", workoutTypes.getWorkoutTypeCount()); + + final List codes = new ArrayList<>(workoutTypes.getWorkoutTypeCount()); + for (final XiaomiProto.WorkoutType workoutType : workoutTypes.getWorkoutTypeList()) { + codes.add(String.valueOf(workoutType.getType())); + } + + final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() + .withPreference(XiaomiPreferences.PREF_WORKOUT_TYPES, String.join(",", codes)); + + getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); + } + private void handleWearingState(int newStateValue) { WearingState newState; @@ -775,6 +813,52 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi getSupport().sendCommand("request battery state", COMMAND_TYPE, CMD_BATTERY); } + private void handleWidgetScreens(final XiaomiProto.WidgetScreens widgetScreens) { + LOG.debug("Got {} widget screens", widgetScreens.getWidgetScreenCount()); + final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() + // FIXME we're just persisting the protobuf bytes - probably not a good idea + .withPreference(XiaomiPreferences.PREF_WIDGET_SCREENS, GB.hexdump(widgetScreens.toByteArray())); + + getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); + } + + private void handleWidgetParts(final XiaomiProto.WidgetParts widgetParts) { + LOG.debug("Got {} widget parts", widgetParts.getWidgetPartCount()); + final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() + .withPreference(XiaomiPreferences.FEAT_WIDGETS, widgetParts.getWidgetPartCount() > 0) + // FIXME we're just persisting the protobuf bytes - probably not a good idea + .withPreference(XiaomiPreferences.PREF_WIDGET_PARTS, GB.hexdump(widgetParts.toByteArray())); + + getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); + } + + private void setWidgets() { + final String hex = getDevicePrefs().getString(XiaomiPreferences.PREF_WIDGET_SCREENS, null); + if (hex == null) { + LOG.warn("raw widget screens hex is null"); + return; + } + + final XiaomiProto.WidgetScreens widgetScreens; + try { + widgetScreens = XiaomiProto.WidgetScreens.parseFrom(GB.hexStringToByteArray(hex)); + } catch (final InvalidProtocolBufferException e) { + LOG.warn("failed to parse raw widget screns hex"); + return; + } + + LOG.debug("Setting {} widget screens", widgetScreens.getWidgetScreenCount()); + + getSupport().sendCommand( + "set widgets", + XiaomiProto.Command.newBuilder() + .setType(COMMAND_TYPE) + .setSubtype(CMD_WIDGET_SCREENS_SET) + .setSystem(XiaomiProto.System.newBuilder().setWidgetScreens(widgetScreens)) + .build() + ); + } + public void onFindPhone(final boolean start) { LOG.debug("Find phone: {}", start); diff --git a/app/src/main/proto/xiaomi.proto b/app/src/main/proto/xiaomi.proto index 7db812883..9bf2ef411 100644 --- a/app/src/main/proto/xiaomi.proto +++ b/app/src/main/proto/xiaomi.proto @@ -111,7 +111,7 @@ message System { optional Language language = 20; // 2, 51 get | 2, 52 create - optional WidgetsScreens widgetScreens = 28; + optional WidgetScreens widgetScreens = 28; // 2, 53 optional WidgetParts widgetParts = 29; @@ -228,7 +228,7 @@ message WorkoutType { optional uint32 unknown2 = 2; // 1 } -message WidgetsScreens { +message WidgetScreens { repeated WidgetScreen widgetScreen = 1; optional uint32 isFullList = 2; // 1 to overwrite the full list optional WidgetsCapabilities widgetsCapabilities = 3; // only in response diff --git a/app/src/main/res/layout/activity_widget_screen_details.xml b/app/src/main/res/layout/activity_widget_screen_details.xml new file mode 100644 index 000000000..5b6929dfe --- /dev/null +++ b/app/src/main/res/layout/activity_widget_screen_details.xml @@ -0,0 +1,254 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_widget_screens_list.xml b/app/src/main/res/layout/activity_widget_screens_list.xml new file mode 100644 index 000000000..0c642fba7 --- /dev/null +++ b/app/src/main/res/layout/activity_widget_screens_list.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/app/src/main/res/layout/item_widget_screen.xml b/app/src/main/res/layout/item_widget_screen.xml new file mode 100644 index 000000000..0adc57d8d --- /dev/null +++ b/app/src/main/res/layout/item_widget_screen.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36a27eb71..d6862b3e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1280,6 +1280,9 @@ Ice Skating Golfing Other + Trekking + Trail run + Wrestling Unknown activity Sport Activities Sport Activity Detail @@ -2457,4 +2460,22 @@ Get a notification when your vitality score reaches 30, 60 or 100 in the past 7 days Daily progress Get a notification when you reached the maximum number of vitality points for the day + Widget + Widget Screen + Delete widget screen + Are you sure you want to delete \'%1$s\'? + The device has no free slots for widget screens (total slots: %1$s) + There must be a minimum of %1$s screens + 1 top, 2 bottom + 2 top, 1 bottom + 2 top, 2 bottom + Widget layout + Widget Subtype + Screen %s + 1 widget + 2 widgets + Move up + Move down + Please select all widgets + Unknown workout - %s diff --git a/app/src/main/res/xml/devicesettings_widgets.xml b/app/src/main/res/xml/devicesettings_widgets.xml new file mode 100644 index 000000000..5c137f993 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_widgets.xml @@ -0,0 +1,7 @@ + + + +