If there is only one list-single field, render as buttons

Stephen Paul Weber created

If there is only one (non-cancel) action and only one field and it's a
list-single then let the user just tap the option they want to continue instead
of choosing something and then tapping next.

Change summary

src/cheogram/res/layout/button_grid_item.xml                    |  10 
src/cheogram/res/layout/command_button_grid_field.xml           |  67 +
src/main/java/eu/siacs/conversations/entities/Conversation.java | 155 ++
3 files changed, 225 insertions(+), 7 deletions(-)

Detailed changes

src/cheogram/res/layout/button_grid_item.xml 🔗

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <Button
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:textAllCaps="false" />
+
+</layout>

src/cheogram/res/layout/command_button_grid_field.xml 🔗

@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <LinearLayout
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:paddingBottom="16dp"
+        android:orientation="vertical">
+
+        <TextView
+            android:id="@+id/label"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:paddingLeft="13dp"
+            android:paddingRight="8dp"
+            android:paddingBottom="8dp"
+            android:gravity="center"
+            android:textAppearance="@style/TextAppearance.Conversations.Subhead"
+            android:textColor="?attr/edit_text_color" />
+
+        <TextView
+            android:id="@+id/desc"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:paddingLeft="16dp"
+            android:paddingRight="8dp"
+            android:gravity="center"
+            android:textAppearance="@style/TextAppearance.Conversations.Status"
+            android:textColor="?android:textColorSecondary" />
+
+        <Button
+            android:id="@+id/default_button"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginRight="40dp"
+            android:layout_marginLeft="40dp"
+            android:layout_marginBottom="24dp"
+            android:layout_marginTop="24dp"
+            android:gravity="center"
+            android:textAllCaps="false"
+            android:minHeight="75dp" />
+
+        <com.cheogram.android.GridView
+            android:id="@+id/buttons"
+            android:layout_width="fill_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="16dp"
+            android:layout_marginRight="16dp"
+            android:paddingLeft="16dp"
+            android:horizontalSpacing="0dp"
+            android:verticalSpacing="0dp"
+            android:gravity="center"
+            android:numColumns="auto_fit" />
+
+        <Button
+            android:id="@+id/open_button"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginRight="8dp"
+            android:layout_marginLeft="8dp"
+            android:layout_marginTop="40dp"
+            android:gravity="center"
+            android:text="other / custom"
+            style="@style/Widget.Conversations.Button.Borderless" />
+
+    </LinearLayout>
+</layout>

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -2,6 +2,7 @@ package eu.siacs.conversations.entities;
 
 import android.content.ContentValues;
 import android.content.Context;
+import android.content.DialogInterface;
 import android.database.Cursor;
 import android.database.DataSetObserver;
 import android.graphics.Rect;
@@ -21,6 +22,7 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.ArrayAdapter;
 import android.widget.AdapterView;
+import android.widget.Button;
 import android.widget.CompoundButton;
 import android.widget.GridLayout;
 import android.widget.ListView;
@@ -37,6 +39,8 @@ import android.util.SparseArray;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AlertDialog.Builder;
 import androidx.core.content.ContextCompat;
 import androidx.databinding.DataBindingUtil;
 import androidx.databinding.ViewDataBinding;
@@ -71,24 +75,27 @@ import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.OmemoSetting;
 import eu.siacs.conversations.crypto.PgpDecryptionService;
-import eu.siacs.conversations.databinding.CommandPageBinding;
-import eu.siacs.conversations.databinding.CommandNoteBinding;
-import eu.siacs.conversations.databinding.CommandResultFieldBinding;
-import eu.siacs.conversations.databinding.CommandResultCellBinding;
-import eu.siacs.conversations.databinding.CommandItemCardBinding;
+import eu.siacs.conversations.databinding.CommandButtonGridFieldBinding;
 import eu.siacs.conversations.databinding.CommandCheckboxFieldBinding;
+import eu.siacs.conversations.databinding.CommandItemCardBinding;
+import eu.siacs.conversations.databinding.CommandNoteBinding;
+import eu.siacs.conversations.databinding.CommandPageBinding;
 import eu.siacs.conversations.databinding.CommandProgressBarBinding;
 import eu.siacs.conversations.databinding.CommandRadioEditFieldBinding;
+import eu.siacs.conversations.databinding.CommandResultCellBinding;
+import eu.siacs.conversations.databinding.CommandResultFieldBinding;
 import eu.siacs.conversations.databinding.CommandSearchListFieldBinding;
 import eu.siacs.conversations.databinding.CommandSpinnerFieldBinding;
 import eu.siacs.conversations.databinding.CommandTextFieldBinding;
 import eu.siacs.conversations.databinding.CommandWebviewBinding;
+import eu.siacs.conversations.databinding.DialogQuickeditBinding;
 import eu.siacs.conversations.persistance.DatabaseBackend;
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.text.FixedURLSpan;
 import eu.siacs.conversations.ui.util.ShareUtil;
+import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 import eu.siacs.conversations.utils.JidHelper;
 import eu.siacs.conversations.utils.MessageUtils;
 import eu.siacs.conversations.utils.UIHelper;
@@ -1840,6 +1847,102 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 }
             }
 
+            class ButtonGridFieldViewHolder extends ViewHolder<CommandButtonGridFieldBinding> {
+                public ButtonGridFieldViewHolder(CommandButtonGridFieldBinding binding) {
+                    super(binding);
+                    options = new ArrayAdapter<Option>(binding.getRoot().getContext(), R.layout.button_grid_item) {
+                        @Override
+                        public View getView(int position, View convertView, ViewGroup parent) {
+                            Button v = (Button) super.getView(position, convertView, parent);
+                            v.setOnClickListener((view) -> {
+                                mValue.setContent(getItem(position).getValue());
+                                execute();
+                            });
+                            return v;
+                        }
+                    };
+                }
+                protected Element mValue = null;
+                protected ArrayAdapter<Option> options;
+                protected Option defaultOption = null;
+
+                @Override
+                public void bind(Item item) {
+                    Field field = (Field) item;
+                    setTextOrHide(binding.label, field.getLabel());
+                    setTextOrHide(binding.desc, field.getDesc());
+
+                    if (field.error != null) {
+                        binding.desc.setVisibility(View.VISIBLE);
+                        binding.desc.setText(field.error);
+                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Design_Error);
+                    } else {
+                        binding.desc.setTextAppearance(binding.getRoot().getContext(), R.style.TextAppearance_Conversations_Status);
+                    }
+
+                    mValue = field.getValue();
+
+                    Element validate = field.el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
+                    binding.openButton.setVisibility((validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null) ? View.VISIBLE : View.GONE);
+                    binding.openButton.setOnClickListener((view) -> {
+                        AlertDialog.Builder builder = new AlertDialog.Builder(binding.getRoot().getContext());
+                        DialogQuickeditBinding dialogBinding = DataBindingUtil.inflate(LayoutInflater.from(binding.getRoot().getContext()), R.layout.dialog_quickedit, null, false);
+                        builder.setPositiveButton(R.string.action_execute, null);
+                        if (field.getDesc().isPresent()) {
+                            dialogBinding.inputLayout.setHint(field.getDesc().get());
+                        }
+                        dialogBinding.inputEditText.requestFocus();
+                        dialogBinding.inputEditText.getText().append(mValue.getContent());
+                        builder.setView(dialogBinding.getRoot());
+                        builder.setNegativeButton(R.string.cancel, null);
+                        final AlertDialog dialog = builder.create();
+                        dialog.setOnShowListener(d -> SoftKeyboardUtils.showKeyboard(dialogBinding.inputEditText));
+                        dialog.show();
+                        View.OnClickListener clickListener = v -> {
+                            String value = dialogBinding.inputEditText.getText().toString();
+                            mValue.setContent(value);
+                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
+                            dialog.dismiss();
+                            execute();
+                        };
+                        dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(clickListener);
+                        dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v -> {
+                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
+                            dialog.dismiss();
+                        }));
+                        dialog.setCanceledOnTouchOutside(false);
+                        dialog.setOnDismissListener(dialog1 -> {
+                            SoftKeyboardUtils.hideSoftKeyboard(dialogBinding.inputEditText);
+                        });
+                    });
+
+                    options.clear();
+                    List<Option> theOptions = field.getOptions();
+
+                    defaultOption = null;
+                    for (Option option : theOptions) {
+                        if (option.getValue().equals(mValue.getContent())) {
+                            defaultOption = option;
+                            break;
+                        }
+                    }
+                    if (defaultOption == null) {
+                        binding.defaultButton.setVisibility(View.GONE);
+                    } else {
+                        theOptions.remove(defaultOption);
+                        binding.defaultButton.setVisibility(View.VISIBLE);
+                        binding.defaultButton.setText(defaultOption.toString());
+                        binding.defaultButton.setOnClickListener((view) -> {
+                            mValue.setContent(defaultOption.getValue());
+                            execute();
+                        });
+                    }
+
+                    options.addAll(theOptions);
+                    binding.buttons.setAdapter(options);
+                }
+            }
+
             class TextFieldViewHolder extends ViewHolder<CommandTextFieldBinding> implements TextWatcher {
                 public TextFieldViewHolder(CommandTextFieldBinding binding) {
                     super(binding);
@@ -2027,6 +2130,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                             Element validate = el.findChild("validate", "http://jabber.org/protocol/xdata-validate");
                             if (Option.forField(el).size() > 9) {
                                 viewType = TYPE_SEARCH_LIST_FIELD;
+                            } else if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 1) {
+                                viewType = TYPE_BUTTON_GRID_FIELD;
                             } else if (el.findChild("value", "jabber:x:data") == null || (validate != null && validate.findChild("open", "http://jabber.org/protocol/xdata-validate") != null)) {
                                 viewType = TYPE_RADIO_EDIT_FIELD;
                             } else {
@@ -2096,6 +2201,23 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                     }
                     return -1;
                 }
+
+                public int countExceptCancel() {
+                    int count = 0;
+                    for(int i = 0; i < getCount(); i++) {
+                        if (!getItem(i).first.equals("cancel")) count++;
+                    }
+                    return count;
+                }
+
+                public void clearExceptCancel() {
+                    Pair<String,String> cancelItem = null;
+                    for(int i = 0; i < getCount(); i++) {
+                        if (getItem(i).first.equals("cancel")) cancelItem = getItem(i);
+                    }
+                    clear();
+                    if (cancelItem != null) add(cancelItem);
+                }
             }
 
             final int TYPE_ERROR = 1;
@@ -2110,6 +2232,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             final int TYPE_PROGRESSBAR = 10;
             final int TYPE_SEARCH_LIST_FIELD = 11;
             final int TYPE_ITEM_CARD = 12;
+            final int TYPE_BUTTON_GRID_FIELD = 13;
 
             protected boolean loading = false;
             protected Timer loadingTimer = new Timer();
@@ -2124,6 +2247,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             protected ActionsAdapter actionsAdapter;
             protected GridLayoutManager layoutManager;
             protected WebView actionToWebview = null;
+            protected int fillableFieldCount = 0;
 
             CommandSession(String title, String node, XmppConnectionService xmppConnectionService) {
                 loading();
@@ -2154,6 +2278,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 this.loadingTimer = new Timer();
                 this.loading = false;
                 this.responseElement = null;
+                this.fillableFieldCount = 0;
                 this.reported = null;
                 this.response = iq;
                 this.items.clear();
@@ -2197,6 +2322,18 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                                     actionsAdapter.add(Pair.create(action.getValue(), action.toString()));
                                 }
                             }
+
+                            String fillableFieldType = null;
+                            for (eu.siacs.conversations.xmpp.forms.Field field : form.getFields()) {
+                                if (field.getType() != null && !field.getType().equals("hidden") && !field.getType().equals("fixed") && !field.getFieldName().equals("http://jabber.org/protocol/commands#actions")) {
+                                    fillableFieldType = field.getType();
+                                    fillableFieldCount++;
+                                }
+                            }
+
+                            if (fillableFieldCount == 1 && actionsAdapter.countExceptCancel() < 2 && fillableFieldType != null && fillableFieldType.equals("list-single")) {
+                                actionsAdapter.clearExceptCancel();
+                            }
                             break;
                         }
                         if (el.getName().equals("x") && el.getNamespace().equals("jabber:x:oob")) {
@@ -2220,13 +2357,13 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                         return;
                     }
 
-                    if (command.getAttribute("status").equals("executing") && actionsAdapter.getCount() < 1) {
+                    if (command.getAttribute("status").equals("executing") && actionsAdapter.countExceptCancel() < 1 && fillableFieldCount > 1) {
                         // No actions have been given, but we are not done?
                         // This is probably a spec violation, but we should do *something*
                         actionsAdapter.add(Pair.create("execute", "execute"));
                     }
 
-                    if (!actionsAdapter.isEmpty()) {
+                    if (!actionsAdapter.isEmpty() || fillableFieldCount > 0) {
                         if (command.getAttribute("status").equals("completed") || command.getAttribute("status").equals("canceled")) {
                             actionsAdapter.add(Pair.create("close", "close"));
                         } else if (actionsAdapter.getPosition("cancel") < 0) {
@@ -2401,6 +2538,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                         CommandSpinnerFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_spinner_field, container, false);
                         return new SpinnerFieldViewHolder(binding);
                     }
+                    case TYPE_BUTTON_GRID_FIELD: {
+                        CommandButtonGridFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_button_grid_field, container, false);
+                        return new ButtonGridFieldViewHolder(binding);
+                    }
                     case TYPE_TEXT_FIELD: {
                         CommandTextFieldBinding binding = DataBindingUtil.inflate(LayoutInflater.from(container.getContext()), R.layout.command_text_field, container, false);
                         return new TextFieldViewHolder(binding);