diff --git a/app/build.gradle b/app/build.gradle index 4c56948..f12fb9a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' +apply plugin: "kotlin-kapt" apply plugin: "androidx.navigation.safeargs.kotlin" android { @@ -51,4 +52,16 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + + /*** Room Dependencies Start ***/ + def room_version = "2.2.5" + implementation "androidx.room:room-runtime:$room_version" + kapt "androidx.room:room-compiler:$room_version" + + // optional - Kotlin Extensions and Coroutines support for Room + implementation "androidx.room:room-ktx:$room_version" + + // optional - Test helpers + testImplementation "androidx.room:room-testing:$room_version" + /*** Room Dependencies End ***/ } \ No newline at end of file diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/AddTodoFragment.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/AddTodoFragment.kt index 0be5ef1..b4a0592 100644 --- a/app/src/main/java/tw/andyang/kotlinandroidworkshop/AddTodoFragment.kt +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/AddTodoFragment.kt @@ -11,6 +11,9 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import kotlinx.android.synthetic.main.fragment_add_todo.* +import tw.andyang.kotlinandroidworkshop.database.AppDatabase +import tw.andyang.kotlinandroidworkshop.mvvm.AnyViewModelFactory +import tw.andyang.kotlinandroidworkshop.repository.TodoItemRepository class AddTodoFragment : Fragment() { @@ -40,7 +43,12 @@ class AddTodoFragment : Fragment() { editTodo.setText(args.memo) editTodo.setSelection(args.memo.length) - val todoViewModel = ViewModelProvider(requireActivity()).get(TodoViewModel::class.java) + val todoItemDb = AppDatabase.getInstance(requireActivity().applicationContext) + val todoItemRepo = TodoItemRepository(todoItemDb) + val viewModelFactory = AnyViewModelFactory { + TodoViewModel(todoItemRepo) + } + val todoViewModel = ViewModelProvider(requireActivity(), viewModelFactory).get(TodoViewModel::class.java) buttonAdd.setOnClickListener { if (editTodo.text.isNullOrEmpty()) { @@ -49,7 +57,8 @@ class AddTodoFragment : Fragment() { // clear error editTodo.error = null // post data to view model - todoViewModel.onNewTodo.postValue(editTodo.text.toString()) + val title = editTodo.text.toString() + todoViewModel.createNewTodo(title) // hide soft keyboard when item added view.clearFocus() inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/OnTodoChangeListener.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/OnTodoChangeListener.kt new file mode 100644 index 0000000..821ce17 --- /dev/null +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/OnTodoChangeListener.kt @@ -0,0 +1,5 @@ +package tw.andyang.kotlinandroidworkshop + +interface OnTodoChangeListener { + fun onChange(todo: Todo.Item) +} \ No newline at end of file diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/Todo.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/Todo.kt index d3a3984..1adb5a4 100644 --- a/app/src/main/java/tw/andyang/kotlinandroidworkshop/Todo.kt +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/Todo.kt @@ -1,10 +1,14 @@ package tw.andyang.kotlinandroidworkshop +import java.util.* + sealed class Todo(val viewType: Int) { data class Title(val text: String) : Todo(TYPE_TITLE) data class Item( + val id: Int, val memo: String, - val checked: Boolean + val checked: Boolean, + val createdAt: Date ) : Todo(TYPE_ITEM) companion object { diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoAdapter.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoAdapter.kt index 928f01c..610e683 100644 --- a/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoAdapter.kt +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoAdapter.kt @@ -8,6 +8,7 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.item_todo.view.* +import java.text.SimpleDateFormat class TodoAdapter : ListAdapter( object : DiffUtil.ItemCallback() { @@ -21,6 +22,8 @@ class TodoAdapter : ListAdapter( } ) { + var onTodoChangeListener: OnTodoChangeListener? = null + override fun getItemViewType(position: Int): Int { return getItem(position).viewType } @@ -28,7 +31,7 @@ class TodoAdapter : ListAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { Todo.TYPE_TITLE -> TodoTitleViewHolder(parent) - else -> TodoViewHolder(parent) + else -> TodoViewHolder(parent, onTodoChangeListener) } } @@ -40,15 +43,21 @@ class TodoAdapter : ListAdapter( } } -class TodoViewHolder(parent: ViewGroup) : RecyclerView.ViewHolder( +class TodoViewHolder(parent: ViewGroup, private val onTodoChangeListener: OnTodoChangeListener?) : RecyclerView.ViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.item_todo, parent, false) ) { private val checkbox: AppCompatCheckBox = itemView.checkbox + private val date = itemView.create_at fun bind(todo: Todo.Item) { checkbox.text = todo.memo checkbox.isChecked = todo.checked + checkbox.setOnClickListener { view -> + onTodoChangeListener?.onChange(Todo.Item(todo.id, todo.memo, !todo.checked, todo.createdAt)) + } + + date.text = SimpleDateFormat.getDateTimeInstance().format(todo.createdAt) } } diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoListFragment.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoListFragment.kt index 0a6c152..a6a429d 100644 --- a/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoListFragment.kt +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoListFragment.kt @@ -11,6 +11,9 @@ import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import kotlinx.android.synthetic.main.fragment_todo_list.* +import tw.andyang.kotlinandroidworkshop.database.AppDatabase +import tw.andyang.kotlinandroidworkshop.mvvm.AnyViewModelFactory +import tw.andyang.kotlinandroidworkshop.repository.TodoItemRepository class TodoListFragment : Fragment() { @@ -25,7 +28,20 @@ class TodoListFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val adapter = TodoAdapter() + val todoItemDb = AppDatabase.getInstance(requireActivity().applicationContext) + val todoItemRepo = TodoItemRepository(todoItemDb) + val viewModelFactory = AnyViewModelFactory { + TodoViewModel(todoItemRepo) + } + val todoViewModel = ViewModelProvider(requireActivity(), viewModelFactory).get(TodoViewModel::class.java) + + val adapter = TodoAdapter().apply { + onTodoChangeListener = object : OnTodoChangeListener { + override fun onChange(todo: Todo.Item) { + todoViewModel.updateTodo(todo) + } + } + } recyclerView.adapter = adapter recyclerView.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) @@ -36,8 +52,6 @@ class TodoListFragment : Fragment() { ) ) - val todoViewModel = ViewModelProvider(requireActivity()).get(TodoViewModel::class.java) - todoViewModel.todoLiveData.observe(viewLifecycleOwner, Observer { todos: List -> adapter.submitList(todos) }) diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoViewModel.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoViewModel.kt index 4386393..f10b239 100644 --- a/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoViewModel.kt +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/TodoViewModel.kt @@ -1,19 +1,52 @@ package tw.andyang.kotlinandroidworkshop -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import androidx.lifecycle.* +import kotlinx.coroutines.launch +import tw.andyang.kotlinandroidworkshop.database.TodoItem +import tw.andyang.kotlinandroidworkshop.repository.TodoItemRepository +import java.util.* -class TodoViewModel : ViewModel() { +class TodoViewModel(private val repository: TodoItemRepository) : ViewModel() { - val onNewTodo = MutableLiveData() + private val title = Todo.Title("This is a title") val todoLiveData: LiveData> = MediatorLiveData>().apply { - addSource(onNewTodo) { text -> - val todo = Todo.Item(text, false) - this.value = this.value!! + listOf(todo) + val source = repository.getTodoItems().map { + it.map { todoItem -> + Todo.Item( + todoItem.id, + todoItem.title, + todoItem.done, + todoItem.createdAt + ) + } + } + addSource(source) { + this.value = mutableListOf(title) + it + } + value = mutableListOf(title) + } + + fun createNewTodo(title: String) { + val todoItem = TodoItem( + title = title, + done = false, + createdAt = Date() + ) + viewModelScope.launch { + repository.insertTodoItem(todoItem) + } + } + + fun updateTodo(todo: Todo.Item) { + val todoItem = TodoItem( + title = todo.memo, + done = todo.checked, + createdAt = todo.createdAt + ).apply { id = todo.id } + + viewModelScope.launch { + repository.updateTodoItem(todoItem) } - value = mutableListOf(Todo.Title("This is a title")) } } \ No newline at end of file diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/AppDatabase.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/AppDatabase.kt new file mode 100644 index 0000000..73b73bf --- /dev/null +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/AppDatabase.kt @@ -0,0 +1,36 @@ +package tw.andyang.kotlinandroidworkshop.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +@Database( + version = 1, + entities = [ + TodoItem::class + ], + exportSchema = false +) +@TypeConverters(Converters::class) +abstract class AppDatabase: RoomDatabase() { + companion object { + private const val DATABASE_NAME = "todo_list_db" + + // For Singleton instantiation + @Volatile private var instance: AppDatabase? = null + + fun getInstance(context: Context): AppDatabase { + return instance ?: synchronized(this) { + instance ?: buildDatabase(context).also { instance = it } + } + } + + private fun buildDatabase(context: Context): AppDatabase { + return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME).build() + } + } + + abstract fun todoItemDao(): TodoItemDao +} \ No newline at end of file diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/Converters.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/Converters.kt new file mode 100644 index 0000000..c469256 --- /dev/null +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/Converters.kt @@ -0,0 +1,16 @@ +package tw.andyang.kotlinandroidworkshop.database + +import androidx.room.TypeConverter +import java.util.* + +class Converters { + @TypeConverter + fun fromTimestamp(value: Long?): Date? { + return value?.let { Date(it) } + } + + @TypeConverter + fun dateToTimestamp(date: Date?): Long? { + return date?.time + } +} diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/GenericDao.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/GenericDao.kt new file mode 100644 index 0000000..92e3c3c --- /dev/null +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/GenericDao.kt @@ -0,0 +1,40 @@ +package tw.andyang.kotlinandroidworkshop.database + +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Update + +interface GenericDao { + + /** + * Insert an object in the database. + * + * @param obj the object to be inserted. + */ + @Insert + suspend fun insert(obj: T) + + /** + * Insert an array of objects in the database. + * + * @param obj the objects to be inserted. + */ + @Insert + suspend fun insert(vararg obj: T) + + /** + * Update an object from the database. + * + * @param obj the object to be updated + */ + @Update + suspend fun update(obj: T) + + /** + * Delete an object from the database + * + * @param obj the object to be deleted + */ + @Delete + suspend fun delete(obj: T) +} \ No newline at end of file diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/TodoItem.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/TodoItem.kt new file mode 100644 index 0000000..2c5f505 --- /dev/null +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/TodoItem.kt @@ -0,0 +1,26 @@ +package tw.andyang.kotlinandroidworkshop.database + +import androidx.room.* +import java.util.* + +@Entity( + tableName = TodoItem.TABLE_NAME +) +data class TodoItem ( + @ColumnInfo(name = COLUMN_TITLE) var title: String, + @ColumnInfo(name = COLUMN_DONE) var done: Boolean, + @ColumnInfo(name = COLUMN_CREATED_AT) var createdAt: Date +) { + companion object { + const val TABLE_NAME = "todo_items" + + const val COLUMN_ID = "id" + const val COLUMN_TITLE = "title" + const val COLUMN_DONE = "done" + const val COLUMN_CREATED_AT = "created_at" + } + + // 必須為 var 才會有 setter + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = COLUMN_ID) var id: Int = 0 +} \ No newline at end of file diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/TodoItemDao.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/TodoItemDao.kt new file mode 100644 index 0000000..9ac617a --- /dev/null +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/database/TodoItemDao.kt @@ -0,0 +1,12 @@ +package tw.andyang.kotlinandroidworkshop.database + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Query + +@Dao +interface TodoItemDao: GenericDao { + + @Query("SELECT * FROM ${TodoItem.TABLE_NAME} ORDER BY ${TodoItem.COLUMN_CREATED_AT} DESC") + fun findAll(): LiveData> +} \ No newline at end of file diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/mvvm/AnyViewModelFactory.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/mvvm/AnyViewModelFactory.kt new file mode 100644 index 0000000..26b821e --- /dev/null +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/mvvm/AnyViewModelFactory.kt @@ -0,0 +1,12 @@ +package tw.andyang.kotlinandroidworkshop.mvvm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class AnyViewModelFactory(val creator: () -> T) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + return creator() as T + } + +} \ No newline at end of file diff --git a/app/src/main/java/tw/andyang/kotlinandroidworkshop/repository/TodoItemRepository.kt b/app/src/main/java/tw/andyang/kotlinandroidworkshop/repository/TodoItemRepository.kt new file mode 100644 index 0000000..a39a282 --- /dev/null +++ b/app/src/main/java/tw/andyang/kotlinandroidworkshop/repository/TodoItemRepository.kt @@ -0,0 +1,21 @@ +package tw.andyang.kotlinandroidworkshop.repository + +import androidx.lifecycle.LiveData +import tw.andyang.kotlinandroidworkshop.database.AppDatabase +import tw.andyang.kotlinandroidworkshop.database.TodoItem + +class TodoItemRepository( + private val database: AppDatabase +) { + suspend fun insertTodoItem(todoItem: TodoItem) { + database.todoItemDao().insert(todoItem) + } + + suspend fun updateTodoItem(todoItem: TodoItem) { + database.todoItemDao().update(todoItem) + } + + fun getTodoItems(): LiveData> { + return database.todoItemDao().findAll() + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/item_todo.xml b/app/src/main/res/layout/item_todo.xml index fe33dd8..526a52e 100644 --- a/app/src/main/res/layout/item_todo.xml +++ b/app/src/main/res/layout/item_todo.xml @@ -8,8 +8,14 @@ + + \ No newline at end of file diff --git a/app/src/test/java/tw/andyang/kotlinandroidworkshop/DateTimeTest.kt b/app/src/test/java/tw/andyang/kotlinandroidworkshop/DateTimeTest.kt new file mode 100644 index 0000000..532cadb --- /dev/null +++ b/app/src/test/java/tw/andyang/kotlinandroidworkshop/DateTimeTest.kt @@ -0,0 +1,20 @@ +package tw.andyang.kotlinandroidworkshop + +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.* + +class DateTimeTest { + @Test + fun testDateTime() { + val iso8601DatetimeString = "2020-09-21T04:34:56.789Z" + // 上面這個 Z 這是 UTC+0 時區!不要把這個 Z 當一般文字處理! + // 拜託不要再用 yyyy-MM-dd'T'HH:mm:ss.SSS'Z' + val date = SimpleDateFormat("y-M-d'T'H:m:s.SSSX").parse(iso8601DatetimeString) + val localTimeString = SimpleDateFormat("yyyy-MM-dd HH:mm:ssX").apply { + timeZone = TimeZone.getDefault() + }.format(date) + + println(localTimeString) + } +} \ No newline at end of file