我正在参加「启航方案」

运用Kotlin+MVVM 构建todo App 应用

项目介绍

运用Kotlin+MVVM完结的todo app,功用界面参考微软的Todo软件(只完结了中心功用,部分功用未完结)。

功用模块介绍

  1. 项目模块:增加/删去项目,项目负责办理todo使命
  2. 使命模块:增加/删去使命,符号使命完结情况,符号使命为重要,符号为我的一天,设置提示时刻(发送前台告诉),设置过期时刻。
  3. 查找模块:根据使命称号模糊查找。

效果截图

使用Kotlin+MVVM 构建todo App 应用

技能栈

  • Kotlin
  • ViewModel + LiveData + Room + AlarmManager + WorkerManager
  • navigation + DiaLog + 前台告诉

功用规划与完结

1. 项目模块规划完结

在项目模块中,分为固定模块和自定义模块。其中固定模块分为以下几个模块:

  • 我的一天:能够检查当天需要完结的使命列表;
  • 重要:能够检查符号为重要的使命列表;
  • 方案内:( 未完结
  • 已分配:(未完结
  • 使命:能够检查未完结的所有使命列表;

而自定义项目模块是提供给用户来将使命归类到项目的功用。

项目模块首要显现:icon + 项目称号 + 包括的使命列表数量

(没啥好说的,简略的recyclerView完结即可

2. 使命列表页面的动态更新

点击项目进入项目后可创建使命,使命是由Recyclerview生成的,由于想要在使命增加/删去时出现列表滑动的效果,所以使命的apater完结了ListAdapter。

class TasksAdapter(val viewModel: TasksViewModel)
   : ListAdapter<Task, TasksAdapter.ViewHolder>(DIFF_CALLBACK) {
    //...
   }

2.1 使命列表页的操作

另外在查找页面上也会用到跟使命列表相同的UI,所以将使命列表的UI用fragment完结,便利复用。

2.1.1 使命列表Fragment化
  • 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? = nullprivate lateinit var adapter: TasksAdapter
  private lateinit var taskRecyclerView: RecyclerView
​
  private var previousList : List<Task>? = null
  private lateinit var baseActivity: BaseTaskActivity
​
  // 查找参数
  var searchName = ""
  var isSearchPage = falseoverride 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 使命item操作的封装

对使命的item操作有三个点击事件,分别是符号完结、点击item进入详情页修正、符号为重要。故构建出TaskItem用来封装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) {
    // 符号完结按钮
    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. 使命详情页的修正操作

3.1 使命的状况规划

使命详情页首要的操作包括:使命item的操作(符号完结、修正使命名、符号为重要),符号为我的一天,使命提示,增加截止日期,重复(未完结),增加附件(未完结) 等。

使命item的操作相同封装在上面的 TaskItem中,直接调用即可,无需再完结

这儿的有几个符号功用,符号为我的一天,符号为重要。因为不想新增一个字段来表明0或1的存储,这儿将这两个属性为归为同一个字段flag,用int存储,用不同的位来表明对应字段的值,如:

  • 当字段值为 1 时,阐明符号为重要的;(01)
  • 当字段值为 2 时,阐明符号为我的一天;(10)
  • 当字段值为 3 时,阐明符号为重要的且是我的一天;(11)
/**
 * Flag 常量
 */
companion object {
  /** 设为重要的 **/
  const val IS_START = 1
  /** 设为我的一天 **/
  const val IN_ONE_DAY = 2
}

其实就是位运算的一种,用二进制的位来表明不同状况下的真或假。判别也就比较简略了,经过与,或运算即可:

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
   }
}

接下来只要用 FlagHelper.containsFlag(task.flag, Task.IN_ONE_DAY) 来判别该使命是否该状况,增加/删去也是同理调用该帮助类即可。

3.2 提示功用的规划

3.2.1 UI规划

提示功用的UI是这样的,日期和时刻有对应的DiaLog完结,也有Picker完结,那么只需要经过Button点击切换两个UI即可完结。

使用Kotlin+MVVM 构建todo App 应用
我这儿选用DiaLogFragment完结的,经过自定义的DtPickerDiaLogFragment 来办理Button与两个时刻Picker组件。遇到的难点在于在两个时刻Picker组件挑选好时刻后,该怎么跟DiaLogFrament做通信呢。这儿运用了EventBus来做DiaLogFrament和两个时刻picker组件对应的fragment做通信。完结如下:

  • 日期挑选器: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)
    }
   }
​
​
}
  • 时刻挑选器: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))
     }
   }
}
  • 日期和时刻的挑选器弹窗: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 提示功用规划

提示功用是选用WorkerManager + AlarmManager完结的,完结流程如下:、

使用Kotlin+MVVM 构建todo App 应用

  1. 当挑选好时刻保存后就会提交一次性的后台使命;
  2. Worker后台接收到使命后,检查提示时刻,没过期的话检查当时使命是否现已存在闹钟,有的话则撤销;
  3. 运用AlarmManager设置闹钟,保存当时使命和闹钟id的联系,便利下一次设置时撤销该闹钟;
  4. 保存下一个闹钟的提示id,避免PendingIntent 的requestCode重复导致使命提示失败。

完结如下:

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()}"
     )
   }
​
}

闹钟时刻到了后,经过broadcast播送。所以还需要用Recevier去接收,接收到播送后。建议前台告诉即完结了使命提示功用。

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")
   }
}

查找功用

查找功用的UI跟使命列表的UI大体类似,只是多了一个查找栏。前面将使命列表fragment化了,直接复用。

完结简略就不说了。

最后

这是本人在学完Android后搞得第一个练手项目,其中很多编码方式不一定规范,有些功用也未完结(如帐号办理,使命云同步,项目可移动,使命可从头分组,使命可细分步骤等功用)。

想说下Kotlin真的好用hh(比起Java),比如扩展函数的特性。在这个app开发中,有个功用是当修正框出现的时分,要主动弹出输入法。这儿直接用扩展函数把View扩展,EditText这个组件就能直接用了,真便利。

/**
 * 显现软键盘
 * 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()

贴下GitHub地址:github.com/yijiajia/My…,有爱好的欢迎给个start ~