Browse Source

Add Start/Finish date support for MAL (#2672)

* Started working on MAL support

* Added date picker UI

* Replaced Date with Calendar

* Added MAL remote update functionality

* Join url methods listEntryUrl and editUrl

* Removed unused methods

* Renamed mangaEditPayload to mangaEditPostBody

* Moved code to separate method

* Uniformed code to project conventions

* Removed wildcard import

* Moved MyAnimeListTrack to private class

* Improved MyAnimeListTrack name

* Removed redundant code

* Add start/finish date in local database

* Fixed format and improved codestyle

* Fixed typo and fixed TrackHolder's format

* Improved code style

* Ran linter

* Add database updating methods

* Change date format to fit new layout

* Review Commits

* Improve SetTrackReadingDatesDialog readability

* Move private methods after public ones

* Fixed SQL error

* Fixed remove date button

* Updated MaterialDesign methods to latest version

* Replaced dismissDialog() with dialog.Dismiss()

* Fixed wrong string resource usage.
Hawk of the Death 5 years ago
parent
commit
f7c139030f

+ 5 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt

@@ -20,7 +20,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
         /**
          * Version of the database.
          */
-        const val DATABASE_VERSION = 8
+        const val DATABASE_VERSION = 9
     }
 
     override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
@@ -69,6 +69,10 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
             db.execSQL(MangaTable.createLibraryIndexQuery)
             db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
         }
+        if (oldVersion < 9) {
+            db.execSQL(TrackTable.addStartDate)
+            db.execSQL(TrackTable.addFinishDate)
+        }
     }
 
     override fun onConfigure(db: SupportSQLiteDatabase) {

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt

@@ -11,12 +11,14 @@ import com.pushtorefresh.storio.sqlite.queries.InsertQuery
 import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
 import eu.kanade.tachiyomi.data.database.models.Track
 import eu.kanade.tachiyomi.data.database.models.TrackImpl
+import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_FINISH_DATE
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LIBRARY_ID
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MEDIA_ID
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
+import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_START_DATE
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
 import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE
@@ -54,6 +56,8 @@ class TrackPutResolver : DefaultPutResolver<Track>() {
         put(COL_STATUS, obj.status)
         put(COL_TRACKING_URL, obj.tracking_url)
         put(COL_SCORE, obj.score)
+        put(COL_START_DATE, obj.started_reading_date)
+        put(COL_FINISH_DATE, obj.finished_reading_date)
     }
 }
 
@@ -71,6 +75,8 @@ class TrackGetResolver : DefaultGetResolver<Track>() {
         status = cursor.getInt(cursor.getColumnIndex(COL_STATUS))
         score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE))
         tracking_url = cursor.getString(cursor.getColumnIndex(COL_TRACKING_URL))
+        started_reading_date = cursor.getLong(cursor.getColumnIndex(COL_START_DATE))
+        finished_reading_date = cursor.getLong(cursor.getColumnIndex(COL_FINISH_DATE))
     }
 }
 

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt

@@ -24,12 +24,18 @@ interface Track : Serializable {
 
     var status: Int
 
+    var started_reading_date: Long
+
+    var finished_reading_date: Long
+
     var tracking_url: String
 
     fun copyPersonalFrom(other: Track) {
         last_chapter_read = other.last_chapter_read
         score = other.score
         status = other.status
+        started_reading_date = other.started_reading_date
+        finished_reading_date = other.finished_reading_date
     }
 
     companion object {

+ 4 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt

@@ -22,6 +22,10 @@ class TrackImpl : Track {
 
     override var status: Int = 0
 
+    override var started_reading_date: Long = 0
+
+    override var finished_reading_date: Long = 0
+
     override var tracking_url: String = ""
 
     override fun equals(other: Any?): Boolean {

+ 12 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt

@@ -26,6 +26,10 @@ object TrackTable {
 
     const val COL_TRACKING_URL = "remote_url"
 
+    const val COL_START_DATE = "start_date"
+
+    const val COL_FINISH_DATE = "finish_date"
+
     val createTableQuery: String
         get() = """CREATE TABLE $TABLE(
             $COL_ID INTEGER NOT NULL PRIMARY KEY,
@@ -39,6 +43,8 @@ object TrackTable {
             $COL_STATUS INTEGER NOT NULL,
             $COL_SCORE FLOAT NOT NULL,
             $COL_TRACKING_URL TEXT NOT NULL,
+            $COL_START_DATE LONG NOT NULL,
+            $COL_FINISH_DATE LONG NOT NULL,
             UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE,
             FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
             ON DELETE CASCADE
@@ -49,4 +55,10 @@ object TrackTable {
 
     val addLibraryId: String
         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL"
+
+    val addStartDate: String
+        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_START_DATE LONG NOT NULL DEFAULT 0"
+
+    val addFinishDate: String
+        get() = "ALTER TABLE $TABLE ADD COLUMN $COL_FINISH_DATE LONG NOT NULL DEFAULT 0"
 }

+ 3 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt

@@ -22,6 +22,9 @@ abstract class TrackService(val id: Int) {
     // Name of the manga sync service to display
     abstract val name: String
 
+    // Application and remote support for reading dates
+    open val supportsReadingDates: Boolean = false
+
     @DrawableRes
     abstract fun getLogo(): Int
 

+ 4 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt

@@ -24,6 +24,10 @@ class TrackSearch : Track {
 
     override var status: Int = 0
 
+    override var started_reading_date: Long = 0
+
+    override var finished_reading_date: Long = 0
+
     override lateinit var tracking_url: String
 
     var cover_url: String = ""

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt

@@ -34,6 +34,8 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
     override val name: String
         get() = "MyAnimeList"
 
+    override val supportsReadingDates: Boolean = true
+
     override fun getLogo() = R.drawable.ic_tracker_mal
 
     override fun getLogoColor() = Color.rgb(46, 81, 162)

+ 205 - 10
app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt

@@ -8,10 +8,15 @@ import eu.kanade.tachiyomi.network.GET
 import eu.kanade.tachiyomi.network.POST
 import eu.kanade.tachiyomi.network.asObservable
 import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.util.lang.toCalendar
 import eu.kanade.tachiyomi.util.selectInt
 import eu.kanade.tachiyomi.util.selectText
 import java.io.BufferedReader
 import java.io.InputStreamReader
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.GregorianCalendar
+import java.util.Locale
 import java.util.zip.GZIPInputStream
 import okhttp3.FormBody
 import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -76,14 +81,29 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
 
     fun updateLibManga(track: Track): Observable<Track> {
         return Observable.defer {
-            authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track)))
+            // Get track data
+            val response = authClient.newCall(GET(url = editPageUrl(track.media_id))).execute()
+            val editData = response.use {
+                val page = Jsoup.parse(it.consumeBody())
+
+                // Extract track data from MAL page
+                extractDataFromEditPage(page).apply {
+                    // Apply changes to the just fetched data
+                    copyPersonalFrom(track)
+                }
+            }
+
+            // Update remote
+            authClient.newCall(POST(url = editPageUrl(track.media_id), body = mangaEditPostBody(editData)))
                     .asObservableSuccess()
-                    .map { track }
+                    .map {
+                        track
+                    }
         }
     }
 
     fun findLibManga(track: Track): Observable<Track?> {
-        return authClient.newCall(GET(url = listEntryUrl(track.media_id)))
+        return authClient.newCall(GET(url = editPageUrl(track.media_id)))
                 .asObservable()
                 .map { response ->
                     var libTrack: Track? = null
@@ -97,6 +117,8 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
                                 status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
                                 score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull()
                                         ?: 0f
+                                started_reading_date = trackForm.searchDatePicker("#add_manga_start_date")
+                                finished_reading_date = trackForm.searchDatePicker("#add_manga_finish_date")
                             }
                         }
                     }
@@ -150,6 +172,8 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
                         score = it.selectInt("my_score").toFloat()
                         total_chapters = it.selectInt("manga_chapters")
                         tracking_url = mangaUrl(media_id)
+                        started_reading_date = it.searchDateXml("my_start_date")
+                        finished_reading_date = it.searchDateXml("my_finish_date")
                     }
                 }
                 .toList()
@@ -194,6 +218,35 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
         }
     }
 
+    private fun extractDataFromEditPage(page: Document): MyAnimeListEditData {
+        val tables = page.select("form#main-form table")
+
+        return MyAnimeListEditData(
+                entry_id = tables[0].select("input[name=entry_id]").`val`(), // Always 0
+                manga_id = tables[0].select("#manga_id").`val`(),
+                status = tables[0].select("#add_manga_status > option[selected]").`val`(),
+                num_read_volumes = tables[0].select("#add_manga_num_read_volumes").`val`(),
+                last_completed_vol = tables[0].select("input[name=last_completed_vol]").`val`(), // Always empty
+                num_read_chapters = tables[0].select("#add_manga_num_read_chapters").`val`(),
+                score = tables[0].select("#add_manga_score > option[selected]").`val`(),
+                start_date_month = tables[0].select("#add_manga_start_date_month > option[selected]").`val`(),
+                start_date_day = tables[0].select("#add_manga_start_date_day > option[selected]").`val`(),
+                start_date_year = tables[0].select("#add_manga_start_date_year > option[selected]").`val`(),
+                finish_date_month = tables[0].select("#add_manga_finish_date_month > option[selected]").`val`(),
+                finish_date_day = tables[0].select("#add_manga_finish_date_day > option[selected]").`val`(),
+                finish_date_year = tables[0].select("#add_manga_finish_date_year > option[selected]").`val`(),
+                tags = tables[1].select("#add_manga_tags").`val`(),
+                priority = tables[1].select("#add_manga_priority > option[selected]").`val`(),
+                storage_type = tables[1].select("#add_manga_storage_type > option[selected]").`val`(),
+                num_retail_volumes = tables[1].select("#add_manga_num_retail_volumes").`val`(),
+                num_read_times = tables[1].select("#add_manga_num_read_times").`val`(),
+                reread_value = tables[1].select("#add_manga_reread_value > option[selected]").`val`(),
+                comments = tables[1].select("#add_manga_comments").`val`(),
+                is_asked_to_discuss = tables[1].select("#add_manga_is_asked_to_discuss > option[selected]").`val`(),
+                sns_post_type = tables[1].select("#add_manga_sns_post_type > option[selected]").`val`()
+        )
+    }
+
     companion object {
         const val CSRF = "csrf_token"
 
@@ -228,19 +281,15 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
                 .appendQueryParameter("go", "export")
                 .toString()
 
-        private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
-                .appendPath("edit.json")
+        private fun editPageUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
+                .appendPath(mediaId.toString())
+                .appendPath("edit")
                 .toString()
 
         private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
                 .appendPath("add.json")
                 .toString()
 
-        private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
-                .appendPath(mediaId.toString())
-                .appendPath("edit")
-                .toString()
-
         private fun loginPostBody(username: String, password: String, csrf: String): RequestBody {
             return FormBody.Builder()
                     .add("user_name", username)
@@ -269,6 +318,53 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
             return body.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
         }
 
+        private fun mangaEditPostBody(track: MyAnimeListEditData): RequestBody {
+            return FormBody.Builder()
+                    .add("entry_id", track.entry_id)
+                    .add("manga_id", track.manga_id)
+                    .add("add_manga[status]", track.status)
+                    .add("add_manga[num_read_volumes]", track.num_read_volumes)
+                    .add("last_completed_vol", track.last_completed_vol)
+                    .add("add_manga[num_read_chapters]", track.num_read_chapters)
+                    .add("add_manga[score]", track.score)
+                    .add("add_manga[start_date][month]", track.start_date_month)
+                    .add("add_manga[start_date][day]", track.start_date_day)
+                    .add("add_manga[start_date][year]", track.start_date_year)
+                    .add("add_manga[finish_date][month]", track.finish_date_month)
+                    .add("add_manga[finish_date][day]", track.finish_date_day)
+                    .add("add_manga[finish_date][year]", track.finish_date_year)
+                    .add("add_manga[tags]", track.tags)
+                    .add("add_manga[priority]", track.priority)
+                    .add("add_manga[storage_type]", track.storage_type)
+                    .add("add_manga[num_retail_volumes]", track.num_retail_volumes)
+                    .add("add_manga[num_read_times]", track.num_read_chapters)
+                    .add("add_manga[reread_value]", track.reread_value)
+                    .add("add_manga[comments]", track.comments)
+                    .add("add_manga[is_asked_to_discuss]", track.is_asked_to_discuss)
+                    .add("add_manga[sns_post_type]", track.sns_post_type)
+                    .add("submitIt", track.submitIt)
+                    .build()
+        }
+
+        private fun Element.searchDateXml(field: String): Long {
+            val text = selectText(field, "0000-00-00")!!
+            // MAL sets the data to 0000-00-00 when date is invalid or missing
+            if (text == "0000-00-00")
+                return 0L
+
+            return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(text)?.time ?: 0L
+        }
+
+        private fun Element.searchDatePicker(id: String): Long {
+            val month = select(id + "_month > option[selected]").`val`().toIntOrNull()
+            val day = select(id + "_day > option[selected]").`val`().toIntOrNull()
+            val year = select(id + "_year > option[selected]").`val`().toIntOrNull()
+            if (year == null || month == null || day == null)
+                return 0L
+
+            return GregorianCalendar(year, month - 1, day).timeInMillis
+        }
+
         private fun Element.searchTitle() = select("strong").text()!!
 
         private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
@@ -302,4 +398,103 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
             else -> 1
         }
     }
+
+    private class MyAnimeListEditData(
+        // entry_id
+        var entry_id: String,
+
+        // manga_id
+        var manga_id: String,
+
+        // add_manga[status]
+        var status: String,
+
+        // add_manga[num_read_volumes]
+        var num_read_volumes: String,
+
+        // last_completed_vol
+        var last_completed_vol: String,
+
+        // add_manga[num_read_chapters]
+        var num_read_chapters: String,
+
+        // add_manga[score]
+        var score: String,
+
+        // add_manga[start_date][month]
+        var start_date_month: String, // [1-12]
+
+        // add_manga[start_date][day]
+        var start_date_day: String,
+
+        // add_manga[start_date][year]
+        var start_date_year: String,
+
+        // add_manga[finish_date][month]
+        var finish_date_month: String, // [1-12]
+
+        // add_manga[finish_date][day]
+        var finish_date_day: String,
+
+        // add_manga[finish_date][year]
+        var finish_date_year: String,
+
+        // add_manga[tags]
+        var tags: String,
+
+        // add_manga[priority]
+        var priority: String,
+
+        // add_manga[storage_type]
+        var storage_type: String,
+
+        // add_manga[num_retail_volumes]
+        var num_retail_volumes: String,
+
+        // add_manga[num_read_times]
+        var num_read_times: String,
+
+        // add_manga[reread_value]
+        var reread_value: String,
+
+        // add_manga[comments]
+        var comments: String,
+
+        // add_manga[is_asked_to_discuss]
+        var is_asked_to_discuss: String,
+
+        // add_manga[sns_post_type]
+        var sns_post_type: String,
+
+        // submitIt
+        val submitIt: String = "0"
+    ) {
+        fun copyPersonalFrom(track: Track) {
+            num_read_chapters = track.last_chapter_read.toString()
+            val numScore = track.score.toInt()
+            if (numScore in 1..9)
+                score = numScore.toString()
+            status = track.status.toString()
+            if (track.started_reading_date == 0L) {
+                start_date_month = ""
+                start_date_day = ""
+                start_date_year = ""
+            }
+            if (track.finished_reading_date == 0L) {
+                finish_date_month = ""
+                finish_date_day = ""
+                finish_date_year = ""
+            }
+            track.started_reading_date.toCalendar()?.let { cal ->
+                start_date_month = (cal[Calendar.MONTH] + 1).toString()
+                start_date_day = cal[Calendar.DAY_OF_MONTH].toString()
+                start_date_year = cal[Calendar.YEAR].toString()
+            }
+            track.finished_reading_date.toCalendar()?.let { cal ->
+                finish_date_month = (cal[Calendar.MONTH] + 1).toString()
+                finish_date_day = cal[Calendar.DAY_OF_MONTH].toString()
+                finish_date_year = cal[Calendar.YEAR].toString()
+            }
+        }
+    }
 }

+ 127 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackReadingDatesDialog.kt

@@ -0,0 +1,127 @@
+package eu.kanade.tachiyomi.ui.manga.track
+
+import android.app.Dialog
+import android.os.Bundle
+import android.widget.NumberPicker
+import com.afollestad.materialdialogs.MaterialDialog
+import com.afollestad.materialdialogs.customview.customView
+import com.afollestad.materialdialogs.customview.getCustomView
+import com.bluelinelabs.conductor.Controller
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.ui.base.controller.DialogController
+import eu.kanade.tachiyomi.util.system.toast
+import java.text.DateFormatSymbols
+import java.util.Calendar
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+class SetTrackReadingDatesDialog<T> : DialogController
+        where T : Controller, T : SetTrackReadingDatesDialog.Listener {
+
+    private val item: TrackItem
+
+    private val dateToUpdate: ReadingDate
+
+    constructor(target: T, dateToUpdate: ReadingDate, item: TrackItem) : super(Bundle().apply {
+        putSerializable(SetTrackReadingDatesDialog.KEY_ITEM_TRACK, item.track)
+    }) {
+        targetController = target
+        this.item = item
+        this.dateToUpdate = dateToUpdate
+    }
+
+    @Suppress("unused")
+    constructor(bundle: Bundle) : super(bundle) {
+        val track = bundle.getSerializable(SetTrackReadingDatesDialog.KEY_ITEM_TRACK) as Track
+        val service = Injekt.get<TrackManager>().getService(track.sync_id)!!
+        item = TrackItem(track, service)
+        dateToUpdate = ReadingDate.Start
+    }
+
+    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
+        val item = item
+
+        val dialog = MaterialDialog(activity!!)
+                .title(when (dateToUpdate) {
+                    ReadingDate.Start -> R.string.track_started_reading_date
+                    ReadingDate.Finish -> R.string.track_finished_reading_date
+                })
+                .customView(R.layout.track_date_dialog, dialogWrapContent = false)
+                .positiveButton(android.R.string.ok) { dialog ->
+                    onDialogConfirm(dialog)
+                }
+                .negativeButton(android.R.string.cancel) { dialog ->
+                    dialog.dismiss()
+                }
+                .neutralButton(R.string.action_remove) { dialog ->
+                    val listener = (targetController as? Listener)
+                    listener?.setReadingDate(item, dateToUpdate, 0L)
+                    dialog.dismiss()
+                }
+                .noAutoDismiss()
+
+        onDialogCreated(dialog)
+
+        return dialog
+    }
+
+    private fun onDialogCreated(dialog: MaterialDialog) {
+        val view = dialog.getCustomView()
+
+        val dayPicker: NumberPicker = view.findViewById(R.id.day_picker)
+        val monthPicker: NumberPicker = view.findViewById(R.id.month_picker)
+        val yearPicker: NumberPicker = view.findViewById(R.id.year_picker)
+
+        val monthNames: Array<String> = DateFormatSymbols().months
+        monthPicker.displayedValues = monthNames
+
+        val calendar = Calendar.getInstance()
+        item.track?.let {
+            val date = when (dateToUpdate) {
+                ReadingDate.Start -> it.started_reading_date
+                ReadingDate.Finish -> it.finished_reading_date
+            }
+            if (date != 0L)
+                calendar.timeInMillis = date
+        }
+        dayPicker.value = calendar[Calendar.DAY_OF_MONTH]
+        monthPicker.value = calendar[Calendar.MONTH]
+        yearPicker.maxValue = calendar[Calendar.YEAR]
+        yearPicker.value = calendar[Calendar.YEAR]
+    }
+
+    private fun onDialogConfirm(dialog: MaterialDialog) {
+        val view = dialog.getCustomView()
+
+        val dayPicker: NumberPicker = view.findViewById(R.id.day_picker)
+        val monthPicker: NumberPicker = view.findViewById(R.id.month_picker)
+        val yearPicker: NumberPicker = view.findViewById(R.id.year_picker)
+
+        try {
+            val calendar = Calendar.getInstance().apply { isLenient = false }
+            calendar.set(yearPicker.value, monthPicker.value, dayPicker.value)
+            calendar.time = calendar.time // Throws if invalid
+
+            val listener = (targetController as? Listener)
+            listener?.setReadingDate(item, dateToUpdate, calendar.timeInMillis)
+            dialog.dismiss()
+        } catch (e: Exception) {
+            activity?.toast(R.string.error_invalid_date_supplied)
+        }
+    }
+
+    interface Listener {
+        fun setReadingDate(item: TrackItem, type: ReadingDate, date: Long)
+    }
+
+    enum class ReadingDate {
+        Start,
+        Finish
+    }
+
+    companion object {
+        private const val KEY_ITEM_TRACK = "SetTrackReadingDatesDialog.item.track"
+    }
+}

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt

@@ -40,5 +40,7 @@ class TrackAdapter(controller: TrackController) : RecyclerView.Adapter<TrackHold
         fun onStatusClick(position: Int)
         fun onChaptersClick(position: Int)
         fun onScoreClick(position: Int)
+        fun onStartDateClick(position: Int)
+        fun onFinishDateClick(position: Int)
     }
 }

+ 24 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackController.kt

@@ -21,7 +21,8 @@ class TrackController : NucleusController<TrackControllerBinding, TrackPresenter
         TrackAdapter.OnClickListener,
         SetTrackStatusDialog.Listener,
         SetTrackChaptersDialog.Listener,
-        SetTrackScoreDialog.Listener {
+        SetTrackScoreDialog.Listener,
+        SetTrackReadingDatesDialog.Listener {
 
     private var adapter: TrackAdapter? = null
 
@@ -123,6 +124,20 @@ class TrackController : NucleusController<TrackControllerBinding, TrackPresenter
         SetTrackScoreDialog(this, item).showDialog(router)
     }
 
+    override fun onStartDateClick(position: Int) {
+        val item = adapter?.getItem(position) ?: return
+        if (item.track == null) return
+
+        SetTrackReadingDatesDialog(this, SetTrackReadingDatesDialog.ReadingDate.Start, item).showDialog(router)
+    }
+
+    override fun onFinishDateClick(position: Int) {
+        val item = adapter?.getItem(position) ?: return
+        if (item.track == null) return
+
+        SetTrackReadingDatesDialog(this, SetTrackReadingDatesDialog.ReadingDate.Finish, item).showDialog(router)
+    }
+
     override fun setStatus(item: TrackItem, selection: Int) {
         presenter.setStatus(item, selection)
         binding.swipeRefresh.isRefreshing = true
@@ -138,6 +153,14 @@ class TrackController : NucleusController<TrackControllerBinding, TrackPresenter
         binding.swipeRefresh.isRefreshing = true
     }
 
+    override fun setReadingDate(item: TrackItem, type: SetTrackReadingDatesDialog.ReadingDate, date: Long) {
+        when (type) {
+            SetTrackReadingDatesDialog.ReadingDate.Start -> presenter.setStartDate(item, date)
+            SetTrackReadingDatesDialog.ReadingDate.Finish -> presenter.setFinishDate(item, date)
+        }
+        binding.swipeRefresh.isRefreshing = true
+    }
+
     private companion object {
         const val TAG_SEARCH_CONTROLLER = "track_search_controller"
     }

+ 29 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt

@@ -2,19 +2,34 @@ package eu.kanade.tachiyomi.ui.manga.track
 
 import android.annotation.SuppressLint
 import android.view.View
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.preference.getOrDefault
 import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
+import eu.kanade.tachiyomi.util.view.gone
 import eu.kanade.tachiyomi.util.view.visibleIf
+import java.text.DateFormat
+import kotlinx.android.synthetic.main.track_item.bottom_divider
 import kotlinx.android.synthetic.main.track_item.logo_container
 import kotlinx.android.synthetic.main.track_item.track_chapters
 import kotlinx.android.synthetic.main.track_item.track_details
+import kotlinx.android.synthetic.main.track_item.track_finish_date
 import kotlinx.android.synthetic.main.track_item.track_logo
 import kotlinx.android.synthetic.main.track_item.track_score
 import kotlinx.android.synthetic.main.track_item.track_set
+import kotlinx.android.synthetic.main.track_item.track_start_date
 import kotlinx.android.synthetic.main.track_item.track_status
 import kotlinx.android.synthetic.main.track_item.track_title
+import kotlinx.android.synthetic.main.track_item.vert_divider_3
+import uy.kohesive.injekt.injectLazy
 
 class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
 
+    private val preferences: PreferencesHelper by injectLazy()
+
+    private val dateFormat: DateFormat by lazy {
+        preferences.dateFormat().getOrDefault()
+    }
+
     init {
         val listener = adapter.rowClickListener
 
@@ -24,6 +39,8 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
         track_status.setOnClickListener { listener.onStatusClick(bindingAdapterPosition) }
         track_chapters.setOnClickListener { listener.onChaptersClick(bindingAdapterPosition) }
         track_score.setOnClickListener { listener.onScoreClick(bindingAdapterPosition) }
+        track_start_date.setOnClickListener { listener.onStartDateClick(bindingAdapterPosition) }
+        track_finish_date.setOnClickListener { listener.onFinishDateClick(bindingAdapterPosition) }
     }
 
     @SuppressLint("SetTextI18n")
@@ -42,6 +59,18 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
                     if (track.total_chapters > 0) track.total_chapters else "-"
             track_status.text = item.service.getStatus(track.status)
             track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track)
+
+            if (item.service.supportsReadingDates) {
+                track_start_date.text =
+                        if (track.started_reading_date != 0L) dateFormat.format(track.started_reading_date) else "-"
+                track_finish_date.text =
+                        if (track.finished_reading_date != 0L) dateFormat.format(track.finished_reading_date) else "-"
+            } else {
+                bottom_divider.gone()
+                vert_divider_3.gone()
+                track_start_date.gone()
+                track_finish_date.gone()
+            }
         }
     }
 }

+ 12 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt

@@ -135,4 +135,16 @@ class TrackPresenter(
         }
         updateRemote(track, item.service)
     }
+
+    fun setStartDate(item: TrackItem, date: Long) {
+        val track = item.track!!
+        track.started_reading_date = date
+        updateRemote(track, item.service)
+    }
+
+    fun setFinishDate(item: TrackItem, date: Long) {
+        val track = item.track!!
+        track.finished_reading_date = date
+        updateRemote(track, item.service)
+    }
 }

+ 13 - 0
app/src/main/java/eu/kanade/tachiyomi/util/lang/DateExtensions.kt

@@ -29,3 +29,16 @@ fun Long.toDateKey(): Date {
     cal[Calendar.MILLISECOND] = 0
     return cal.time
 }
+
+/**
+ * Convert epoch long to Calendar instance
+ *
+ * @return Calendar instance at supplied epoch time. Null if epoch was 0.
+ */
+fun Long.toCalendar(): Calendar? {
+    if (this == 0L)
+        return null
+    val cal = Calendar.getInstance()
+    cal.timeInMillis = this
+    return cal
+}

+ 37 - 0
app/src/main/res/layout/track_date_dialog.xml

@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+              xmlns:app="http://schemas.android.com/apk/res-auto"
+              android:layout_width="match_parent"
+              android:layout_height="match_parent"
+              android:orientation="horizontal">
+
+    <eu.kanade.tachiyomi.widget.MinMaxNumberPicker
+        android:id="@+id/day_picker"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:layout_margin="3dp"
+        android:descendantFocusability="blocksDescendants"
+        app:max="31"
+        app:min="1" />
+
+    <eu.kanade.tachiyomi.widget.MinMaxNumberPicker
+        android:id="@+id/month_picker"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:layout_margin="3dp"
+        android:descendantFocusability="blocksDescendants"
+        app:max="11"
+        app:min="0"  />
+
+    <eu.kanade.tachiyomi.widget.MinMaxNumberPicker
+        android:id="@+id/year_picker"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_weight="1"
+        android:layout_margin="3dp"
+        android:descendantFocusability="blocksDescendants"
+        app:min="1900" />
+
+</LinearLayout>

+ 56 - 6
app/src/main/res/layout/track_item.xml

@@ -92,7 +92,6 @@
                 android:gravity="center"
                 android:maxLines="1"
                 android:padding="16dp"
-                app:layout_constraintBottom_toBottomOf="parent"
                 app:layout_constraintEnd_toStartOf="@+id/vert_divider_1"
                 app:layout_constraintStart_toStartOf="parent"
                 app:layout_constraintTop_toBottomOf="@+id/top_divider"
@@ -106,7 +105,7 @@
                 android:layout_marginBottom="8dp"
                 android:alpha="0.25"
                 android:background="?android:attr/textColorHint"
-                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintBottom_toTopOf="@+id/bottom_divider"
                 app:layout_constraintEnd_toStartOf="@+id/track_chapters"
                 app:layout_constraintStart_toEndOf="@+id/track_status"
                 app:layout_constraintTop_toTopOf="parent" />
@@ -121,7 +120,6 @@
                 android:gravity="center"
                 android:maxLines="1"
                 android:padding="16dp"
-                app:layout_constraintBottom_toBottomOf="parent"
                 app:layout_constraintEnd_toStartOf="@+id/vert_divider_2"
                 app:layout_constraintStart_toEndOf="@+id/vert_divider_1"
                 app:layout_constraintTop_toBottomOf="@+id/top_divider"
@@ -135,7 +133,7 @@
                 android:layout_marginBottom="8dp"
                 android:alpha="0.25"
                 android:background="?android:attr/textColorHint"
-                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintBottom_toTopOf="@+id/bottom_divider"
                 app:layout_constraintEnd_toStartOf="@+id/track_score"
                 app:layout_constraintStart_toEndOf="@+id/track_chapters"
                 app:layout_constraintTop_toTopOf="parent" />
@@ -150,12 +148,64 @@
                 android:gravity="center"
                 android:maxLines="1"
                 android:padding="16dp"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintEnd_toEndOf="parent"
                 app:layout_constraintStart_toEndOf="@+id/vert_divider_2"
+                app:layout_constraintEnd_toEndOf="parent"
                 app:layout_constraintTop_toBottomOf="@+id/top_divider"
                 tools:text="10" />
 
+            <View
+                android:id="@+id/bottom_divider"
+                android:layout_width="0dp"
+                android:layout_height="1dp"
+                android:alpha="0.25"
+                android:background="?android:attr/textColorHint"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@+id/track_score" />
+
+            <TextView
+                android:id="@+id/track_start_date"
+                style="@style/TextAppearance.Regular.Body1.Secondary"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:background="@drawable/list_item_selector"
+                android:ellipsize="end"
+                android:gravity="center"
+                android:maxLines="1"
+                android:padding="16dp"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toStartOf="@+id/vert_divider_3"
+                app:layout_constraintTop_toBottomOf="@+id/bottom_divider"
+                tools:text="4/16/2020" />
+
+            <View
+                android:id="@+id/vert_divider_3"
+                android:layout_width="1dp"
+                android:layout_height="0dp"
+                android:layout_marginTop="8dp"
+                android:layout_marginBottom="8dp"
+                android:alpha="0.25"
+                android:background="?android:attr/textColorHint"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintStart_toEndOf="@+id/track_start_date"
+                app:layout_constraintEnd_toStartOf="@+id/track_finish_date"
+                app:layout_constraintTop_toTopOf="@+id/bottom_divider" />
+
+            <TextView
+                android:id="@+id/track_finish_date"
+                style="@style/TextAppearance.Regular.Body1.Secondary"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:background="@drawable/list_item_selector"
+                android:ellipsize="end"
+                android:gravity="center"
+                android:maxLines="1"
+                android:padding="16dp"
+                app:layout_constraintStart_toEndOf="@+id/vert_divider_3"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@+id/bottom_divider"
+                tools:text="4/16/2020" />
+
         </androidx.constraintlayout.widget.ConstraintLayout>
 
     </LinearLayout>

+ 3 - 0
app/src/main/res/values/strings.xml

@@ -475,8 +475,11 @@
     <string name="status">Status</string>
     <string name="track_status">Status</string>
     <string name="track_start_date">Started</string>
+    <string name="track_started_reading_date">Started reading date</string>
+    <string name="track_finished_reading_date">Finished reading date</string>
     <string name="track_type">Type</string>
     <string name="track_author">Author</string>
+    <string name="error_invalid_date_supplied">Invalid date supplied</string>
     <string name="url_not_set">Manga URL not set, please click title and select manga again</string>
 
     <!-- Category activity -->