From dcab8df4b91f1a35b813f9d55019fb03520d8da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Pich?= Date: Mon, 4 Mar 2019 12:13:37 +0100 Subject: [PATCH] Add grade statistics (#251) --- app/build.gradle | 5 +- app/proguard-rules.pro | 3 + .../grade/GradeStatisticsLocalTest.kt | 69 +++++++ app/src/main/AndroidManifest.xml | 4 +- .../github/wulkanowy/data/RepositoryModule.kt | 4 + .../github/wulkanowy/data/db/AppDatabase.kt | 11 +- .../data/db/dao/GradeStatisticsDao.kt | 26 +++ .../data/db/entities/GradeStatistics.kt | 27 +++ .../data/db/migrations/Migration7.kt | 18 ++ .../gradestatistics/GradeStatisticsLocal.kt | 34 ++++ .../gradestatistics/GradeStatisticsRemote.kt | 29 +++ .../GradeStatisticsRepository.kt | 33 +++ ...bjectRepostory.kt => SubjectRepository.kt} | 2 +- .../summary/AttendanceSummaryPresenter.kt | 4 +- .../ui/modules/grade/CustomTabLayout.kt | 34 ++++ .../ui/modules/grade/GradeFragment.kt | 5 +- .../wulkanowy/ui/modules/grade/GradeModule.kt | 5 + .../ui/modules/grade/GradePresenter.kt | 2 +- .../statistics/GradeStatisticsFragment.kt | 191 ++++++++++++++++++ .../statistics/GradeStatisticsPresenter.kt | 140 +++++++++++++ .../grade/statistics/GradeStatisticsView.kt | 31 +++ .../wulkanowy/utils/SpinnerExtension.kt | 10 +- app/src/main/res/layout/fragment_grade.xml | 18 +- .../res/layout/fragment_grade_details.xml | 6 +- .../res/layout/fragment_grade_statistics.xml | 128 ++++++++++++ .../res/layout/fragment_grade_summary.xml | 6 +- .../main/res/layout/item_login_options.xml | 2 +- app/src/main/res/values-pl/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../remote/GradeStatisticsRemoteTest.kt | 53 +++++ 30 files changed, 881 insertions(+), 25 deletions(-) create mode 100644 app/src/androidTest/java/io/github/wulkanowy/data/repositories/grade/GradeStatisticsLocalTest.kt create mode 100644 app/src/main/java/io/github/wulkanowy/data/db/dao/GradeStatisticsDao.kt create mode 100644 app/src/main/java/io/github/wulkanowy/data/db/entities/GradeStatistics.kt create mode 100644 app/src/main/java/io/github/wulkanowy/data/db/migrations/Migration7.kt create mode 100644 app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsLocal.kt create mode 100644 app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsRemote.kt create mode 100644 app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsRepository.kt rename app/src/main/java/io/github/wulkanowy/data/repositories/subject/{SubjectRepostory.kt => SubjectRepository.kt} (96%) create mode 100644 app/src/main/java/io/github/wulkanowy/ui/modules/grade/CustomTabLayout.kt create mode 100644 app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsFragment.kt create mode 100644 app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsPresenter.kt create mode 100644 app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsView.kt create mode 100644 app/src/main/res/layout/fragment_grade_statistics.xml create mode 100644 app/src/test/java/io/github/wulkanowy/data/repositories/remote/GradeStatisticsRemoteTest.kt diff --git a/app/build.gradle b/app/build.gradle index 66542f78f..4d53481bf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 12360845c..15b628384 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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.** {*;} diff --git a/app/src/androidTest/java/io/github/wulkanowy/data/repositories/grade/GradeStatisticsLocalTest.kt b/app/src/androidTest/java/io/github/wulkanowy/data/repositories/grade/GradeStatisticsLocalTest.kt new file mode 100644 index 000000000..f85dabf36 --- /dev/null +++ b/app/src/androidTest/java/io/github/wulkanowy/data/repositories/grade/GradeStatisticsLocalTest.kt @@ -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) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2bc7e0a91..cb24e2aa5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,7 +4,7 @@ package="io.github.wulkanowy" android:installLocation="internalOnly"> - + @@ -20,7 +20,7 @@ android:supportsRtl="false" android:theme="@style/WulkanowyTheme" android:usesCleartextTraffic="true" - tools:ignore="GoogleAppIndexingWarning"> + tools:ignore="GoogleAppIndexingWarning,UnusedAttribute"> ) + + @Delete + fun deleteAll(gradesStatistics: List) + + @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> + + @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> +} diff --git a/app/src/main/java/io/github/wulkanowy/data/db/entities/GradeStatistics.kt b/app/src/main/java/io/github/wulkanowy/data/db/entities/GradeStatistics.kt new file mode 100644 index 000000000..8ad8b8b8d --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/data/db/entities/GradeStatistics.kt @@ -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 +} diff --git a/app/src/main/java/io/github/wulkanowy/data/db/migrations/Migration7.kt b/app/src/main/java/io/github/wulkanowy/data/db/migrations/Migration7.kt new file mode 100644 index 000000000..683051244 --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/data/db/migrations/Migration7.kt @@ -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)") + } +} diff --git a/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsLocal.kt b/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsLocal.kt new file mode 100644 index 000000000..581ac2f81 --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsLocal.kt @@ -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> { + return gradeStatisticsDb.loadAll(semester.semesterId, semester.studentId, isSemester) + .filter { !it.isEmpty() } + } + + fun getGradesStatistics(semester: Semester, isSemester: Boolean, subjectName: String): Maybe> { + 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) { + gradeStatisticsDb.insertAll(gradesStatistics) + } + + fun deleteGradesStatistics(gradesStatistics: List) { + gradeStatisticsDb.deleteAll(gradesStatistics) + } +} diff --git a/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsRemote.kt b/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsRemote.kt new file mode 100644 index 000000000..fa3b951f6 --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsRemote.kt @@ -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> { + 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 + ) + } + } + } +} diff --git a/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsRepository.kt b/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsRepository.kt new file mode 100644 index 000000000..870bd1e93 --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/data/repositories/gradestatistics/GradeStatisticsRepository.kt @@ -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> { + 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()) }) + } +} diff --git a/app/src/main/java/io/github/wulkanowy/data/repositories/subject/SubjectRepostory.kt b/app/src/main/java/io/github/wulkanowy/data/repositories/subject/SubjectRepository.kt similarity index 96% rename from app/src/main/java/io/github/wulkanowy/data/repositories/subject/SubjectRepostory.kt rename to app/src/main/java/io/github/wulkanowy/data/repositories/subject/SubjectRepository.kt index 7571ecf14..6167251b9 100644 --- a/app/src/main/java/io/github/wulkanowy/data/repositories/subject/SubjectRepostory.kt +++ b/app/src/main/java/io/github/wulkanowy/data/repositories/subject/SubjectRepository.kt @@ -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 diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/attendance/summary/AttendanceSummaryPresenter.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/attendance/summary/AttendanceSummaryPresenter.kt index 0d8d827af..ab9ce1519 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/attendance/summary/AttendanceSummaryPresenter.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/attendance/summary/AttendanceSummaryPresenter.kt @@ -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, diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/CustomTabLayout.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/CustomTabLayout.kt new file mode 100644 index 000000000..e6f01497c --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/CustomTabLayout.kt @@ -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 Tabs don't fit to screen with tabmode=scrollable, Even with a Custom Tab Layout + */ +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) + } +} diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradeFragment.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradeFragment.kt index 7a1c67ee3..8fdecda2d 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradeFragment.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradeFragment.kt @@ -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) diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradeModule.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradeModule.kt index b7b52f218..46a923d9a 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradeModule.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradeModule.kt @@ -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 } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradePresenter.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradePresenter.kt index 7413f056b..cc127a90f 100644 --- a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradePresenter.kt +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/GradePresenter.kt @@ -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) } } diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsFragment.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsFragment.kt new file mode 100644 index 000000000..2bd51eac6 --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsFragment.kt @@ -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 + + 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()) + 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) { + subjectsAdapter.run { + clear() + addAll(data) + notifyDataSetChanged() + } + } + + override fun updateData(items: List) { + 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() + } +} diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsPresenter.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsPresenter.kt new file mode 100644 index 000000000..073231c09 --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsPresenter.kt @@ -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(errorHandler) { + + private var subjects = emptyList() + + 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) + }) + } +} diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsView.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsView.kt new file mode 100644 index 000000000..59a71c003 --- /dev/null +++ b/app/src/main/java/io/github/wulkanowy/ui/modules/grade/statistics/GradeStatisticsView.kt @@ -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) + + fun updateData(items: List) + + 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) +} diff --git a/app/src/main/java/io/github/wulkanowy/utils/SpinnerExtension.kt b/app/src/main/java/io/github/wulkanowy/utils/SpinnerExtension.kt index bea2bd3dc..d9e34e87c 100644 --- a/app/src/main/java/io/github/wulkanowy/utils/SpinnerExtension.kt +++ b/app/src/main/java/io/github/wulkanowy/utils/SpinnerExtension.kt @@ -4,11 +4,19 @@ import android.view.View import android.widget.AdapterView import android.widget.Spinner +/** + * @see How to keep onItemSelected from firing off on a newly instantiated Spinner? + */ 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) + } + } } } } diff --git a/app/src/main/res/layout/fragment_grade.xml b/app/src/main/res/layout/fragment_grade.xml index 522312a28..6cd226361 100644 --- a/app/src/main/res/layout/fragment_grade.xml +++ b/app/src/main/res/layout/fragment_grade.xml @@ -5,32 +5,33 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + app:tabMode="scrollable" + app:tabTextColor="@android:color/white" + tools:visibility="visible" /> + android:visibility="invisible" + tools:visibility="visible" /> + android:indeterminate="true" + tools:visibility="invisible" /> + android:visibility="invisible" + tools:visibility="visible"> + android:layout_height="match_parent" + tools:context=".ui.modules.grade.details.GradeDetailsFragment"> + android:indeterminate="true" + tools:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_grade_summary.xml b/app/src/main/res/layout/fragment_grade_summary.xml index 37b970287..92a34f71c 100644 --- a/app/src/main/res/layout/fragment_grade_summary.xml +++ b/app/src/main/res/layout/fragment_grade_summary.xml @@ -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"> + android:indeterminate="true" + tools:visibility="gone" /> Obliczona średnia Końcowa średnia Podsumowanie + Oceny klasy Oznacz jako przeczytane + Oceny cząstkowe + Oceny semestralne %d ocena %d oceny diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6c3612e9..4a2cc9167 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,7 +60,10 @@ Calculated average Final average Summary + Class grades Mark as read + Partial grades + Semester grades %d grade %d grades diff --git a/app/src/test/java/io/github/wulkanowy/data/repositories/remote/GradeStatisticsRemoteTest.kt b/app/src/test/java/io/github/wulkanowy/data/repositories/remote/GradeStatisticsRemoteTest.kt new file mode 100644 index 000000000..14381791d --- /dev/null +++ b/app/src/test/java/io/github/wulkanowy/data/repositories/remote/GradeStatisticsRemoteTest.kt @@ -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 + } + } +}