Kotlin+MVVM crea una aplicación para tareas pendientes

Autor: ¡Ay!

Introducción al proyecto

La aplicación Todo implementada usando Kotlin + MVVM, la interfaz funcional se refiere al software Todo de Microsoft (solo se implementan las funciones principales y algunas funciones no).

Introducción al módulo de funciones
  1. Módulo de proyecto: agregar/eliminar proyectos, el proyecto es responsable de administrar las tareas pendientes
  2. Módulo de tareas: agregar/eliminar tareas, marcar tareas completadas, marcar tareas como importantes, marcar como mi día, establecer hora de recordatorio (enviar notificación en primer plano), establecer hora de vencimiento.
  3. Módulo de búsqueda: Búsqueda difusa basada en el nombre de la tarea.
Captura de pantalla del efecto

pila de tecnología
  • Kotlin
  • ViewModel + LiveData + Habitación + AlarmManager + WorkerManager
  • navegación + DiaLog + notificación en primer plano

Diseño funcional e implementación.

1. Diseño e implementación del módulo del proyecto.

En el módulo del proyecto, se divide en módulo fijo y módulo personalizado. Los módulos fijos se dividen en los siguientes módulos:

  • Mi día: puede ver la lista de tareas que deben completarse ese día;
  • Importante: puede ver una lista de tareas marcadas como importantes;
  • Planeado: (no realizado)
  • Asignado: (no implementado
  • Tareas: puede ver una lista de todas las tareas pendientes;

El módulo de proyecto personalizado es una función que brinda a los usuarios la capacidad de clasificar tareas en proyectos.

El módulo del proyecto muestra principalmente: icono + nombre del proyecto + número de listas de tareas incluidas

(Nada que decir, solo una implementación simple de recyclerView

2. Actualización dinámica de la página de lista de tareas.

Después de hacer clic en el proyecto para ingresar al proyecto, puede crear una tarea. La tarea es generada por Recyclerview. Dado que desea que aparezca el efecto de deslizamiento de la lista al agregar/eliminar tareas, el controlador de la tarea implementa ListAdapter.

class TasksAdapter(val viewModel: TasksViewModel)
    : ListAdapter<Task, TasksAdapter.ViewHolder>(DIFF_CALLBACK) {
        //...
    }
2.1 Operaciones en la página de lista de tareas

Además, se utilizará la misma interfaz de usuario que la lista de tareas en la página de búsqueda, por lo que la interfaz de usuario de la lista de tareas se implementa como un fragmento para facilitar la reutilización.

2.1.1 Fragmentación de la lista de tareas
  • TareasFragment.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
​
    // 搜索参数
    var searchName = ""
    var isSearchPage = false
​
    override fun initView(rootView: View) {
        // 判断当前fragment的Activty是哪个,方便做特殊操作
        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
​
        // 下拉刷新
        val swipeRefreshTask: SwipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_task)
        swipeRefreshTask.setOnRefreshListener {
            refreshList("refresh")
            swipeRefreshTask.isRefreshing = false   // 取消刷新状态
        }
        
        override fun initEvent(rootView: View) {
            initClickListener()
​
            initObserve()
        }
    }
2.1.2 Encapsulación de operaciones de elementos de tarea

Hay tres eventos de clic para las operaciones de elementos de tareas: marcar como completado, hacer clic en el elemento para ingresar a la página de detalles para editarlo y marcar como importante. Por lo tanto, TaskItem está construido para encapsular las tres operaciones del elemento.

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) {
        // 标记完成按钮
        checkTaskBtn.setOnClickListener {
            val upState = if (task.state == TaskState.DONE) {
                TaskState.DOING
            } else  {
                Log.d(Constants.TASK_PAGE_TAG,"播放动画")
                TaskState.DONE
            }
            task.state = upState
            viewModel.updateTask(task)
            flushItemUI()
            Log.d(Constants.TASK_PAGE_TAG,"update task state id= ${task.id} state for $upState")
        }
        // 标记重要按钮
        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() {
        /**
         * 从task中获取到名字 或者从输入框获取到名字
         */
        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) /** 划线的效果 **/
            }
            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. Edición de operaciones en la página de detalles de la tarea.

3.1 Diseño del estado de la tarea

Las operaciones principales en la página de detalles de la tarea incluyen: operaciones de elementos de la tarea (marcar como completada, modificar el nombre de la tarea, marcar como importante), marcar como mi día, recordatorio de tarea, agregar fecha límite, repetir (no implementado), agregar archivo adjunto (no implementado) esperar .

La operación del elemento de tarea también está encapsulada en el TaskItem anterior y se puede llamar directamente sin ninguna implementación adicional.

Aquí hay varias funciones de marcado, Marcar como mi día, Marcar como importante. Debido a que no queremos agregar un nuevo campo para representar el almacenamiento de 0 o 1, estos dos atributos se clasifican en el mismo indicador de campo, se almacenan en int y se usan diferentes bits para representar el valor del campo correspondiente, como como:

  • Cuando el valor del campo es 1, indica que está marcado como importante; (01)
  • Cuando el valor del campo es 2, la descripción se marca como Mi día; (10)
  • Cuando el valor del campo es 3, la descripción se marca como importante y es mi día; (11)
/**
 * Flag 常量
 */
companion object {
    /** 设为重要的 **/
    const val IS_START = 1
    /** 设为我的一天 **/
    const val IN_ONE_DAY = 2
}

De hecho, es una especie de operación de bits que utiliza bits binarios para representar verdadero o falso en diferentes estados . El juicio es relativamente simple, simplemente mediante operaciones Y u O:

object FlagHelper {
​
    /**
     * 添加标识
     */
    fun addFlag(flag: Int, newFlag : Int) : Int {
        return flag.or(newFlag)
    }
​
    /**
     * 移除标识
     */
    fun removeFlag(flag: Int, newFlag: Int) : Int {
        return flag.and(newFlag.inv())
    }
​
    /**
     * 判断是否包含该标识
     */
    fun containsFlag(flag: Int, checkFlag: Int) : Boolean {
        return flag.and(checkFlag) == checkFlag
    }
}

A continuación, simplemente use para FlagHelper.containsFlag(task.flag, Task.IN_ONE_DAY)determinar si la tarea está en este estado y agregue/elimine llamando a la clase de ayuda de la misma manera.

3.2 Diseño de la función de recordatorio
3.2.1 diseño de interfaz de usuario

La interfaz de usuario de la función de recordatorio es así: la fecha y la hora tienen implementaciones de DiaLog e implementaciones de Picker correspondientes, luego solo necesita cambiar entre las dos UI haciendo clic en el botón.

Utilizo DiaLogFragment para implementarlo aquí y administro los componentes Button y Picker dos veces a través de un DtPickerDiaLogFragment personalizado. La dificultad encontrada es cómo comunicarse con DiaLogFrament después de que los dos componentes del Selector de tiempo seleccionen el tiempo . EventBus se utiliza aquí para comunicarse entre DiaLogFrament y los fragmentos correspondientes a los dos componentes del selector de tiempo. La implementación es la siguiente:

  • Selector de fecha: 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)
       }
    }
​
​
}
  • Selector de tiempo: 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))
        }
    }
}
  • Ventana emergente del selector de fecha y hora: 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 && 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("","选中的时间为:$chooseDateTime")
            dateTimeClick.onSaveDateTimeClick(chooseDateTime!!)
            dialog?.dismiss()
        }
​
        // 注册
        EventBus.getDefault().register(this)
        return curView
    }
​
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        initWindow()
    }
​
    override fun onDestroy() {
        EventBus.getDefault().unregister(this)  // 注销
        super.onDestroy()
    }
​
    fun initWindow() {
        val window = dialog?.window
        window?.attributes?.width = 800 // 单位px
        window?.attributes?.height = 1450 // 单位px
        window?.attributes?.gravity = Gravity.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消息,chooseDate=$chooseDate,chooseTime=$chooseTime")
    }
​
}
3.2.2 Diseño de la función de recordatorio

La función de recordatorio se implementa utilizando WorkerManager + AlarmManager. El proceso de implementación es el siguiente:

  1. Cuando se selecciona y guarda la hora, se enviará una tarea en segundo plano única;
  2. Después de que el fondo de trabajador recibe la tarea, verifica el tiempo del recordatorio, si no ha expirado, verifica si hay una alarma para la tarea actual, si es así, la cancela;
  3. Utilice AlarmManager para configurar el despertador y guardar la relación entre la tarea actual y la ID del despertador para facilitar la cancelación del despertador la próxima vez que lo configure;
  4. Guarde el ID del recordatorio del siguiente despertador para evitar que el recordatorio de la tarea falle debido a una solicitud repetida. Código de intención pendiente.

La implementación es la siguiente:

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,"需要提醒的任务为:task=$task, projectName=$projectName")
        if (LocalDateTime.now().isBefore(task.remindTime)) { // 未执行,发起广播
            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())   // 找到旧的请求id,如果有值的话说明需要重设,取消旧闹钟
        var pi : PendingIntent
        if (oldAlarmId != 0 && LocalDateTime.now().isAfter(task.remindTime)) {
            // 取消闹钟,重设
            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,
            "闹钟设置成功;taskName=${task.name};remindTime=${task.remindTime};;now=${System.currentTimeMillis()}"
        )
    }
​
}

Cuando finaliza la hora de la alarma, la alarma se transmite mediante transmisión. Por lo tanto, aún necesitarás usar Recevier para recibirlo, después de recibir la transmisión. Al iniciar una notificación en primer plano se realiza la función de recordatorio de tarea.

class RemindAlarmReceiver: BroadcastReceiver() {
​
    private val channelId = "remind"
    private val channelName = "任务提醒"
​
    override fun onReceive(context: Context, intent: Intent) {
        Log.d("RemindAlarmReceiver", "请求收到了.")
        val taskByteArray = intent.getByteArrayExtra(Constants.TASK)
        val task = taskByteArray?.toObject() as Task
        val projectName = intent.getStringExtra(Constants.PROJECT_NAME)
        Log.d("RemindAlarmReceiver","接收到任务 task=$task,projectName=$projectName")
        val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        // 创建渠道
        // Android8.0 以上才有下面的API
        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)   // 必须传入已经创建好的渠道ID
                .setContentTitle("提醒")
                .setContentText(task.name)
                .setSmallIcon(R.drawable.todo)
                .setColor(Color.BLUE)
                .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL)
                .setContentIntent(pi)       // 设置内容点击的Intent
                .setAutoCancel(true)        // 点击后自动关闭
                .build()
​
           manager.notify(1, notification)
           Log.d("RemindAlarmReceiver", "通知发送成功;task=$task")
    }
}
Buscando función

La interfaz de usuario de la función de búsqueda es generalmente similar a la interfaz de usuario de la lista de tareas, excepto que hay una barra de búsqueda adicional. La lista de tareas se fragmentó anteriormente y se puede reutilizar directamente.

Sin mencionar la simplicidad de implementación.

por fin

Este es mi primer proyecto práctico después de aprender Android. Muchos de los métodos de codificación no están necesariamente estandarizados y algunas funciones no se han implementado (como administración de cuentas, sincronización de tareas en la nube, proyectos que se pueden mover, tareas que se pueden reagrupar y las tareas se pueden subdividir), pasos, etc.).

Me gustaría decir que Kotlin es realmente fácil de usar (en comparación con Java), como las características de las funciones de extensión. En el desarrollo de esta aplicación, hay una función que muestra automáticamente el método de entrada cuando aparece el cuadro de edición. Aquí, usamos directamente la función de extensión para extender la Vista, y el componente EditText se puede usar directamente, lo cual es realmente conveniente.

/**
 * 显示软键盘
 * postDelayed:避免界面还没绘制完毕就请求焦点导致不弹出键盘
 */
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)
}
​
/**
 * 隐藏软键盘
 */
fun View.hideSoftInputFromWindow(flags: Int = 0) {
    val inManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
    inManager.hideSoftInputFromWindow(this.windowToken, flags)
}
​
// 直接调用,看起来真优雅
editTaskName.showSoftInput()

notas de estudio de Android

Artículo sobre optimización del rendimiento de Android: https://qr18.cn/FVlo89
Artículo sobre vehículos de Android: https://qr18.cn/F05ZCM
Notas de estudio de seguridad inversa de Android: Artículo sobre https://qr18.cn/CQ5TcL
principios subyacentes del marco de trabajo de Android: Artículo https://qr18.cn/AQpN4J
de audio y video de Android: https://qr18.cn/Ei3VPD
Artículo del grupo de la familia Jetpack (incluido Compose): https://qr18.cn/A0gajp
Artículo de Kotlin: https://qr18.cn/CdjtAF
Artículo de Gradle: https://qr18.cn/DzrmMB
Notas de análisis del código fuente de OkHttp: https://qr18.cn/Cw0pBD
Artículo de Flutter : https://qr18.cn/DIvKma
Ocho cuerpos de conocimiento de Android: https://qr18.cn/CyxarU
Notas principales de Android: https://qr21.cn/CaZQLo
Preguntas de la entrevista de Android de años anteriores: https://qr18.cn/CKV8OZ
Las últimas preguntas de la entrevista de Android en 2023: https://qr18.cn/CgxrRy
Ejercicios de entrevista para el puesto de desarrollo de vehículos de Android: https://qr18.cn/FTlyCJ
Preguntas de la entrevista en audio y video:https://qr18.cn/AcV6Ap

Supongo que te gusta

Origin blog.csdn.net/weixin_61845324/article/details/132736515
Recomendado
Clasificación