Kotlin+MVVM build todo App application

Author: Yike

Project introduction

Todo app implemented using Kotlin + MVVM, the functional interface refers to Microsoft’s Todo software (only core functions are implemented, and some functions are not implemented).

Function module introduction
  1. Project module: add/delete projects, the project is responsible for managing todo tasks
  2. Task module: add/delete tasks, mark task completion, mark tasks as important, mark as my day, set reminder time (send foreground notification), set expiration time.
  3. Search module: Fuzzy search based on task name.
Screenshot of effect

Technology stack
  • Kotlin
  • ViewModel + LiveData + Room + AlarmManager + WorkerManager
  • navigation + DiaLog + foreground notification

Functional design and implementation

1. Project module design and implementation

In the project module, it is divided into fixed module and custom module. The fixed modules are divided into the following modules:

  • My Day: You can view the list of tasks that need to be completed that day;
  • Important: You can view a list of tasks marked as important;
  • Planned: (not realized)
  • Assigned: (not implemented
  • Tasks: You can view a list of all unfinished tasks;

The custom project module is a function that provides users with the ability to classify tasks into projects.

The project module mainly displays: icon + project name + number of task lists included

(Nothing to say, just a simple recyclerView implementation

2. Dynamic update of task list page

After clicking on the project to enter the project, you can create a task. The task is generated by Recyclerview. Since you want the list sliding effect to appear when adding/deleting tasks, the apater of the task implements ListAdapter.

class TasksAdapter(val viewModel: TasksViewModel)
    : ListAdapter<Task, TasksAdapter.ViewHolder>(DIFF_CALLBACK) {
        //...
    }
2.1 Operations on task list page

In addition, the same UI as the task list will be used on the search page, so the UI of the task list is implemented as a fragment to facilitate reuse.

2.1.1 Fragmentation of task list
  • TasksFragment.kt
class TasksFragment: BaseFragment() {
?
    override fun getResourceId() = R.layout.fragment_task_list
?
    lateinit var taskViewModel : TasksViewModel
    private var projectId = 0L
    private var projectName = ""
    private var projectSign : ProjectSign? = null
?
    private lateinit var adapter: TasksAdapter
    private lateinit var taskRecyclerView: RecyclerView
?
    private var previousList : List<Task>? = null
    private lateinit var baseActivity: BaseTaskActivity
?
    //Search parameters
    var searchName = ""
    var isSearchPage = false
?
    override fun initView(rootView: View) {
        // Determine which Activity of the current fragment is to facilitate special operations
        baseActivity = if (activity is TasksMainActivity) {
            activity as TasksMainActivity
        }else {
            isSearchPage = true
            activity as SearchMainActivity
        }
        taskViewModel = ViewModelProvider(baseActivity)[TasksViewModel::class.java]
?
        projectId = baseActivity.intent.getLongExtra(Constants.PROJECT_ID, 0L)
        projectName = baseActivity.intent.getStringExtra(Constants.PROJECT_NAME).toString()
        val serializable = baseActivity.intent.getSerializableExtra(Constants.PROJECT_SIGN)
        if (serializable != null) {
            projectSign = serializable as ProjectSign
        }
?
        Log.d(Constants.TASK_PAGE_TAG, "projectId = $projectId, projectName= $projectName")
        refreshList("onCreate")
?
        adapter = TasksAdapter(taskViewModel)
        taskRecyclerView = rootView.findViewById(R.id.task_recycle_view)
        taskRecyclerView.layoutManager = LinearLayoutManager(baseActivity)
        taskRecyclerView.adapter = adapter
?
        // Pull down to refresh
        val swipeRefreshTask: SwipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_task)
        swipeRefreshTask.setOnRefreshListener {
            refreshList("refresh")
            swipeRefreshTask.isRefreshing = false // Cancel refresh status
        }
        
        override fun initEvent(rootView: View) {
            initClickListener()
?
            initObserve()
        }
    }
2.1.2 Encapsulation of task item operations

There are three click events for task item operations, namely marking completed, clicking the item to enter the details page for editing, and marking as important. Therefore, TaskItem is constructed to encapsulate the three operations of item.

class TaskItem(private val nameText: MaterialTextView?,
               private val checkTaskBtn : ImageButton,
               private val setTaskStartBtn: ImageButton,
               val task: Task) {
?
    var nameTextEdit: EditText? = null
    var curTaskName : String? = null
    
    fun initItem() {
        flushItemUI()
    }
?
    fun initClickListener(viewModel: TasksViewModel) {
        // mark completion button
        checkTaskBtn.setOnClickListener {
            val upState = if (task.state == TaskState.DONE) {
                TaskState.DOING
            } else {
                Log.d(Constants.TASK_PAGE_TAG,"Play animation")
                TaskState.DONE
            }
            task.state = upState
            viewModel.updateTask(task)
            flushItemUI()
            Log.d(Constants.TASK_PAGE_TAG,"update task state id= ${task.id} state for $upState")
        }
        // Mark important buttons
        setTaskStartBtn.setOnClickListener {
            val isStart = !FlagHelper.containsFlag(task.flag, Task.IS_START)
            if (isStart) {
                task.flag = FlagHelper.addFlag(task.flag, Task.IS_START)
            }else {
                task.flag = FlagHelper.removeFlag(task.flag,Task.IS_START)
            }
            viewModel.setStart(task.id, task.flag)
            updateStartUI()
            Log.d(Constants.TASK_PAGE_TAG,"update task start id= ${task.id} isStart for $isStart")
        }
    }
?
?
    fun flushItemUI() {
        updateNameUI()
        updateStartUI()
    }
?
    fun updateNameUI() {
        /**
         * Get the name from the task or get the name from the input box
         */
        if (curTaskName == null) {
            curTaskName = task.name
        }else {
            curTaskName = if (nameText?.visibility == View.VISIBLE) {
                nameText.text.toString()
            } else {
                nameTextEdit?.text.toString()
            }
        }
?
        /**
         * checkTaskBtn
         */
        var resId = R.drawable.ic_select
        if (task.state == TaskState.DONE) {
            val spannableString = SpannableString(curTaskName)
            spannableString.setSpan(
                StrikethroughSpan(),
                0,
                spannableString.length,
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
            )
            if (nameText?.visibility == View.VISIBLE) {
                nameText.text = spannableString
            } else {
                nameTextEdit?.setText(spannableString) /** The effect of underlining **/
            }
            resId = R.drawable.ic_select_check
        }else {
            if (nameText?.visibility == View.VISIBLE) {
                nameText.text = curTaskName
            } else {
                nameTextEdit?.setText(curTaskName)
            }
        }
        checkTaskBtn.setImageResource(resId)
    }
?
    fun updateStartUI() {
        var startResId = R.drawable.ic_shoucang
        if (FlagHelper.containsFlag(task.flag, Task.IS_START)) {
            startResId = R.drawable.ic_shoucang_check
        }
        setTaskStartBtn.setImageResource(startResId)
    }
}

3. Editing operations on the task details page

3.1 Task status design

The main operations on the task details page include: task item operations (mark complete, modify task name, mark as important), mark as my day, task reminder, add deadline, repeat (not implemented), add attachment (not implemented) wait.

The operation of the task item is also encapsulated in the TaskItem above and can be called directly without any further implementation.

There are several marking functions here, Mark as My Day, Mark as Important. Because we don’t want to add a new field to represent the storage of 0 or 1, these two attributes are classified into the same field flag, stored in int, and different bits are used to represent the value of the corresponding field, such as:

  • When the field value is 1, the description is marked as important; (01)
  • When the field value is 2, the description is marked as My Day; (10)
  • When the field value is 3, the description is marked as important and is my day; (11)
/**
 *Flag constant
 */
companion object {
    /** Set as important **/
    const val IS_START = 1
    /** Set as my day **/
    const val IN_ONE_DAY = 2
}

In fact, it is a kind of bit operation, using binary bits to represent true or false in different states. The judgment is relatively simple, just through AND or OR operations:

object FlagHelper {
?
    /**
     * Add logo
     */
    fun addFlag(flag: Int, newFlag : Int) : Int {
        return flag.or(newFlag)
    }
?
    /**
     * Remove logo
     */
    fun removeFlag(flag: Int, newFlag: Int) : Int {
        return flag.and(newFlag.inv())
    }
?
    /**
     * Determine whether the identifier is included
     */
    fun containsFlag(flag: Int, checkFlag: Int) : Boolean {
        return flag.and(checkFlag) == checkFlag
    }
}

Next, just use FlagHelper.containsFlag(task.flag, Task.IN_ONE_DAY) to determine whether the task is in this status. Add/delete can also call the helper class in the same way.

3.2 Design of reminder function
3.2.1 UI design

The UI of the reminder function is like this. The date and time have corresponding DiaLog implementations and Picker implementations. Then you only need to switch between the two UIs by clicking the Button.

I use DiaLogFragment to implement it here, and manage Button and two time Picker components through a customized DtPickerDiaLogFragment. The difficulty encountered is how to communicate with DiaLogFrament after the two time Picker components select the time. EventBus is used here to communicate between DiaLogFrament and the fragments corresponding to the two time picker components. The implementation is as follows:

  • Date picker: DatePickerFragment.kt
class DatePickerFragment : BaseFragment() {
?
    private lateinit var dp : DatePicker
    lateinit var localDate : LocalDate
?
    override fun getResourceId() = R.layout.fragment_datepicker
?
    override fun initView(rootView: View) {
        dp = rootView.findViewById(R.id.datePicker)
    }
?
    override fun initEvent(rootView: View) {
        /**
         * The month that was set (0-11) for compatibility with java.util.Calendar.
         */
       dp.setOnDateChangedListener { view, year, monthOfYear, dayOfMonth ->
           localDate = LocalDate.of(year, monthOfYear + 1, dayOfMonth)
           EventBus.getDefault().post(DateTimeMessage(localDate))
           findNavController().navigate(R.id.switchTime)
       }
    }
?
?
}
  • Time picker: TimePickerFragment.kt
class TimePickerFragment : BaseFragment() {
    override fun getResourceId() = R.layout.fragment_timepicker
?
    private lateinit var tp : TimePicker
    private lateinit var localTime: LocalTime
?
    override fun initView(rootView: View) {
        tp = rootView.findViewById(R.id.timePicker)
    }
?
    override fun initEvent(rootView: View) {
        tp.setOnTimeChangedListener { view, hourOfDay, minute ->
            localTime = LocalTime.of(hourOfDay,minute)
            EventBus.getDefault().post(DateTimeMessage(localTime))
        }
    }
}
  • Date and time picker popup: DtPickerDiaLogFragment.kt
class DtPickerDiaLogFragment(private val dateTimeClick: DateTimeClickListener) : DialogFragment() {
?
    private var chooseDate: LocalDate? = null
    private var chooseTime: LocalTime? = null
    private var chooseDateTime : LocalDateTime? = null
?
?
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
?
        Log.d("DtPickerDiaLogFragment","onCreateView")
        val curView = inflater.inflate(R.layout.dialog_datetime_picker, null)
?
        val navHostFragment : FragmentContainerView = curView.findViewById(R.id.fragment_container_view)
        val switchCalendar : Button = curView.findViewById(R.id.switchCalendar)
        val switchTime : Button = curView.findViewById(R.id.switchTime)
        val cancelDialog : TextView = curView.findViewById(R.id.cancelDialog)
        val saveDateTime : TextView = curView.findViewById(R.id.saveDateTime)
?
        switchCalendar.setOnClickListener {
            switchCalendar.setTextColor(ContextCompat.getColor(MyToDoApplication.context, R.color.light_blue))
            switchTime.setTextColor(ContextCompat.getColor(MyToDoApplication.context, R.color.gray))
            navHostFragment.findNavController().navigate(R.id.switchCalendar)
        }
?
        switchTime.setOnClickListener {
            switchCalendar.setTextColor(ContextCompat.getColor(MyToDoApplication.context, R.color.gray))
            switchTime.setTextColor(ContextCompat.getColor(MyToDoApplication.context, R.color.light_blue))
            navHostFragment.findNavController().navigate(R.id.switchTime)
        }
?
        cancelDialog.setOnClickListener {
            dialog?.dismiss()
        }
?
        saveDateTime.setOnClickListener {
            chooseDateTime = if (chooseDate == null & amp; & amp; chooseTime == null) {
                LocalDateTime.now()
            }else if (chooseDate == null) {
                LocalDateTime.of(LocalDate.now(), chooseTime)
            }else if (chooseTime == null) {
                LocalDateTime.of(chooseDate, LocalTime.now())
            } else {
                LocalDateTime.of(chooseDate, chooseTime)
            }
            Log.d("","The selected time is: $chooseDateTime")
            dateTimeClick.onSaveDateTimeClick(chooseDateTime!!)
            dialog?.dismiss()
        }
?
        // register
        EventBus.getDefault().register(this)
        return curView
    }
?
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        initWindow()
    }
?
    override fun onDestroy() {
        EventBus.getDefault().unregister(this) // Logout
        super.onDestroy()
    }
?
    fun initWindow() {
        val window = dialog?.window
        window?.attributes?.width = 800 // unit px
        window?.attributes?.height = 1450 // unit px
        window?.attributes?.gravity = Gravity.CENTER // Center
    }
?
    fun getChooseTime() = chooseDateTime
?
?
    @Subscribe(threadMode = ThreadMode.MAIN)
    fun receiveDateTime(dateTimeMessage: DateTimeMessage) {
        if (dateTimeMessage.localDate != null) {
            chooseDate = dateTimeMessage.localDate!!
        }
        if (dateTimeMessage.localTime != null) {
            chooseTime = dateTimeMessage.localTime!!
        }
        Log.d("","Event message received, chooseDate=$chooseDate,chooseTime=$chooseTime")
    }
?
}
3.2.2 Reminder function design

The reminder function is implemented using WorkerManager + AlarmManager. The implementation process is as follows:,

  1. When the time is selected and saved, a one-time background task will be submitted;
  2. After the Worker background receives the task, it checks the reminder time. If it has not expired, it checks whether there is an alarm for the current task. If so, it cancels it;
  3. Use AlarmManager to set the alarm clock and save the relationship between the current task and the alarm clock ID to facilitate canceling the alarm clock the next time you set it;
  4. Save the reminder ID of the next alarm clock to prevent the task reminder from failing due to repeated requestCode of PendingIntent.

The implementation is as follows:

class RemindWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
?
    companion object {
        val Tag = "RemindWorker"
    }
?
    private lateinit var alarmManager: AlarmManager
?
    @RequiresApi(Build.VERSION_CODES.S)
    override fun doWork(): Result {
        val taskByte = inputData.getByteArray(Constants.TASK_BYTE)
        val task = taskByte?.toObject() as Task
        val projectName = inputData.getString(Constants.PROJECT_NAME)
        alarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
?
        Log.d(Tag,"The task that needs to be reminded is: task=$task, projectName=$projectName")
        if (LocalDateTime.now().isBefore(task.remindTime)) { // Not executed, initiate broadcast
            alarmTask(task, projectName)
        }
?
        return Result.success()
    }
?
    private fun alarmTask(task: Task, projectName: String?) {
        val bundle = Bundle()
        bundle.putByteArray(Constants.TASK, task.toByteArray())
        bundle.putString(Constants.PROJECT_NAME, projectName)
        val intent = Intent(applicationContext, RemindAlarmReceiver::class.java).apply {
            putExtras(bundle)
        }
        val oldAlarmId = Repository.getInteger4Broad(task.id.toString()) // Find the old request id. If there is a value, it means that it needs to be reset and cancel the old alarm.
        varpi:PendingIntent
        if (oldAlarmId != 0 & amp; & amp; LocalDateTime.now().isAfter(task.remindTime)) {
            // Cancel the alarm and reset it
            pi = PendingIntent.getBroadcast(applicationContext, oldAlarmId, intent, 0)
            alarmManager.cancel(pi)
        }
        var alarmId = Repository.getInteger4Alarm(Constants.ALARM_ID, 0)
        pi = PendingIntent.getBroadcast(applicationContext, alarmId, intent, 0)
        val triggerAtMillis = task.remindTime!!.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
        alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pi)
?
        Repository.setInteger4Broad(task.id.toString(), alarmId)
        Repository.setInteger4Alarm(Constants.ALARM_ID, + + alarmId)
        Log.d(Tag,
            "Alarm clock set successfully;taskName=${task.name};remindTime=${task.remindTime};;now=${System.currentTimeMillis()}"
        )
    }
?
}

When the alarm time is up, the alarm is broadcast via broadcast. So you still need to use Recevier to receive it, after receiving the broadcast. Initiating a foreground notification realizes the task reminder function.

class RemindAlarmReceiver: BroadcastReceiver() {
?
    private val channelId = "remind"
    private val channelName = "task reminder"
?
    override fun onReceive(context: Context, intent: Intent) {
        Log.d("RemindAlarmReceiver", "Request received.")
        val taskByteArray = intent.getByteArrayExtra(Constants.TASK)
        val task = taskByteArray?.toObject() as Task
        val projectName = intent.getStringExtra(Constants.PROJECT_NAME)
        Log.d("RemindAlarmReceiver","Received task task=$task,projectName=$projectName")
        val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        //Create channel
        //Only Android8.0 and above have the following APIs
        val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
        manager.createNotificationChannel(channel)
        
        val intent = Intent(context, EditTaskActivity::class.java).apply {
                putExtra(Constants.TASK, task)
                putExtra(Constants.PROJECT_NAME, projectName)
                setPackage("com.example.mytodo")
          }
           val alarmId = Repository.getAndSet4Alarm(Constants.ALARM_ID, 0)
           val pi = PendingIntent.getActivity(context, alarmId, intent, PendingIntent.FLAG_IMMUTABLE)
           val notification = NotificationCompat.Builder(context, channelId) //The created channel ID must be passed in
                .setContentTitle("Reminder")
                .setContentText(task.name)
                .setSmallIcon(R.drawable.todo)
                .setColor(Color.BLUE)
                .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL)
                .setContentIntent(pi) //Set the Intent for content click
                .setAutoCancel(true) // Automatically close after clicking
                .build()
?
           manager.notify(1, notification)
           Log.d("RemindAlarmReceiver", "Notification sent successfully; task=$task")
    }
}
Search function

The UI of the search function is generally similar to the UI of the task list, except that there is an additional search bar. The task list was fragmented earlier and can be reused directly.

Not to mention the simplicity of implementation.

Finally

This is my first hands-on project after learning Android. Many of the coding methods are not necessarily standardized, and some functions have not been implemented (such as account management, task cloud synchronization, projects can be moved, tasks can be regrouped, and tasks can be subdivided) steps, etc.).

I would like to say that Kotlin is really easy to use hh (compared to Java), such as the features of extension functions. In the development of this app, there is a function that automatically pops up the input method when the edit box appears. Here, we directly use the extension function to extend the View, and the EditText component can be used directly, which is really convenient.

/**
 * Show soft keyboard
 * postDelayed: To avoid requesting focus before the interface is drawn, resulting in the keyboard not popping up.
 */
fun View.showSoftInput(flags: Int = InputMethodManager.SHOW_IMPLICIT) {
    postDelayed({
        requestFocus()
        val inManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        inManager.showSoftInput(this, flags)
    },100)
}
?
/**
 * Hide soft keyboard
 */
fun View.hideSoftInputFromWindow(flags: Int = 0) {
    val inManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
    inManager.hideSoftInputFromWindow(this.windowToken, flags)
}
?
// Called directly, it looks so elegant
editTaskName.showSoftInput()

Android learning notes

Android performance optimization: https://qr18.cn/FVlo89
Android car version: https://qr18.cn/F05ZCM
Android reverse security study notes: https://qr18.cn/CQ5TcL
The underlying principles of Android Framework: https://qr18.cn/AQpN4J
Android audio and video: https://qr18.cn/Ei3VPD
Jetpack family bucket chapter (including Compose): https://qr18.cn/A0gajp
Kotlin article: https://qr18.cn/CdjtAF
Gradle article: https://qr18.cn/DzrmMB
OkHttp source code analysis notes: https://qr18.cn/Cw0pBD
Flutter article: https://qr18.cn/DIvKma
Eight major bodies of Android knowledge: https://qr18.cn/CyxarU
Android core notes: https://qr21.cn/CaZQLo
Android interview questions from previous years: https://qr18.cn/CKV8OZ
The latest Android interview question set in 2023: https://qr18.cn/CgxrRy
Interview questions for Android vehicle development positions: https://qr18.cn/FTlyCJ
Audio and video interview questions: https://qr18.cn/AcV6Ap