Add grade statistics (#251)

This commit is contained in:
Mikołaj Pich 2019-03-04 12:13:37 +01:00 committed by Rafał Borcz
parent cae4f140e6
commit dcab8df4b9
30 changed files with 881 additions and 25 deletions

View File

@ -97,6 +97,9 @@ dependencies {
implementation "com.aurelhubert:ahbottomnavigation:2.3.4"
implementation 'com.ncapdevi:frag-nav:3.1.0'
implementation "com.hootsuite.android:nachos:1.1.1"
implementation 'com.github.PhilJay:MPAndroidChart:971640b29d'
implementation 'com.github.pwittchen:reactivenetwork-rx2:3.0.2'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation "io.reactivex.rxjava2:rxjava:2.2.5"
@ -125,8 +128,6 @@ dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation 'org.mockito:mockito-android:2.23.4'
androidTestImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
implementation "com.hootsuite.android:nachos:1.1.1"
}
apply plugin: 'com.google.gms.google-services'

View File

@ -32,5 +32,8 @@
-dontwarn rx.internal.util.**
-dontwarn sun.misc.Unsafe
#Config for MPAndroidChart
-keep class com.github.mikephil.charting.** { *; }
#Config for API
-keep class io.github.wulkanowy.api.** {*;}

View File

@ -0,0 +1,69 @@
package io.github.wulkanowy.data.repositories.grade
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.github.wulkanowy.data.db.AppDatabase
import io.github.wulkanowy.data.db.entities.GradeStatistics
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.repositories.gradestatistics.GradeStatisticsLocal
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
class GradeStatisticsLocalTest {
private lateinit var gradeStatisticsLocal: GradeStatisticsLocal
private lateinit var testDb: AppDatabase
@Before
fun createDb() {
testDb = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java)
.build()
gradeStatisticsLocal = GradeStatisticsLocal(testDb.gradeStatistics)
}
@After
fun closeDb() {
testDb.close()
}
@Test
fun saveAndRead_subject() {
gradeStatisticsLocal.saveGradesStatistics(listOf(
getGradeStatistics("Matematyka", 2, 1),
getGradeStatistics("Fizyka", 1, 2)
))
val stats = gradeStatisticsLocal.getGradesStatistics(
Semester(2, 2, "", 1, 2, true, 1 ,1), false,
"Matematyka"
).blockingGet()
assertEquals(1, stats.size)
assertEquals(stats[0].subject, "Matematyka")
}
@Test
fun saveAndRead_all() {
gradeStatisticsLocal.saveGradesStatistics(listOf(
getGradeStatistics("Matematyka", 2, 1),
getGradeStatistics("Chemia", 2, 1),
getGradeStatistics("Fizyka", 1, 2)
))
val stats = gradeStatisticsLocal.getGradesStatistics(
Semester(2, 2, "", 1, 2, true, 1, 1), false,
"Wszystkie"
).blockingGet()
assertEquals(1, stats.size)
assertEquals(stats[0].subject, "Wszystkie")
}
private fun getGradeStatistics(subject: String, studentId: Int, semesterId: Int): GradeStatistics {
return GradeStatistics(studentId, semesterId, subject, 5, 5, false)
}
}

View File

@ -4,7 +4,7 @@
package="io.github.wulkanowy"
android:installLocation="internalOnly">
<uses-sdk tools:overrideLibrary="com.readystatesoftware.chuck"/>
<uses-sdk tools:overrideLibrary="com.readystatesoftware.chuck" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@ -20,7 +20,7 @@
android:supportsRtl="false"
android:theme="@style/WulkanowyTheme"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning">
tools:ignore="GoogleAppIndexingWarning,UnusedAttribute">
<activity
android:name=".ui.modules.splash.SplashActivity"
android:screenOrientation="portrait"

View File

@ -82,6 +82,10 @@ internal class RepositoryModule {
@Provides
fun provideGradeSummaryDao(database: AppDatabase) = database.gradeSummaryDao
@Singleton
@Provides
fun provideGradeStatisticsDao(database: AppDatabase) = database.gradeStatistics
@Singleton
@Provides
fun provideMessagesDao(database: AppDatabase) = database.messagesDao

View File

@ -11,6 +11,7 @@ import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao
import io.github.wulkanowy.data.db.dao.CompletedLessonsDao
import io.github.wulkanowy.data.db.dao.ExamDao
import io.github.wulkanowy.data.db.dao.GradeDao
import io.github.wulkanowy.data.db.dao.GradeStatisticsDao
import io.github.wulkanowy.data.db.dao.GradeSummaryDao
import io.github.wulkanowy.data.db.dao.HomeworkDao
import io.github.wulkanowy.data.db.dao.LuckyNumberDao
@ -27,6 +28,7 @@ import io.github.wulkanowy.data.db.entities.AttendanceSummary
import io.github.wulkanowy.data.db.entities.CompletedLesson
import io.github.wulkanowy.data.db.entities.Exam
import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeStatistics
import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.db.entities.Homework
import io.github.wulkanowy.data.db.entities.LuckyNumber
@ -43,6 +45,7 @@ import io.github.wulkanowy.data.db.migrations.Migration3
import io.github.wulkanowy.data.db.migrations.Migration4
import io.github.wulkanowy.data.db.migrations.Migration5
import io.github.wulkanowy.data.db.migrations.Migration6
import io.github.wulkanowy.data.db.migrations.Migration7
import javax.inject.Singleton
@Singleton
@ -56,6 +59,7 @@ import javax.inject.Singleton
AttendanceSummary::class,
Grade::class,
GradeSummary::class,
GradeStatistics::class,
Message::class,
Note::class,
Homework::class,
@ -72,7 +76,7 @@ import javax.inject.Singleton
abstract class AppDatabase : RoomDatabase() {
companion object {
const val VERSION_SCHEMA = 6
const val VERSION_SCHEMA = 7
fun newInstance(context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "wulkanowy_database")
@ -84,7 +88,8 @@ abstract class AppDatabase : RoomDatabase() {
Migration3(),
Migration4(),
Migration5(),
Migration6()
Migration6(),
Migration7()
)
.build()
}
@ -106,6 +111,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract val gradeSummaryDao: GradeSummaryDao
abstract val gradeStatistics: GradeStatisticsDao
abstract val messagesDao: MessagesDao
abstract val noteDao: NoteDao

View File

@ -0,0 +1,26 @@
package io.github.wulkanowy.data.db.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import io.github.wulkanowy.data.db.entities.GradeStatistics
import io.reactivex.Maybe
import javax.inject.Singleton
@Singleton
@Dao
interface GradeStatisticsDao {
@Insert
fun insertAll(gradesStatistics: List<GradeStatistics>)
@Delete
fun deleteAll(gradesStatistics: List<GradeStatistics>)
@Query("SELECT * FROM GradesStatistics WHERE student_id = :studentId AND semester_id = :semesterId AND subject = :subjectName AND is_semester = :isSemester")
fun loadSubject(semesterId: Int, studentId: Int, subjectName: String, isSemester: Boolean): Maybe<List<GradeStatistics>>
@Query("SELECT * FROM GradesStatistics WHERE student_id = :studentId AND semester_id = :semesterId AND is_semester = :isSemester")
fun loadAll(semesterId: Int, studentId: Int, isSemester: Boolean): Maybe<List<GradeStatistics>>
}

View File

@ -0,0 +1,27 @@
package io.github.wulkanowy.data.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "GradesStatistics")
data class GradeStatistics(
@ColumnInfo(name = "student_id")
val studentId: Int,
@ColumnInfo(name = "semester_id")
val semesterId: Int,
val subject: String,
val grade: Int,
val amount: Int,
@ColumnInfo(name = "is_semester")
val semester: Boolean
) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}

View File

@ -0,0 +1,18 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration7 : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS `GradesStatistics` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," +
"`student_id` INTEGER NOT NULL," +
"`semester_id` INTEGER NOT NULL," +
"`subject` TEXT NOT NULL," +
"`grade` INTEGER NOT NULL," +
"`amount` INTEGER NOT NULL," +
"`is_semester` INTEGER NOT NULL)")
}
}

View File

@ -0,0 +1,34 @@
package io.github.wulkanowy.data.repositories.gradestatistics
import io.github.wulkanowy.data.db.dao.GradeStatisticsDao
import io.github.wulkanowy.data.db.entities.GradeStatistics
import io.github.wulkanowy.data.db.entities.Semester
import io.reactivex.Maybe
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GradeStatisticsLocal @Inject constructor(private val gradeStatisticsDb: GradeStatisticsDao) {
fun getGradesStatistics(semester: Semester, isSemester: Boolean): Maybe<List<GradeStatistics>> {
return gradeStatisticsDb.loadAll(semester.semesterId, semester.studentId, isSemester)
.filter { !it.isEmpty() }
}
fun getGradesStatistics(semester: Semester, isSemester: Boolean, subjectName: String): Maybe<List<GradeStatistics>> {
return (if ("Wszystkie" == subjectName) gradeStatisticsDb.loadAll(semester.semesterId, semester.studentId, isSemester).map { list ->
list.groupBy { it.grade }.map {
GradeStatistics(semester.studentId, semester.semesterId, subjectName, it.key, it.value.fold(0) { acc, e -> acc + e.amount }, false)
}
}
else gradeStatisticsDb.loadSubject(semester.semesterId, semester.studentId, subjectName, isSemester)).filter { !it.isEmpty() }
}
fun saveGradesStatistics(gradesStatistics: List<GradeStatistics>) {
gradeStatisticsDb.insertAll(gradesStatistics)
}
fun deleteGradesStatistics(gradesStatistics: List<GradeStatistics>) {
gradeStatisticsDb.deleteAll(gradesStatistics)
}
}

View File

@ -0,0 +1,29 @@
package io.github.wulkanowy.data.repositories.gradestatistics
import io.github.wulkanowy.api.Api
import io.github.wulkanowy.data.db.entities.GradeStatistics
import io.github.wulkanowy.data.db.entities.Semester
import io.reactivex.Single
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GradeStatisticsRemote @Inject constructor(private val api: Api) {
fun getGradeStatistics(semester: Semester, isSemester: Boolean): Single<List<GradeStatistics>> {
return Single.just(api.apply { diaryId = semester.diaryId })
.flatMap { it.getGradesStatistics(semester.semesterId, isSemester) }
.map { gradeStatistics ->
gradeStatistics.map {
GradeStatistics(
semesterId = semester.semesterId,
studentId = semester.studentId,
subject = it.subject,
grade = it.gradeValue,
amount = it.amount ?: 0,
semester = isSemester
)
}
}
}
}

View File

@ -0,0 +1,33 @@
package io.github.wulkanowy.data.repositories.gradestatistics
import com.github.pwittchen.reactivenetwork.library.rx2.ReactiveNetwork
import com.github.pwittchen.reactivenetwork.library.rx2.internet.observing.InternetObservingSettings
import io.github.wulkanowy.data.db.entities.GradeStatistics
import io.github.wulkanowy.data.db.entities.Semester
import io.reactivex.Single
import java.net.UnknownHostException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GradeStatisticsRepository @Inject constructor(
private val settings: InternetObservingSettings,
private val local: GradeStatisticsLocal,
private val remote: GradeStatisticsRemote
) {
fun getGradesStatistics(semester: Semester, subjectName: String, isSemester: Boolean, forceRefresh: Boolean = false): Single<List<GradeStatistics>> {
return local.getGradesStatistics(semester, isSemester, subjectName).filter { !forceRefresh }
.switchIfEmpty(ReactiveNetwork.checkInternetConnectivity(settings)
.flatMap {
if (it) remote.getGradeStatistics(semester, isSemester)
else Single.error(UnknownHostException())
}.flatMap { newGradesStats ->
local.getGradesStatistics(semester, isSemester).toSingle(emptyList())
.doOnSuccess { oldGradesStats ->
local.deleteGradesStatistics(oldGradesStats - newGradesStats)
local.saveGradesStatistics(newGradesStats - oldGradesStats)
}
}.flatMap { local.getGradesStatistics(semester, isSemester, subjectName).toSingle(emptyList()) })
}
}

View File

@ -10,7 +10,7 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SubjectRepostory @Inject constructor(
class SubjectRepository @Inject constructor(
private val settings: InternetObservingSettings,
private val local: SubjectLocal,
private val remote: SubjectRemote

View File

@ -5,7 +5,7 @@ import io.github.wulkanowy.data.db.entities.Subject
import io.github.wulkanowy.data.repositories.attendancesummary.AttendanceSummaryRepository
import io.github.wulkanowy.data.repositories.semester.SemesterRepository
import io.github.wulkanowy.data.repositories.student.StudentRepository
import io.github.wulkanowy.data.repositories.subject.SubjectRepostory
import io.github.wulkanowy.data.repositories.subject.SubjectRepository
import io.github.wulkanowy.ui.base.session.BaseSessionPresenter
import io.github.wulkanowy.ui.base.session.SessionErrorHandler
import io.github.wulkanowy.utils.FirebaseAnalyticsHelper
@ -21,7 +21,7 @@ import javax.inject.Inject
class AttendanceSummaryPresenter @Inject constructor(
private val errorHandler: SessionErrorHandler,
private val attendanceSummaryRepository: AttendanceSummaryRepository,
private val subjectRepository: SubjectRepostory,
private val subjectRepository: SubjectRepository,
private val studentRepository: StudentRepository,
private val semesterRepository: SemesterRepository,
private val schedulers: SchedulersProvider,

View File

@ -0,0 +1,34 @@
package io.github.wulkanowy.ui.modules.grade
import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import com.google.android.material.tabs.TabLayout
/**
* @see <a href="https://stackoverflow.com/a/50382854">Tabs don't fit to screen with tabmode=scrollable, Even with a Custom Tab Layout</a>
*/
class CustomTabLayout : TabLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(widthMeasureSpec, heightMeasureSpec)
val tabLayout = getChildAt(0) as ViewGroup
val childCount = tabLayout.childCount
if (childCount == 0) return
val tabMinWidth = context.resources.displayMetrics.widthPixels / childCount
for (i in 0 until childCount) {
tabLayout.getChildAt(i).minimumWidth = tabMinWidth
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}

View File

@ -14,6 +14,7 @@ import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseFragmentPagerAdapter
import io.github.wulkanowy.ui.base.session.BaseSessionFragment
import io.github.wulkanowy.ui.modules.grade.details.GradeDetailsFragment
import io.github.wulkanowy.ui.modules.grade.statistics.GradeStatisticsFragment
import io.github.wulkanowy.ui.modules.grade.summary.GradeSummaryFragment
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.utils.setOnSelectPageListener
@ -63,12 +64,14 @@ class GradeFragment : BaseSessionFragment(), GradeView, MainView.MainChildView,
containerId = gradeViewPager.id
addFragmentsWithTitle(mapOf(
GradeDetailsFragment.newInstance() to getString(R.string.all_details),
GradeSummaryFragment.newInstance() to getString(R.string.grade_menu_summary)
GradeSummaryFragment.newInstance() to getString(R.string.grade_menu_summary),
GradeStatisticsFragment.newInstance() to getString(R.string.grade_menu_statistics)
))
}
gradeViewPager.run {
adapter = pagerAdapter
offscreenPageLimit = 3
setOnSelectPageListener { presenter.onPageSelected(it) }
}
gradeTabLayout.setupWithViewPager(gradeViewPager)

View File

@ -7,6 +7,7 @@ import io.github.wulkanowy.di.scopes.PerChildFragment
import io.github.wulkanowy.di.scopes.PerFragment
import io.github.wulkanowy.ui.base.BaseFragmentPagerAdapter
import io.github.wulkanowy.ui.modules.grade.details.GradeDetailsFragment
import io.github.wulkanowy.ui.modules.grade.statistics.GradeStatisticsFragment
import io.github.wulkanowy.ui.modules.grade.summary.GradeSummaryFragment
@Module
@ -28,4 +29,8 @@ abstract class GradeModule {
@PerChildFragment
@ContributesAndroidInjector
abstract fun binGradeSummaryFragment(): GradeSummaryFragment
@PerChildFragment
@ContributesAndroidInjector
abstract fun binGradeStatisticsFragment(): GradeStatisticsFragment
}

View File

@ -114,6 +114,6 @@ class GradePresenter @Inject constructor(
}
private fun notifyChildrenSemesterChange() {
for (i in 0..1) view?.notifyChildSemesterChange(i)
for (i in 0..2) view?.notifyChildSemesterChange(i)
}
}

View File

@ -0,0 +1,191 @@
package io.github.wulkanowy.ui.modules.grade.statistics
import android.graphics.Color.WHITE
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.github.mikephil.charting.components.Legend
import com.github.mikephil.charting.components.LegendEntry
import com.github.mikephil.charting.data.PieData
import com.github.mikephil.charting.data.PieDataSet
import com.github.mikephil.charting.data.PieEntry
import com.github.mikephil.charting.formatter.ValueFormatter
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.GradeStatistics
import io.github.wulkanowy.ui.base.session.BaseSessionFragment
import io.github.wulkanowy.ui.modules.grade.GradeFragment
import io.github.wulkanowy.ui.modules.grade.GradeView
import io.github.wulkanowy.utils.getThemeAttrColor
import io.github.wulkanowy.utils.setOnItemSelectedListener
import kotlinx.android.synthetic.main.fragment_grade_statistics.*
import javax.inject.Inject
class GradeStatisticsFragment : BaseSessionFragment(), GradeStatisticsView, GradeView.GradeChildView {
@Inject
lateinit var presenter: GradeStatisticsPresenter
private lateinit var subjectsAdapter: ArrayAdapter<String>
companion object {
private const val SAVED_CHART_TYPE = "CURRENT_TYPE"
fun newInstance() = GradeStatisticsFragment()
}
override val isViewEmpty
get() = gradeStatisticsChart.isEmpty
private val gradeColors = listOf(
6 to R.color.grade_material_six,
5 to R.color.grade_material_five,
4 to R.color.grade_material_four,
3 to R.color.grade_material_three,
2 to R.color.grade_material_two,
1 to R.color.grade_material_one
)
private val gradeLabels = listOf(
"6, 6-", "5, 5-, 5+", "4, 4-, 4+", "3, 3-, 3+", "2, 2-, 2+", "1, 1+"
)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_grade_statistics, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
messageContainer = gradeStatisticsChart
presenter.onAttachView(this, savedInstanceState?.getBoolean(SAVED_CHART_TYPE))
}
override fun initView() {
gradeStatisticsChart.run {
description.isEnabled = false
setHoleColor(context.getThemeAttrColor(android.R.attr.windowBackground))
setCenterTextColor(context.getThemeAttrColor(android.R.attr.textColorPrimary))
animateXY(1000, 1000)
minAngleForSlices = 25f
legend.apply {
textColor = context.getThemeAttrColor(android.R.attr.textColorPrimary)
setCustom(gradeLabels.mapIndexed { i, it ->
LegendEntry().apply {
label = it
formColor = ContextCompat.getColor(context, gradeColors[i].second)
form = Legend.LegendForm.SQUARE
}
})
}
}
context?.let {
subjectsAdapter = ArrayAdapter(it, android.R.layout.simple_spinner_item, ArrayList<String>())
subjectsAdapter.setDropDownViewResource(R.layout.item_attendance_summary_subject)
}
gradeStatisticsSubjects.run {
adapter = subjectsAdapter
setOnItemSelectedListener { presenter.onSubjectSelected((it as TextView).text.toString()) }
}
gradeStatisticsSwipe.setOnRefreshListener { presenter.onSwipeRefresh() }
}
override fun updateSubjects(data: ArrayList<String>) {
subjectsAdapter.run {
clear()
addAll(data)
notifyDataSetChanged()
}
}
override fun updateData(items: List<GradeStatistics>) {
gradeStatisticsChart.run {
data = PieData(PieDataSet(items.map {
PieEntry(it.amount.toFloat(), it.grade.toString())
}, "Legenda").apply {
valueTextSize = 12f
sliceSpace = 1f
valueTextColor = WHITE
setColors(items.map {
gradeColors.single { color -> color.first == it.grade }.second
}.toIntArray(), context)
}).apply {
setTouchEnabled(false)
setValueFormatter(object : ValueFormatter() {
override fun getPieLabel(value: Float, pieEntry: PieEntry): String {
return resources.getQuantityString(R.plurals.grade_number_item, value.toInt(), value.toInt())
}
})
centerText = items.fold(0) { acc, it -> acc + it.amount }
.let { resources.getQuantityString(R.plurals.grade_number_item, it, it) }
}
invalidate()
}
}
override fun showSubjects(show: Boolean) {
gradeStatisticsSubjectsContainer.visibility = if (show) View.VISIBLE else View.INVISIBLE
}
override fun clearView() {
gradeStatisticsChart.clear()
}
override fun showContent(show: Boolean) {
gradeStatisticsChart.visibility = if (show) View.VISIBLE else View.GONE
}
override fun showEmpty(show: Boolean) {
gradeStatisticsEmpty.visibility = if (show) View.VISIBLE else View.INVISIBLE
}
override fun showProgress(show: Boolean) {
gradeStatisticsProgress.visibility = if (show) View.VISIBLE else View.GONE
}
override fun showRefresh(show: Boolean) {
gradeStatisticsSwipe.isRefreshing = show
}
override fun onParentLoadData(semesterId: Int, forceRefresh: Boolean) {
presenter.onParentViewLoadData(semesterId, forceRefresh)
}
override fun onParentReselected() {
//
}
override fun onParentChangeSemester() {
presenter.onParentViewChangeSemester()
}
override fun notifyParentDataLoaded(semesterId: Int) {
(parentFragment as? GradeFragment)?.onChildFragmentLoaded(semesterId)
}
override fun notifyParentRefresh() {
(parentFragment as? GradeFragment)?.onChildRefresh()
}
override fun onResume() {
super.onResume()
gradeStatisticsTypeSwitch.setOnCheckedChangeListener { _, checkedId ->
presenter.onTypeChange(checkedId == R.id.gradeStatisticsTypeSemester)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(GradeStatisticsFragment.SAVED_CHART_TYPE, presenter.currentIsSemester)
}
override fun onDestroyView() {
super.onDestroyView()
presenter.onDetachView()
}
}

View File

@ -0,0 +1,140 @@
package io.github.wulkanowy.ui.modules.grade.statistics
import io.github.wulkanowy.data.db.entities.Subject
import io.github.wulkanowy.data.repositories.gradestatistics.GradeStatisticsRepository
import io.github.wulkanowy.data.repositories.semester.SemesterRepository
import io.github.wulkanowy.data.repositories.student.StudentRepository
import io.github.wulkanowy.data.repositories.subject.SubjectRepository
import io.github.wulkanowy.ui.base.session.BaseSessionPresenter
import io.github.wulkanowy.ui.base.session.SessionErrorHandler
import io.github.wulkanowy.utils.FirebaseAnalyticsHelper
import io.github.wulkanowy.utils.SchedulersProvider
import timber.log.Timber
import javax.inject.Inject
class GradeStatisticsPresenter @Inject constructor(
private val errorHandler: SessionErrorHandler,
private val gradeStatisticsRepository: GradeStatisticsRepository,
private val subjectRepository: SubjectRepository,
private val studentRepository: StudentRepository,
private val semesterRepository: SemesterRepository,
private val schedulers: SchedulersProvider,
private val analytics: FirebaseAnalyticsHelper
) : BaseSessionPresenter<GradeStatisticsView>(errorHandler) {
private var subjects = emptyList<Subject>()
private var currentSemesterId = 0
private var currentSubjectName: String = "Wszystkie"
var currentIsSemester = false
private set
fun onAttachView(view: GradeStatisticsView, isSemester: Boolean?) {
super.onAttachView(view)
currentIsSemester = isSemester ?: false
view.initView()
}
fun onParentViewLoadData(semesterId: Int, forceRefresh: Boolean) {
currentSemesterId = semesterId
loadSubjects()
loadData(semesterId, currentSubjectName, currentIsSemester, forceRefresh)
}
fun onParentViewChangeSemester() {
view?.run {
showProgress(true)
showRefresh(false)
showContent(false)
showEmpty(false)
clearView()
}
disposable.clear()
}
fun onSwipeRefresh() {
Timber.i("Force refreshing the grade stats")
view?.notifyParentRefresh()
}
fun onSubjectSelected(name: String) {
Timber.i("Select attendance stats subject $name")
view?.run {
showContent(false)
showProgress(true)
showEmpty(false)
clearView()
}
(subjects.singleOrNull { it.name == name }?.name).let {
if (it != currentSubjectName) loadData(currentSemesterId, name, currentIsSemester)
}
}
fun onTypeChange(isSemester: Boolean) {
Timber.i("Select attendance stats semester: $isSemester")
view?.run {
showContent(false)
showProgress(true)
showEmpty(false)
clearView()
}
loadData(currentSemesterId, currentSubjectName, isSemester)
}
private fun loadSubjects() {
Timber.i("Loading grade stats subjects started")
disposable.add(studentRepository.getCurrentStudent()
.flatMap { semesterRepository.getCurrentSemester(it) }
.flatMap { subjectRepository.getSubjects(it) }
.doOnSuccess { subjects = it }
.map { ArrayList(it.map { subject -> subject.name }) }
.subscribeOn(schedulers.backgroundThread)
.observeOn(schedulers.mainThread)
.subscribe({
Timber.i("Loading grade stats subjects result: Success")
view?.run {
updateSubjects(it)
showSubjects(true)
}
}, {
Timber.e("Loading grade stats subjects result: An exception occurred")
errorHandler.dispatch(it)
})
)
}
private fun loadData(semesterId: Int, subjectName: String, isSemester: Boolean, forceRefresh: Boolean = false) {
Timber.i("Loading grade stats data started")
currentSubjectName = subjectName
currentIsSemester = isSemester
disposable.add(studentRepository.getCurrentStudent()
.flatMap { semesterRepository.getSemesters(it) }
.flatMap { gradeStatisticsRepository.getGradesStatistics(it.first { item -> item.semesterId == semesterId }, subjectName, isSemester, forceRefresh) }
.map { list -> list.sortedByDescending { it.grade } }
.map { list -> list.filter { it.amount != 0 } }
.subscribeOn(schedulers.backgroundThread)
.observeOn(schedulers.mainThread)
.doFinally {
view?.run {
showRefresh(false)
showProgress(false)
notifyParentDataLoaded(semesterId)
}
}
.subscribe({
Timber.i("Loading grade stats result: Success")
view?.run {
showEmpty(it.isEmpty())
showContent(it.isNotEmpty())
updateData(it)
}
analytics.logEvent("load_grade_statistics", "items" to it.size, "force_refresh" to forceRefresh)
}) {
Timber.e("Loading grade stats result: An exception occurred")
view?.run { showEmpty(isViewEmpty) }
errorHandler.dispatch(it)
})
}
}

View File

@ -0,0 +1,31 @@
package io.github.wulkanowy.ui.modules.grade.statistics
import io.github.wulkanowy.data.db.entities.GradeStatistics
import io.github.wulkanowy.ui.base.session.BaseSessionView
interface GradeStatisticsView : BaseSessionView {
val isViewEmpty: Boolean
fun initView()
fun updateSubjects(data: ArrayList<String>)
fun updateData(items: List<GradeStatistics>)
fun showSubjects(show: Boolean)
fun notifyParentDataLoaded(semesterId: Int)
fun notifyParentRefresh()
fun clearView()
fun showContent(show: Boolean)
fun showEmpty(show: Boolean)
fun showProgress(show: Boolean)
fun showRefresh(show: Boolean)
}

View File

@ -4,11 +4,19 @@ import android.view.View
import android.widget.AdapterView
import android.widget.Spinner
/**
* @see <a href="https://stackoverflow.com/a/29602298">How to keep onItemSelected from firing off on a newly instantiated Spinner?</a>
*/
inline fun Spinner.setOnItemSelectedListener(crossinline listener: (view: View?) -> Unit) {
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) {}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
listener(view)
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) {}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
listener(view)
}
}
}
}
}

View File

@ -5,32 +5,33 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.tabs.TabLayout
<io.github.wulkanowy.ui.modules.grade.CustomTabLayout
android:id="@+id/gradeTabLayout"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@color/colorPrimary"
android:elevation="5dp"
android:visibility="invisible"
app:tabGravity="fill"
app:tabIndicatorColor="@android:color/white"
app:tabMaxWidth="0dp"
app:tabMode="fixed"
app:tabTextColor="@android:color/white" />
app:tabMode="scrollable"
app:tabTextColor="@android:color/white"
tools:visibility="visible" />
<androidx.viewpager.widget.ViewPager
android:id="@+id/gradeViewPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="48dp"
android:visibility="invisible" />
android:visibility="invisible"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/gradeProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
android:indeterminate="true"
tools:visibility="invisible" />
<LinearLayout
android:id="@+id/gradeEmpty"
@ -38,7 +39,8 @@
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="invisible">
android:visibility="invisible"
tools:visibility="visible">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="wrap_content"

View File

@ -2,7 +2,8 @@
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">
android:layout_height="match_parent"
tools:context=".ui.modules.grade.details.GradeDetailsFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/gradeDetailsSwipe"
@ -20,7 +21,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
android:indeterminate="true"
tools:visibility="gone" />
<LinearLayout
android:id="@+id/gradeDetailsEmpty"

View File

@ -0,0 +1,128 @@
<LinearLayout 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"
android:orientation="vertical"
tools:context=".ui.modules.grade.statistics.GradeStatisticsFragment">
<LinearLayout
android:id="@+id/gradeStatisticsSubjectsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground"
android:elevation="5dp"
android:padding="5dp"
android:visibility="invisible"
tools:listitem="@layout/item_attendance_summary"
tools:targetApi="lollipop"
tools:visibility="visible">
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/gradeStatisticsSubjects"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:entries="@array/endpoints_keys"
android:paddingStart="10dp"
android:paddingLeft="10dp"
android:paddingTop="10dp"
android:paddingEnd="30dp"
android:paddingRight="30dp"
android:paddingBottom="10dp"
android:spinnerMode="dialog" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/gradeStatisticsSwipe"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioGroup
android:id="@+id/gradeStatisticsTypeSwitch"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="horizontal"
android:paddingTop="5dp">
<RadioButton
android:id="@+id/gradeStatisticsTypePartial"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:tag="partial"
android:text="@string/grade_statistics_partial" />
<RadioButton
android:id="@+id/gradeStatisticsTypeSemester"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:tag="annual"
android:text="@string/grade_statistics_semester" />
</RadioGroup>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.github.mikephil.charting.charts.PieChart
android:id="@+id/gradeStatisticsChart"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/windowBackground"
android:minHeight="400dp"
android:visibility="gone"
android:layout_margin="10dp"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/gradeStatisticsProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
tools:visibility="gone" />
<LinearLayout
android:id="@+id/gradeStatisticsEmpty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="invisible">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="100dp"
android:minHeight="100dp"
app:srcCompat="@drawable/ic_menu_main_grade_26dp"
app:tint="?android:attr/textColorPrimary"
tools:ignore="contentDescription" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:text="@string/grade_no_items"
android:textSize="20sp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>

View File

@ -2,7 +2,8 @@
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">
android:layout_height="match_parent"
tools:context=".ui.modules.grade.summary.GradeSummaryFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/gradeSummarySwipe"
@ -20,7 +21,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
android:indeterminate="true"
tools:visibility="gone" />
<LinearLayout
android:id="@+id/gradeSummaryEmpty"

View File

@ -16,7 +16,7 @@
android:layout_height="40dp"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:tint="?android:attr/textColorPrimary"
android:tint="@android:color/black"
app:srcCompat="@drawable/ic_all_account_24dp" />
<TextView

View File

@ -60,7 +60,10 @@
<string name="grade_summary_calculated_average">Obliczona średnia</string>
<string name="grade_summary_final_average">Końcowa średnia</string>
<string name="grade_menu_summary">Podsumowanie</string>
<string name="grade_menu_statistics">Oceny klasy</string>
<string name="grade_menu_read">Oznacz jako przeczytane</string>
<string name="grade_statistics_partial">Oceny cząstkowe</string>
<string name="grade_statistics_semester">Oceny semestralne</string>
<plurals name="grade_number_item">
<item quantity="one">%d ocena</item>
<item quantity="few">%d oceny</item>

View File

@ -60,7 +60,10 @@
<string name="grade_summary_calculated_average">Calculated average</string>
<string name="grade_summary_final_average">Final average</string>
<string name="grade_menu_summary">Summary</string>
<string name="grade_menu_statistics">Class grades</string>
<string name="grade_menu_read">Mark as read</string>
<string name="grade_statistics_partial">Partial grades</string>
<string name="grade_statistics_semester">Semester grades</string>
<plurals name="grade_number_item">
<item quantity="one">%d grade</item>
<item quantity="other">%d grades</item>

View File

@ -0,0 +1,53 @@
package io.github.wulkanowy.data.repositories.remote
import io.github.wulkanowy.api.Api
import io.github.wulkanowy.api.grades.GradeStatistics
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.repositories.gradestatistics.GradeStatisticsRemote
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.SpyK
import io.reactivex.Single
import org.junit.Assert
import org.junit.Before
import org.junit.Test
class GradeStatisticsRemoteTest {
@SpyK
private var mockApi = Api()
@MockK
private lateinit var semesterMock: Semester
@Before
fun initApi() {
MockKAnnotations.init(this)
}
@Test
fun getGradeStatisticsTest() {
every { mockApi.getGradesStatistics(1, any()) } returns Single.just(listOf(
getGradeStatistics("Fizyka"),
getGradeStatistics("Matematyka")
))
every { mockApi.diaryId } returns 1
every { semesterMock.studentId } returns 1
every { semesterMock.semesterId } returns 1
every { semesterMock.semesterName } returns 2
every { semesterMock.diaryId } returns 1
val stats = GradeStatisticsRemote(mockApi).getGradeStatistics(semesterMock, false).blockingGet()
Assert.assertEquals(2, stats.size)
}
private fun getGradeStatistics(subjectName: String): GradeStatistics {
return GradeStatistics().apply {
subject = subjectName
gradeValue = 5
amount = 10
}
}
}