声明:本App仅用于学习,禁止用于一切商业用途。
之前复习了一下Android的基础知识,自己又没有完全开发过一个能用的App,都是写一些小Demo。于是就想写一个简单的App来巩固自己的知识。于是SpecialDay就诞生了。
一、需求分析
作为一款倒计时的App,最主要的功能就是用户添加提醒事件,然后在App主页面向用户展示事件的剩余天数。
并且有一些事件属于是每年重复或每月重复的事件,用户在添加事件的时候可以选择重复类型。App需要调整重复事件的事件。
对于已有的事件用户也可以进行修改和删除。
二、实现
1.数据库设计
一个事件应该至少有标题,类型(方便后续可分类查看), 日期,重复类型的属性,所以数据库设计如下
列名 | 类型 | 注释 |
id | integer | 主键,自增 |
title | text | 标题 |
type | integer | 事件类型,对应于type表格 |
event_date | date | 事件日期 |
repeat | integer | 重复类型,0-不重复,1-每年重复,2-每月重复 |
同时需要一个type表格存储事件类型
列名 | 类型 | 注释 |
id | integer | 主键 |
name | text | 类型名称 |
2.界面设计
2.1 主页面
在主页面里,需要展示的是标题栏和事件列表。
标题栏用一个RelativeLayout实现即可,这里排列的是三张图片,源于网络。而事件列表的展示我选用了RecyclerView。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<RelativeLayout
android:id="@+id/main_menu"
android:layout_width="match_parent"
android:layout_height="60dp"
tools:ignore="UselessParent"
android:gravity="center">
<ImageView
android:id="@+id/setting"
android:layout_width="25dp"
android:layout_height="25dp"
android:src="@drawable/setting"
android:layout_gravity="center_vertical"
android:background="@color/white"/>
<ImageView
android:id="@+id/title"
android:layout_width="150dp"
android:layout_height="30dp"
android:layout_gravity="center_vertical"
android:layout_marginLeft="50dp"
android:layout_marginStart="50dp"
android:src="@drawable/title"
android:layout_toEndOf="@id/setting"
android:layout_toRightOf="@id/setting" />
<ImageView
android:id="@+id/add"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_gravity="center_vertical"
android:layout_marginLeft="50dp"
android:layout_marginStart="50dp"
android:src="@drawable/add"
android:layout_toRightOf="@id/title"
android:layout_toEndOf="@id/title"
/>
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/main_menu">
</androidx.recyclerview.widget.RecyclerView>
</RelativeLayout>
而RecyclerView具体的item也是用RelativeLayout和LinearLayout搭配使用实现布局。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:orientation="horizontal"
android:paddingTop="10dp"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_icon"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginTop="15dp"
android:layout_marginLeft="15dp"
android:layout_marginStart="15dp"
android:layout_marginBottom="15dp"
android:src="@drawable/calendar"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="55dp"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="center_vertical"
>
<LinearLayout
android:id="@+id/date_layout"
android:layout_width="150dp"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="30dp"
android:text="123"
android:textSize="20sp"
android:textColor="@color/black"
android:gravity="center_vertical"
android:paddingLeft="10dp"/>
<TextView
android:id="@+id/tv_date"
android:layout_width="match_parent"
android:layout_height="20dp"
android:text="2021-01-04"
android:textSize="15sp"
android:paddingLeft="10dp"/>
</LinearLayout>
<TextView
android:id="@+id/tv_countDown"
android:layout_width="180dp"
android:layout_height="match_parent"
android:paddingRight="10dp"
android:paddingTop="5dp"
android:text="就在今天"
android:textSize="20dp"
android:gravity="end"
android:paddingVertical="10dp"
android:textColor="#3FBFBF"
android:layout_toRightOf="@id/date_layout"
android:layout_toEndOf="@id/date_layout"/>
</RelativeLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="4dp"
android:layout_marginLeft="5dp"
android:background="#000000"
android:layout_marginStart="5dp" />
</LinearLayout>
</LinearLayout>
这样主页面的布局就完成了。
2.2 添加事件页面
先看一下效果。
同样是通过RelativeLayout和LinearLayout就能实现这个布局。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".AddEventActivity">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="60dp"
android:gravity="center"
tools:ignore="UselessParent">
<ImageView
android:id="@+id/icon_return"
android:layout_width="25dp"
android:layout_height="25dp"
android:src="@drawable/icon_return"
android:layout_gravity="center_vertical"
android:background="@color/white"/>
<ImageView
android:id="@+id/title"
android:layout_width="150dp"
android:layout_height="30dp"
android:layout_gravity="center_vertical"
android:layout_marginLeft="50dp"
android:layout_marginStart="50dp"
android:src="@drawable/title"
android:layout_toRightOf="@id/icon_return"
android:layout_toEndOf="@id/icon_return"/>
<ImageView
android:id="@+id/submit"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_gravity="center_vertical"
android:layout_marginLeft="50dp"
android:layout_marginStart="50dp"
android:src="@drawable/submit"
android:layout_toRightOf="@id/title"
android:layout_toEndOf="@id/title"
/>
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
>
<TextView
android:layout_width="80dp"
android:layout_height="match_parent"
android:layout_marginLeft="10dp"
android:text="标题"
android:textSize="15sp"
android:gravity="center"
android:textColor="@color/black"/>
<EditText
android:id="@+id/add_et_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@null"
android:layout_marginRight="40dp"
android:layout_marginEnd="40dp"
android:gravity="end"
android:maxLines="1"
android:maxLength="15"
android:layout_marginTop="5dp"
android:textSize="15sp"
/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp">
<TextView
android:layout_width="80dp"
android:layout_height="match_parent"
android:layout_marginLeft="10dp"
android:text="日期"
android:textSize="15sp"
android:gravity="center"
android:textColor="@color/black"/>
<TextView
android:id="@+id/add_tv_date"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@null"
android:layout_marginRight="40dp"
android:layout_marginEnd="40dp"
android:gravity="end"
android:maxLines="1"
android:textSize="15sp"
android:paddingTop="8dp"
android:text="2022-1-4"
android:textColor="@color/black"
/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp">
<TextView
android:layout_width="80dp"
android:layout_height="match_parent"
android:layout_marginLeft="10dp"
android:text="分类"
android:textSize="15sp"
android:gravity="center"
android:textColor="@color/black"/>
<Spinner
android:id="@+id/select_type"
android:layout_width="140dp"
android:layout_height="match_parent"
android:layout_marginLeft="140dp"
android:layout_marginStart="140dp"
android:layout_gravity="center_vertical"/>
</LinearLayout>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="50dp">
<TextView
android:layout_width="80dp"
android:layout_height="match_parent"
android:layout_marginLeft="10dp"
android:text="重复"
android:textSize="15sp"
android:gravity="center"
android:textColor="@color/black"/>
<Spinner
android:id="@+id/select_repeat"
android:layout_marginLeft="140dp"
android:layout_width="140dp"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:layout_marginStart="140dp" />
</LinearLayout>
</LinearLayout>
3.具体实现
3.1 EventItemAdapter
RecyclerView需要Adapter去填充数据,这里Adapter主要做的事情就是把数据绑定到UI控件上去。
class EventItemAdapter(private val list: List<EventItem>) : RecyclerView.Adapter<EventItemAdapter.ItemViewHolder>() {
inner class ItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val icon: ImageView = view.findViewById(R.id.iv_icon)
val title: TextView = view.findViewById(R.id.tv_title)
val date: TextView = view.findViewById(R.id.tv_date)
val countDown: TextView = view.findViewById(R.id.tv_countDown)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.special_day_item, parent, false)
return ItemViewHolder(view)
}
@SuppressLint("SetTextI18n", "SimpleDateFormat")
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val event = list[position]
holder.title.text = event.title
holder.date.text = event.date
val day = DateUtil.getCountDown(event.date)
if (day > 0) {
holder.countDown.text = "还有 $day 天"
holder.countDown.setTextColor(android.graphics.Color.BLUE)
} else if (day == 0) {
holder.countDown.text = "就在今天!"
} else if (day < -365){
holder.countDown.text = "已经过了 ${abs(day/365)} 年"
holder.countDown.setTextColor(android.graphics.Color.RED)
} else {
holder.countDown.text = "已经过了 ${abs(day)} 天"
holder.countDown.setTextColor(android.graphics.Color.RED)
}
}
override fun getItemCount(): Int = list.size
}
其中需要用到一个工具类去计算目标时间和系统时间相差的天数。由于用毫秒相减的方式对于需求来说并不适合,这里计算的方法是硬算有多少天。
@SuppressLint("SimpleDateFormat")
fun getCountDown(date: String): Int {
val nowYear = getYear()
val nowDay = getDayOfYear()
val target = Calendar.getInstance()
val ft = SimpleDateFormat("yyyy-MM-dd")
val targetDate:Date?
try {
targetDate = ft.parse(date)!!
}catch (e: ParseException) {
e.printStackTrace()
return 0
}
target.time = targetDate
val targetYear = target.get(Calendar.YEAR)
val targetDay = target.get(Calendar.DAY_OF_YEAR)
if (nowYear == targetYear) {
return targetDay - nowDay
} else if (targetYear < nowYear) {
return (targetYear - nowYear) * 365
} else {
var days = 0
for (i in nowYear..targetYear) {
if (i == nowYear) {
days += if(GregorianCalendar().isLeapYear(nowYear)) {
365 - nowDay
} else {
366 - nowDay
}
} else if (i == targetYear) {
days += targetDay
} else {
days += if(GregorianCalendar().isLeapYear(i)) {
365
} else {
366
}
}
}
return days
}
}
3.2 MainActivity
这是整个app的主界面。他要做的工作如下(这里其实应该使用MVVM的方式,但是因为还没有深入了解过MVVM框架,这些事暂时由Activity完成):
- 在启动app的时候,判断一下是否有需要修改的事件日期。比如昨天的一个事件是每月重复,就需要先更新。
- 然后去数据库里读取数据,把数据传递给Adapter。
- 给控件添加点击事件
class MainActivity : AppCompatActivity() , View.OnClickListener{
var width: Int = 0
lateinit var myHelper: MyDatabaseHelper
private val databaseName = "specialDay.db"
private lateinit var db: SQLiteDatabase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
setContentView(R.layout.activity_main)
setting.setOnClickListener(this)
add.setOnClickListener(this)
width = DeviceUtils.getScreenWidth(this)
myHelper = MyDatabaseHelper(this, databaseName, 1)
db = myHelper.writableDatabase
checkRepeat()
initContent()
}
override fun onClick(v: View) {
when (v.id) {
R.id.setting -> {
val sql = "delete from event"
db.execSQL(sql)
Toast.makeText(this, "11111", Toast.LENGTH_SHORT).show()
Log.d("MainActivity", width.toString())
}
R.id.add -> {
val intent = Intent(this, AddEventActivity::class.java)
startActivity(intent)
finish()
}
}
}
private fun initContent() {
val fakeData = getData()
Log.d("MainActivity", fakeData.size.toString())
val adapter = EventItemAdapter(fakeData)
val manager = LinearLayoutManager(this)
rv_content.layoutManager = manager
rv_content.adapter = adapter
}
@SuppressLint("SimpleDateFormat")
private fun getData(): List<EventItem>{
val sql = "select * from event order by event_date"
val cursor = db.rawQuery(sql, null)
val expired = ArrayList<EventItem>()
val unExpired = ArrayList<EventItem>()
val ft = SimpleDateFormat("yyyy-MM-dd")
if (cursor.moveToFirst()) {
val calendar = Calendar.getInstance()
do {
val id = cursor.getInt(0)
val title = cursor.getString(1)
val type = cursor.getInt(2)
val date = cursor.getString(3)
val time = ft.parse(date)!!
calendar.time = time
val repeat = cursor.getInt(4)
val year = calendar.get(Calendar.YEAR)
val day = calendar.get(Calendar.DAY_OF_YEAR)
if (year < DateUtil.getYear() ||
(year == DateUtil.getYear() && day < DateUtil.getDayOfYear())) {
expired.add(0, EventItem(id, title, type, date, repeat))
} else {
unExpired.add(EventItem(id, title, type, date, repeat))
}
}while (cursor.moveToNext())
}
cursor.close()
return unExpired + expired
}
@SuppressLint("SimpleDateFormat")
private fun checkRepeat() {
val nowDate = "${DateUtil.getYear()}-${DateUtil.getMonth()}-${DateUtil.getDayOfMonth()}"
val sql = "select * from event where event_date < ? and repeat != 0"
val db = myHelper.writableDatabase
val cursor = db.rawQuery(sql, arrayOf(nowDate))
if (cursor.moveToFirst()) {
val calendar = Calendar.getInstance()
val ft = SimpleDateFormat("yyyy-MM-dd")
do {
val id = cursor.getInt(0)
val repeat = cursor.getInt(4)
Log.d("MainActivity", repeat.toString())
val date = cursor.getString(3)
Log.d("MainActivity", "date: $date")
Log.d("MainActivity", "id: $id")
val time = ft.parse(date)!!
calendar.time = time
var year = calendar.get(Calendar.YEAR)
var month = calendar.get(Calendar.MONTH) + 1
val day = calendar.get(Calendar.DAY_OF_MONTH)
if (repeat == 1) {
year += 1
} else if (repeat == 2) {
month += 1
Log.d("MainActivity", "月份+1")
}
val newDate = "$year-$month-$day"
Log.d("MainActivity", "newDate $newDate")
val updateSql = "update event set event_date = ? where id = ?"
db.execSQL(updateSql, arrayOf(newDate, id))
} while (cursor.moveToNext())
}
cursor.close()
}
3.3 AddEventActivity
添加事件方面,有选择日期和选择类型、是否重复的选项。
3.3.1 选择日期
选择日期主要用到了DatePicker控件,然后利用AlertDialog弹出一个对话框的形式,效果如下:
首先,我们要先获取当前系统时间,用全局变量存储。
private fun initDateTime() {
year = DateUtil.getYear()
month = DateUtil.getMonth()
day = DateUtil.getDayOfMonth()
}
然后添加点击事件弹出对话框。
private fun chooseDate() {
val dateStr = StringBuffer()
val dialogView = View.inflate(this, R.layout.dialog_date, null)
val datePicker: DatePicker = dialogView.findViewById(R.id.datePicker)!!
datePicker.init(year, month - 1, day, this)
AlertDialog.Builder(this).apply {
setPositiveButton("设置") {dialog, _ ->
mDate = dateStr.append(year.toString()).append("-")
.append(month.toString()).append("-").append(day.toString()).toString()
add_tv_date.text = mDate
dialog.dismiss()
}
setNegativeButton("取消") {dialog, _ ->
dialog.dismiss()
}
setTitle("选择日期")
create()
setView(dialogView)
show()
}
}
同时还需要实现接口DatePicker.OnDateChangedListener,重写方法onDateChanged
override fun onDateChanged(view: DatePicker?, year: Int, monthOfYear: Int, dayOfMonth: Int) {
this.year = year
this.month = monthOfYear + 1
this.day = dayOfMonth
}
3.3.2 选择类型和是否重复
这两个都是用了下拉框Spinner实现。
后续类型应该是从数据库中读取,方便用户自定义类型。不过这里一开始是写死的。在arrays.xml里写死了两个Spinner的数据。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="repeat">
<item>不重复</item>
<item>每年</item>
<item>每月</item>
</string-array>
<string-array name="type">
<item>事件</item>
<item>生日</item>
<item>爱情</item>
<item>生活</item>
<item>节日</item>
<item>娱乐</item>
<item>学习</item>
<item>工作</item>
</string-array>
</resources>
然后写两个内部类实现接口AdapterView.OnItemSelectedListener来监听选择的内容
inner class RepeatSelectListener: AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
repeat = position
}
override fun onNothingSelected(parent: AdapterView<*>?) {
repeat = 0
}
}
inner class TypeSelectListener: AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
mType = position
}
override fun onNothingSelected(parent: AdapterView<*>?) {
mType = 0
}
}
三、总结
这篇文章是实现app过程的笔记。
其实整个还有很多的不足。比如可以采用MVVM框架,让Activity的任务不要那么繁重;后续添加又滑删除事件的操作,点击标题可以选择分类进行查看事件等功能。
前面也声明了这只是一个用来巩固知识学习的练手app,如果想要源码的小伙伴可以戳这里:SpecialDay: 一个简单的Android应用。用于记录各种特殊日子。