Don't forget to give the user a choice
This is part 3 of the Making "Call mom" series. If you haven't read the first part, here it is: Don't forget to call your mom
The first part of this series, described how and why I started building two Android 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.
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);
}
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);
}
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
}
}
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.
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>
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();
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>
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
}
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" />
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);
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" />
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);
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;
}
}
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());
Summary 🔖
- Start the contact picker using startActivityForResult
- Check that the user really has a registered contact picker using resolveActivity
- Read contact data by overriding the onActivityResult method
- Use built-in widgets as much as possible, like RadioButton, NumberPicker and TimePicker views
- If you are handling times and dates, try to keep all modifications as simple and possible, and use the built-in classes like Calendar or LocalDateTime (if you're targeting version 26 and above)
Articles in this series: