Add attendance calculator (#1597)

---------

Co-authored-by: Mikołaj Pich <m.pich@outlook.com>
Co-authored-by: Faierbel <RafalBO99@outlook.com>
This commit is contained in:
Michael 2024-03-08 20:36:43 +01:00 committed by GitHub
parent ce09b07cfd
commit 8f5a210ec7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 981 additions and 14 deletions

View File

@ -1,11 +1,16 @@
package io.github.wulkanowy.data
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
@ -14,8 +19,10 @@ import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
sealed class Resource<T> {
sealed class Resource<out T> {
open class Loading<T> : Resource<T>()
@ -64,6 +71,19 @@ fun <T, U> Resource<T>.mapData(block: (T) -> U) = when (this) {
is Resource.Error -> Resource.Error(this.error)
}
inline fun <T1, T2, R> Flow<Resource<T1>>.combineWithResourceData(
flow: Flow<T2>,
crossinline block: suspend (T1, T2) -> R
): Flow<Resource<R>> =
combine(flow) { resource, inject ->
when (resource) {
is Resource.Success -> Resource.Success(block(resource.data, inject))
is Resource.Intermediate -> Resource.Intermediate(block(resource.data, inject))
is Resource.Loading -> Resource.Loading()
is Resource.Error -> Resource.Error(resource.error)
}
}
fun <T> Flow<Resource<T>>.logResourceStatus(name: String, showData: Boolean = false) = onEach {
val description = when (it) {
is Resource.Intermediate -> "intermediate data received" + if (showData) " (data: `${it.data}`)" else ""
@ -74,8 +94,29 @@ fun <T> Flow<Resource<T>>.logResourceStatus(name: String, showData: Boolean = fa
Timber.i("$name: $description")
}
fun <T, U> Flow<Resource<T>>.mapResourceData(block: (T) -> U) = map {
it.mapData(block)
fun <T, U> Flow<Resource<T>>.mapResourceData(block: suspend (T) -> U) = map {
when (it) {
is Resource.Success -> Resource.Success(block(it.data))
is Resource.Intermediate -> Resource.Intermediate(block(it.data))
is Resource.Loading -> Resource.Loading()
is Resource.Error -> Resource.Error(it.error)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun <T, U> Flow<Resource<T>>.flatMapResourceData(
inheritIntermediate: Boolean = true, block: suspend (T) -> Flow<Resource<U>>
) = flatMapLatest {
when (it) {
is Resource.Success -> block(it.data)
is Resource.Intermediate -> block(it.data).map { newRes ->
if (inheritIntermediate && newRes is Resource.Success) Resource.Intermediate(newRes.data)
else newRes
}
is Resource.Loading -> flowOf(Resource.Loading())
is Resource.Error -> flowOf(Resource.Error(it.error))
}
}
fun <T> Flow<Resource<T>>.onResourceData(block: suspend (T) -> Unit) = onEach {
@ -105,13 +146,13 @@ fun <T> Flow<Resource<T>>.onResourceSuccess(block: suspend (T) -> Unit) = onEach
}
}
fun <T> Flow<Resource<T>>.onResourceError(block: (Throwable) -> Unit) = onEach {
fun <T> Flow<Resource<T>>.onResourceError(block: suspend (Throwable) -> Unit) = onEach {
if (it is Resource.Error) {
block(it.error)
}
}
fun <T> Flow<Resource<T>>.onResourceNotLoading(block: () -> Unit) = onEach {
fun <T> Flow<Resource<T>>.onResourceNotLoading(block: suspend () -> Unit) = onEach {
if (it !is Resource.Loading) {
block()
}
@ -121,6 +162,55 @@ suspend fun <T> Flow<Resource<T>>.toFirstResult() = filter { it !is Resource.Loa
suspend fun <T> Flow<Resource<T>>.waitForResult() = takeWhile { it is Resource.Loading }.collect()
// Can cause excessive amounts of `Resource.Intermediate` to be emitted. Unless that is desired,
// use `debounceIntermediates` to alleviate this behavior.
inline fun <reified T> combineResourceFlows(
flows: Iterable<Flow<Resource<T>>>,
): Flow<Resource<List<T>>> = combine(flows) { items ->
var isIntermediate = false
val data = mutableListOf<T>()
for (item in items) {
when (item) {
is Resource.Success -> data.add(item.data)
is Resource.Intermediate -> {
isIntermediate = true
data.add(item.data)
}
is Resource.Loading -> return@combine Resource.Loading()
is Resource.Error -> continue
}
}
if (data.isEmpty()) {
// All items have to be errors for this to happen, so just return the first one.
// mapData is functionally useless and exists only to satisfy the type checker
items.first().mapData { listOf(it) }
} else if (isIntermediate) {
Resource.Intermediate(data)
} else {
Resource.Success(data)
}
}
@OptIn(FlowPreview::class)
fun <T> Flow<Resource<T>>.debounceIntermediates(timeout: Duration = 5.seconds) = flow {
var wasIntermediate = false
emitAll(this@debounceIntermediates.debounce {
if (it is Resource.Intermediate) {
if (!wasIntermediate) {
wasIntermediate = true
Duration.ZERO
} else {
timeout
}
} else {
wasIntermediate = false
Duration.ZERO
}
})
}
inline fun <ResultType, RequestType> networkBoundResource(
mutex: Mutex = Mutex(),
showSavedOnLoading: Boolean = true,

View File

@ -0,0 +1,13 @@
package io.github.wulkanowy.data.enums
enum class AttendanceCalculatorSortingMode(private val value: String) {
ALPHABETIC("alphabetic"),
ATTENDANCE("attendance_percentage"),
LESSON_BALANCE("lesson_balance");
companion object {
fun getByValue(value: String) =
AttendanceCalculatorSortingMode.values()
.find { it.value == value } ?: ALPHABETIC
}
}

View File

@ -0,0 +1,14 @@
package io.github.wulkanowy.data.pojos
data class AttendanceData(
val subjectName: String,
val lessonBalance: Int,
val presences: Int,
val absences: Int,
) {
val total: Int
get() = presences + absences
val presencePercentage: Double
get() = if (total == 0) 0.0 else (presences.toDouble() / total) * 100
}

View File

@ -1,12 +1,12 @@
package io.github.wulkanowy.data.repositories
import androidx.room.withTransaction
import io.github.wulkanowy.data.*
import io.github.wulkanowy.data.db.AppDatabase
import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.data.networkBoundResource
import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey

View File

@ -10,6 +10,7 @@ import com.fredporciuncula.flow.preferences.Serializer
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.enums.AppTheme
import io.github.wulkanowy.data.enums.AttendanceCalculatorSortingMode
import io.github.wulkanowy.data.enums.GradeColorTheme
import io.github.wulkanowy.data.enums.GradeExpandMode
import io.github.wulkanowy.data.enums.GradeSortingMode
@ -41,6 +42,18 @@ class PreferencesRepository @Inject constructor(
R.bool.pref_default_attendance_present
)
val targetAttendanceFlow: Flow<Int>
get() = flowSharedPref.getInt(
context.getString(R.string.pref_key_attendance_target),
context.resources.getInteger(R.integer.pref_default_attendance_target)
).asFlow()
val attendanceCalculatorSortingModeFlow: Flow<AttendanceCalculatorSortingMode>
get() = flowSharedPref.getString(
context.getString(R.string.pref_key_attendance_calculator_sorting_mode),
context.resources.getString(R.string.pref_default_attendance_calculator_sorting_mode)
).asFlow().map(AttendanceCalculatorSortingMode::getByValue)
private val gradeAverageModePref: Preference<GradeAverageMode>
get() = getObjectFlow(
R.string.pref_key_grade_average_mode,

View File

@ -0,0 +1,103 @@
package io.github.wulkanowy.domain.attendance
import io.github.wulkanowy.data.*
import io.github.wulkanowy.data.db.entities.AttendanceSummary
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Subject
import io.github.wulkanowy.data.enums.AttendanceCalculatorSortingMode
import io.github.wulkanowy.data.enums.AttendanceCalculatorSortingMode.*
import io.github.wulkanowy.data.pojos.AttendanceData
import io.github.wulkanowy.data.repositories.AttendanceSummaryRepository
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.SubjectRepository
import io.github.wulkanowy.utils.allAbsences
import io.github.wulkanowy.utils.allPresences
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import kotlin.math.ceil
import kotlin.math.floor
class GetAttendanceCalculatorDataUseCase @Inject constructor(
private val subjectRepository: SubjectRepository,
private val attendanceSummaryRepository: AttendanceSummaryRepository,
private val preferencesRepository: PreferencesRepository,
) {
operator fun invoke(
student: Student,
semester: Semester,
forceRefresh: Boolean,
): Flow<Resource<List<AttendanceData>>> =
subjectRepository.getSubjects(student, semester, forceRefresh)
.mapResourceData { subjects -> subjects.sortedBy(Subject::name) }
.combineWithResourceData(preferencesRepository.targetAttendanceFlow, ::Pair)
.flatMapResourceData { (subjects, targetFreq) ->
combineResourceFlows(subjects.map { subject ->
attendanceSummaryRepository.getAttendanceSummary(
student = student,
semester = semester,
subjectId = subject.realId,
forceRefresh = forceRefresh
).mapResourceData { summaries ->
summaries.toAttendanceData(subject.name, targetFreq)
}
})
// Every individual combined flow causes separate network requests to update data.
// When there is N child flows, they can cause up to N-1 items to be emitted. Since all
// requests are usually completed in less than 5s, there is no need to emit multiple
// intermediates that will be visible for barely any time.
.debounceIntermediates()
}
.combineWithResourceData(preferencesRepository.attendanceCalculatorSortingModeFlow, List<AttendanceData>::sortedBy)
}
private fun List<AttendanceSummary>.toAttendanceData(subjectName: String, targetFreq: Int): AttendanceData {
val presences = sumOf { it.allPresences }
val absences = sumOf { it.allAbsences }
return AttendanceData(
subjectName = subjectName,
lessonBalance = calcLessonBalance(
targetFreq.toDouble() / 100, presences, absences
),
presences = presences,
absences = absences,
)
}
private fun calcLessonBalance(targetFreq: Double, presences: Int, absences: Int): Int {
val total = presences + absences
// The `+ 1` is to avoid false positives in close cases. Eg.:
// target frequency 99%, 1 presence. Without the `+ 1` this would be reported shown as
// a positive balance of +1, however that is not actually true as skipping one class
// would make it so that the balance would actually be negative (-98). The `+ 1`
// fixes this and makes sure that in situations like these, it's not reporting incorrect
// balances
return when {
presences / (total + 1f) >= targetFreq -> calcMissingAbsences(
targetFreq, absences, presences
)
presences / (total + 0f) < targetFreq -> -calcMissingPresences(
targetFreq, absences, presences
)
else -> 0
}
}
private fun calcMissingPresences(targetFreq: Double, absences: Int, presences: Int) =
calcMinRequiredPresencesFor(targetFreq, absences) - presences
private fun calcMinRequiredPresencesFor(targetFreq: Double, absences: Int) =
ceil((targetFreq / (1 - targetFreq)) * absences).toInt()
private fun calcMissingAbsences(targetFreq: Double, absences: Int, presences: Int) =
calcMinRequiredAbsencesFor(targetFreq, presences) - absences
private fun calcMinRequiredAbsencesFor(targetFreq: Double, presences: Int) =
floor((presences * (1 - targetFreq)) / targetFreq).toInt()
private fun List<AttendanceData>.sortedBy(mode: AttendanceCalculatorSortingMode) = when (mode) {
ALPHABETIC -> sortedBy(AttendanceData::subjectName)
ATTENDANCE -> sortedByDescending(AttendanceData::presencePercentage)
LESSON_BALANCE -> sortedBy(AttendanceData::lessonBalance)
}

View File

@ -14,6 +14,7 @@ import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.databinding.DialogExcuseBinding
import io.github.wulkanowy.databinding.FragmentAttendanceBinding
import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.attendance.calculator.AttendanceCalculatorFragment
import io.github.wulkanowy.ui.modules.attendance.summary.AttendanceSummaryFragment
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.ui.modules.main.MainView
@ -134,6 +135,7 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.attendanceMenuSummary) presenter.onSummarySwitchSelected()
else if (item.itemId == R.id.attendanceMenuCalculator) presenter.onCalculatorSwitchSelected()
else false
}
@ -253,6 +255,10 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
(activity as? MainActivity)?.pushView(AttendanceSummaryFragment.newInstance())
}
override fun openCalculatorView() {
(activity as? MainActivity)?.pushView(AttendanceCalculatorFragment.newInstance())
}
override fun startActionMode() {
actionMode = (activity as MainActivity?)?.startSupportActionMode(actionModeCallback)
}

View File

@ -1,16 +1,36 @@
package io.github.wulkanowy.ui.modules.attendance
import android.annotation.SuppressLint
import io.github.wulkanowy.data.*
import io.github.wulkanowy.data.Resource
import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.flatResourceFlow
import io.github.wulkanowy.data.logResourceStatus
import io.github.wulkanowy.data.mapResourceData
import io.github.wulkanowy.data.onResourceData
import io.github.wulkanowy.data.onResourceError
import io.github.wulkanowy.data.onResourceIntermediate
import io.github.wulkanowy.data.onResourceLoading
import io.github.wulkanowy.data.onResourceNotLoading
import io.github.wulkanowy.data.onResourceSuccess
import io.github.wulkanowy.data.repositories.AttendanceRepository
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.SemesterRepository
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.data.resourceFlow
import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.*
import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.capitalise
import io.github.wulkanowy.utils.getLastSchoolDayIfHoliday
import io.github.wulkanowy.utils.isExcusableOrNotExcused
import io.github.wulkanowy.utils.isHolidays
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.nextSchoolDay
import io.github.wulkanowy.utils.previousOrSameSchoolDay
import io.github.wulkanowy.utils.previousSchoolDay
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.toFormattedString
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
@ -195,6 +215,11 @@ class AttendancePresenter @Inject constructor(
return true
}
fun onCalculatorSwitchSelected(): Boolean {
view?.openCalculatorView()
return true
}
private fun loadData(forceRefresh: Boolean = false) {
Timber.i("Loading attendance data started")

View File

@ -56,6 +56,8 @@ interface AttendanceView : BaseView {
fun openSummaryView()
fun openCalculatorView()
fun startSendMessageIntent(date: LocalDate, numbers: String, reason: String)
fun startActionMode()

View File

@ -0,0 +1,63 @@
package io.github.wulkanowy.ui.modules.attendance.calculator
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.github.wulkanowy.R
import io.github.wulkanowy.data.pojos.AttendanceData
import io.github.wulkanowy.databinding.ItemAttendanceCalculatorHeaderBinding
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.roundToInt
class AttendanceCalculatorAdapter @Inject constructor() :
RecyclerView.Adapter<AttendanceCalculatorAdapter.ViewHolder>() {
var items = emptyList<AttendanceData>()
override fun getItemCount() = items.size
override fun onCreateViewHolder(
parent: ViewGroup, viewType: Int
) = ViewHolder(
ItemAttendanceCalculatorHeaderBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
)
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(parent: ViewHolder, position: Int) {
with(parent.binding) {
val item = items[position]
attendanceCalculatorPercentage.text = "${item.presencePercentage.roundToInt()}"
if (item.lessonBalance > 0) {
attendanceCalculatorSummaryBalance.text = root.context.getString(
R.string.attendance_calculator_summary_balance_positive,
item.lessonBalance
)
} else if (item.lessonBalance < 0) {
attendanceCalculatorSummaryBalance.text = root.context.getString(
R.string.attendance_calculator_summary_balance_negative,
abs(item.lessonBalance)
)
} else {
attendanceCalculatorSummaryBalance.text = root.context.getString(
R.string.attendance_calculator_summary_balance_neutral,
)
}
attendanceCalculatorWarning.isVisible = item.lessonBalance < 0
attendanceCalculatorTitle.text = item.subjectName
attendanceCalculatorSummaryValues.text = root.context.getString(
R.string.attendance_calculator_summary_values,
item.presences,
item.total
)
}
}
class ViewHolder(val binding: ItemAttendanceCalculatorHeaderBinding) :
RecyclerView.ViewHolder(binding.root)
}

View File

@ -0,0 +1,105 @@
package io.github.wulkanowy.ui.modules.attendance.calculator
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.data.pojos.AttendanceData
import io.github.wulkanowy.databinding.FragmentAttendanceCalculatorBinding
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.getThemeAttrColor
import javax.inject.Inject
@AndroidEntryPoint
class AttendanceCalculatorFragment :
BaseFragment<FragmentAttendanceCalculatorBinding>(R.layout.fragment_attendance_calculator),
AttendanceCalculatorView, MainView.TitledView {
@Inject
lateinit var presenter: AttendanceCalculatorPresenter
@Inject
lateinit var attendanceCalculatorAdapter: AttendanceCalculatorAdapter
override val titleStringId get() = R.string.attendance_title
companion object {
fun newInstance() = AttendanceCalculatorFragment()
}
override val isViewEmpty get() = attendanceCalculatorAdapter.items.isEmpty()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentAttendanceCalculatorBinding.bind(view)
messageContainer = binding.attendanceCalculatorRecycler
presenter.onAttachView(this)
}
override fun initView() {
with(binding.attendanceCalculatorRecycler) {
layoutManager = LinearLayoutManager(context)
adapter = attendanceCalculatorAdapter
addItemDecoration(DividerItemDecoration(context))
}
with(binding) {
attendanceCalculatorSwipe.setOnRefreshListener(presenter::onSwipeRefresh)
attendanceCalculatorSwipe.setColorSchemeColors(requireContext().getThemeAttrColor(R.attr.colorPrimary))
attendanceCalculatorSwipe.setProgressBackgroundColorSchemeColor(requireContext().getThemeAttrColor(R.attr.colorSwipeRefresh))
attendanceCalculatorErrorRetry.setOnClickListener { presenter.onRetry() }
attendanceCalculatorErrorDetails.setOnClickListener { presenter.onDetailsClick() }
}
}
override fun updateData(data: List<AttendanceData>) {
with(attendanceCalculatorAdapter) {
items = data
notifyDataSetChanged()
}
}
override fun clearView() {
with(attendanceCalculatorAdapter) {
items = emptyList()
notifyDataSetChanged()
}
}
override fun showEmpty(show: Boolean) {
binding.attendanceCalculatorEmpty.isVisible = show
}
override fun showErrorView(show: Boolean) {
binding.attendanceCalculatorError.isVisible = show
}
override fun setErrorDetails(message: String) {
binding.attendanceCalculatorErrorMessage.text = message
}
override fun showProgress(show: Boolean) {
binding.attendanceCalculatorProgress.isVisible = show
}
override fun enableSwipe(enable: Boolean) {
binding.attendanceCalculatorSwipe.isEnabled = enable
}
override fun showContent(show: Boolean) {
binding.attendanceCalculatorRecycler.isVisible = show
}
override fun showRefresh(show: Boolean) {
binding.attendanceCalculatorSwipe.isRefreshing = show
}
override fun onDestroyView() {
presenter.onDetachView()
super.onDestroyView()
}
}

View File

@ -0,0 +1,84 @@
package io.github.wulkanowy.ui.modules.attendance.calculator
import io.github.wulkanowy.data.*
import io.github.wulkanowy.data.repositories.SemesterRepository
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.domain.attendance.GetAttendanceCalculatorDataUseCase
import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler
import timber.log.Timber
import javax.inject.Inject
class AttendanceCalculatorPresenter @Inject constructor(
errorHandler: ErrorHandler,
studentRepository: StudentRepository,
private val semesterRepository: SemesterRepository,
private val getAttendanceCalculatorData: GetAttendanceCalculatorDataUseCase,
) : BasePresenter<AttendanceCalculatorView>(errorHandler, studentRepository) {
private lateinit var lastError: Throwable
override fun onAttachView(view: AttendanceCalculatorView) {
super.onAttachView(view)
view.initView()
Timber.i("Attendance calculator view was initialized")
errorHandler.showErrorMessage = ::showErrorViewOnError
loadData()
}
fun onSwipeRefresh() {
Timber.i("Force refreshing the attendance calculator")
loadData(forceRefresh = true)
}
fun onRetry() {
view?.run {
showErrorView(false)
showProgress(true)
}
loadData()
}
fun onDetailsClick() {
view?.showErrorDetailsDialog(lastError)
}
private fun loadData(forceRefresh: Boolean = false) {
flatResourceFlow {
val student = studentRepository.getCurrentStudent()
val semester = semesterRepository.getCurrentSemester(student)
getAttendanceCalculatorData(student, semester, forceRefresh)
}
.logResourceStatus("load attendance calculator")
.onResourceData {
view?.run {
showProgress(false)
showErrorView(false)
showContent(it.isNotEmpty())
showEmpty(it.isEmpty())
updateData(it)
}
}
.onResourceIntermediate { view?.showRefresh(true) }
.onResourceNotLoading {
view?.run {
enableSwipe(true)
showRefresh(false)
showProgress(false)
}
}
.onResourceError(errorHandler::dispatch)
.launch()
}
private fun showErrorViewOnError(message: String, error: Throwable) {
view?.run {
if (isViewEmpty) {
lastError = error
setErrorDetails(message)
showErrorView(true)
showEmpty(false)
} else showError(message, error)
}
}
}

View File

@ -0,0 +1,29 @@
package io.github.wulkanowy.ui.modules.attendance.calculator
import io.github.wulkanowy.data.pojos.AttendanceData
import io.github.wulkanowy.ui.base.BaseView
interface AttendanceCalculatorView : BaseView {
val isViewEmpty: Boolean
fun initView()
fun showRefresh(show: Boolean)
fun showContent(show: Boolean)
fun showProgress(show: Boolean)
fun enableSwipe(enable: Boolean)
fun showEmpty(show: Boolean)
fun showErrorView(show: Boolean)
fun setErrorDetails(message: String)
fun updateData(data: List<AttendanceData>)
fun clearView()
}

View File

@ -4,6 +4,7 @@ import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SeekBarPreference
import com.yariksoffice.lingver.Lingver
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
@ -36,6 +37,15 @@ class AppearanceFragment : PreferenceFragmentCompat(),
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.scheme_preferences_appearance, rootKey)
val attendanceTargetPref =
findPreference<SeekBarPreference>(requireContext().getString(R.string.pref_key_attendance_target))!!
attendanceTargetPref.setOnPreferenceChangeListener { _, newValueObj ->
val newValue = (((newValueObj as Int).toDouble() + 2.5) / 5).toInt() * 5
attendanceTargetPref.value =
newValue.coerceIn(attendanceTargetPref.min, attendanceTargetPref.max)
false
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {

View File

@ -10,19 +10,19 @@ import io.github.wulkanowy.sdk.scrapper.attendance.AttendanceCategory
* (https://www.vulcan.edu.pl/vulcang_files/user/AABW/AABW-PDF/uonetplus/uonetplus_Frekwencja-liczby-obecnych-nieobecnych.pdf)
*/
private inline val AttendanceSummary.allPresences: Double
get() = presence.toDouble() + absenceForSchoolReasons + lateness + latenessExcused
inline val AttendanceSummary.allPresences: Int
get() = presence + absenceForSchoolReasons + lateness + latenessExcused
private inline val AttendanceSummary.allAbsences: Double
get() = absence.toDouble() + absenceExcused
inline val AttendanceSummary.allAbsences: Int
get() = absence + absenceExcused
inline val Attendance.isExcusableOrNotExcused: Boolean
get() = (excusable || ((absence || lateness) && !excused)) && excuseStatus == null
fun AttendanceSummary.calculatePercentage() = calculatePercentage(allPresences, allAbsences)
fun AttendanceSummary.calculatePercentage() = calculatePercentage(allPresences.toDouble(), allAbsences.toDouble())
fun List<AttendanceSummary>.calculatePercentage(): Double {
return calculatePercentage(sumOf { it.allPresences }, sumOf { it.allAbsences })
return calculatePercentage(sumOf { it.allPresences.toDouble() }, sumOf { it.allAbsences.toDouble() })
}
private fun calculatePercentage(presence: Double, absence: Double): Double {

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,7H9C7.9,7 7,7.9 7,9v10c0,1.1 0.9,2 2,2h10c1.1,0 2,-0.9 2,-2V9C21,7.9 20.1,7 19,7zM19,9v2H9V9H19zM13,15v-2h2v2H13zM15,17v2h-2v-2H15zM11,15H9v-2h2V15zM17,13h2v2h-2V13zM9,17h2v2H9V17zM17,19v-2h2v2H17zM6,17H5c-1.1,0 -2,-0.9 -2,-2V5c0,-1.1 0.9,-2 2,-2h10c1.1,0 2,0.9 2,2v1h-2V5H5v10h1V17z"/>
</vector>

View File

@ -0,0 +1,103 @@
<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.attendance.calculator.AttendanceCalculatorFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/attendanceCalculatorSwipe"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/attendanceCalculatorRecycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:listitem="@layout/item_attendance_calculator_header" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/attendanceCalculatorProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
tools:visibility="gone" />
<LinearLayout
android:id="@+id/attendanceCalculatorEmpty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="invisible"
tools:ignore="UseCompoundDrawables">
<ImageView
android:layout_width="100dp"
android:layout_height="100dp"
app:srcCompat="@drawable/ic_main_attendance"
app:tint="?colorOnBackground"
tools:ignore="contentDescription" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:text="@string/attendance_no_items"
android:textSize="20sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/attendanceCalculatorError"
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/attendanceCalculatorErrorMessage"
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/attendanceCalculatorErrorDetails"
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/attendanceCalculatorErrorRetry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/all_retry" />
</LinearLayout>
</LinearLayout>
</FrameLayout>

View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<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:paddingStart="8dp"
android:paddingTop="6dp"
android:paddingEnd="12dp"
android:paddingBottom="6dp">
<TextView
android:id="@+id/attendanceCalculatorPercentage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="right|center_vertical"
android:minWidth="32dp"
android:minHeight="36dp"
android:textSize="22sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="50" />
<TextView
android:id="@+id/attendanceCalculatorPercentagePercentSign"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="%"
android:textColor="?android:textColorSecondary"
app:layout_constraintBaseline_toBaselineOf="@+id/attendanceCalculatorPercentage"
app:layout_constraintStart_toEndOf="@+id/attendanceCalculatorPercentage" />
<TextView
android:id="@+id/attendanceCalculatorSummaryValues"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="left"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="13sp"
app:layout_constraintEnd_toStartOf="@+id/attendanceCalculatorSummaryDot"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="@+id/attendanceCalculatorTitle"
app:layout_constraintTop_toBottomOf="@+id/attendanceCalculatorTitle"
tools:text="11/123 obecności" />
<TextView
android:id="@+id/attendanceCalculatorSummaryDot"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:gravity="center"
android:text="·"
android:textColor="?android:textColorSecondary"
android:textSize="13sp"
app:layout_constraintEnd_toStartOf="@+id/attendanceCalculatorSummaryBalance"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/attendanceCalculatorSummaryValues"
app:layout_constraintTop_toBottomOf="@+id/attendanceCalculatorTitle" />
<TextView
android:id="@+id/attendanceCalculatorSummaryBalance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="left"
android:maxLines="1"
android:minWidth="24dp"
android:textColor="?android:textColorSecondary"
android:textSize="13sp"
app:layout_constraintEnd_toStartOf="@+id/attendanceCalculatorWarning"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@+id/attendanceCalculatorSummaryDot"
app:layout_constraintTop_toBottomOf="@+id/attendanceCalculatorTitle"
tools:text="12 powyżej celu" />
<TextView
android:id="@+id/attendanceCalculatorTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/attendanceCalculatorWarning"
app:layout_constraintStart_toEndOf="@+id/attendanceCalculatorPercentagePercentSign"
app:layout_constraintTop_toTopOf="parent"
tools:text="Informatyka" />
<ImageView
android:id="@+id/attendanceCalculatorWarning"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription=""
android:layout_marginStart="12dp"
android:gravity="center_vertical|right"
android:minWidth="24dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_all_round_mark"
app:tint="?colorPrimary"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Taken from https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-camerax-release/preference/preference/res/layout/preference_widget_seekbar_material.xml.
~ Copyright (C) 2018 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<!-- Layout used by SeekBarPreference for the seekbar widget style. -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:gravity="center_vertical"
android:paddingLeft="?android:attr/listPreferredItemPaddingLeft"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingRight="?android:attr/listPreferredItemPaddingRight"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:background="?android:attr/selectableItemBackground"
android:clipChildren="false"
android:clipToPadding="false"
android:baselineAligned="false">
<include layout="@layout/image_frame"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:clipChildren="false"
android:clipToPadding="false">
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1">
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem"
android:ellipsize="marquee"/>
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignLeft="@android:id/title"
android:layout_alignStart="@android:id/title"
android:layout_gravity="start"
android:textAlignment="viewStart"
android:textColor="?android:attr/textColorSecondary"
android:maxLines="4"
style="@style/PreferenceSummaryTextStyle"/>
</RelativeLayout>
<!-- Using UnPressableLinearLayout as a workaround to disable the pressed state propagation
to the children of this container layout. Otherwise, the animated pressed state will also
play for the thumb in the AbsSeekBar in addition to the preference's ripple background.
The background of the SeekBar is also set to null to disable the ripple background -->
<androidx.preference.UnPressableLinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingLeft="0dp"
android:paddingStart="0dp"
android:paddingRight="16dp"
android:paddingEnd="16dp"
android:clipChildren="false"
android:clipToPadding="false">
<!-- The total height of the Seekbar widget's area should be 48dp - this allows for an
increased touch area so you do not need to exactly tap the thumb to move it. However,
setting the Seekbar height directly causes the thumb and seekbar to be misaligned on
API 22 and 23 - so instead we just set 15dp padding above and below, to account for the
18dp default height of the Seekbar thumb for a total of 48dp.
Note: we set 0dp padding at the start and end of this seekbar to allow it to properly
fit into the layout, but this means that there's no leeway on either side for touch
input - this might be something we should reconsider down the line. -->
<SeekBar
android:id="@+id/seekbar"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/preference_seekbar_padding_horizontal"
android:paddingStart="@dimen/preference_seekbar_padding_horizontal"
android:paddingRight="@dimen/preference_seekbar_padding_horizontal"
android:paddingEnd="@dimen/preference_seekbar_padding_horizontal"
android:paddingTop="@dimen/preference_seekbar_padding_vertical"
android:paddingBottom="@dimen/preference_seekbar_padding_vertical"
android:background="@null"/>
<!-- If the value is shown, we reserve a minimum width of 36dp to allow for consistent
seekbar width for smaller values. If the value is ~4 or more digits, it will expand
into the seekbar width. -->
<TextView
android:id="@+id/seekbar_value"
android:minWidth="@dimen/preference_seekbar_value_minWidth"
android:paddingLeft="8dp"
android:paddingStart="8dp"
android:paddingRight="0dp"
android:paddingEnd="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="right"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:scrollbars="none"/>
<!-- Wulkanowy start -->
<TextView
android:minWidth="0dp"
android:paddingLeft="0dp"
android:paddingStart="0dp"
android:paddingRight="0dp"
android:paddingEnd="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="left"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceListItem"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:scrollbars="none"
android:text="%"
/>
<!-- Wulkanowy end -->
</androidx.preference.UnPressableLinearLayout>
</LinearLayout>
</LinearLayout>

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/attendanceMenuCalculator"
android:icon="@drawable/ic_menu_attendance_calculator"
android:orderInCategory="0"
android:title="@string/attendance_calculator_button"
app:iconTint="@color/material_on_surface_emphasis_medium"
app:showAsAction="ifRoom" />
<item
android:id="@+id/attendanceMenuSummary"
android:icon="@drawable/ic_menu_attendance_summary"

View File

@ -876,4 +876,7 @@
<string name="message_unmute">Wyłącz wyciszenie</string>
<string name="message_mute_success">Wyciszyleś tego użytkownika</string>
<string name="message_unmute_success">Wyłączyłeś wyciszenie tego użytkownika</string>
<!-- attendance calculator -->
<string name="pref_attendance_target">Docelowa frekwencja (w %)</string>
<string name="attendance_calculator_button">Kalkulator frekwencji</string>
</resources>

View File

@ -2,6 +2,8 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="pref_default_startup">0</string>
<bool name="pref_default_attendance_present">true</bool>
<integer name="pref_default_attendance_target">50</integer>
<string name="pref_default_attendance_calculator_sorting_mode">alphabetic</string>
<string name="pref_default_grade_average_mode">only_one_semester</string>
<bool name="pref_default_grade_average_force_calc">false</bool>
<string name="pref_default_expand_grade_mode">one</string>

View File

@ -2,6 +2,8 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="pref_key_start_menu">default_menu_index</string>
<string name="pref_key_attendance_present">attendance_present</string>
<string name="pref_key_attendance_target">attendance_target</string>
<string name="pref_key_attendance_calculator_sorting_mode">attendance_calculator_sorting_mode</string>
<string name="pref_key_app_theme">app_theme</string>
<string name="pref_key_dashboard_tiles">dashboard_tiles</string>
<string name="pref_key_grade_color_scheme">grade_color_scheme</string>

View File

@ -79,6 +79,17 @@
<item>0.75</item>
</string-array>
<string-array name="attendance_calculator_sorting_mode_entries">
<item>Alphabetically</item>
<item>By attendance percentage</item>
<item>By lesson balance</item>
</string-array>
<string-array name="attendance_calculator_sorting_mode_values" translatable="false">
<item>alphabetic</item>
<item>attendance_percentage</item>
<item>lesson_balance</item>
</string-array>
<string-array name="grade_sorting_mode_entries">
<item>Alphabetically</item>
<item>By date</item>

View File

@ -258,6 +258,11 @@
<!--Attendance-->
<string name="attendance_summary_button">Attendance summary</string>
<string name="attendance_calculator_button">Attendance calculator</string>
<string name="attendance_calculator_summary_balance_positive"><b>%1$d</b> over target</string>
<string name="attendance_calculator_summary_balance_neutral">right on target</string>
<string name="attendance_calculator_summary_balance_negative"><b>%1$d</b> under target</string>
<string name="attendance_calculator_summary_values">%1$d/%2$d presences</string>
<string name="attendance_absence_school">Absent for school reasons</string>
<string name="attendance_absence_excused">Excused absence</string>
<string name="attendance_absence_unexcused">Unexcused absence</string>
@ -715,6 +720,8 @@
<string name="pref_view_grade_average_mode">Calculated average options</string>
<string name="pref_view_grade_average_force_calc">Force average calculation by app</string>
<string name="pref_view_present">Show presence</string>
<string name="pref_attendance_target">Attendance target</string>
<string name="pref_view_attendance_calculator_sorting_mode">Attendance calculator sorting</string>
<string name="pref_view_app_theme">Theme</string>
<string name="pref_view_expand_grade">Grades expanding</string>
<string name="pref_view_timetable_show_groups">Show groups next to subjects</string>

View File

@ -85,6 +85,25 @@
app:iconSpaceReserved="false"
app:key="@string/pref_key_attendance_present"
app:title="@string/pref_view_present" />
<SeekBarPreference
app:defaultValue="@integer/pref_default_attendance_target"
app:iconSpaceReserved="false"
android:layout="@layout/pref_target_attendance"
app:key="@string/pref_key_attendance_target"
app:title="@string/pref_attendance_target"
app:min="1"
app:updatesContinuously="true"
android:max="99"
app:showSeekBarValue="true"
/>
<ListPreference
app:defaultValue="@string/pref_default_attendance_calculator_sorting_mode"
app:entries="@array/attendance_calculator_sorting_mode_entries"
app:entryValues="@array/attendance_calculator_sorting_mode_values"
app:iconSpaceReserved="false"
app:key="@string/pref_key_attendance_calculator_sorting_mode"
app:title="@string/pref_view_attendance_calculator_sorting_mode"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
<PreferenceCategory
android:layout_height="wrap_content"