First of all, let me apologise in advance. This is going to be a somewhat lengthy one. I had gotten caught up in the development and rounding out of the features, and neglected to report on the progress. However, as it currently stands, the app is in a functional state. So much so that I have been using it consistently in my daily life already, despite some missing features that make it a bit frustrating to use.
In this devlog, I will be covering how I added user settings to control the lengths of the tasks and breaks, as well as some interesting bugs and how I overcame them.
Setting Things Right
A key feature of the app is that the user is able to adjust the lengths of the task sessions, and the long and short breaks in between. Since we are following the principles of the Pomodorro technique the suggested times for these are as follows:
- Task sessions are traditionally 25 minutes
- Short breaks are 3-5 minutes
- Long breaks are 15-30 minutes long
However, I would like the users to be able to increase or decrease these times according to what works best for them and are still within reasonable limits. For example, we don’t want someone to say work for 1 minute, have a 15 minute short break, followed by a long break of 1 hour since that undermines the balance of work to rest that allows the brain maximise the overall time spent on task.
Therefore I opted for a “sliding scale” design for setting each length from “shorter” to “longer” which hopefully will promote a feeling of control in the user, but ultimately keep them balanced in terms of the ratio between work and rest.
As you can see above, each slider (a SeekBar widget) only has three settings. This is intentionally done to reduce the search space for the user to find an optimal overall setting for the app since altogether there are 27 possible combinations of settings. I used a bit of code to convert the actual SeekBar values (specified as integers from 0 to max) into actual times relating to the type of setting.
settingBaseLength + (settingSeekBar.progress*settingIncrement)
This means that in future, if it is necessary for users to have finer control over a given setting all that needs to be adjusted is the number of notches for the slider and that setting’s increment value. Currently the base values and increments are as follows:
Name | Base length(min) | Increment length(min) |
---|---|---|
Task | 20 | 5 |
Short break | 3 | 2 |
Long break | 20 | 10 |
These numbers are all loaded from ‘default_lengths.xml‘ so that if adjustments are needed, one only needs to touch the resource file.
With the higher level design out of the way, let’s talk implementation. The official documentation suggests using a SharedPreferences file that stores key-value pairs. However, initially I made the mistake of thinking that I needed to use a Preferences file since I only needed one preference file for the app and only the settings activity would write to it. I was wrong. Initially it looked something like this in my FlowSettings activity:
with(this.getPreferences(Context.MODE_PRIVATE).edit())
{
putInt(getString(R.string.task_length_key), taskLength + (taskTimeSeekBar.progress*taskIncrement))
putInt(getString(R.string.short_break_key), shortBreakLength + (shortBreakSeekBar.progress*shortBreakIncrement))
putInt(getString(R.string.long_break_key), longBreakLength + (longBreakSeekBar.progress*longBreakIncrement))
apply()
}
As soon as I tried to load the settings from the Flow activity with the following:
val settings = this.getPreferences(Context.MODE_PRIVATE)
val taskLength = settings.getInt(..., fallback)
val shortBreakLength = settings.getInt(..., fallback)
val longBreakLength = settings.getInt(..., fallback)
I found that they were all defaulting to the fallbacks I set. They key issue? using getPreferences instead of getSharedPreferences. The former requires no filename, but creates a unique preferences file for a single activity to be used only by that activity. This meant that I was writing to and reading from two different preferences files instead of one shared by all activities. Instead, the getSharedPreferences works by supplying a filename, thereby ensuring that activities can access the same preferences file. All it took was that one change and my settings were now saving and loading correctly across activities.
Activity Stacked
I noticed a strange behaviour when testing the adding of tasks that when I went to add a task, and canceled (returning me to the main activity) I could press back to go to the add task activity again. To make this even stranger, I could then press back again and navigate round to the main activity. This was wrong since I wanted the user to exit/minimise the app when they pressed the back button on the main activity. The answer to this behaviour turned out to lead to a good catch!
Originally, in my AddTask activity when the user presses cancel the following code was executed:
fun cancel(view: View)
{
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
}
Now I could go to AddTask, hit cancel and… crash the app. Reliably.
You might have already noticed my mistake, which seemed so simple once I figured it out. By calling startActivity() I was not “returning” to the main activity, but instead starting another instance of it. Therefore, when I followed it by finishing the AddTask activity and tried to navigate back, it would crash because the activity that started it would no longer exists. This also meant that there was no way to navigate back to the original instance of MainActivity. The figure below shows the “activity stack” I was creating.
To fix this issue, I needed only to remove the all of the startActivity calls from the AddTasks activity since it is not actually supposed to start an activity, but rather just finish its job. However, that presented a crash as well. Again, the crash was consistent with only pressing the cancel button in the AddTask activity. It turns out that if you look at the figure above, you will notice that I start the AddTaskActivity for a result. And when I canceled the process and just called finish, no activity result had been set causing the crash. To fix this, finallly, my cancel function now looks something like this:
fun cancel(view: View)
{
setResult(Activity.RESULT_OK, Intent())
finish()
}
After checking the other activities spawned by the main activity, I ensured that all of them, on termination only call finish and set an activity result if they have to.
Small Bugs, Big Problems
Once I had addressed the main implementation and app crashing fixes, that left a few less noticeable bugs to clean up.
Ephemeral Tasks
As soon as I started really using my app, I noticed that tasks for which I had assigned multiple sessions were only lasting one. Easily fixed by ensuring that once a task session has finished, we update the task and if there are still more sessions left schedule for the next session. Furthermore, we also update the task database to ensure that the “expended” task sessions are reflected even when the flow is stopped prematurely.
Adding an update rule to the TaskDao was straightforward enough with:
@Dao
interface TaskDao {
...
@Update
fun updateTask(task: Task)
...
}
As well as including an TaskUpdateAsync for committing changes to the Room database in a non-blocking way as discussed in this devlog.
Null Safety
Finally, a problem of oversight and something that I would not have caught during my testing was when an empty task would be saved. Since I knew how I wanted the user to enter information I did not explicitly think about and test this case, but when I stumbled upon it the app crashed beautifully.
Now, I could use Kotlin’s null-safety systems such as the let statement, but this is not helpful to the user to understand what is going on as it doesn’t even give feedback. Instead, I checked for incomplete data entry and sent the user a toast to let them know that they had performed an illegal operation. This way is more in line with Nielsen’s Usability Heuristics.
Wrapping up
All this work has left the app in a useable state where the core function can be performed: adding tasks, changing settings, and getting into and out of flow correctly. However, the app is still very rough and intuitive features like task reordering, editing, and deletion still need to be implemented. Regardless, I am happy with the progress so far and excited to see what comes next.