Add additional lessons to timetable (#1019)

This commit is contained in:
Mikołaj Pich 2020-12-27 14:06:07 +01:00 committed by GitHub
parent 295fd0fd90
commit 9763208688
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 2777 additions and 64 deletions

View File

@ -142,7 +142,7 @@ configurations.all {
}
dependencies {
implementation "io.github.wulkanowy:sdk:0.23.1"
implementation "io.github.wulkanowy:sdk:6edc8531"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1'

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,7 @@ class TimetableLocalTest {
fun createDb() {
testDb = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java)
.build()
timetableDb = TimetableLocal(testDb.timetableDao)
timetableDb = TimetableLocal(testDb.timetableDao, testDb.timetableAdditionalDao)
}
@After

View File

@ -46,7 +46,7 @@ class TimetableRepositoryTest {
fun initApi() {
MockKAnnotations.init(this)
testDb = Room.inMemoryDatabaseBuilder(getApplicationContext(), AppDatabase::class.java).build()
timetableLocal = TimetableLocal(testDb.timetableDao)
timetableLocal = TimetableLocal(testDb.timetableDao, testDb.timetableAdditionalDao)
}
@After
@ -65,12 +65,12 @@ class TimetableRepositoryTest {
))
}
coEvery { timetableRemote.getTimetable(student, semester, any(), any()) } returns listOf(
coEvery { timetableRemote.getTimetable(student, semester, any(), any()) } returns (listOf(
createTimetableLocal(of(2019, 3, 5, 8, 0), 1, "", "Przyroda"),
createTimetableLocal(of(2019, 3, 5, 8, 50), 2, "", "Religia"),
createTimetableLocal(of(2019, 3, 5, 9, 40), 3, "", "W-F"),
createTimetableLocal(of(2019, 3, 5, 10, 30), 4, "", "W-F")
)
) to emptyList())
val lessons = runBlocking {
TimetableRepository(timetableLocal, timetableRemote, timetableNotificationSchedulerHelper).getTimetable(
@ -79,7 +79,7 @@ class TimetableRepositoryTest {
start = LocalDate.of(2019, 3, 5),
end = LocalDate.of(2019, 3, 5),
forceRefresh = true
).filter { it.status == Status.SUCCESS }.first().data.orEmpty()
).filter { it.status == Status.SUCCESS }.first().data?.first.orEmpty()
}
assertEquals(4, lessons.size)
@ -108,7 +108,7 @@ class TimetableRepositoryTest {
)
runBlocking { timetableLocal.saveTimetable(list) }
coEvery { timetableRemote.getTimetable(student, semester, any(), any()) } returns listOf(
coEvery { timetableRemote.getTimetable(student, semester, any(), any()) } returns (listOf(
createTimetableLocal(of(2019, 12, 23, 8, 0), 1, "123", "Matematyka", "Paweł Poniedziałkowski", false),
createTimetableLocal(of(2019, 12, 23, 8, 50), 2, "124", "Matematyka", "Jakub Wtorkowski", true),
createTimetableLocal(of(2019, 12, 23, 9, 40), 3, "125", "Język polski", "Joanna Poniedziałkowska", false),
@ -123,7 +123,7 @@ class TimetableRepositoryTest {
createTimetableLocal(of(2019, 12, 25, 8, 50), 2, "124", "Matematyka", "Paweł Czwartkowski", true),
createTimetableLocal(of(2019, 12, 25, 9, 40), 3, "125", "Matematyka", "Paweł Środowski", false),
createTimetableLocal(of(2019, 12, 25, 10, 40), 4, "126", "Matematyka", "Paweł Czwartkowski", true)
)
) to emptyList())
val lessons = runBlocking {
TimetableRepository(timetableLocal, timetableRemote, timetableNotificationSchedulerHelper).getTimetable(
@ -132,7 +132,7 @@ class TimetableRepositoryTest {
start = LocalDate.of(2019, 12, 23),
end = LocalDate.of(2019, 12, 25),
forceRefresh = true
).filter { it.status == Status.SUCCESS }.first().data.orEmpty()
).filter { it.status == Status.SUCCESS }.first().data?.first.orEmpty()
}
assertEquals(12, lessons.size)

View File

@ -162,4 +162,8 @@ internal class RepositoryModule {
@Singleton
@Provides
fun provideConferenceDao(database: AppDatabase) = database.conferenceDao
@Singleton
@Provides
fun provideTimetableAdditionalDao(database: AppDatabase) = database.timetableAdditionalDao
}

View File

@ -30,6 +30,7 @@ import io.github.wulkanowy.data.db.dao.SemesterDao
import io.github.wulkanowy.data.db.dao.StudentDao
import io.github.wulkanowy.data.db.dao.SubjectDao
import io.github.wulkanowy.data.db.dao.TeacherDao
import io.github.wulkanowy.data.db.dao.TimetableAdditionalDao
import io.github.wulkanowy.data.db.dao.TimetableDao
import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.db.entities.AttendanceSummary
@ -55,6 +56,7 @@ import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Subject
import io.github.wulkanowy.data.db.entities.Teacher
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import io.github.wulkanowy.data.db.migrations.Migration10
import io.github.wulkanowy.data.db.migrations.Migration11
import io.github.wulkanowy.data.db.migrations.Migration12
@ -77,6 +79,7 @@ import io.github.wulkanowy.data.db.migrations.Migration27
import io.github.wulkanowy.data.db.migrations.Migration28
import io.github.wulkanowy.data.db.migrations.Migration29
import io.github.wulkanowy.data.db.migrations.Migration3
import io.github.wulkanowy.data.db.migrations.Migration30
import io.github.wulkanowy.data.db.migrations.Migration4
import io.github.wulkanowy.data.db.migrations.Migration5
import io.github.wulkanowy.data.db.migrations.Migration6
@ -112,6 +115,7 @@ import javax.inject.Singleton
Teacher::class,
School::class,
Conference::class,
TimetableAdditional::class,
],
version = AppDatabase.VERSION_SCHEMA,
exportSchema = true
@ -120,7 +124,7 @@ import javax.inject.Singleton
abstract class AppDatabase : RoomDatabase() {
companion object {
const val VERSION_SCHEMA = 29
const val VERSION_SCHEMA = 30
fun getMigrations(sharedPrefProvider: SharedPrefProvider): Array<Migration> {
return arrayOf(
@ -151,7 +155,8 @@ abstract class AppDatabase : RoomDatabase() {
Migration26(),
Migration27(),
Migration28(),
Migration29()
Migration29(),
Migration30(),
)
}
@ -212,4 +217,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract val schoolDao: SchoolDao
abstract val conferenceDao: ConferenceDao
abstract val timetableAdditionalDao: TimetableAdditionalDao
}

View File

@ -0,0 +1,16 @@
package io.github.wulkanowy.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import javax.inject.Singleton
@Dao
@Singleton
interface TimetableAdditionalDao : BaseDao<TimetableAdditional> {
@Query("SELECT * FROM TimetableAdditional WHERE diary_id = :diaryId AND student_id = :studentId AND date >= :from AND date <= :end")
fun loadAll(diaryId: Int, studentId: Int, from: LocalDate, end: LocalDate): Flow<List<TimetableAdditional>>
}

View File

@ -0,0 +1,30 @@
package io.github.wulkanowy.data.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
import java.time.LocalDate
import java.time.LocalDateTime
@Entity(tableName = "TimetableAdditional")
data class TimetableAdditional(
@ColumnInfo(name = "student_id")
val studentId: Int,
@ColumnInfo(name = "diary_id")
val diaryId: Int,
val start: LocalDateTime,
val end: LocalDateTime,
val date: LocalDate,
val subject: String,
) : Serializable {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}

View File

@ -0,0 +1,21 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration30 : Migration(29, 30) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("""
CREATE TABLE TimetableAdditional (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
student_id INTEGER NOT NULL,
diary_id INTEGER NOT NULL,
start INTEGER NOT NULL,
`end` INTEGER NOT NULL,
date INTEGER NOT NULL,
subject TEXT NOT NULL
)
""")
}
}

View File

@ -21,11 +21,15 @@ class GradeRepository @Inject constructor(
fun getGrades(student: Student, semester: Semester, forceRefresh: Boolean, notify: Boolean = false) = networkBoundResource(
shouldFetch = { (details, summaries) -> details.isEmpty() || summaries.isEmpty() || forceRefresh },
query = { local.getGradesDetails(semester).combine(local.getGradesSummary(semester)) { details, summaries -> details to summaries } },
query = {
local.getGradesDetails(semester).combine(local.getGradesSummary(semester)) { details, summaries ->
details to summaries
}
},
fetch = { remote.getGrades(student, semester) },
saveFetchResult = { old, new ->
refreshGradeDetails(student, old.first, new.first, notify)
refreshGradeSummaries(old.second, new.second, notify)
saveFetchResult = { (oldDetails, oldSummary), (newDetails, newSummary) ->
refreshGradeDetails(student, oldDetails, newDetails, notify)
refreshGradeSummaries(oldSummary, newSummary, notify)
}
)

View File

@ -1,25 +1,42 @@
package io.github.wulkanowy.data.repositories.timetable
import io.github.wulkanowy.data.db.dao.TimetableAdditionalDao
import io.github.wulkanowy.data.db.dao.TimetableDao
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TimetableLocal @Inject constructor(private val timetableDb: TimetableDao) {
class TimetableLocal @Inject constructor(
private val timetableDb: TimetableDao,
private val timetableAdditionalDb: TimetableAdditionalDao
) {
suspend fun saveTimetable(timetables: List<Timetable>) {
timetableDb.insertAll(timetables)
}
suspend fun saveTimetableAdditional(additional: List<TimetableAdditional>) {
timetableAdditionalDb.insertAll(additional)
}
suspend fun deleteTimetable(timetables: List<Timetable>) {
timetableDb.deleteAll(timetables)
}
suspend fun deleteTimetableAdditional(additional: List<TimetableAdditional>) {
timetableAdditionalDb.deleteAll(additional)
}
fun getTimetable(semester: Semester, startDate: LocalDate, endDate: LocalDate): Flow<List<Timetable>> {
return timetableDb.loadAll(semester.diaryId, semester.studentId, startDate, endDate)
}
fun getTimetableAdditional(semester: Semester, startDate: LocalDate, endDate: LocalDate): Flow<List<TimetableAdditional>> {
return timetableAdditionalDb.loadAll(semester.diaryId, semester.studentId, startDate, endDate)
}
}

View File

@ -3,6 +3,7 @@ package io.github.wulkanowy.data.repositories.timetable
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.init
import java.time.LocalDate
@ -12,29 +13,40 @@ import javax.inject.Singleton
@Singleton
class TimetableRemote @Inject constructor(private val sdk: Sdk) {
suspend fun getTimetable(student: Student, semester: Semester, startDate: LocalDate, endDate: LocalDate): List<Timetable> {
return sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
.getTimetable(startDate, endDate).first
.map {
Timetable(
studentId = semester.studentId,
diaryId = semester.diaryId,
number = it.number,
start = it.start,
end = it.end,
date = it.date,
subject = it.subject,
subjectOld = it.subjectOld,
group = it.group,
room = it.room,
roomOld = it.roomOld,
teacher = it.teacher,
teacherOld = it.teacherOld,
info = it.info,
isStudentPlan = it.studentPlan,
changes = it.changes,
canceled = it.canceled
)
}
suspend fun getTimetable(student: Student, semester: Semester, startDate: LocalDate, endDate: LocalDate): Pair<List<Timetable>, List<TimetableAdditional>> {
val (normal, additional) = sdk.init(student)
.switchDiary(semester.diaryId, semester.schoolYear)
.getTimetable(startDate, endDate)
return normal.map {
Timetable(
studentId = semester.studentId,
diaryId = semester.diaryId,
number = it.number,
start = it.start,
end = it.end,
date = it.date,
subject = it.subject,
subjectOld = it.subjectOld,
group = it.group,
room = it.room,
roomOld = it.roomOld,
teacher = it.teacher,
teacherOld = it.teacherOld,
info = it.info,
isStudentPlan = it.studentPlan,
changes = it.changes,
canceled = it.canceled
)
} to additional.map {
TimetableAdditional(
studentId = semester.studentId,
diaryId = semester.diaryId,
subject = it.subject,
date = it.date,
start = it.start,
end = it.end
)
}
}
}

View File

@ -2,11 +2,14 @@ package io.github.wulkanowy.data.repositories.timetable
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import io.github.wulkanowy.services.alarm.TimetableNotificationSchedulerHelper
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.time.LocalDate
import javax.inject.Inject
@ -19,23 +22,44 @@ class TimetableRepository @Inject constructor(
private val schedulerHelper: TimetableNotificationSchedulerHelper
) {
fun getTimetable(student: Student, semester: Semester, start: LocalDate, end: LocalDate, forceRefresh: Boolean) = networkBoundResource(
shouldFetch = { it.isEmpty() || forceRefresh },
query = { local.getTimetable(semester, start.monday, end.sunday).map { schedulerHelper.scheduleNotifications(it, student); it } },
fetch = { remote.getTimetable(student, semester, start.monday, end.sunday) },
saveFetchResult = { old, new ->
local.deleteTimetable(old.uniqueSubtract(new).also { schedulerHelper.cancelScheduled(it) })
local.saveTimetable(new.uniqueSubtract(old).also { schedulerHelper.scheduleNotifications(it, student) }.map { item ->
item.also { new ->
old.singleOrNull { new.start == it.start }?.let { old ->
return@map new.copy(
room = if (new.room.isEmpty()) old.room else new.room,
teacher = if (new.teacher.isEmpty() && !new.changes && !old.changes) old.teacher else new.teacher
)
}
fun getTimetable(student: Student, semester: Semester, start: LocalDate, end: LocalDate, forceRefresh: Boolean, refreshAdditional: Boolean = false) = networkBoundResource(
shouldFetch = { (timetable, additional) -> timetable.isEmpty() || (additional.isEmpty() && refreshAdditional) || forceRefresh },
query = {
local.getTimetable(semester, start.monday, end.sunday)
.map { schedulerHelper.scheduleNotifications(it, student); it }
.combine(local.getTimetableAdditional(semester, start.monday, end.sunday)) { timetable, additional ->
timetable to additional
}
})
},
filterResult = { it.filter { item -> item.date in start..end } }
fetch = { remote.getTimetable(student, semester, start.monday, end.sunday) },
saveFetchResult = { (oldTimetable, oldAdditional), (newTimetable, newAdditional) ->
refreshTimetable(student, oldTimetable, newTimetable)
refreshAdditional(oldAdditional, newAdditional)
},
filterResult = { (timetable, additional) ->
timetable.filter { item ->
item.date in start..end
} to additional.filter { item -> item.date in start..end }
}
)
private suspend fun refreshTimetable(student: Student, old: List<Timetable>, new: List<Timetable>) {
local.deleteTimetable(old.uniqueSubtract(new).also { schedulerHelper.cancelScheduled(it) })
local.saveTimetable(new.uniqueSubtract(old).also { schedulerHelper.scheduleNotifications(it, student) }.map { item ->
item.also { new ->
old.singleOrNull { new.start == it.start }?.let { old ->
return@map new.copy(
room = if (new.room.isEmpty()) old.room else new.room,
teacher = if (new.teacher.isEmpty() && !new.changes && !old.changes) old.teacher else new.teacher
)
}
}
})
}
private suspend fun refreshAdditional(old: List<TimetableAdditional>, new: List<TimetableAdditional>) {
local.deleteTimetableAdditional(old.uniqueSubtract(new))
local.saveTimetableAdditional(new.uniqueSubtract(old))
}
}

View File

@ -16,6 +16,7 @@ import io.github.wulkanowy.databinding.FragmentTimetableBinding
import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.ui.modules.timetable.additional.AdditionalLessonsFragment
import io.github.wulkanowy.ui.modules.timetable.completed.CompletedLessonsFragment
import io.github.wulkanowy.ui.widgets.DividerItemDecoration
import io.github.wulkanowy.utils.SchooldaysRangeLimiter
@ -84,8 +85,11 @@ class TimetableFragment : BaseFragment<FragmentTimetableBinding>(R.layout.fragme
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.timetableMenuCompletedLessons) presenter.onCompletedLessonsSwitchSelected()
else false
return when (item.itemId) {
R.id.timetableMenuAdditionalLessons -> presenter.onAdditionalLessonsSwitchSelected()
R.id.timetableMenuCompletedLessons -> presenter.onCompletedLessonsSwitchSelected()
else -> false
}
}
override fun updateData(data: List<Timetable>, showWholeClassPlanType: String, showGroupsInPlanType: Boolean, showTimetableTimers: Boolean) {
@ -176,6 +180,10 @@ class TimetableFragment : BaseFragment<FragmentTimetableBinding>(R.layout.fragme
}
}
override fun openAdditionalLessonsView() {
(activity as? MainActivity)?.pushView(AdditionalLessonsFragment.newInstance())
}
override fun openCompletedLessonsView() {
(activity as? MainActivity)?.pushView(CompletedLessonsFragment.newInstance())
}

View File

@ -109,6 +109,11 @@ class TimetablePresenter @Inject constructor(
view?.showTimetableDialog(lesson)
}
fun onAdditionalLessonsSwitchSelected(): Boolean {
view?.openAdditionalLessonsView()
return true
}
fun onCompletedLessonsSwitchSelected(): Boolean {
view?.openCompletedLessonsView()
return true
@ -144,18 +149,18 @@ class TimetablePresenter @Inject constructor(
showWholeClassPlanType = prefRepository.showWholeClassPlan,
showGroupsInPlanType = prefRepository.showGroupsInPlan,
showTimetableTimers = prefRepository.showTimetableTimers,
data = it.data!!
data = it.data!!.first
.filter { item -> if (prefRepository.showWholeClassPlan == "no") item.isStudentPlan else true }
.sortedWith(compareBy({ item -> item.number }, { item -> !item.isStudentPlan }))
)
showEmpty(it.data.isEmpty())
showEmpty(it.data.first.isEmpty())
showErrorView(false)
showContent(it.data.isNotEmpty())
showContent(it.data.first.isNotEmpty())
}
analytics.logEvent(
"load_data",
"type" to "timetable",
"items" to it.data!!.size
"items" to it.data!!.first.size
)
}
Status.ERROR -> {

View File

@ -44,5 +44,7 @@ interface TimetableView : BaseView {
fun popView()
fun openAdditionalLessonsView()
fun openCompletedLessonsView()
}

View File

@ -0,0 +1,35 @@
package io.github.wulkanowy.ui.modules.timetable.additional
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import io.github.wulkanowy.databinding.ItemTimetableAdditionalBinding
import io.github.wulkanowy.utils.toFormattedString
import javax.inject.Inject
class AdditionalLessonsAdapter @Inject constructor() :
RecyclerView.Adapter<AdditionalLessonsAdapter.ItemViewHolder>() {
var items = emptyList<TimetableAdditional>()
override fun getItemCount() = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ItemViewHolder(
ItemTimetableAdditionalBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val item = items[position]
with(holder.binding) {
additionalLessonItemTime.text = "${item.start.toFormattedString("HH:mm")} - ${item.end.toFormattedString("HH:mm")}"
additionalLessonItemSubject.text = item.subject
}
}
class ItemViewHolder(val binding: ItemTimetableAdditionalBinding) :
RecyclerView.ViewHolder(binding.root)
}

View File

@ -0,0 +1,144 @@
package io.github.wulkanowy.ui.modules.timetable.additional
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import com.wdullaer.materialdatetimepicker.date.DatePickerDialog
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import io.github.wulkanowy.databinding.FragmentTimetableAdditionalBinding
import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.ui.widgets.DividerItemDecoration
import io.github.wulkanowy.utils.SchooldaysRangeLimiter
import io.github.wulkanowy.utils.dpToPx
import java.time.LocalDate
import javax.inject.Inject
@AndroidEntryPoint
class AdditionalLessonsFragment :
BaseFragment<FragmentTimetableAdditionalBinding>(R.layout.fragment_timetable_additional),
AdditionalLessonsView, MainView.TitledView {
@Inject
lateinit var presenter: AdditionalLessonsPresenter
@Inject
lateinit var additionalLessonsAdapter: AdditionalLessonsAdapter
companion object {
private const val SAVED_DATE_KEY = "CURRENT_DATE"
fun newInstance() = AdditionalLessonsFragment()
}
override val titleStringId get() = R.string.additional_lessons_title
override val isViewEmpty get() = additionalLessonsAdapter.items.isEmpty()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentTimetableAdditionalBinding.bind(view)
messageContainer = binding.additionalLessonsRecycler
presenter.onAttachView(this, savedInstanceState?.getLong(SAVED_DATE_KEY))
}
override fun initView() {
with(binding.additionalLessonsRecycler) {
layoutManager = LinearLayoutManager(context)
adapter = additionalLessonsAdapter
addItemDecoration(DividerItemDecoration(context))
}
with(binding) {
additionalLessonsSwipe.setOnRefreshListener(presenter::onSwipeRefresh)
additionalLessonsErrorRetry.setOnClickListener { presenter.onRetry() }
additionalLessonsPreviousButton.setOnClickListener { presenter.onPreviousDay() }
additionalLessonsNavDate.setOnClickListener { presenter.onPickDate() }
additionalLessonsNextButton.setOnClickListener { presenter.onNextDay() }
additionalLessonsNavContainer.setElevationCompat(requireContext().dpToPx(8f))
}
}
override fun updateData(data: List<TimetableAdditional>) {
with(additionalLessonsAdapter) {
items = data
notifyDataSetChanged()
}
}
override fun clearData() {
with(additionalLessonsAdapter) {
items = emptyList()
notifyDataSetChanged()
}
}
override fun updateNavigationDay(date: String) {
binding.additionalLessonsNavDate.text = date
}
override fun hideRefresh() {
binding.additionalLessonsSwipe.isRefreshing = false
}
override fun showEmpty(show: Boolean) {
binding.additionalLessonsEmpty.visibility = if (show) View.VISIBLE else View.GONE
}
override fun showErrorView(show: Boolean) {
binding.additionalLessonsError.visibility = if (show) View.VISIBLE else View.GONE
}
override fun setErrorDetails(message: String) {
binding.additionalLessonsErrorMessage.text = message
}
override fun showProgress(show: Boolean) {
binding.additionalLessonsProgress.visibility = if (show) View.VISIBLE else View.GONE
}
override fun enableSwipe(enable: Boolean) {
binding.additionalLessonsSwipe.isEnabled = enable
}
override fun showContent(show: Boolean) {
binding.additionalLessonsRecycler.visibility = if (show) View.VISIBLE else View.GONE
}
override fun showPreButton(show: Boolean) {
binding.additionalLessonsPreviousButton.visibility = if (show) View.VISIBLE else View.INVISIBLE
}
override fun showNextButton(show: Boolean) {
binding.additionalLessonsNextButton.visibility = if (show) View.VISIBLE else View.INVISIBLE
}
override fun showDatePickerDialog(currentDate: LocalDate) {
val dateSetListener = DatePickerDialog.OnDateSetListener { _, year, month, dayOfMonth ->
presenter.onDateSet(year, month + 1, dayOfMonth)
}
val datePickerDialog = DatePickerDialog.newInstance(dateSetListener,
currentDate.year, currentDate.monthValue - 1, currentDate.dayOfMonth)
with(datePickerDialog) {
setDateRangeLimiter(SchooldaysRangeLimiter())
version = DatePickerDialog.Version.VERSION_2
scrollOrientation = DatePickerDialog.ScrollOrientation.VERTICAL
show(this@AdditionalLessonsFragment.parentFragmentManager, null)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putLong(SAVED_DATE_KEY, presenter.currentDate.toEpochDay())
}
override fun onDestroyView() {
presenter.onDetachView()
super.onDestroyView()
}
}

View File

@ -0,0 +1,166 @@
package io.github.wulkanowy.ui.modules.timetable.additional
import android.annotation.SuppressLint
import io.github.wulkanowy.data.Status
import io.github.wulkanowy.data.repositories.semester.SemesterRepository
import io.github.wulkanowy.data.repositories.student.StudentRepository
import io.github.wulkanowy.data.repositories.timetable.TimetableRepository
import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.afterLoading
import io.github.wulkanowy.utils.flowWithResourceIn
import io.github.wulkanowy.utils.getLastSchoolDayIfHoliday
import io.github.wulkanowy.utils.isHolidays
import io.github.wulkanowy.utils.nextOrSameSchoolDay
import io.github.wulkanowy.utils.nextSchoolDay
import io.github.wulkanowy.utils.previousSchoolDay
import io.github.wulkanowy.utils.toFormattedString
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.time.LocalDate
import javax.inject.Inject
class AdditionalLessonsPresenter @Inject constructor(
studentRepository: StudentRepository,
errorHandler: ErrorHandler,
private val semesterRepository: SemesterRepository,
private val timetableRepository: TimetableRepository,
private val analytics: AnalyticsHelper
) : BasePresenter<AdditionalLessonsView>(errorHandler, studentRepository) {
private var baseDate: LocalDate = LocalDate.now().nextOrSameSchoolDay
lateinit var currentDate: LocalDate
private set
private lateinit var lastError: Throwable
fun onAttachView(view: AdditionalLessonsView, date: Long?) {
super.onAttachView(view)
view.initView()
Timber.i("Additional lessons was initialized")
errorHandler.showErrorMessage = ::showErrorViewOnError
loadData(LocalDate.ofEpochDay(date ?: baseDate.toEpochDay()))
if (currentDate.isHolidays) setBaseDateOnHolidays()
reloadView()
}
fun onPreviousDay() {
loadData(currentDate.previousSchoolDay)
reloadView()
}
fun onNextDay() {
loadData(currentDate.nextSchoolDay)
reloadView()
}
fun onPickDate() {
view?.showDatePickerDialog(currentDate)
}
fun onDateSet(year: Int, month: Int, day: Int) {
loadData(LocalDate.of(year, month, day))
reloadView()
}
fun onSwipeRefresh() {
Timber.i("Force refreshing the additional lessons")
loadData(currentDate, true)
}
fun onRetry() {
view?.run {
showErrorView(false)
showProgress(true)
}
loadData(currentDate, true)
}
private fun setBaseDateOnHolidays() {
flow {
val student = studentRepository.getCurrentStudent()
emit(semesterRepository.getCurrentSemester(student))
}.catch {
Timber.i("Loading semester result: An exception occurred")
}.onEach {
baseDate = baseDate.getLastSchoolDayIfHoliday(it.schoolYear)
currentDate = baseDate
reloadNavigation()
}.launch("holidays")
}
private fun loadData(date: LocalDate, forceRefresh: Boolean = false) {
currentDate = date
flowWithResourceIn {
val student = studentRepository.getCurrentStudent()
val semester = semesterRepository.getCurrentSemester(student)
timetableRepository.getTimetable(student, semester, date, date, forceRefresh, true)
}.onEach {
when (it.status) {
Status.LOADING -> Timber.i("Loading additional lessons data started")
Status.SUCCESS -> {
Timber.i("Loading additional lessons lessons result: Success")
view?.apply {
updateData(it.data!!.second.sortedBy { item -> item.date })
showEmpty(it.data.second.isEmpty())
showErrorView(false)
showContent(it.data.second.isNotEmpty())
}
analytics.logEvent(
"load_data",
"type" to "additional_lessons",
"items" to it.data!!.second.size
)
}
Status.ERROR -> {
Timber.i("Loading additional lessons result: An exception occurred")
errorHandler.dispatch(it.error!!)
}
}
}.afterLoading {
view?.run {
hideRefresh()
showProgress(false)
enableSwipe(true)
}
}.launch()
}
private fun showErrorViewOnError(message: String, error: Throwable) {
view?.run {
if (isViewEmpty) {
lastError = error
setErrorDetails(message)
showErrorView(true)
showEmpty(false)
} else showError(message, error)
}
}
private fun reloadView() {
Timber.i("Reload additional lessons view with the date ${currentDate.toFormattedString()}")
view?.apply {
showProgress(true)
enableSwipe(false)
showContent(false)
showEmpty(false)
showErrorView(false)
clearData()
reloadNavigation()
}
}
@SuppressLint("DefaultLocale")
private fun reloadNavigation() {
view?.apply {
showPreButton(!currentDate.minusDays(1).isHolidays)
showNextButton(!currentDate.plusDays(1).isHolidays)
updateNavigationDay(currentDate.toFormattedString("EEEE, dd.MM").capitalize())
}
}
}

View File

@ -0,0 +1,38 @@
package io.github.wulkanowy.ui.modules.timetable.additional
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import io.github.wulkanowy.ui.base.BaseView
import java.time.LocalDate
interface AdditionalLessonsView : BaseView {
val isViewEmpty: Boolean
fun initView()
fun updateData(data: List<TimetableAdditional>)
fun clearData()
fun updateNavigationDay(date: String)
fun hideRefresh()
fun showEmpty(show: Boolean)
fun showErrorView(show: Boolean)
fun setErrorDetails(message: String)
fun showProgress(show: Boolean)
fun enableSwipe(enable: Boolean)
fun showContent(show: Boolean)
fun showPreButton(show: Boolean)
fun showNextButton(show: Boolean)
fun showDatePickerDialog(currentDate: LocalDate)
}

View File

@ -107,7 +107,7 @@ class TimetableWidgetFactory(
val semester = semesterRepository.getCurrentSemester(student)
timetableRepository.getTimetable(student, semester, date, date, false)
.toFirstResult().data.orEmpty()
.toFirstResult().data?.first.orEmpty()
.sortedWith(compareBy({ it.number }, { !it.isStudentPlan }))
.filter { if (prefRepository.showWholeClassPlan == "no") it.isStudentPlan else true }
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFF"
android:pathData="M19,19V8H5V19H19M16,1H18V3H19C20.11,3 21,3.9 21,5V19C21,20.11 20.11,21 19,21H5C3.89,21 3,20.1 3,19V5C3,3.89 3.89,3 5,3H6V1H8V3H16V1M11,9.5H13V12.5H16V14.5H13V17.5H11V14.5H8V12.5H11V9.5Z" />
</vector>

View File

@ -0,0 +1,163 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.modules.timetable.additional.AdditionalLessonsFragment">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="50dp">
<me.zhanghai.android.materialprogressbar.MaterialProgressBar
android:id="@+id/additionalLessonsProgress"
style="@style/Widget.MaterialProgressBar.ProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/additionalLessonsSwipe"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/additionalLessonsRecycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/item_timetable_additional" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/additionalLessonsEmpty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="10dp"
android:visibility="gone">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/additionalLessonsInfoImage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="100dp"
android:minHeight="100dp"
app:srcCompat="@drawable/ic_menu_timetable_lessons_additional"
app:tint="?android:attr/textColorPrimary"
tools:ignore="contentDescription" />
<TextView
android:id="@+id/additionalLessonsInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:text="@string/additional_lessons_no_items"
android:textSize="20sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/additionalLessonsError"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="invisible"
tools:ignore="UseCompoundDrawables"
tools:visibility="invisible">
<ImageView
android:layout_width="100dp"
android:layout_height="100dp"
app:srcCompat="@drawable/ic_error"
app:tint="?colorOnBackground"
tools:ignore="contentDescription" />
<TextView
android:id="@+id/additionalLessonsErrorMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:padding="8dp"
android:text="@string/error_unknown"
android:textSize="20sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/additionalLessonsErrorDetails"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:text="@string/all_details" />
<com.google.android.material.button.MaterialButton
android:id="@+id/additionalLessonsErrorRetry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/all_retry" />
</LinearLayout>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<io.github.wulkanowy.ui.widgets.MaterialLinearLayout
android:id="@+id/additionalLessonsNavContainer"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_gravity="bottom"
android:gravity="center"
android:orientation="horizontal"
tools:ignore="UnusedAttribute">
<ImageButton
android:id="@+id/additionalLessonsPreviousButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/all_prev"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp"
android:scaleType="fitStart"
android:tint="?colorPrimary"
app:srcCompat="@drawable/ic_chevron_left" />
<TextView
android:id="@+id/additionalLessonsNavDate"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?selectableItemBackgroundBorderless"
android:fontFamily="sans-serif"
android:gravity="center"
android:textSize="16sp"
tools:text="@tools:sample/date/ddmmyy" />
<ImageButton
android:id="@+id/additionalLessonsNextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/all_next"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp"
android:scaleType="fitEnd"
android:tint="?colorPrimary"
app:srcCompat="@drawable/ic_chevron_right" />
</io.github.wulkanowy.ui.widgets.MaterialLinearLayout>
</FrameLayout>

View File

@ -0,0 +1,41 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:paddingStart="8dp"
android:paddingTop="6dp"
android:paddingEnd="12dp"
android:paddingBottom="6dp"
tools:context=".ui.modules.timetable.additional.AdditionalLessonsAdapter">
<TextView
android:id="@+id/additionalLessonItemSubject"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="15sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/lorem" />
<TextView
android:id="@+id/additionalLessonItemTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="13sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/additionalLessonItemSubject"
tools:text="11:11 - 12:12" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,6 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/timetableMenuAdditionalLessons"
android:icon="@drawable/ic_menu_timetable_lessons_additional"
android:orderInCategory="1"
android:title="@string/additional_lessons_button"
app:iconTint="@color/material_on_surface_emphasis_medium"
app:showAsAction="ifRoom" />
<item
android:id="@+id/timetableMenuCompletedLessons"
android:icon="@drawable/ic_menu_timetable_lessons_completed"

View File

@ -161,6 +161,12 @@
<string name="completed_lessons_resources">Resources</string>
<!--Additional lessons-->
<string name="additional_lessons_title">Additional lessons</string>
<string name="additional_lessons_button">Show additional lessons</string>
<string name="additional_lessons_no_items">No info about additional lessons</string>
<!--Attendance-->
<string name="attendance_summary_button">Attendance summary</string>
<string name="attendance_absence_school">Absent for school reasons</string>

View File

@ -56,7 +56,7 @@ class TimetableRemoteTest {
of(2018, 9, 15)
)
}
assertEquals(2, timetable.size)
assertEquals(2, timetable.first.size)
}
private fun getTimetable(date: LocalDate): Timetable {