DEV Community

Cover image for Don't forget to give the user a choice πŸ’­
Anders Marzi Tornblad
Anders Marzi Tornblad

Posted on • Edited on • Originally published at atornblad.se

Don't forget to give the user a choice πŸ’­

The first part of this series, described how and why I started building two apps named Call Mom and Call Dad. The second part covered some aspects of the first version of those apps. This part goes on to describing how to put the user in control, by providing them with a choice. If you just want the summary and some links, go to the Summary section at the bottom.

Don’t forget to give the user a choice πŸ’­

To allow a user to select a contact from their phonebook, the recommended way is to use the contact picker registered by the operating system. Start by creating an Intent and calling startActivityForResult.

// Select a unique identifier between 1 and 65535
private static final int PICK_CONTACT_REQUEST_ID = 12345;

private void startContactSelection() {
    // This URI is used to identify what contact picker the user has
    // picked (or the OS has defaulted) for this device
    Uri uri = Uri.parse("content://contacts/people");

    // Create an intent to start the picker
    Intent pickContactIntent = new Intent(Intent.ACTION_PICK, uri);

    // Tell the picker to only show contacts with phone numbers
    pickContactIntent.setType(ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE);

    // Start the activity. Use the startActivityForResult method instead of
    // the startActivity one, because we want the result of the action!
    startActivityForResult(pickContactIntent, PICK_CONTACT_REQUEST_ID);
}
Enter fullscreen mode Exit fullscreen mode

When this method is called, preferably from a button click or other user interaction, the contact picker opens. This worked perfectly on all devices and emulators I tested on, but later when releasing the app publicly, this method caused crashes on some phones.

It turns out some devices don't have any contact picker registered, so the startActivityForResult throws an exception. To remedy this, you first need to check if the Intent really resolves to an action. If it doesn't, you'll need to provide the user with some other way to select the phone number to call.

private void startContactSelection() {
    Uri uri = Uri.parse("content://contacts/people");
    Intent pickContactIntent = new Intent(Intent.ACTION_PICK, uri);

    // Check which component the URI above is connected to
    PackageManager pm = getPackageManager();
    ComponentName component = pickContactIntent.resolveActivity(pm);
    if (component == null) {
        // This device has no contact picker
        // Show an error message to the user, or solve this
        // in some other way. Maybe let the user enter the
        // phone number to call manually in a text box.
        return;
    }

    pickContactIntent.setType(ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE);
    startActivityForResult(pickContactIntent, PICK_CONTACT_REQUEST_ID);
}
Enter fullscreen mode Exit fullscreen mode

Getting the contact details

When the user picks one of the contacts from the list and closes the contact picker, your Activity class's onActivityResult method is called, which you must override to be able to read the results of the user's selection.

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    // First check if this is a result from the intent we started above
    if (requestCode != PICK_CONTACT_REQUEST_ID) return;

    // Then check if the action was successful (the user clicked OK and not Cancel)
    if (resultCode != RESULT_OK) return;

    // Get the base URI for the selected contact, if any
    Uri contactUri = data.getData();
    if (contactUri == null) return;

    // Read fields from the contact data by querying the contact, using a projection
    // The projection is where you pick what data fields you want to read
    // This projection reads the display name and the phone number
    String[] projection = new String[] {
        ContactsContract.Contacts.DISPLAY_NAME,
        ContactsContract.CommonDataKinds.Phone.NUMBER
    };

    // Query the data using a `ContentResolver`
    Cursor cursor = getContentResolver().query(contarcUti, project, null, null, null);

    // If the cursor if valid and has data, get the data field values out
    if (cursor != null && cursor.moveToFirst()) {
        String displayName = cursor.getString(0);
        String phoneNumber = cursor.getString(1);

        // TODO: Store the contact information in some form of persistant storage, to
        // be able to display the contact name, and to call the phone number later
    }
}
Enter fullscreen mode Exit fullscreen mode

Creating a schedule editor πŸ“…

Not everyone calls their parents with the same frequency, or at the same time of day. One thing I had to implement was a schedule editor. I wanted it to look similar to the alarm settings input on some phones. That way, most people would find the user interface familiar, and I wouldn't have to completely reinvent something that much more capable UX people have already solved.

HTC Alarm app screenshot

Screenshots, from left: HTC Alarm Clock, DelightRoom Alarmy

Pick the time of day

I started by creating a new Activity where the user would select their calling schedule. At the top of the layout, I put a TimePicker for selecting the time of day to call:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical">

    <TimePicker android:id="@+id/time_of_day"
      android:layout_width="match_parent"
      android:layout_height="wrap_content" />

</LinearLayout>
Enter fullscreen mode Exit fullscreen mode

This lets the user pick hour and minute of day correctly. The display adapts to the user's locale settings so the experience feels consistent and familiar. To read the user selection from code, call the getHour and getMinute methods:

// Get the TimePicker view
TimePicker timeOfDayPicker = findViewById(R.id.time_of_day);

// Read the data selected by the user
int hourOfDay = timeOfDayPicker.getHour();
int minuteOfDay = timeOfDayPicker.getMinute();
Enter fullscreen mode Exit fullscreen mode

Pick a schedule frequency

Below the TimePicker view, I placed a RadioGroup containing four RadioButton views, for picking the type of repetition. All texts in this example are hardcoded, but you should always use resource references for text content.

    <TextView android:text="Repeat:"
      android:layout_width="match_parent"
      android:layout_height="wrap_content">

    <RadioGroup android:id="@+id/repeat_selector"
      android:orientation="vertical"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content">

        <RadioButton android:id="@+id/repeat_daily"
          android:layout_width="match_parent"
          android:layout_height="wrap_content"
          android:text="Daily" />

        <RadioButton android:id="@+id/repeat_weekly"
          android:layout_width="match_parent"
          android:layout_height="wrap_content"
          android:text="Weekly" />

        <RadioButton android:id="@+id/repeat_monthly"
          android:layout_width="match_parent"
          android:layout_height="wrap_content"
          android:text="Monthly" />

    </RadioGroup>
Enter fullscreen mode Exit fullscreen mode

The best way I've found to read the user selection from code is to use the getCheckedRadioButtonId method on the RadioGroup object. This lets you switch and perform different tasks depending on which radio button is selected:

// Get the RadioGroup view
RadioGroup repeatSelector = findViewById(R.id.repeat_selector);

// Read the radio button id selected by the user
int checkedId = repeatSelector.getCheckedRadioButtonId();

// Act upon the user's choice
switch (checkedId) {
    case R.id.repeat_daily:
        // Put code here to hide views for picking weekday,
        // or day of month
        break;
    case R.id.repeat_weekly:
        // Put code here to show views for picking weekday
        break;
    case R.id.repeat_monthly:
        // Put code here to show views for picking day of month
        break;
    default:
        // No repetition pattern selected, show an error message
}
Enter fullscreen mode Exit fullscreen mode

Pick a weekday

If the user picks the weekly pattern, they must be able to pick a day of the week to call. A simple way of allowing that choice is to use a NumberPicker.

    <NumberPicker android:id="@+id/day_of_week"
      android:layout_width="match_parent"
      android:layout_height="wrap_content" />
Enter fullscreen mode Exit fullscreen mode

Prepare the NumberPicker in the onCreate method. A nice trick is to use the Calendar weekday constants for this. The value of SUNDAY is 1, and the value of SATURDAY is 7, so set the minimum and maximum allowed value to these constants. To get the correct names for the weekdays, you could use one of the DateFormatSymbols methods getShortWeekdays or getWeekdays.

NumberPicker weekdayPicker = findViewById(R.id.day_of_week);
weekdayPicker.setMinValue(Calendar.SUNDAY);
weekdayPicker.setMaxValue(Calendar.SATURDAY);

// Get full weekday names, like "Sunday", "Monday", ...
String[] weekdayNames = DateFormatSymbols.getInstance().getWeekdays();

// The NumberPicker needs the first string to be at index 0, but
// because SUNDAY is defined as 1, the values in the weekdayNames
// array actually start at index 1. So we have to copy indices 1..7
// into a new array at indices 0..6
// The final argument value of 8 is the index directly after the
// last index to copy, which might look confusing.
String[] displayNames = Arrays.copyOfRange(weekdayNames, 1, 8);

// Tell the NumberPicker to use these strings instead of the
// numeric values 1 to 7
weekdayPicker.setDisplayedValues(displayNames);
weekdayPicker.setWrapSelectorWheel(true);
Enter fullscreen mode Exit fullscreen mode

Pick a day of the month

For the montly pattern, the user can pick the day of the month from a NumberPicker ranging from 1 to 31, without any special display text settings. In the layout xml file:

    <NumberPicker android:id="@+id/day_of_month"
      android:layout_width="match_parent"
      android:layout_height="wrap_content" />
Enter fullscreen mode Exit fullscreen mode

And prepare the picker in the onCreate method:

NumberPicker monthDayPicker = findViewById(R.id.day_of_month);
monthDayPicker.setMinValue(1);
monthDayPicker.setMaxValue(31);
monthDayPicker.setWrapSelectorWheel(true);
Enter fullscreen mode Exit fullscreen mode

Calculating the correct time for the next notification

Time calculations for scheduling can be tricky, and I have tried to keep the rules as simple as possible. First of all, there is a parameter called notBefore that is used to pass in the time before which the notification should not show. I have decided to set this variable to one hour after the latest phone call.

Then the repetition pattern (daily, weekly or monthly) is used to invoke the corresponding algorithm. For now, this solution only supports the GregorianCalendar.

public class ScheduleModel {
    public final static int DAILY = 1;
    public final static int WEEKLY = 2;
    public final static int MONTHLY = 3;

    // Hour and minute of the day
    private int hour;
    private int minute;

    // Any of DAILY, WEEKLY or MONTHLY
    private int type;

    // Any of the Calendar.SUNDAY .. Calendar.SATURDAY constants
    private int dayOfWeek;

    // Anywhere between 1 and 31
    private int dayOfMonth;

    public ScheduleModel(int hour, int minute, int type, int dayOfWeek, int dayOfMonth) {
        this.hour = hour;
        this.minute = minute;
        this.type = type;
        this.dayOfWeek = dayOfWeek;
        this.dayOfMonth = dayOfMonth;
    }

    public Calendar getNextNotification(Calendar notBefore) {
        // Switch on the type and call the corresponding method
        switch (type) {
            case DAILY:
                return getDailyNext(notBefore);
            case WEEKLY:
                return getWeeklyNext(notBefore);
            case MONTHLY:
                return getMonthlyNext(notBefore);
            default:
                // Unsupported schedule type
                return notBefore;
        }
    }

    private Calendar getDailyNext(Calendar notBefore) {
        // Start at the notBefore date, with the selected time of day
        Calendar next = new GregorianCalendar(
            notBefore.get(Calendar.YEAR),
            notBefore.get(Calendar.MONTH),
            notBefore.get(Calendar.DAY_OF_MONTH),
            hour, minute);
        );

        // If that time is before the earliest allowed time to call,
        // step forward one day
        if (next.before(notBefore)) {
            next.add(Calendar.DAY_OF_YEAR, 1);
        }

        return next;
    }

    private Calendar getWeeklyNext(Calendar notBefore) {
        // Start at the notBefore date, with the selected time of day
        Calendar next = new GregorianCalendar(
            notBefore.get(Calendar.YEAR),
            notBefore.get(Calendar.MONTH),
            notBefore.get(Calendar.DAY_OF_MONTH),
            hour, minute);
        );

        // While that time is before the earliest allowed time to call,
        // or the day isn't the selected weekday, step forward one day
        while (next.get(Calendar.DAY_OF_WEEK) != dayOfWeek || next.before(notBefore)) {
            next.add(Calendar.DAY_OF_YEAR, 1);
        }

        return next;
    }

    private Calendar getMonthlyNext(Calendar notBefore) {
        // Start at the notBefore month, with the selected day of the
        // month and the selected time of day
        Calendar next = new GregorianCalendar(
            notBefore.get(Calendar.YEAR),
            notBefore.get(Calendar.MONTH),
            dayOfMonth,
            hour, minute);
        );

        // If that time is before the earliest allowed time to call,
        // step forward one month
        if (next.before(notBefore)) {
            next.add(Calendar.MONTH, 1);
        }

        return next;
    }
}
Enter fullscreen mode Exit fullscreen mode

The result from the getNextNotification method is passed into the alarms system, to set the time for the next notification.

// First get the last call time and calculate the notBefore value
Calendar notBefore = getLastCallTime();
notBefore.add(Calendar.HOUR_OF_DAY, 1);

// Get the time for the next notification
Calendar next = schedule.getNextNotification(notBefore);

// Set the alarm
myAlarmsInstance.setAlarm(next.getTimeInMillis());
Enter fullscreen mode Exit fullscreen mode

Summary πŸ”–

Cover photo by Pavan Trikutam on Unsplash

Top comments (0)