sync fork with v2.4.2

This commit is contained in:
sadorowo 2024-02-25 20:14:04 +01:00
commit c1687f5856
146 changed files with 7236 additions and 671 deletions

2
.gitignore vendored
View File

@ -65,6 +65,8 @@ captures/
.idea/uiDesigner.xml .idea/uiDesigner.xml
.idea/runConfigurations.xml .idea/runConfigurations.xml
.idea/discord.xml .idea/discord.xml
.idea/migrations.xml
.idea/androidTestResultsUserPreferences.xml
# Keystore files # Keystore files
*.jks *.jks

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

View File

@ -27,8 +27,8 @@ android {
testApplicationId "io.github.tests.wulkanowy" testApplicationId "io.github.tests.wulkanowy"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 34 targetSdkVersion 34
versionCode 138 versionCode 148
versionName "2.2.6" versionName "2.4.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "app_name", "Wulkanowy" resValue "string", "app_name", "Wulkanowy"
@ -143,7 +143,8 @@ android {
resources { resources {
excludes += ['META-INF/library_release.kotlin_module', excludes += ['META-INF/library_release.kotlin_module',
'META-INF/library-core_release.kotlin_module', 'META-INF/library-core_release.kotlin_module',
'META-INF/*'] 'META-INF/LICENSE.md',
'META-INF/LICENSE-notice.md']
} }
} }
@ -163,8 +164,8 @@ play {
defaultToAppBundles = false defaultToAppBundles = false
track = 'production' track = 'production'
releaseStatus = ReleaseStatus.IN_PROGRESS releaseStatus = ReleaseStatus.IN_PROGRESS
userFraction = 0.15d userFraction = 0.99d
updatePriority = 3 updatePriority = 2
enabled.set(false) enabled.set(false)
} }
@ -189,16 +190,16 @@ ext {
android_hilt = "1.1.0" android_hilt = "1.1.0"
room = "2.6.1" room = "2.6.1"
chucker = "4.0.0" chucker = "4.0.0"
mockk = "1.13.8" mockk = "1.13.9"
coroutines = "1.7.3" coroutines = "1.8.0"
} }
dependencies { dependencies {
implementation 'io.github.wulkanowy:sdk:2.3.1' implementation 'io.github.wulkanowy:sdk:2.4.2-SNAPSHOT'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
@ -222,7 +223,7 @@ dependencies {
implementation "androidx.work:work-runtime:$work_manager" implementation "androidx.work:work-runtime:$work_manager"
playImplementation "androidx.work:work-gcm:$work_manager" playImplementation "androidx.work:work-gcm:$work_manager"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.2" implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
implementation "androidx.room:room-runtime:$room" implementation "androidx.room:room-runtime:$room"
implementation "androidx.room:room-ktx:$room" implementation "androidx.room:room-ktx:$room"
@ -239,9 +240,10 @@ dependencies {
implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0" implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0"
implementation "com.squareup.okhttp3:logging-interceptor:4.12.0" implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
implementation "com.squareup.okhttp3:okhttp-urlconnection:4.12.0"
implementation "com.jakewharton.timber:timber:5.0.1" implementation "com.jakewharton.timber:timber:5.0.1"
implementation "at.favre.lib:slf4j-timber:1.0.1" implementation 'com.github.Faierbel:slf4j-timber:2.0'
implementation 'com.github.bastienpaulfr:Treessence:1.1.2' implementation 'com.github.bastienpaulfr:Treessence:1.1.2'
implementation "com.mikepenz:aboutlibraries-core:$about_libraries" implementation "com.mikepenz:aboutlibraries-core:$about_libraries"
implementation 'io.coil-kt:coil:2.5.0' implementation 'io.coil-kt:coil:2.5.0'
@ -250,7 +252,7 @@ dependencies {
implementation 'com.fredporciuncula:flow-preferences:1.9.1' implementation 'com.fredporciuncula:flow-preferences:1.9.1'
implementation 'org.apache.commons:commons-text:1.11.0' implementation 'org.apache.commons:commons-text:1.11.0'
playImplementation platform('com.google.firebase:firebase-bom:32.7.0') playImplementation platform('com.google.firebase:firebase-bom:32.7.2')
playImplementation 'com.google.firebase:firebase-analytics' playImplementation 'com.google.firebase:firebase-analytics'
playImplementation 'com.google.firebase:firebase-messaging' playImplementation 'com.google.firebase:firebase-messaging'
playImplementation 'com.google.firebase:firebase-crashlytics:' playImplementation 'com.google.firebase:firebase-crashlytics:'
@ -262,7 +264,7 @@ dependencies {
playImplementation 'com.google.android.play:review-ktx:2.0.1' playImplementation 'com.google.android.play:review-ktx:2.0.1'
playImplementation "com.google.android.ump:user-messaging-platform:2.1.0" playImplementation "com.google.android.ump:user-messaging-platform:2.1.0"
hmsImplementation 'com.huawei.hms:hianalytics:6.12.0.300' hmsImplementation 'com.huawei.hms:hianalytics:6.12.0.301'
hmsImplementation 'com.huawei.agconnect:agconnect-crash:1.9.1.303' hmsImplementation 'com.huawei.agconnect:agconnect-crash:1.9.1.303'
releaseImplementation "com.github.chuckerteam.chucker:library-no-op:$chucker" releaseImplementation "com.github.chuckerteam.chucker:library-no-op:$chucker"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,34 +14,37 @@ import kotlin.test.assertFailsWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ScramblerTest { class ScramblerTest {
private val scrambler = Scrambler(ApplicationProvider.getApplicationContext())
@Test @Test
fun encryptDecryptTest() { fun encryptDecryptTest() {
assertEquals("TEST", decrypt(encrypt("TEST", assertEquals(
ApplicationProvider.getApplicationContext()))) "TEST", scrambler.decrypt(scrambler.encrypt("TEST"))
)
} }
@Test @Test
fun emptyTextEncryptTest() { fun emptyTextEncryptTest() {
assertFailsWith<ScramblerException> { assertFailsWith<ScramblerException> {
decrypt("") scrambler.decrypt("")
} }
assertFailsWith<ScramblerException> { assertFailsWith<ScramblerException> {
encrypt("", ApplicationProvider.getApplicationContext()) scrambler.encrypt("")
} }
} }
@Test @Test
@SdkSuppress(minSdkVersion = 18) @SdkSuppress(minSdkVersion = 18)
fun emptyKeyStoreTest() { fun emptyKeyStoreTest() {
val text = encrypt("test", ApplicationProvider.getApplicationContext()) val text = scrambler.encrypt("test")
val keyStore = KeyStore.getInstance("AndroidKeyStore") val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null) keyStore.load(null)
keyStore.deleteEntry("wulkanowy_password") keyStore.deleteEntry("wulkanowy_password")
assertFailsWith<ScramblerException> { assertFailsWith<ScramblerException> {
decrypt(text) scrambler.decrypt(text)
} }
} }
} }

View File

@ -44,6 +44,7 @@
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="false" android:supportsRtl="false"
android:theme="@style/WulkanowyTheme" android:theme="@style/WulkanowyTheme"
android:resizeableActivity="true"
tools:ignore="DataExtractionRules,UnusedAttribute"> tools:ignore="DataExtractionRules,UnusedAttribute">
<activity <activity
android:name=".ui.modules.splash.SplashActivity" android:name=".ui.modules.splash.SplashActivity"

View File

@ -21,6 +21,7 @@ import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.utils.RemoteConfigHelper import io.github.wulkanowy.utils.RemoteConfigHelper
import io.github.wulkanowy.utils.WebkitCookieManagerProxy
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -43,6 +44,7 @@ internal class DataModule {
buildTag = android.os.Build.MODEL buildTag = android.os.Build.MODEL
userAgentTemplate = remoteConfig.userAgentTemplate userAgentTemplate = remoteConfig.userAgentTemplate
setSimpleHttpLogger { Timber.d(it) } setSimpleHttpLogger { Timber.d(it) }
setAdditionalCookieManager(WebkitCookieManagerProxy())
// for debug only // for debug only
addInterceptor(chuckerInterceptor, network = true) addInterceptor(chuckerInterceptor, network = true)
@ -251,4 +253,8 @@ internal class DataModule {
@Singleton @Singleton
@Provides @Provides
fun provideAdminMessageDao(database: AppDatabase) = database.adminMessagesDao fun provideAdminMessageDao(database: AppDatabase) = database.adminMessagesDao
@Singleton
@Provides
fun provideGradeDescriptiveDao(database: AppDatabase) = database.gradeDescriptiveDao
} }

View File

@ -1,6 +1,16 @@
package io.github.wulkanowy.data package io.github.wulkanowy.data
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
@ -20,8 +30,15 @@ val <T> Resource<T>.dataOrNull: T?
get() = when (this) { get() = when (this) {
is Resource.Success -> this.data is Resource.Success -> this.data
is Resource.Intermediate -> this.data is Resource.Intermediate -> this.data
is Resource.Loading -> null else -> null
is Resource.Error -> null }
val <T> Resource<T>.dataOrThrow: T
get() = when (this) {
is Resource.Success -> this.data
is Resource.Intermediate -> this.data
is Resource.Loading -> throw IllegalStateException("Resource is in loading state")
is Resource.Error -> throw this.error
} }
val <T> Resource<T>.errorOrNull: Throwable? val <T> Resource<T>.errorOrNull: Throwable?
@ -131,7 +148,7 @@ inline fun <ResultType, RequestType> networkBoundResource(
query().map { Resource.Success(filterResult(it)) } query().map { Resource.Success(filterResult(it)) }
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
onFetchFailed(throwable) onFetchFailed(throwable)
query().map { Resource.Error(throwable) } flowOf(Resource.Error(throwable))
} }
} else { } else {
query().map { Resource.Success(filterResult(it)) } query().map { Resource.Success(filterResult(it)) }
@ -165,7 +182,7 @@ inline fun <ResultType, RequestType, T> networkBoundResource(
query().map { Resource.Success(mapResult(it)) } query().map { Resource.Success(mapResult(it)) }
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
onFetchFailed(throwable) onFetchFailed(throwable)
query().map { Resource.Error(throwable) } flowOf(Resource.Error(throwable))
} }
} else { } else {
query().map { Resource.Success(mapResult(it)) } query().map { Resource.Success(mapResult(it)) }

View File

@ -1,11 +1,126 @@
package io.github.wulkanowy.data.db package io.github.wulkanowy.data.db
import android.content.Context import android.content.Context
import androidx.room.* import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.RoomDatabase.JournalMode.TRUNCATE import androidx.room.RoomDatabase.JournalMode.TRUNCATE
import io.github.wulkanowy.data.db.dao.* import androidx.room.TypeConverters
import io.github.wulkanowy.data.db.entities.* import io.github.wulkanowy.data.db.dao.AdminMessageDao
import io.github.wulkanowy.data.db.migrations.* import io.github.wulkanowy.data.db.dao.AttendanceDao
import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao
import io.github.wulkanowy.data.db.dao.CompletedLessonsDao
import io.github.wulkanowy.data.db.dao.ConferenceDao
import io.github.wulkanowy.data.db.dao.ExamDao
import io.github.wulkanowy.data.db.dao.GradeDao
import io.github.wulkanowy.data.db.dao.GradeDescriptiveDao
import io.github.wulkanowy.data.db.dao.GradePartialStatisticsDao
import io.github.wulkanowy.data.db.dao.GradePointsStatisticsDao
import io.github.wulkanowy.data.db.dao.GradeSemesterStatisticsDao
import io.github.wulkanowy.data.db.dao.GradeSummaryDao
import io.github.wulkanowy.data.db.dao.HomeworkDao
import io.github.wulkanowy.data.db.dao.LuckyNumberDao
import io.github.wulkanowy.data.db.dao.MailboxDao
import io.github.wulkanowy.data.db.dao.MessageAttachmentDao
import io.github.wulkanowy.data.db.dao.MessagesDao
import io.github.wulkanowy.data.db.dao.MobileDeviceDao
import io.github.wulkanowy.data.db.dao.NoteDao
import io.github.wulkanowy.data.db.dao.NotificationDao
import io.github.wulkanowy.data.db.dao.RecipientDao
import io.github.wulkanowy.data.db.dao.SchoolAnnouncementDao
import io.github.wulkanowy.data.db.dao.SchoolDao
import io.github.wulkanowy.data.db.dao.SemesterDao
import io.github.wulkanowy.data.db.dao.StudentDao
import io.github.wulkanowy.data.db.dao.StudentInfoDao
import io.github.wulkanowy.data.db.dao.SubjectDao
import io.github.wulkanowy.data.db.dao.TeacherDao
import io.github.wulkanowy.data.db.dao.TimetableAdditionalDao
import io.github.wulkanowy.data.db.dao.TimetableDao
import io.github.wulkanowy.data.db.dao.TimetableHeaderDao
import io.github.wulkanowy.data.db.entities.AdminMessage
import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.db.entities.AttendanceSummary
import io.github.wulkanowy.data.db.entities.CompletedLesson
import io.github.wulkanowy.data.db.entities.Conference
import io.github.wulkanowy.data.db.entities.Exam
import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeDescriptive
import io.github.wulkanowy.data.db.entities.GradePartialStatistics
import io.github.wulkanowy.data.db.entities.GradePointsStatistics
import io.github.wulkanowy.data.db.entities.GradeSemesterStatistics
import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.db.entities.Homework
import io.github.wulkanowy.data.db.entities.LuckyNumber
import io.github.wulkanowy.data.db.entities.Mailbox
import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.db.entities.MessageAttachment
import io.github.wulkanowy.data.db.entities.MobileDevice
import io.github.wulkanowy.data.db.entities.Note
import io.github.wulkanowy.data.db.entities.Notification
import io.github.wulkanowy.data.db.entities.Recipient
import io.github.wulkanowy.data.db.entities.School
import io.github.wulkanowy.data.db.entities.SchoolAnnouncement
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.StudentInfo
import io.github.wulkanowy.data.db.entities.Subject
import io.github.wulkanowy.data.db.entities.Teacher
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import io.github.wulkanowy.data.db.entities.TimetableHeader
import io.github.wulkanowy.data.db.migrations.Migration10
import io.github.wulkanowy.data.db.migrations.Migration11
import io.github.wulkanowy.data.db.migrations.Migration12
import io.github.wulkanowy.data.db.migrations.Migration13
import io.github.wulkanowy.data.db.migrations.Migration14
import io.github.wulkanowy.data.db.migrations.Migration15
import io.github.wulkanowy.data.db.migrations.Migration16
import io.github.wulkanowy.data.db.migrations.Migration17
import io.github.wulkanowy.data.db.migrations.Migration18
import io.github.wulkanowy.data.db.migrations.Migration19
import io.github.wulkanowy.data.db.migrations.Migration2
import io.github.wulkanowy.data.db.migrations.Migration20
import io.github.wulkanowy.data.db.migrations.Migration21
import io.github.wulkanowy.data.db.migrations.Migration22
import io.github.wulkanowy.data.db.migrations.Migration23
import io.github.wulkanowy.data.db.migrations.Migration24
import io.github.wulkanowy.data.db.migrations.Migration25
import io.github.wulkanowy.data.db.migrations.Migration26
import io.github.wulkanowy.data.db.migrations.Migration27
import io.github.wulkanowy.data.db.migrations.Migration28
import io.github.wulkanowy.data.db.migrations.Migration29
import io.github.wulkanowy.data.db.migrations.Migration3
import io.github.wulkanowy.data.db.migrations.Migration30
import io.github.wulkanowy.data.db.migrations.Migration31
import io.github.wulkanowy.data.db.migrations.Migration32
import io.github.wulkanowy.data.db.migrations.Migration33
import io.github.wulkanowy.data.db.migrations.Migration34
import io.github.wulkanowy.data.db.migrations.Migration35
import io.github.wulkanowy.data.db.migrations.Migration36
import io.github.wulkanowy.data.db.migrations.Migration37
import io.github.wulkanowy.data.db.migrations.Migration38
import io.github.wulkanowy.data.db.migrations.Migration39
import io.github.wulkanowy.data.db.migrations.Migration4
import io.github.wulkanowy.data.db.migrations.Migration40
import io.github.wulkanowy.data.db.migrations.Migration41
import io.github.wulkanowy.data.db.migrations.Migration42
import io.github.wulkanowy.data.db.migrations.Migration43
import io.github.wulkanowy.data.db.migrations.Migration44
import io.github.wulkanowy.data.db.migrations.Migration46
import io.github.wulkanowy.data.db.migrations.Migration49
import io.github.wulkanowy.data.db.migrations.Migration5
import io.github.wulkanowy.data.db.migrations.Migration50
import io.github.wulkanowy.data.db.migrations.Migration51
import io.github.wulkanowy.data.db.migrations.Migration53
import io.github.wulkanowy.data.db.migrations.Migration54
import io.github.wulkanowy.data.db.migrations.Migration55
import io.github.wulkanowy.data.db.migrations.Migration57
import io.github.wulkanowy.data.db.migrations.Migration58
import io.github.wulkanowy.data.db.migrations.Migration6
import io.github.wulkanowy.data.db.migrations.Migration7
import io.github.wulkanowy.data.db.migrations.Migration8
import io.github.wulkanowy.data.db.migrations.Migration9
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
import javax.inject.Singleton import javax.inject.Singleton
@ -41,7 +156,8 @@ import javax.inject.Singleton
TimetableHeader::class, TimetableHeader::class,
SchoolAnnouncement::class, SchoolAnnouncement::class,
Notification::class, Notification::class,
AdminMessage::class AdminMessage::class,
GradeDescriptive::class,
], ],
autoMigrations = [ autoMigrations = [
AutoMigration(from = 44, to = 45), AutoMigration(from = 44, to = 45),
@ -51,6 +167,8 @@ import javax.inject.Singleton
AutoMigration(from = 54, to = 55, spec = Migration55::class), AutoMigration(from = 54, to = 55, spec = Migration55::class),
AutoMigration(from = 55, to = 56), AutoMigration(from = 55, to = 56),
AutoMigration(from = 56, to = 57, spec = Migration57::class), AutoMigration(from = 56, to = 57, spec = Migration57::class),
AutoMigration(from = 57, to = 58, spec = Migration58::class),
AutoMigration(from = 58, to = 59),
], ],
version = AppDatabase.VERSION_SCHEMA, version = AppDatabase.VERSION_SCHEMA,
exportSchema = true exportSchema = true
@ -59,7 +177,7 @@ import javax.inject.Singleton
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
companion object { companion object {
const val VERSION_SCHEMA = 57 const val VERSION_SCHEMA = 59
fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf( fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf(
Migration2(), Migration2(),
@ -184,4 +302,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract val notificationDao: NotificationDao abstract val notificationDao: NotificationDao
abstract val adminMessagesDao: AdminMessageDao abstract val adminMessagesDao: AdminMessageDao
abstract val gradeDescriptiveDao: GradeDescriptiveDao
} }

View File

@ -0,0 +1,15 @@
package io.github.wulkanowy.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import io.github.wulkanowy.data.db.entities.GradeDescriptive
import kotlinx.coroutines.flow.Flow
import javax.inject.Singleton
@Singleton
@Dao
interface GradeDescriptiveDao : BaseDao<GradeDescriptive> {
@Query("SELECT * FROM GradesDescriptive WHERE semester_id = :semesterId AND student_id = :studentId")
fun loadAll(semesterId: Int, studentId: Int): Flow<List<GradeDescriptive>>
}

View File

@ -1,11 +1,16 @@
package io.github.wulkanowy.data.db.dao package io.github.wulkanowy.data.db.dao
import androidx.room.* import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.StudentName import io.github.wulkanowy.data.db.entities.StudentName
import io.github.wulkanowy.data.db.entities.StudentNickAndAvatar import io.github.wulkanowy.data.db.entities.StudentNickAndAvatar
import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
@ -47,6 +52,9 @@ abstract class StudentDao {
@Query("UPDATE Students SET is_current = 0") @Query("UPDATE Students SET is_current = 0")
abstract suspend fun resetCurrent() abstract suspend fun resetCurrent()
@Query("DELETE FROM Students WHERE email = :email AND user_name = :userName")
abstract suspend fun deleteByEmailAndUserName(email: String, userName: String)
@Transaction @Transaction
open suspend fun switchCurrent(id: Long) { open suspend fun switchCurrent(id: Long) {
resetCurrent() resetCurrent()

View File

@ -15,5 +15,5 @@ interface TimetableDao : BaseDao<Timetable> {
fun loadAll(diaryId: Int, studentId: Int, from: LocalDate, end: LocalDate): Flow<List<Timetable>> fun loadAll(diaryId: Int, studentId: Int, from: LocalDate, end: LocalDate): Flow<List<Timetable>>
@Query("SELECT * FROM Timetable WHERE diary_id = :diaryId AND student_id = :studentId AND date >= :from AND date <= :end") @Query("SELECT * FROM Timetable WHERE diary_id = :diaryId AND student_id = :studentId AND date >= :from AND date <= :end")
fun load(diaryId: Int, studentId: Int, from: LocalDate, end: LocalDate): List<Timetable> suspend fun load(diaryId: Int, studentId: Int, from: LocalDate, end: LocalDate): List<Timetable>
} }

View File

@ -37,6 +37,9 @@ data class AdminMessage(
@ColumnInfo(name = "types", defaultValue = "[]") @ColumnInfo(name = "types", defaultValue = "[]")
val types: List<MessageType> = emptyList(), val types: List<MessageType> = emptyList(),
@ColumnInfo(name = "is_dismissible") @ColumnInfo(name = "is_ok_visible", defaultValue = "0")
val isDismissible: Boolean = false val isOkVisible: Boolean = false,
@ColumnInfo(name = "is_x_visible", defaultValue = "0")
val isXVisible: Boolean = false
) )

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
import java.io.Serializable
@Entity(tableName = "GradesDescriptive")
data class GradeDescriptive(
@ColumnInfo(name = "semester_id")
val semesterId: Int,
@ColumnInfo(name = "student_id")
val studentId: Int,
val subject: String,
val description: String,
) : Serializable {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
@ColumnInfo(name = "is_notified")
var isNotified: Boolean = true
}

View File

@ -0,0 +1,10 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.DeleteColumn
import androidx.room.migration.AutoMigrationSpec
@DeleteColumn(
tableName = "AdminMessages",
columnName = "is_dismissible",
)
class Migration58 : AutoMigrationSpec

View File

@ -1,10 +1,12 @@
package io.github.wulkanowy.data.mappers package io.github.wulkanowy.data.mappers
import io.github.wulkanowy.data.db.entities.Grade import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeDescriptive
import io.github.wulkanowy.data.db.entities.GradeSummary import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.sdk.pojo.GradeSummary as SdkGradeSummary
import io.github.wulkanowy.sdk.pojo.Grade as SdkGrade import io.github.wulkanowy.sdk.pojo.Grade as SdkGrade
import io.github.wulkanowy.sdk.pojo.GradeDescriptive as SdkGradeDescriptive
import io.github.wulkanowy.sdk.pojo.GradeSummary as SdkGradeSummary
fun List<SdkGrade>.mapToEntities(semester: Semester) = map { fun List<SdkGrade>.mapToEntities(semester: Semester) = map {
Grade( Grade(
@ -40,3 +42,15 @@ fun List<SdkGradeSummary>.mapToEntities(semester: Semester) = map {
average = it.average average = it.average
) )
} }
@JvmName("mapGradeDescriptiveToEntities")
fun List<SdkGradeDescriptive>.mapToEntities(semester: Semester) = map {
GradeDescriptive(
semesterId = semester.semesterId,
studentId = semester.studentId,
subject = it.subject,
description = it.description
)
}

View File

@ -10,17 +10,17 @@ import io.github.wulkanowy.data.networkBoundResource
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.sdk.pojo.Absent import io.github.wulkanowy.sdk.pojo.Absent
import io.github.wulkanowy.ui.modules.dashboard.DashboardItem import io.github.wulkanowy.ui.modules.dashboard.DashboardItem
import io.github.wulkanowy.utils.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
@ -88,7 +88,7 @@ class AttendanceRepository @Inject constructor(
} }
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getAttendance(start.monday, end.sunday) .getAttendance(start.monday, end.sunday)
.filter { attendance -> .filter { attendance ->
!badAttendanceHidden || !badAttendanceHidden ||
@ -138,7 +138,7 @@ class AttendanceRepository @Inject constructor(
) )
} }
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.excuseForAbsence(items, reason) .excuseForAbsence(items, reason)
} }
} }

View File

@ -9,6 +9,7 @@ import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
@ -40,7 +41,7 @@ class AttendanceSummaryRepository @Inject constructor(
query = { attendanceDb.loadAll(semester.diaryId, semester.studentId, subjectId) }, query = { attendanceDb.loadAll(semester.diaryId, semester.studentId, subjectId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getAttendanceSummary(subjectId) .getAttendanceSummary(subjectId)
.mapToEntities(semester, subjectId) .mapToEntities(semester, subjectId)
}, },

View File

@ -48,7 +48,7 @@ class CompletedLessonsRepository @Inject constructor(
}, },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getCompletedLessons(start.monday, end.sunday) .getCompletedLessons(start.monday, end.sunday)
.mapToEntities(semester) .mapToEntities(semester)
}, },

View File

@ -10,6 +10,7 @@ import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -46,7 +47,7 @@ class ConferenceRepository @Inject constructor(
}, },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getConferences() .getConferences()
.mapToEntities(semester) .mapToEntities(semester)
.filter { it.date >= startDate } .filter { it.date >= startDate }

View File

@ -7,7 +7,13 @@ import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.mappers.mapToEntities import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.data.networkBoundResource import io.github.wulkanowy.data.networkBoundResource
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.* import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.endExamsDay
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.startExamsDay
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import java.time.LocalDate import java.time.LocalDate
@ -51,7 +57,7 @@ class ExamRepository @Inject constructor(
}, },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getExams(start.startExamsDay, start.endExamsDay) .getExams(start.startExamsDay, start.endExamsDay)
.mapToEntities(semester) .mapToEntities(semester)
}, },
@ -67,14 +73,16 @@ class ExamRepository @Inject constructor(
filterResult = { it.filter { item -> item.date in start..end } } filterResult = { it.filter { item -> item.date in start..end } }
) )
fun getExamsFromDatabase(semester: Semester, start: LocalDate): Flow<List<Exam>> { fun getExamsFromDatabase(
return examDb.loadAll( semester: Semester,
diaryId = semester.diaryId, start: LocalDate,
studentId = semester.studentId, end: LocalDate
from = start.startExamsDay, ): Flow<List<Exam>> = examDb.loadAll(
end = start.endExamsDay diaryId = semester.diaryId,
) studentId = semester.studentId,
} from = start,
end = end,
)
suspend fun updateExam(exam: List<Exam>) = examDb.updateAll(exam) suspend fun updateExam(exam: List<Exam>) = examDb.updateAll(exam)
} }

View File

@ -1,8 +1,10 @@
package io.github.wulkanowy.data.repositories package io.github.wulkanowy.data.repositories
import io.github.wulkanowy.data.db.dao.GradeDao import io.github.wulkanowy.data.db.dao.GradeDao
import io.github.wulkanowy.data.db.dao.GradeDescriptiveDao
import io.github.wulkanowy.data.db.dao.GradeSummaryDao import io.github.wulkanowy.data.db.dao.GradeSummaryDao
import io.github.wulkanowy.data.db.entities.Grade import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeDescriptive
import io.github.wulkanowy.data.db.entities.GradeSummary import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
@ -10,7 +12,12 @@ import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.data.networkBoundResource import io.github.wulkanowy.data.networkBoundResource
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.ui.modules.dashboard.DashboardItem import io.github.wulkanowy.ui.modules.dashboard.DashboardItem
import io.github.wulkanowy.utils.* import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.toLocalDate
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -24,14 +31,13 @@ import javax.inject.Singleton
class GradeRepository @Inject constructor( class GradeRepository @Inject constructor(
private val gradeDb: GradeDao, private val gradeDb: GradeDao,
private val gradeSummaryDb: GradeSummaryDao, private val gradeSummaryDb: GradeSummaryDao,
private val gradeDescriptiveDb: GradeDescriptiveDao,
private val sdk: Sdk, private val sdk: Sdk,
private val refreshHelper: AutoRefreshHelper, private val refreshHelper: AutoRefreshHelper,
private val preferencesRepository: PreferencesRepository private val preferencesRepository: PreferencesRepository
) { ) {
private val saveFetchResultMutex = Mutex() private val saveFetchResultMutex = Mutex()
private val cacheKey = "grade"
private fun loadGrades(semesterId: Int, studentId: Int): Flow<List<Grade>> { private fun loadGrades(semesterId: Int, studentId: Int): Flow<List<Grade>> {
val badGradesHidden = preferencesRepository val badGradesHidden = preferencesRepository
.selectedHiddenSettingTiles .selectedHiddenSettingTiles
@ -56,22 +62,28 @@ class GradeRepository @Inject constructor(
//When details is empty and summary is not, app will not use summary cache - edge case //When details is empty and summary is not, app will not use summary cache - edge case
it.first.isEmpty() it.first.isEmpty()
}, },
shouldFetch = { (details, summaries) -> shouldFetch = { (details, summaries, descriptive) ->
val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, semester)) val isExpired =
details.isEmpty() || summaries.isEmpty() || forceRefresh || isExpired refreshHelper.shouldBeRefreshed(getRefreshKey(GRADE_CACHE_KEY, semester))
details.isEmpty() || (summaries.isEmpty() && descriptive.isEmpty()) || forceRefresh || isExpired
}, },
query = { query = {
val detailsFlow = loadGrades(semester.semesterId, semester.studentId) val detailsFlow = loadGrades(semester.semesterId, semester.studentId)
val summaryFlow = gradeSummaryDb.loadAll(semester.semesterId, semester.studentId) val summaryFlow = gradeSummaryDb.loadAll(semester.semesterId, semester.studentId)
detailsFlow.combine(summaryFlow) { details, summaries -> details to summaries } val descriptiveFlow =
gradeDescriptiveDb.loadAll(semester.semesterId, semester.studentId)
combine(detailsFlow, summaryFlow, descriptiveFlow) { details, summaries, descriptive ->
Triple(details, summaries, descriptive)
}
}, },
fetch = { fetch = {
val badGradesHidden = preferencesRepository val badGradesHidden = preferencesRepository
.selectedHiddenSettingTiles .selectedHiddenSettingTiles
.contains(DashboardItem.HiddenSettingTile.BAD_GRADES) .contains(DashboardItem.HiddenSettingTile.BAD_GRADES)
val (details, summary) = sdk.init(student) val (details, summary, descriptive) = sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getGrades(semester.semesterId) .getGrades(semester.semesterId)
val censoredDetails = if (badGradesHidden) { val censoredDetails = if (badGradesHidden) {
@ -80,16 +92,32 @@ class GradeRepository @Inject constructor(
details details
} }
censoredDetails.mapToEntities(semester) to summary.mapToEntities(semester) Triple(
censoredDetails.mapToEntities(semester),
summary.mapToEntities(semester),
descriptive.mapToEntities(semester)
)
}, },
saveFetchResult = { (oldDetails, oldSummary), (newDetails, newSummary) -> saveFetchResult = { (oldDetails, oldSummary, oldDescriptive), (newDetails, newSummary, newDescriptive) ->
refreshGradeDetails(student, oldDetails, newDetails, notify) refreshGradeDetails(student, oldDetails, newDetails, notify)
refreshGradeSummaries(oldSummary, newSummary, notify) refreshGradeSummaries(oldSummary, newSummary, notify)
refreshGradeDescriptions(oldDescriptive, newDescriptive, notify)
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester)) refreshHelper.updateLastRefreshTimestamp(getRefreshKey(GRADE_CACHE_KEY, semester))
} }
) )
private suspend fun refreshGradeDescriptions(
old: List<GradeDescriptive>,
new: List<GradeDescriptive>,
notify: Boolean
) {
gradeDescriptiveDb.deleteAll(old uniqueSubtract new)
gradeDescriptiveDb.insertAll((new uniqueSubtract old).onEach {
if (notify) it.isNotified = false
})
}
private suspend fun refreshGradeDetails( private suspend fun refreshGradeDetails(
student: Student, student: Student,
oldGrades: List<Grade>, oldGrades: List<Grade>,
@ -157,6 +185,10 @@ class GradeRepository @Inject constructor(
return gradeSummaryDb.loadAll(semester.semesterId, semester.studentId) return gradeSummaryDb.loadAll(semester.semesterId, semester.studentId)
} }
fun getGradesDescriptiveFromDatabase(semester: Semester): Flow<List<GradeDescriptive>> {
return gradeDescriptiveDb.loadAll(semester.semesterId, semester.studentId)
}
suspend fun updateGrade(grade: Grade) { suspend fun updateGrade(grade: Grade) {
return gradeDb.updateAll(listOf(grade)) return gradeDb.updateAll(listOf(grade))
} }
@ -168,4 +200,13 @@ class GradeRepository @Inject constructor(
suspend fun updateGradesSummary(gradesSummary: List<GradeSummary>) { suspend fun updateGradesSummary(gradesSummary: List<GradeSummary>) {
return gradeSummaryDb.updateAll(gradesSummary) return gradeSummaryDb.updateAll(gradesSummary)
} }
suspend fun updateGradesDescriptive(gradesDescriptive: List<GradeDescriptive>) {
return gradeDescriptiveDb.updateAll(gradesDescriptive)
}
private companion object {
private const val GRADE_CACHE_KEY = "grade"
}
} }

View File

@ -16,6 +16,7 @@ import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import java.util.* import java.util.*
@ -56,7 +57,7 @@ class GradeStatisticsRepository @Inject constructor(
query = { gradePartialStatisticsDb.loadAll(semester.semesterId, semester.studentId) }, query = { gradePartialStatisticsDb.loadAll(semester.semesterId, semester.studentId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getGradesPartialStatistics(semester.semesterId) .getGradesPartialStatistics(semester.semesterId)
.mapToEntities(semester) .mapToEntities(semester)
}, },
@ -101,7 +102,7 @@ class GradeStatisticsRepository @Inject constructor(
query = { gradeSemesterStatisticsDb.loadAll(semester.semesterId, semester.studentId) }, query = { gradeSemesterStatisticsDb.loadAll(semester.semesterId, semester.studentId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getGradesSemesterStatistics(semester.semesterId) .getGradesSemesterStatistics(semester.semesterId)
.mapToEntities(semester) .mapToEntities(semester)
}, },
@ -157,7 +158,7 @@ class GradeStatisticsRepository @Inject constructor(
query = { gradePointsStatisticsDb.loadAll(semester.semesterId, semester.studentId) }, query = { gradePointsStatisticsDb.loadAll(semester.semesterId, semester.studentId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getGradesPointsStatistics(semester.semesterId) .getGradesPointsStatistics(semester.semesterId)
.mapToEntities(semester) .mapToEntities(semester)
}, },

View File

@ -7,7 +7,13 @@ import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.mappers.mapToEntities import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.data.networkBoundResource import io.github.wulkanowy.data.networkBoundResource
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.* import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
@ -50,7 +56,7 @@ class HomeworkRepository @Inject constructor(
}, },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getHomework(start.monday, end.sunday) .getHomework(start.monday, end.sunday)
.mapToEntities(semester) .mapToEntities(semester)
}, },

View File

@ -35,12 +35,15 @@ class LuckyNumberRepository @Inject constructor(
fetch = { fetch = {
sdk.init(student).getLuckyNumber(student.schoolShortName)?.mapToEntity(student) sdk.init(student).getLuckyNumber(student.schoolShortName)?.mapToEntity(student)
}, },
saveFetchResult = { old, new -> saveFetchResult = { oldLuckyNumber, newLuckyNumber ->
if (new != old) { newLuckyNumber ?: return@networkBoundResource
old?.let { luckyNumberDb.deleteAll(listOfNotNull(it)) }
luckyNumberDb.insertAll(listOfNotNull((new?.apply { if (newLuckyNumber != oldLuckyNumber) {
if (notify) isNotified = false val updatedLuckNumberList =
}))) listOf(newLuckyNumber.apply { if (notify) isNotified = false })
oldLuckyNumber?.let { luckyNumberDb.deleteAll(listOfNotNull(it)) }
luckyNumberDb.insertAll(updatedLuckNumberList)
} }
} }
) )

View File

@ -12,6 +12,7 @@ import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
@ -42,7 +43,7 @@ class MobileDeviceRepository @Inject constructor(
query = { mobileDb.loadAll(student.userLoginId) }, query = { mobileDb.loadAll(student.userLoginId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getRegisteredDevices() .getRegisteredDevices()
.mapToEntities(student) .mapToEntities(student)
}, },
@ -56,7 +57,7 @@ class MobileDeviceRepository @Inject constructor(
suspend fun unregisterDevice(student: Student, semester: Semester, device: MobileDevice) { suspend fun unregisterDevice(student: Student, semester: Semester, device: MobileDevice) {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.unregisterDevice(device.deviceId) .unregisterDevice(device.deviceId)
mobileDb.deleteAll(listOf(device)) mobileDb.deleteAll(listOf(device))
@ -64,7 +65,7 @@ class MobileDeviceRepository @Inject constructor(
suspend fun getToken(student: Student, semester: Semester): MobileDeviceToken { suspend fun getToken(student: Student, semester: Semester): MobileDeviceToken {
return sdk.init(student) return sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getToken() .getToken()
.mapToMobileDeviceToken() .mapToMobileDeviceToken()
} }

View File

@ -56,7 +56,7 @@ class NoteRepository @Inject constructor(
.contains(DashboardItem.HiddenSettingTile.NOTES) .contains(DashboardItem.HiddenSettingTile.NOTES)
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getNotes() .getNotes()
.filter { !notesHidden } .filter { !notesHidden }
.mapToEntities(semester) .mapToEntities(semester)

View File

@ -9,6 +9,7 @@ import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -40,7 +41,7 @@ class SchoolRepository @Inject constructor(
query = { schoolDb.load(semester.studentId, semester.classId) }, query = { schoolDb.load(semester.studentId, semester.classId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getSchool() .getSchool()
.mapToEntity(semester) .mapToEntity(semester)
}, },

View File

@ -11,6 +11,7 @@ import io.github.wulkanowy.ui.modules.login.LoginData
import io.github.wulkanowy.utils.IntegrityHelper import io.github.wulkanowy.utils.IntegrityHelper
import io.github.wulkanowy.utils.getCurrentOrLast import io.github.wulkanowy.utils.getCurrentOrLast
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -42,11 +43,7 @@ class SchoolsRepository @Inject constructor(
val schoolInfo = sdk val schoolInfo = sdk
.init(student.copy(password = loginData.password)) .init(student.copy(password = loginData.password))
.switchDiary( .switchSemester(semester)
diaryId = semester.diaryId,
kindergartenDiaryId = semester.kindergartenDiaryId,
schoolYear = semester.schoolYear
)
.getSchool() .getSchool()
schoolsService.logLoginEvent( schoolsService.logLoginEvent(

View File

@ -7,6 +7,7 @@ import io.github.wulkanowy.data.mappers.mapToEntity
import io.github.wulkanowy.data.networkBoundResource import io.github.wulkanowy.data.networkBoundResource
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -30,7 +31,7 @@ class StudentInfoRepository @Inject constructor(
query = { studentInfoDao.loadStudentInfo(student.studentId) }, query = { studentInfoDao.loadStudentInfo(student.studentId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getStudentInfo().mapToEntity(semester) .getStudentInfo().mapToEntity(semester)
}, },
saveFetchResult = { old, new -> saveFetchResult = { old, new ->

View File

@ -1,8 +1,6 @@
package io.github.wulkanowy.data.repositories package io.github.wulkanowy.data.repositories
import android.content.Context
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.data.db.AppDatabase import io.github.wulkanowy.data.db.AppDatabase
import io.github.wulkanowy.data.db.dao.SemesterDao import io.github.wulkanowy.data.db.dao.SemesterDao
import io.github.wulkanowy.data.db.dao.StudentDao import io.github.wulkanowy.data.db.dao.StudentDao
@ -17,20 +15,20 @@ import io.github.wulkanowy.data.pojos.RegisterUser
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.DispatchersProvider import io.github.wulkanowy.utils.DispatchersProvider
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.security.decrypt import io.github.wulkanowy.utils.security.Scrambler
import io.github.wulkanowy.utils.security.encrypt import io.github.wulkanowy.utils.switchSemester
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class StudentRepository @Inject constructor( class StudentRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val dispatchers: DispatchersProvider, private val dispatchers: DispatchersProvider,
private val studentDb: StudentDao, private val studentDb: StudentDao,
private val semesterDb: SemesterDao, private val semesterDb: SemesterDao,
private val sdk: Sdk, private val sdk: Sdk,
private val appDatabase: AppDatabase private val appDatabase: AppDatabase,
private val scrambler: Scrambler,
) { ) {
suspend fun isCurrentStudentSet() = studentDb.loadCurrent()?.isCurrent ?: false suspend fun isCurrentStudentSet() = studentDb.loadCurrent()?.isCurrent ?: false
@ -68,7 +66,7 @@ class StudentRepository @Inject constructor(
student = student.apply { student = student.apply {
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.HEBE) { if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.HEBE) {
student.password = withContext(dispatchers.io) { student.password = withContext(dispatchers.io) {
decrypt(student.password) scrambler.decrypt(student.password)
} }
} }
}, },
@ -86,7 +84,7 @@ class StudentRepository @Inject constructor(
}.apply { }.apply {
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.HEBE) { if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.HEBE) {
student.password = withContext(dispatchers.io) { student.password = withContext(dispatchers.io) {
decrypt(student.password) scrambler.decrypt(student.password)
} }
} }
} }
@ -96,7 +94,7 @@ class StudentRepository @Inject constructor(
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.HEBE) { if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.HEBE) {
student.password = withContext(dispatchers.io) { student.password = withContext(dispatchers.io) {
decrypt(student.password) scrambler.decrypt(student.password)
} }
} }
return student return student
@ -107,7 +105,7 @@ class StudentRepository @Inject constructor(
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.HEBE) { if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.HEBE) {
student.password = withContext(dispatchers.io) { student.password = withContext(dispatchers.io) {
decrypt(student.password) scrambler.decrypt(student.password)
} }
} }
return student return student
@ -120,7 +118,7 @@ class StudentRepository @Inject constructor(
it.apply { it.apply {
if (Sdk.Mode.valueOf(it.loginMode) != Sdk.Mode.HEBE) { if (Sdk.Mode.valueOf(it.loginMode) != Sdk.Mode.HEBE) {
password = withContext(dispatchers.io) { password = withContext(dispatchers.io) {
encrypt(password, context) scrambler.encrypt(password)
} }
} }
} }
@ -152,12 +150,12 @@ class StudentRepository @Inject constructor(
suspend fun authorizePermission(student: Student, semester: Semester, pesel: String) = suspend fun authorizePermission(student: Student, semester: Semester, pesel: String) =
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.authorizePermission(pesel) .authorizePermission(pesel)
suspend fun refreshStudentName(student: Student, semester: Semester) { suspend fun refreshStudentName(student: Student, semester: Semester) {
val newCurrentApiStudent = sdk.init(student) val newCurrentApiStudent = sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getCurrentStudent() ?: return .getCurrentStudent() ?: return
val studentName = StudentName( val studentName = StudentName(
@ -166,4 +164,15 @@ class StudentRepository @Inject constructor(
studentDb.update(studentName) studentDb.update(studentName)
} }
suspend fun deleteStudentsAssociatedWithAccount(student: Student) {
studentDb.deleteByEmailAndUserName(student.email, student.userName)
}
suspend fun clearAll() {
withContext(dispatchers.io) {
scrambler.clearKeyPair()
appDatabase.clearAllTables()
}
}
} }

View File

@ -9,6 +9,7 @@ import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
@ -39,8 +40,9 @@ class SubjectRepository @Inject constructor(
query = { subjectDao.loadAll(semester.diaryId, semester.studentId) }, query = { subjectDao.loadAll(semester.diaryId, semester.studentId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getSubjects().mapToEntities(semester) .getSubjects()
.mapToEntities(semester)
}, },
saveFetchResult = { old, new -> saveFetchResult = { old, new ->
subjectDao.deleteAll(old uniqueSubtract new) subjectDao.deleteAll(old uniqueSubtract new)

View File

@ -9,6 +9,7 @@ import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
@ -39,7 +40,7 @@ class TeacherRepository @Inject constructor(
query = { teacherDb.loadAll(semester.studentId, semester.classId) }, query = { teacherDb.loadAll(semester.studentId, semester.classId) },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getTeachers() .getTeachers()
.mapToEntities(semester) .mapToEntities(semester)
}, },

View File

@ -3,13 +3,23 @@ package io.github.wulkanowy.data.repositories
import io.github.wulkanowy.data.db.dao.TimetableAdditionalDao import io.github.wulkanowy.data.db.dao.TimetableAdditionalDao
import io.github.wulkanowy.data.db.dao.TimetableDao import io.github.wulkanowy.data.db.dao.TimetableDao
import io.github.wulkanowy.data.db.dao.TimetableHeaderDao import io.github.wulkanowy.data.db.dao.TimetableHeaderDao
import io.github.wulkanowy.data.db.entities.* import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.db.entities.TimetableAdditional
import io.github.wulkanowy.data.db.entities.TimetableHeader
import io.github.wulkanowy.data.mappers.mapToEntities import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.data.networkBoundResource import io.github.wulkanowy.data.networkBoundResource
import io.github.wulkanowy.data.pojos.TimetableFull import io.github.wulkanowy.data.pojos.TimetableFull
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.services.alarm.TimetableNotificationSchedulerHelper import io.github.wulkanowy.services.alarm.TimetableNotificationSchedulerHelper
import io.github.wulkanowy.utils.* import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.switchSemester
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -65,7 +75,7 @@ class TimetableRepository @Inject constructor(
query = { getFullTimetableFromDatabase(student, semester, start, end) }, query = { getFullTimetableFromDatabase(student, semester, start, end) },
fetch = { fetch = {
val timetableFull = sdk.init(student) val timetableFull = sdk.init(student)
.switchDiary(semester.diaryId, semester.kindergartenDiaryId, semester.schoolYear) .switchSemester(semester)
.getTimetable(start.monday, end.sunday) .getTimetable(start.monday, end.sunday)
timetableFull.mapToEntities(semester) timetableFull.mapToEntities(semester)
@ -121,12 +131,12 @@ class TimetableRepository @Inject constructor(
} }
} }
fun getTimetableFromDatabase( suspend fun getTimetableFromDatabase(
semester: Semester, semester: Semester,
from: LocalDate, start: LocalDate,
end: LocalDate end: LocalDate
): Flow<List<Timetable>> { ): List<Timetable> {
return timetableDb.loadAll(semester.diaryId, semester.studentId, from, end) return timetableDb.load(semester.diaryId, semester.studentId, start, end)
} }
suspend fun updateTimetable(timetable: List<Timetable>) { suspend fun updateTimetable(timetable: List<Timetable>) {

View File

@ -0,0 +1,26 @@
package io.github.wulkanowy.domain.timetable
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.repositories.TimetableRepository
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.sunday
import java.time.LocalDate
import javax.inject.Inject
class IsStudentHasLessonsOnWeekendUseCase @Inject constructor(
private val timetableRepository: TimetableRepository,
private val isWeekendHasLessonsUseCase: IsWeekendHasLessonsUseCase,
) {
suspend operator fun invoke(
semester: Semester,
currentDate: LocalDate = LocalDate.now(),
): Boolean {
val lessons = timetableRepository.getTimetableFromDatabase(
semester = semester,
start = currentDate.monday,
end = currentDate.sunday,
)
return isWeekendHasLessonsUseCase(lessons)
}
}

View File

@ -0,0 +1,17 @@
package io.github.wulkanowy.domain.timetable
import io.github.wulkanowy.data.db.entities.Timetable
import java.time.DayOfWeek
import javax.inject.Inject
class IsWeekendHasLessonsUseCase @Inject constructor() {
operator fun invoke(
lessons: List<Timetable>,
): Boolean = lessons.any {
it.date.dayOfWeek in listOf(
DayOfWeek.SATURDAY,
DayOfWeek.SUNDAY,
)
}
}

View File

@ -65,8 +65,6 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
range = lesson.start..lesson.end, range = lesson.start..lesson.end,
requestCode = getRequestCode(lesson.start, studentId) requestCode = getRequestCode(lesson.start, studentId)
) )
Timber.d("TimetableNotification canceled: type 1 & 2, subject: ${lesson.subject}, start: ${lesson.start}, student: $studentId")
} }
} }
} }

View File

@ -4,6 +4,7 @@ import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Grade import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeDescriptive
import io.github.wulkanowy.data.db.entities.GradeSummary import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.GroupNotificationData import io.github.wulkanowy.data.pojos.GroupNotificationData
@ -19,7 +20,7 @@ class NewGradeNotification @Inject constructor(
suspend fun notifyDetails(items: List<Grade>, student: Student) { suspend fun notifyDetails(items: List<Grade>, student: Student) {
val notificationDataList = items val notificationDataList = items
.filter { !listOf("1", "1+", "2", "2-", "2+").contains(it.entry) } .filter { !it.isNotified }
.map { .map {
NotificationData( NotificationData(
title = context.getPlural(R.plurals.grade_new_items, 1), title = context.getPlural(R.plurals.grade_new_items, 1),
@ -89,4 +90,28 @@ class NewGradeNotification @Inject constructor(
appNotificationManager.sendMultipleNotifications(groupNotificationData, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
suspend fun notifyDescriptive(items: List<GradeDescriptive>, student: Student) {
val notificationDataList = items.map {
NotificationData(
title = context.getPlural(R.plurals.grade_new_items_descriptive, 1),
content = "${it.subject}: ${it.description}",
destination = Destination.Grade,
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.grade_new_items_descriptive, items.size),
content = context.getPlural(
R.plurals.grade_notify_new_items_descriptive,
items.size,
items.size
),
destination = Destination.Grade,
type = NotificationType.NEW_GRADE_DESCRIPTIVE
)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
} }

View File

@ -37,6 +37,10 @@ enum class NotificationType(
channel = NewGradesChannel.CHANNEL_ID, channel = NewGradesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_grade, icon = R.drawable.ic_stat_grade,
), ),
NEW_GRADE_DESCRIPTIVE(
channel = NewGradesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_grade,
),
NEW_HOMEWORK( NEW_HOMEWORK(
channel = NewHomeworkChannel.CHANNEL_ID, channel = NewHomeworkChannel.CHANNEL_ID,
icon = R.drawable.ic_more_homework, icon = R.drawable.ic_more_homework,

View File

@ -16,17 +16,24 @@ class AttendanceWork @Inject constructor(
) : Work { ) : Work {
override suspend fun doWork(student: Student, semester: Semester, notify: Boolean) { override suspend fun doWork(student: Student, semester: Semester, notify: Boolean) {
val startDate = now().previousOrSameSchoolDay
val endDate = startDate.plusDays(7)
attendanceRepository.getAttendance( attendanceRepository.getAttendance(
student = student, student = student,
semester = semester, semester = semester,
start = now().previousOrSameSchoolDay, start = startDate,
end = now().previousOrSameSchoolDay, end = endDate,
forceRefresh = true, forceRefresh = true,
notify = notify, notify = notify,
) )
.waitForResult() .waitForResult()
attendanceRepository.getAttendanceFromDatabase(semester, now().minusDays(7), now()) attendanceRepository.getAttendanceFromDatabase(
semester = semester,
start = startDate,
end = endDate,
)
.first() .first()
.filterNot { it.isNotified } .filterNot { it.isNotified }
.let { .let {

View File

@ -5,6 +5,8 @@ import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.ExamRepository import io.github.wulkanowy.data.repositories.ExamRepository
import io.github.wulkanowy.data.waitForResult import io.github.wulkanowy.data.waitForResult
import io.github.wulkanowy.services.sync.notifications.NewExamNotification import io.github.wulkanowy.services.sync.notifications.NewExamNotification
import io.github.wulkanowy.utils.endExamsDay
import io.github.wulkanowy.utils.startExamsDay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.time.LocalDate.now import java.time.LocalDate.now
import javax.inject.Inject import javax.inject.Inject
@ -15,16 +17,24 @@ class ExamWork @Inject constructor(
) : Work { ) : Work {
override suspend fun doWork(student: Student, semester: Semester, notify: Boolean) { override suspend fun doWork(student: Student, semester: Semester, notify: Boolean) {
val startDate = now().startExamsDay
val endDate = startDate.endExamsDay
examRepository.getExams( examRepository.getExams(
student = student, student = student,
semester = semester, semester = semester,
start = now(), start = startDate,
end = now(), end = endDate,
forceRefresh = true, forceRefresh = true,
notify = notify, notify = notify,
).waitForResult() ).waitForResult()
examRepository.getExamsFromDatabase(semester, now()).first() examRepository.getExamsFromDatabase(
semester = semester,
start = startDate,
end = endDate,
)
.first()
.filter { !it.isNotified }.let { .filter { !it.isNotified }.let {
if (it.isNotEmpty()) newExamNotification.notify(it, student) if (it.isNotEmpty()) newExamNotification.notify(it, student)

View File

@ -45,5 +45,15 @@ class GradeWork @Inject constructor(
grade.isFinalGradeNotified = true grade.isFinalGradeNotified = true
}) })
} }
gradeRepository.getGradesDescriptiveFromDatabase(semester).first()
.filter { !it.isNotified }
.let {
if (it.isNotEmpty()) newGradeNotification.notifyDescriptive(it, student)
gradeRepository.updateGradesDescriptive(it.onEach { grade ->
grade.isNotified = true
})
}
} }
} }

View File

@ -5,7 +5,9 @@ import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.HomeworkRepository import io.github.wulkanowy.data.repositories.HomeworkRepository
import io.github.wulkanowy.data.waitForResult import io.github.wulkanowy.data.waitForResult
import io.github.wulkanowy.services.sync.notifications.NewHomeworkNotification import io.github.wulkanowy.services.sync.notifications.NewHomeworkNotification
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.nextOrSameSchoolDay import io.github.wulkanowy.utils.nextOrSameSchoolDay
import io.github.wulkanowy.utils.sunday
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.time.LocalDate.now import java.time.LocalDate.now
import javax.inject.Inject import javax.inject.Inject
@ -16,16 +18,24 @@ class HomeworkWork @Inject constructor(
) : Work { ) : Work {
override suspend fun doWork(student: Student, semester: Semester, notify: Boolean) { override suspend fun doWork(student: Student, semester: Semester, notify: Boolean) {
val startDate = now().nextOrSameSchoolDay.monday
val endDate = startDate.sunday
homeworkRepository.getHomework( homeworkRepository.getHomework(
student = student, student = student,
semester = semester, semester = semester,
start = now().nextOrSameSchoolDay, start = startDate,
end = now().nextOrSameSchoolDay, end = endDate,
forceRefresh = true, forceRefresh = true,
notify = notify, notify = notify,
).waitForResult() ).waitForResult()
homeworkRepository.getHomeworkFromDatabase(semester, now(), now().plusDays(7)).first() homeworkRepository.getHomeworkFromDatabase(
semester = semester,
start = startDate,
end = endDate
)
.first()
.filter { !it.isNotified }.let { .filter { !it.isNotified }.let {
if (it.isNotEmpty()) newHomeworkNotification.notify(it, student) if (it.isNotEmpty()) newHomeworkNotification.notify(it, student)

View File

@ -6,7 +6,6 @@ import io.github.wulkanowy.data.repositories.TimetableRepository
import io.github.wulkanowy.data.waitForResult import io.github.wulkanowy.data.waitForResult
import io.github.wulkanowy.services.sync.notifications.ChangeTimetableNotification import io.github.wulkanowy.services.sync.notifications.ChangeTimetableNotification
import io.github.wulkanowy.utils.nextOrSameSchoolDay import io.github.wulkanowy.utils.nextOrSameSchoolDay
import kotlinx.coroutines.flow.first
import java.time.LocalDate.now import java.time.LocalDate.now
import javax.inject.Inject import javax.inject.Inject
@ -16,18 +15,24 @@ class TimetableWork @Inject constructor(
) : Work { ) : Work {
override suspend fun doWork(student: Student, semester: Semester, notify: Boolean) { override suspend fun doWork(student: Student, semester: Semester, notify: Boolean) {
val startDate = now().nextOrSameSchoolDay
val endDate = startDate.plusDays(7)
timetableRepository.getTimetable( timetableRepository.getTimetable(
student = student, student = student,
semester = semester, semester = semester,
start = now().nextOrSameSchoolDay, start = startDate,
end = now().nextOrSameSchoolDay, end = endDate,
forceRefresh = true, forceRefresh = true,
notify = notify, notify = notify,
) )
.waitForResult() .waitForResult()
timetableRepository.getTimetableFromDatabase(semester, now(), now().plusDays(7)) timetableRepository.getTimetableFromDatabase(
.first() semester = semester,
start = startDate,
end = endDate,
)
.filterNot { it.isNotified } .filterNot { it.isNotified }
.let { .let {
if (it.isNotEmpty()) changeTimetableNotification.notify(it, student) if (it.isNotEmpty()) changeTimetableNotification.notify(it, student)

View File

@ -11,6 +11,7 @@ import com.google.android.material.snackbar.Snackbar
import com.google.android.material.snackbar.Snackbar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.ui.modules.auth.AuthDialog import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.ui.modules.captcha.CaptchaDialog
import io.github.wulkanowy.ui.modules.login.LoginActivity import io.github.wulkanowy.ui.modules.login.LoginActivity
import io.github.wulkanowy.utils.FragmentLifecycleLogger import io.github.wulkanowy.utils.FragmentLifecycleLogger
import io.github.wulkanowy.utils.getThemeAttrColor import io.github.wulkanowy.utils.getThemeAttrColor
@ -68,11 +69,24 @@ abstract class BaseActivity<T : BasePresenter<out BaseView>, VB : ViewBinding> :
} else Toast.makeText(this, text, Toast.LENGTH_LONG).show() } else Toast.makeText(this, text, Toast.LENGTH_LONG).show()
} }
override fun showExpiredDialog() { override fun showExpiredCredentialsDialog() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.main_expired_credentials_title)
.setMessage(R.string.main_expired_credentials_description)
.setPositiveButton(R.string.main_log_in) { _, _ -> presenter.onConfirmExpiredCredentialsSelected() }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
override fun onCaptchaVerificationRequired(url: String?) {
CaptchaDialog.newInstance(url).show(supportFragmentManager, "captcha_dialog")
}
override fun showDecryptionFailedDialog() {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.main_session_expired) .setTitle(R.string.main_session_expired)
.setMessage(R.string.main_session_relogin) .setMessage(R.string.main_session_relogin)
.setPositiveButton(R.string.main_log_in) { _, _ -> presenter.onExpiredLoginSelected() } .setPositiveButton(R.string.main_log_in) { _, _ -> presenter.onConfirmDecryptionFailedSelected() }
.setNegativeButton(android.R.string.cancel) { _, _ -> } .setNegativeButton(android.R.string.cancel) { _, _ -> }
.show() .show()
} }

View File

@ -8,7 +8,6 @@ import android.widget.Toast
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.elevation.SurfaceColors import com.google.android.material.elevation.SurfaceColors
import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.utils.AnalyticsHelper import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.lifecycleAwareVariable import io.github.wulkanowy.utils.lifecycleAwareVariable
import javax.inject.Inject import javax.inject.Inject
@ -28,8 +27,16 @@ abstract class BaseDialogFragment<VB : ViewBinding> : DialogFragment(), BaseView
Toast.makeText(context, text, Toast.LENGTH_LONG).show() Toast.makeText(context, text, Toast.LENGTH_LONG).show()
} }
override fun showExpiredDialog() { override fun showExpiredCredentialsDialog() {
(activity as? BaseActivity<*, *>)?.showExpiredDialog() (activity as? BaseActivity<*, *>)?.showExpiredCredentialsDialog()
}
override fun onCaptchaVerificationRequired(url: String?) {
(activity as? BaseActivity<*, *>)?.onCaptchaVerificationRequired(url)
}
override fun showDecryptionFailedDialog() {
(activity as? BaseActivity<*, *>)?.showDecryptionFailedDialog()
} }
override fun openClearLoginView() { override fun openClearLoginView() {
@ -41,7 +48,7 @@ abstract class BaseDialogFragment<VB : ViewBinding> : DialogFragment(), BaseView
} }
override fun showAuthDialog() { override fun showAuthDialog() {
AuthDialog.newInstance().show(childFragmentManager, "auth_dialog") (activity as? BaseActivity<*, *>)?.showAuthDialog()
} }
override fun showErrorDetailsDialog(error: Throwable) { override fun showErrorDetailsDialog(error: Throwable) {

View File

@ -7,7 +7,6 @@ import androidx.viewbinding.ViewBinding
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.snackbar.Snackbar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.utils.lifecycleAwareVariable import io.github.wulkanowy.utils.lifecycleAwareVariable
abstract class BaseFragment<VB : ViewBinding>(@LayoutRes layoutId: Int) : Fragment(layoutId), abstract class BaseFragment<VB : ViewBinding>(@LayoutRes layoutId: Int) : Fragment(layoutId),
@ -39,12 +38,20 @@ abstract class BaseFragment<VB : ViewBinding>(@LayoutRes layoutId: Int) : Fragme
} }
} }
override fun showExpiredDialog() { override fun showExpiredCredentialsDialog() {
(activity as? BaseActivity<*, *>)?.showExpiredDialog() (activity as? BaseActivity<*, *>)?.showExpiredCredentialsDialog()
}
override fun onCaptchaVerificationRequired(url: String?) {
(activity as? BaseActivity<*, *>)?.onCaptchaVerificationRequired(url)
}
override fun showDecryptionFailedDialog() {
(activity as? BaseActivity<*, *>)?.showDecryptionFailedDialog()
} }
override fun showAuthDialog() { override fun showAuthDialog() {
AuthDialog.newInstance().show(childFragmentManager, "auth_dialog") (activity as? BaseActivity<*, *>)?.showAuthDialog()
} }
override fun openClearLoginView() { override fun openClearLoginView() {

View File

@ -28,20 +28,38 @@ open class BasePresenter<T : BaseView>(
this.view = view this.view = view
errorHandler.apply { errorHandler.apply {
showErrorMessage = view::showError showErrorMessage = view::showError
onSessionExpired = view::showExpiredDialog onExpiredCredentials = view::showExpiredCredentialsDialog
onCaptchaVerificationRequired = view::onCaptchaVerificationRequired
onDecryptionFailed = view::showDecryptionFailedDialog
onNoCurrentStudent = view::openClearLoginView onNoCurrentStudent = view::openClearLoginView
onPasswordChangeRequired = view::showChangePasswordSnackbar onPasswordChangeRequired = view::showChangePasswordSnackbar
onAuthorizationRequired = view::showAuthDialog onAuthorizationRequired = view::showAuthDialog
} }
} }
fun onExpiredLoginSelected() { fun onConfirmDecryptionFailedSelected() {
Timber.i("Attempt to switch the student after the session expires") Timber.i("Attempt to clear all data")
presenterScope.launch {
runCatching { studentRepository.clearAll() }
.onFailure {
Timber.i("Clear data result: An exception occurred")
errorHandler.dispatch(it)
}
.onSuccess {
Timber.i("Clear data result: Open login view")
view?.openClearLoginView()
}
}
}
fun onConfirmExpiredCredentialsSelected() {
Timber.i("Attempt to delete students associated with the account and switch to new student")
presenterScope.launch { presenterScope.launch {
runCatching { runCatching {
val student = studentRepository.getCurrentStudent(false) val student = studentRepository.getCurrentStudent(false)
studentRepository.logoutStudent(student) studentRepository.deleteStudentsAssociatedWithAccount(student)
val students = studentRepository.getSavedStudents(false) val students = studentRepository.getSavedStudents(false)
if (students.isNotEmpty()) { if (students.isNotEmpty()) {
@ -50,11 +68,11 @@ open class BasePresenter<T : BaseView>(
} }
} }
.onFailure { .onFailure {
Timber.i("Switch student result: An exception occurred") Timber.i("Delete students result: An exception occurred")
errorHandler.dispatch(it) errorHandler.dispatch(it)
} }
.onSuccess { .onSuccess {
Timber.i("Switch student result: Open login view") Timber.i("Delete students result: Open login view")
view?.openClearLoginView() view?.openClearLoginView()
} }
} }

View File

@ -6,7 +6,11 @@ interface BaseView {
fun showMessage(text: String) fun showMessage(text: String)
fun showExpiredDialog() fun showExpiredCredentialsDialog()
fun onCaptchaVerificationRequired(url: String?)
fun showDecryptionFailedDialog()
fun showAuthDialog() fun showAuthDialog()

View File

@ -4,6 +4,7 @@ import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.data.exceptions.NoCurrentStudentException import io.github.wulkanowy.data.exceptions.NoCurrentStudentException
import io.github.wulkanowy.sdk.scrapper.exception.AuthorizationRequiredException import io.github.wulkanowy.sdk.scrapper.exception.AuthorizationRequiredException
import io.github.wulkanowy.sdk.scrapper.exception.CloudflareVerificationException
import io.github.wulkanowy.sdk.scrapper.login.BadCredentialsException import io.github.wulkanowy.sdk.scrapper.login.BadCredentialsException
import io.github.wulkanowy.sdk.scrapper.login.PasswordChangeRequiredException import io.github.wulkanowy.sdk.scrapper.login.PasswordChangeRequiredException
import io.github.wulkanowy.utils.getErrorString import io.github.wulkanowy.utils.getErrorString
@ -15,7 +16,9 @@ open class ErrorHandler @Inject constructor(@ApplicationContext protected val co
var showErrorMessage: (String, Throwable) -> Unit = { _, _ -> } var showErrorMessage: (String, Throwable) -> Unit = { _, _ -> }
var onSessionExpired: () -> Unit = {} var onExpiredCredentials: () -> Unit = {}
var onDecryptionFailed: () -> Unit = {}
var onNoCurrentStudent: () -> Unit = {} var onNoCurrentStudent: () -> Unit = {}
@ -23,24 +26,33 @@ open class ErrorHandler @Inject constructor(@ApplicationContext protected val co
var onAuthorizationRequired: () -> Unit = {} var onAuthorizationRequired: () -> Unit = {}
var onCaptchaVerificationRequired: (url: String?) -> Unit = {}
fun dispatch(error: Throwable) { fun dispatch(error: Throwable) {
Timber.e(error, "An exception occurred while the Wulkanowy was running") Timber.e(error, "An exception occurred while the Wulkanowy was running")
proceed(error) proceed(error)
} }
protected open fun proceed(error: Throwable) { protected open fun proceed(error: Throwable) {
showErrorMessage(context.resources.getErrorString(error), error) showDefaultMessage(error)
when (error) { when (error) {
is PasswordChangeRequiredException -> onPasswordChangeRequired(error.redirectUrl) is PasswordChangeRequiredException -> onPasswordChangeRequired(error.redirectUrl)
is ScramblerException, is BadCredentialsException -> onSessionExpired() is ScramblerException -> onDecryptionFailed()
is BadCredentialsException -> onExpiredCredentials()
is NoCurrentStudentException -> onNoCurrentStudent() is NoCurrentStudentException -> onNoCurrentStudent()
is AuthorizationRequiredException -> onAuthorizationRequired() is AuthorizationRequiredException -> onAuthorizationRequired()
is CloudflareVerificationException -> onCaptchaVerificationRequired(error.originalUrl)
} }
} }
fun showDefaultMessage(error: Throwable) {
showErrorMessage(context.resources.getErrorString(error), error)
}
open fun clear() { open fun clear() {
showErrorMessage = { _, _ -> } showErrorMessage = { _, _ -> }
onSessionExpired = {} onExpiredCredentials = {}
onDecryptionFailed = {}
onNoCurrentStudent = {} onNoCurrentStudent = {}
onPasswordChangeRequired = {} onPasswordChangeRequired = {}
onAuthorizationRequired = {} onAuthorizationRequired = {}

View File

@ -1,5 +1,6 @@
package io.github.wulkanowy.ui.modules.attendance package io.github.wulkanowy.ui.modules.attendance
import android.graphics.Typeface
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -10,6 +11,7 @@ import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.enums.SentExcuseStatus import io.github.wulkanowy.data.enums.SentExcuseStatus
import io.github.wulkanowy.databinding.ItemAttendanceBinding import io.github.wulkanowy.databinding.ItemAttendanceBinding
import io.github.wulkanowy.utils.descriptionRes import io.github.wulkanowy.utils.descriptionRes
import io.github.wulkanowy.utils.getThemeAttrColor
import io.github.wulkanowy.utils.isExcusableOrNotExcused import io.github.wulkanowy.utils.isExcusableOrNotExcused
import javax.inject.Inject import javax.inject.Inject
@ -39,7 +41,33 @@ class AttendanceAdapter @Inject constructor() :
root.context.getString(R.string.all_no_data) root.context.getString(R.string.all_no_data)
} }
attendanceItemDescription.setText(item.descriptionRes) attendanceItemDescription.setText(item.descriptionRes)
attendanceItemAlert.isVisible = item.let { it.absence && !it.excused }
attendanceItemDescription.setTextColor(
root.context.getThemeAttrColor(
when {
item.absence && !item.excused -> R.attr.colorAttendanceAbsence
item.lateness && !item.excused -> R.attr.colorAttendanceLateness
else -> android.R.attr.textColorSecondary
}
)
)
if (item.exemption || item.excused) {
attendanceItemDescription.setTypeface(null, Typeface.BOLD)
} else {
attendanceItemDescription.setTypeface(null, Typeface.NORMAL)
}
attendanceItemAlert.isVisible =
item.let { (it.absence && !it.excused) || (it.lateness && !it.excused) }
attendanceItemAlert.setColorFilter(root.context.getThemeAttrColor(
when{
item.absence && !item.excused -> R.attr.colorAttendanceAbsence
item.lateness && !item.excused -> R.attr.colorAttendanceLateness
else -> android.R.attr.colorPrimary
}
))
attendanceItemNumber.visibility = View.GONE attendanceItemNumber.visibility = View.GONE
attendanceItemExcuseInfo.visibility = View.GONE attendanceItemExcuseInfo.visibility = View.GONE
attendanceItemExcuseCheckbox.visibility = View.GONE attendanceItemExcuseCheckbox.visibility = View.GONE
@ -54,10 +82,12 @@ class AttendanceAdapter @Inject constructor() :
attendanceItemExcuseInfo.visibility = View.VISIBLE attendanceItemExcuseInfo.visibility = View.VISIBLE
attendanceItemAlert.visibility = View.INVISIBLE attendanceItemAlert.visibility = View.INVISIBLE
} }
SentExcuseStatus.DENIED -> { SentExcuseStatus.DENIED -> {
attendanceItemExcuseInfo.setImageResource(R.drawable.ic_excuse_denied) attendanceItemExcuseInfo.setImageResource(R.drawable.ic_excuse_denied)
attendanceItemExcuseInfo.visibility = View.VISIBLE attendanceItemExcuseInfo.visibility = View.VISIBLE
} }
else -> { else -> {
if (item.isExcusableOrNotExcused && excuseActionMode) { if (item.isExcusableOrNotExcused && excuseActionMode) {
attendanceItemNumber.visibility = View.GONE attendanceItemNumber.visibility = View.GONE

View File

@ -6,10 +6,12 @@ import android.view.View
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Attendance import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.databinding.DialogAttendanceBinding import io.github.wulkanowy.databinding.DialogAttendanceBinding
import io.github.wulkanowy.ui.base.BaseDialogFragment import io.github.wulkanowy.ui.base.BaseDialogFragment
import io.github.wulkanowy.utils.descriptionRes import io.github.wulkanowy.utils.descriptionRes
import io.github.wulkanowy.utils.getThemeAttrColor
import io.github.wulkanowy.utils.serializable import io.github.wulkanowy.utils.serializable
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
@ -44,6 +46,16 @@ class AttendanceDialog : BaseDialogFragment<DialogAttendanceBinding>() {
with(binding) { with(binding) {
attendanceDialogSubjectValue.text = attendance.subject attendanceDialogSubjectValue.text = attendance.subject
attendanceDialogDescriptionValue.setText(attendance.descriptionRes) attendanceDialogDescriptionValue.setText(attendance.descriptionRes)
attendanceDialogDescriptionValue.setTextColor(
root.context.getThemeAttrColor(
when {
attendance.absence && !attendance.excused -> R.attr.colorAttendanceAbsence
attendance.lateness && !attendance.excused -> R.attr.colorAttendanceLateness
else -> android.R.attr.textColorSecondary
}
)
)
attendanceDialogDateValue.text = attendance.date.toFormattedString() attendanceDialogDateValue.text = attendance.date.toFormattedString()
attendanceDialogNumberValue.text = attendance.number.toString() attendanceDialogNumberValue.text = attendance.number.toString()
attendanceDialogClose.setOnClickListener { dismiss() } attendanceDialogClose.setOnClickListener { dismiss() }

View File

@ -12,6 +12,7 @@ import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.ui.base.BasePresenter import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.* import io.github.wulkanowy.utils.*
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import timber.log.Timber import timber.log.Timber
import java.time.DayOfWeek import java.time.DayOfWeek
@ -205,7 +206,7 @@ class AttendancePresenter @Inject constructor(
val semester = semesterRepository.getCurrentSemester(student) val semester = semesterRepository.getCurrentSemester(student)
checkInitialAndCurrentDate(student, semester) checkInitialAndCurrentDate(semester)
attendanceRepository.getAttendance( attendanceRepository.getAttendance(
student = student, student = student,
semester = semester, semester = semester,
@ -261,15 +262,13 @@ class AttendancePresenter @Inject constructor(
.launch() .launch()
} }
private suspend fun checkInitialAndCurrentDate(student: Student, semester: Semester) { private suspend fun checkInitialAndCurrentDate(semester: Semester) {
if (initialDate == null) { if (initialDate == null) {
val lessons = attendanceRepository.getAttendance( val lessons = attendanceRepository.getAttendanceFromDatabase(
student = student,
semester = semester, semester = semester,
start = now().monday, start = now().monday,
end = now().sunday, end = now().sunday,
forceRefresh = false, ).firstOrNull().orEmpty()
).toFirstResult().dataOrNull.orEmpty()
isWeekendHasLessons = isWeekendHasLessons(lessons) isWeekendHasLessons = isWeekendHasLessons(lessons)
initialDate = getInitialDate(semester) initialDate = getInitialDate(semester)
} }
@ -311,6 +310,7 @@ class AttendancePresenter @Inject constructor(
showContent(false) showContent(false)
showExcuseButton(false) showExcuseButton(false)
} }
is Resource.Success -> { is Resource.Success -> {
Timber.i("Excusing for absence result: Success") Timber.i("Excusing for absence result: Success")
analytics.logEvent("excuse_absence", "items" to attendanceToExcuseList.size) analytics.logEvent("excuse_absence", "items" to attendanceToExcuseList.size)
@ -323,6 +323,7 @@ class AttendancePresenter @Inject constructor(
} }
loadData(forceRefresh = true) loadData(forceRefresh = true)
} }
is Resource.Error -> { is Resource.Error -> {
Timber.i("Excusing for absence result: An exception occurred") Timber.i("Excusing for absence result: An exception occurred")
errorHandler.dispatch(it.error) errorHandler.dispatch(it.error)

View File

@ -62,7 +62,11 @@ class AuthPresenter @Inject constructor(
} }
isSuccess isSuccess
} }
.onFailure { errorHandler.dispatch(it) } .onFailure {
errorHandler.dispatch(it)
view?.showProgress(false)
view?.showContent(true)
}
.onSuccess { .onSuccess {
if (it) { if (it) {
view?.showSuccess(true) view?.showSuccess(true)

View File

@ -0,0 +1,86 @@
package io.github.wulkanowy.ui.modules.captcha
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.os.bundleOf
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.databinding.DialogCaptchaBinding
import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.ui.base.BaseDialogFragment
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class CaptchaDialog : BaseDialogFragment<DialogCaptchaBinding>() {
@Inject
lateinit var sdk: Sdk
private var webView: WebView? = null
companion object {
const val CAPTCHA_SUCCESS = "captcha_success"
private const val CAPTCHA_URL = "captcha_url"
private const val CAPTCHA_CHECK_JS = "document.getElementById('challenge-running') == null"
fun newInstance(url: String?): CaptchaDialog {
return CaptchaDialog().apply {
arguments = bundleOf(CAPTCHA_URL to url)
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = DialogCaptchaBinding.inflate(inflater).apply { binding = this }.root
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isCancelable = false
binding.captchaRefresh.setOnClickListener {
binding.captchaWebview.loadUrl(arguments?.getString(CAPTCHA_URL).orEmpty())
}
binding.captchaClose.setOnClickListener { dismiss() }
with(binding.captchaWebview) {
webView = this
with(settings) {
javaScriptEnabled = true
userAgentString = sdk.userAgent
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.evaluateJavascript(CAPTCHA_CHECK_JS) {
if (it == "true") {
onChallengeAccepted()
}
}
}
}
loadUrl(arguments?.getString(CAPTCHA_URL).orEmpty())
}
}
private fun onChallengeAccepted() {
runCatching { parentFragmentManager.setFragmentResult(CAPTCHA_SUCCESS, bundleOf()) }
.onFailure { Timber.e(it) }
showMessage(getString(R.string.captcha_verified_message))
dismissAllowingStateLoss()
}
override fun onDestroy() {
webView?.destroy()
super.onDestroy()
}
}

View File

@ -18,8 +18,10 @@ import io.github.wulkanowy.databinding.FragmentDashboardBinding
import io.github.wulkanowy.ui.base.BaseFragment import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.account.accountdetails.AccountDetailsFragment import io.github.wulkanowy.ui.modules.account.accountdetails.AccountDetailsFragment
import io.github.wulkanowy.ui.modules.attendance.summary.AttendanceSummaryFragment import io.github.wulkanowy.ui.modules.attendance.summary.AttendanceSummaryFragment
import io.github.wulkanowy.ui.modules.captcha.CaptchaDialog.Companion.CAPTCHA_SUCCESS
import io.github.wulkanowy.ui.modules.conference.ConferenceFragment import io.github.wulkanowy.ui.modules.conference.ConferenceFragment
import io.github.wulkanowy.ui.modules.dashboard.adapters.DashboardAdapter import io.github.wulkanowy.ui.modules.dashboard.adapters.DashboardAdapter
import io.github.wulkanowy.ui.modules.dashboard.viewholders.AdminMessageViewHolder
import io.github.wulkanowy.ui.modules.exam.ExamFragment import io.github.wulkanowy.ui.modules.exam.ExamFragment
import io.github.wulkanowy.ui.modules.grade.GradeFragment import io.github.wulkanowy.ui.modules.grade.GradeFragment
import io.github.wulkanowy.ui.modules.homework.HomeworkFragment import io.github.wulkanowy.ui.modules.homework.HomeworkFragment
@ -30,7 +32,12 @@ import io.github.wulkanowy.ui.modules.message.MessageFragment
import io.github.wulkanowy.ui.modules.notificationscenter.NotificationsCenterFragment import io.github.wulkanowy.ui.modules.notificationscenter.NotificationsCenterFragment
import io.github.wulkanowy.ui.modules.schoolannouncement.SchoolAnnouncementFragment import io.github.wulkanowy.ui.modules.schoolannouncement.SchoolAnnouncementFragment
import io.github.wulkanowy.ui.modules.timetable.TimetableFragment import io.github.wulkanowy.ui.modules.timetable.TimetableFragment
import io.github.wulkanowy.utils.* import io.github.wulkanowy.utils.capitalise
import io.github.wulkanowy.utils.dpToPx
import io.github.wulkanowy.utils.getErrorString
import io.github.wulkanowy.utils.getThemeAttrColor
import io.github.wulkanowy.utils.openInternetBrowser
import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
@ -57,6 +64,9 @@ class DashboardFragment : BaseFragment<FragmentDashboardBinding>(R.layout.fragme
return ((recyclerWidth - margin) / resources.displayMetrics.density).toInt() return ((recyclerWidth - margin) / resources.displayMetrics.density).toInt()
} }
override val isViewEmpty
get() = dashboardAdapter.itemCount == 0
companion object { companion object {
fun newInstance() = DashboardFragment() fun newInstance() = DashboardFragment()
@ -72,6 +82,13 @@ class DashboardFragment : BaseFragment<FragmentDashboardBinding>(R.layout.fragme
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentDashboardBinding.bind(view) binding = FragmentDashboardBinding.bind(view)
presenter.onAttachView(this) presenter.onAttachView(this)
initializeCaptchaResultObserver()
}
private fun initializeCaptchaResultObserver() {
childFragmentManager.setFragmentResultListener(CAPTCHA_SUCCESS, this) { _, _ ->
presenter.onRetryAfterCaptcha()
}
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -182,8 +199,17 @@ class DashboardFragment : BaseFragment<FragmentDashboardBinding>(R.layout.fragme
binding.dashboardRecycler.isVisible = show binding.dashboardRecycler.isVisible = show
} }
override fun showErrorView(show: Boolean) { override fun showErrorView(show: Boolean, adminMessageItem: DashboardItem.AdminMessages?) {
binding.dashboardErrorContainer.isVisible = show binding.dashboardErrorContainer.isVisible = show
binding.dashboardErrorAdminMessage.root.isVisible = adminMessageItem != null
if (adminMessageItem != null) {
AdminMessageViewHolder(
binding = binding.dashboardErrorAdminMessage,
onAdminMessageDismissClickListener = presenter::onAdminMessageDismissed,
onAdminMessageClickListener = presenter::onAdminMessageSelected,
).bind(adminMessageItem.adminMessage)
}
} }
override fun setErrorDetails(error: Throwable) { override fun setErrorDetails(error: Throwable) {

View File

@ -24,11 +24,13 @@ import io.github.wulkanowy.data.repositories.SemesterRepository
import io.github.wulkanowy.data.repositories.StudentRepository import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.data.repositories.TimetableRepository import io.github.wulkanowy.data.repositories.TimetableRepository
import io.github.wulkanowy.domain.adminmessage.GetAppropriateAdminMessageUseCase import io.github.wulkanowy.domain.adminmessage.GetAppropriateAdminMessageUseCase
import io.github.wulkanowy.domain.timetable.IsStudentHasLessonsOnWeekendUseCase
import io.github.wulkanowy.ui.base.BasePresenter import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.AdsHelper import io.github.wulkanowy.utils.AdsHelper
import io.github.wulkanowy.utils.calculatePercentage import io.github.wulkanowy.utils.calculatePercentage
import io.github.wulkanowy.utils.nextOrSameSchoolDay import io.github.wulkanowy.utils.nextOrSameSchoolDay
import io.github.wulkanowy.utils.sunday
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -56,6 +58,7 @@ class DashboardPresenter @Inject constructor(
private val messageRepository: MessageRepository, private val messageRepository: MessageRepository,
private val attendanceSummaryRepository: AttendanceSummaryRepository, private val attendanceSummaryRepository: AttendanceSummaryRepository,
private val timetableRepository: TimetableRepository, private val timetableRepository: TimetableRepository,
private val isStudentHasLessonsOnWeekendUseCase: IsStudentHasLessonsOnWeekendUseCase,
private val homeworkRepository: HomeworkRepository, private val homeworkRepository: HomeworkRepository,
private val examRepository: ExamRepository, private val examRepository: ExamRepository,
private val conferenceRepository: ConferenceRepository, private val conferenceRepository: ConferenceRepository,
@ -239,6 +242,14 @@ class DashboardPresenter @Inject constructor(
loadData(selectedDashboardTiles, forceRefresh = true) loadData(selectedDashboardTiles, forceRefresh = true)
} }
fun onRetryAfterCaptcha() {
view?.run {
showErrorView(false)
showProgress(true)
}
loadData(selectedDashboardTiles, forceRefresh = true)
}
fun onViewReselected() { fun onViewReselected() {
Timber.i("Dashboard view is reselected") Timber.i("Dashboard view is reselected")
view?.run { view?.run {
@ -320,7 +331,7 @@ class DashboardPresenter @Inject constructor(
) { luckyNumberResource, messageResource, attendanceResource -> ) { luckyNumberResource, messageResource, attendanceResource ->
val resList = listOf(luckyNumberResource, messageResource, attendanceResource) val resList = listOf(luckyNumberResource, messageResource, attendanceResource)
DashboardItem.HorizontalGroup( resList to DashboardItem.HorizontalGroup(
isLoading = resList.any { it is Resource.Loading }, isLoading = resList.any { it is Resource.Loading },
error = resList.map { it.errorOrNull }.let { errors -> error = resList.map { it.errorOrNull }.let { errors ->
if (errors.all { it != null }) { if (errors.all { it != null }) {
@ -345,9 +356,9 @@ class DashboardPresenter @Inject constructor(
) )
}) })
} }
.filterNot { it.isLoading && forceRefresh } .filterNot { (_, it) -> it.isLoading && forceRefresh }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach { (_, it) ->
updateData(it, forceRefresh) updateData(it, forceRefresh)
if (it.isLoading) { if (it.isLoading) {
@ -365,7 +376,7 @@ class DashboardPresenter @Inject constructor(
) )
errorHandler.dispatch(it) errorHandler.dispatch(it)
} }
.launch("horizontal_group ${if (forceRefresh) "-forceRefresh" else ""}") .launchWithUniqueRefreshJob("horizontal_group", forceRefresh)
} }
private fun loadGrades(student: Student, forceRefresh: Boolean) { private fun loadGrades(student: Student, forceRefresh: Boolean) {
@ -399,7 +410,7 @@ class DashboardPresenter @Inject constructor(
subjectWithGrades = it.dataOrNull, subjectWithGrades = it.dataOrNull,
gradeTheme = preferencesRepository.gradeColorTheme, gradeTheme = preferencesRepository.gradeColorTheme,
isLoading = true isLoading = true
), forceRefresh ), false
) )
if (!it.dataOrNull.isNullOrEmpty()) { if (!it.dataOrNull.isNullOrEmpty()) {
@ -431,14 +442,17 @@ class DashboardPresenter @Inject constructor(
private fun loadLessons(student: Student, forceRefresh: Boolean) { private fun loadLessons(student: Student, forceRefresh: Boolean) {
flatResourceFlow { flatResourceFlow {
val semester = semesterRepository.getCurrentSemester(student) val semester = semesterRepository.getCurrentSemester(student)
val date = LocalDate.now() val date = when (isStudentHasLessonsOnWeekendUseCase(semester)) {
true -> LocalDate.now()
else -> LocalDate.now().nextOrSameSchoolDay
}
timetableRepository.getTimetable( timetableRepository.getTimetable(
student = student, student = student,
semester = semester, semester = semester,
start = date, start = date,
end = date.plusDays(1), end = date.sunday,
forceRefresh = forceRefresh forceRefresh = forceRefresh,
) )
} }
.onEach { .onEach {
@ -448,7 +462,7 @@ class DashboardPresenter @Inject constructor(
if (forceRefresh) return@onEach if (forceRefresh) return@onEach
updateData( updateData(
DashboardItem.Lessons(it.dataOrNull, isLoading = true), DashboardItem.Lessons(it.dataOrNull, isLoading = true),
forceRefresh false
) )
if (!it.dataOrNull?.lessons.isNullOrEmpty()) { if (!it.dataOrNull?.lessons.isNullOrEmpty()) {
@ -505,7 +519,7 @@ class DashboardPresenter @Inject constructor(
val data = it.dataOrNull.orEmpty() val data = it.dataOrNull.orEmpty()
updateData( updateData(
DashboardItem.Homework(data, isLoading = true), DashboardItem.Homework(data, isLoading = true),
forceRefresh false
) )
if (data.isNotEmpty()) { if (data.isNotEmpty()) {
@ -539,7 +553,7 @@ class DashboardPresenter @Inject constructor(
if (forceRefresh) return@onEach if (forceRefresh) return@onEach
updateData( updateData(
DashboardItem.Announcements(it.dataOrNull.orEmpty(), isLoading = true), DashboardItem.Announcements(it.dataOrNull.orEmpty(), isLoading = true),
forceRefresh false
) )
if (!it.dataOrNull.isNullOrEmpty()) { if (!it.dataOrNull.isNullOrEmpty()) {
@ -582,7 +596,7 @@ class DashboardPresenter @Inject constructor(
if (forceRefresh) return@onEach if (forceRefresh) return@onEach
updateData( updateData(
DashboardItem.Exams(it.dataOrNull.orEmpty(), isLoading = true), DashboardItem.Exams(it.dataOrNull.orEmpty(), isLoading = true),
forceRefresh false
) )
if (!it.dataOrNull.isNullOrEmpty()) { if (!it.dataOrNull.isNullOrEmpty()) {
@ -623,7 +637,7 @@ class DashboardPresenter @Inject constructor(
if (forceRefresh) return@onEach if (forceRefresh) return@onEach
updateData( updateData(
DashboardItem.Conferences(it.dataOrNull.orEmpty(), isLoading = true), DashboardItem.Conferences(it.dataOrNull.orEmpty(), isLoading = true),
forceRefresh false
) )
if (!it.dataOrNull.isNullOrEmpty()) { if (!it.dataOrNull.isNullOrEmpty()) {
@ -658,7 +672,7 @@ class DashboardPresenter @Inject constructor(
is Resource.Loading -> { is Resource.Loading -> {
Timber.i("Loading dashboard admin message data started") Timber.i("Loading dashboard admin message data started")
if (forceRefresh) return@onEach if (forceRefresh) return@onEach
updateData(DashboardItem.AdminMessages(), forceRefresh) updateData(DashboardItem.AdminMessages(), false)
} }
is Resource.Success -> { is Resource.Success -> {
@ -688,7 +702,7 @@ class DashboardPresenter @Inject constructor(
private fun loadAds(forceRefresh: Boolean) { private fun loadAds(forceRefresh: Boolean) {
presenterScope.launch { presenterScope.launch {
if (!forceRefresh) { if (!forceRefresh) {
updateData(DashboardItem.Ads(), forceRefresh) updateData(DashboardItem.Ads(), false)
} }
val dashboardAdItem = val dashboardAdItem =
@ -809,6 +823,8 @@ class DashboardPresenter @Inject constructor(
val filteredItems = itemsLoadedList.filterNot { val filteredItems = itemsLoadedList.filterNot {
it.type == DashboardItem.Type.ACCOUNT || it.type == DashboardItem.Type.ADMIN_MESSAGE it.type == DashboardItem.Type.ACCOUNT || it.type == DashboardItem.Type.ADMIN_MESSAGE
} }
val dataLoadedAdminMessageItem =
itemsLoadedList.find { it.type == DashboardItem.Type.ADMIN_MESSAGE && it.isDataLoaded } as DashboardItem.AdminMessages?
val isAccountItemError = val isAccountItemError =
itemsLoadedList.find { it.type == DashboardItem.Type.ACCOUNT }?.error != null itemsLoadedList.find { it.type == DashboardItem.Type.ACCOUNT }?.error != null
val isGeneralError = val isGeneralError =
@ -830,7 +846,7 @@ class DashboardPresenter @Inject constructor(
showRefresh(false) showRefresh(false)
if ((forceRefresh && wasGeneralError) || !forceRefresh) { if ((forceRefresh && wasGeneralError) || !forceRefresh) {
showContent(false) showContent(false)
showErrorView(true) showErrorView(true, dataLoadedAdminMessageItem)
setErrorDetails(lastError) setErrorDetails(lastError)
} }
} }
@ -858,6 +874,28 @@ class DashboardPresenter @Inject constructor(
onEach { onEach {
if (it is Resource.Success) { if (it is Resource.Success) {
cancelJobs(jobName) cancelJobs(jobName)
} else if (it is Resource.Error) {
cancelJobs(jobName)
}
}.launch(jobName)
} else {
launch(jobName)
}
}
@JvmName("launchWithUniqueRefreshJobHorizontalGroup")
private fun Flow<Pair<List<Resource<*>>, *>>.launchWithUniqueRefreshJob(
name: String,
forceRefresh: Boolean
) {
val jobName = if (forceRefresh) "$name-forceRefresh" else name
if (forceRefresh) {
onEach { (resources, _) ->
if (resources.all { it is Resource.Success<*> }) {
cancelJobs(jobName)
} else if (resources.any { it is Resource.Error<*> }) {
cancelJobs(jobName)
} }
}.launch(jobName) }.launch(jobName)
} else { } else {

View File

@ -6,6 +6,8 @@ interface DashboardView : BaseView {
val tileWidth: Int val tileWidth: Int
val isViewEmpty: Boolean
fun initView() fun initView()
fun updateData(data: List<DashboardItem>) fun updateData(data: List<DashboardItem>)
@ -18,7 +20,7 @@ interface DashboardView : BaseView {
fun showRefresh(show: Boolean) fun showRefresh(show: Boolean)
fun showErrorView(show: Boolean) fun showErrorView(show: Boolean, adminMessageItem: DashboardItem.AdminMessages? = null)
fun setErrorDetails(error: Throwable) fun setErrorDetails(error: Throwable)

View File

@ -7,7 +7,6 @@ import androidx.recyclerview.widget.RecyclerView
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.AdminMessage import io.github.wulkanowy.data.db.entities.AdminMessage
import io.github.wulkanowy.databinding.ItemDashboardAdminMessageBinding import io.github.wulkanowy.databinding.ItemDashboardAdminMessageBinding
import io.github.wulkanowy.ui.modules.dashboard.DashboardItem
import io.github.wulkanowy.utils.getThemeAttrColor import io.github.wulkanowy.utils.getThemeAttrColor
class AdminMessageViewHolder( class AdminMessageViewHolder(
@ -25,9 +24,11 @@ class AdminMessageViewHolder(
context.getThemeAttrColor(R.attr.colorMessageHigh) to context.getThemeAttrColor(R.attr.colorMessageHigh) to
context.getThemeAttrColor(R.attr.colorOnMessageHigh) context.getThemeAttrColor(R.attr.colorOnMessageHigh)
} }
"MEDIUM" -> { "MEDIUM" -> {
context.getThemeAttrColor(R.attr.colorMessageMedium) to Color.BLACK context.getThemeAttrColor(R.attr.colorMessageMedium) to Color.BLACK
} }
else -> null to context.getThemeAttrColor(R.attr.colorOnSurface) else -> null to context.getThemeAttrColor(R.attr.colorOnSurface)
} }
@ -37,11 +38,16 @@ class AdminMessageViewHolder(
dashboardAdminMessageItemDescription.text = item.content dashboardAdminMessageItemDescription.text = item.content
dashboardAdminMessageItemDescription.setTextColor(textColor) dashboardAdminMessageItemDescription.setTextColor(textColor)
dashboardAdminMessageItemIcon.setColorFilter(textColor) dashboardAdminMessageItemIcon.setColorFilter(textColor)
dashboardAdminMessageItemDismiss.isVisible = item.isDismissible dashboardAdminMessageItemDismiss.isVisible = item.isOkVisible
dashboardAdminMessageItemClose.isVisible = item.isXVisible
dashboardAdminMessageItemDismiss.setTextColor(textColor) dashboardAdminMessageItemDismiss.setTextColor(textColor)
dashboardAdminMessageItemClose.imageTintList = ColorStateList.valueOf(textColor)
dashboardAdminMessageItemDismiss.setOnClickListener { dashboardAdminMessageItemDismiss.setOnClickListener {
onAdminMessageDismissClickListener(item) onAdminMessageDismissClickListener(item)
} }
dashboardAdminMessageItemClose.setOnClickListener {
onAdminMessageDismissClickListener(item)
}
root.setCardBackgroundColor(backgroundColor?.let { ColorStateList.valueOf(it) }) root.setCardBackgroundColor(backgroundColor?.let { ColorStateList.valueOf(it) })
item.destinationUrl?.let { url -> item.destinationUrl?.let { url ->

View File

@ -2,6 +2,7 @@ package io.github.wulkanowy.ui.modules.debug
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.webkit.CookieManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
@ -58,6 +59,10 @@ class DebugFragment : BaseFragment<FragmentDebugBinding>(R.layout.fragment_debug
(activity as? MainActivity)?.pushView(NotificationDebugFragment.newInstance()) (activity as? MainActivity)?.pushView(NotificationDebugFragment.newInstance())
} }
override fun clearWebkitCookies() {
CookieManager.getInstance().removeAllCookies(null)
}
override fun onDestroyView() { override fun onDestroyView() {
presenter.onDetachView() presenter.onDetachView()
super.onDestroyView() super.onDestroyView()

View File

@ -15,6 +15,7 @@ class DebugPresenter @Inject constructor(
val items = listOf( val items = listOf(
DebugItem(R.string.logviewer_title), DebugItem(R.string.logviewer_title),
DebugItem(R.string.notification_debug_title), DebugItem(R.string.notification_debug_title),
DebugItem(R.string.debug_cookies_clear),
) )
override fun onAttachView(view: DebugView) { override fun onAttachView(view: DebugView) {
@ -31,6 +32,7 @@ class DebugPresenter @Inject constructor(
when (item.title) { when (item.title) {
R.string.logviewer_title -> view?.openLogViewer() R.string.logviewer_title -> view?.openLogViewer()
R.string.notification_debug_title -> view?.openNotificationsDebug() R.string.notification_debug_title -> view?.openNotificationsDebug()
R.string.debug_cookies_clear -> view?.clearWebkitCookies()
else -> Timber.d("Unknown debug item: $item") else -> Timber.d("Unknown debug item: $item")
} }
} }

View File

@ -11,4 +11,6 @@ interface DebugView : BaseView {
fun openLogViewer() fun openLogViewer()
fun openNotificationsDebug() fun openNotificationsDebug()
fun clearWebkitCookies()
} }

View File

@ -18,6 +18,7 @@ import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugAttendanceItems import io.github.wulkanowy.ui.modules.debug.notification.mock.debugAttendanceItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugConferenceItems import io.github.wulkanowy.ui.modules.debug.notification.mock.debugConferenceItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugExamItems import io.github.wulkanowy.ui.modules.debug.notification.mock.debugExamItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugGradeDescriptiveItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugGradeDetailsItems import io.github.wulkanowy.ui.modules.debug.notification.mock.debugGradeDetailsItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugGradeSummaryItems import io.github.wulkanowy.ui.modules.debug.notification.mock.debugGradeSummaryItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugHomeworkItems import io.github.wulkanowy.ui.modules.debug.notification.mock.debugHomeworkItems
@ -55,6 +56,14 @@ class NotificationDebugPresenter @Inject constructor(
NotificationDebugItem(R.string.grade_summary_final_grade) { n -> NotificationDebugItem(R.string.grade_summary_final_grade) { n ->
withStudent { newGradeNotification.notifyFinal(debugGradeSummaryItems.take(n), it) } withStudent { newGradeNotification.notifyFinal(debugGradeSummaryItems.take(n), it) }
}, },
NotificationDebugItem(R.string.grade_summary_descriptive) { n ->
withStudent {
newGradeNotification.notifyDescriptive(
debugGradeDescriptiveItems.take(n),
it
)
}
},
NotificationDebugItem(R.string.homework_title) { n -> NotificationDebugItem(R.string.homework_title) { n ->
withStudent { newHomeworkNotification.notify(debugHomeworkItems.take(n), it) } withStudent { newHomeworkNotification.notify(debugHomeworkItems.take(n), it) }
}, },

View File

@ -0,0 +1,48 @@
package io.github.wulkanowy.ui.modules.debug.notification.mock
import io.github.wulkanowy.data.db.entities.GradeDescriptive
val debugGradeDescriptiveItems = listOf(
generateGradeDescriptive(
"Matematyka",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
),
generateGradeDescriptive("Fizyka", "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
generateGradeDescriptive(
"Geografia",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
),
generateGradeDescriptive(
"Sieci komputerowe",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
),
generateGradeDescriptive(
"Systemy operacyjne",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
),
generateGradeDescriptive(
"Język polski",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
),
generateGradeDescriptive(
"Język angielski",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
),
generateGradeDescriptive("Religia", "Lorem ipsum dolor sit amet, consectetur adipiscing elit."),
generateGradeDescriptive(
"Język niemiecki",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
),
generateGradeDescriptive(
"Wychowanie fizyczne",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit."
),
)
private fun generateGradeDescriptive(subject: String, description: String) =
GradeDescriptive(
semesterId = 0,
studentId = 0,
subject = subject,
description = description
)

View File

@ -1,15 +1,23 @@
package io.github.wulkanowy.ui.modules.grade package io.github.wulkanowy.ui.modules.grade
import io.github.wulkanowy.data.* import io.github.wulkanowy.data.Resource
import io.github.wulkanowy.data.dataOrNull
import io.github.wulkanowy.data.db.entities.Grade import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeDescriptive
import io.github.wulkanowy.data.db.entities.GradeSummary import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.errorOrNull
import io.github.wulkanowy.data.flatResourceFlow
import io.github.wulkanowy.data.mapData
import io.github.wulkanowy.data.mapResourceData
import io.github.wulkanowy.data.repositories.GradeRepository import io.github.wulkanowy.data.repositories.GradeRepository
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.SemesterRepository import io.github.wulkanowy.data.repositories.SemesterRepository
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.ui.modules.grade.GradeAverageMode.* import io.github.wulkanowy.ui.modules.grade.GradeAverageMode.ALL_YEAR
import io.github.wulkanowy.ui.modules.grade.GradeAverageMode.BOTH_SEMESTERS
import io.github.wulkanowy.ui.modules.grade.GradeAverageMode.ONE_SEMESTER
import io.github.wulkanowy.utils.calcAverage import io.github.wulkanowy.utils.calcAverage
import io.github.wulkanowy.utils.changeModifier import io.github.wulkanowy.utils.changeModifier
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -62,6 +70,7 @@ class GradeAverageProvider @Inject constructor(
forceRefresh = forceRefresh, forceRefresh = forceRefresh,
params = params, params = params,
) )
BOTH_SEMESTERS -> calculateCombinedAverage( BOTH_SEMESTERS -> calculateCombinedAverage(
student = student, student = student,
semesters = semesters, semesters = semesters,
@ -69,6 +78,7 @@ class GradeAverageProvider @Inject constructor(
forceRefresh = forceRefresh, forceRefresh = forceRefresh,
config = params, config = params,
) )
ALL_YEAR -> calculateCombinedAverage( ALL_YEAR -> calculateCombinedAverage(
student = student, student = student,
semesters = semesters, semesters = semesters,
@ -189,36 +199,73 @@ class GradeAverageProvider @Inject constructor(
): Flow<Resource<List<GradeSubject>>> { ): Flow<Resource<List<GradeSubject>>> {
return gradeRepository.getGrades(student, semester, forceRefresh = forceRefresh) return gradeRepository.getGrades(student, semester, forceRefresh = forceRefresh)
.mapResourceData { res -> .mapResourceData { res ->
val (details, summaries) = res val (details, summaries, descriptives) = res
val isAnyAverage = summaries.any { it.average != .0 } val isAnyAverage = summaries.any { it.average != .0 }
val allGrades = details.groupBy { it.subject } val allGrades = details.groupBy { it.subject }
val descriptiveGradesBySubject = descriptives.associateBy { it.subject }
val items = summaries.emulateEmptySummaries( val items = summaries
student = student, .createEmptySummariesByGradesIfNeeded(
semester = semester, student = student,
grades = allGrades.toList(), semester = semester,
calcAverage = isAnyAverage, grades = allGrades.toList(),
params = params, calcAverage = isAnyAverage,
).map { summary -> params = params,
val grades = allGrades[summary.subject].orEmpty()
GradeSubject(
subject = summary.subject,
average = if (!isAnyAverage || params.forceAverageCalc) {
grades.updateModifiers(student, params)
.calcAverage(params.isOptionalArithmeticAverage)
} else summary.average,
points = summary.pointsSum,
summary = summary,
grades = grades,
isVulcanAverage = isAnyAverage
) )
} .createEmptySummariesByDescriptiveGradesIfNeeded(
student = student,
semester = semester,
descriptives = descriptives,
)
.map { summary ->
val grades = allGrades[summary.subject].orEmpty()
val descriptiveGrade = descriptiveGradesBySubject[summary.subject]
GradeSubject(
subject = summary.subject,
average = if (!isAnyAverage || params.forceAverageCalc) {
grades.updateModifiers(student, params)
.calcAverage(params.isOptionalArithmeticAverage)
} else summary.average,
points = summary.pointsSum,
summary = summary,
grades = grades,
descriptive = descriptiveGrade,
isVulcanAverage = isAnyAverage
)
}
items items
} }
} }
private fun List<GradeSummary>.emulateEmptySummaries( private fun List<GradeSummary>.createEmptySummariesByDescriptiveGradesIfNeeded(
student: Student,
semester: Semester,
descriptives: List<GradeDescriptive>
): List<GradeSummary> {
val summarySubjects = this.map { it.subject }
val gradeSummaryToAdd = descriptives.mapNotNull { gradeDescriptive ->
if (gradeDescriptive.subject in summarySubjects) return@mapNotNull null
GradeSummary(
studentId = student.studentId,
semesterId = semester.semesterId,
position = 0,
subject = gradeDescriptive.subject,
predictedGrade = "",
finalGrade = "",
proposedPoints = "",
finalPoints = "",
pointsSum = "",
average = .0
)
}
return this + gradeSummaryToAdd
}
private fun List<GradeSummary>.createEmptySummariesByGradesIfNeeded(
student: Student, student: Student,
semester: Semester, semester: Semester,
grades: List<Pair<String, List<Grade>>>, grades: List<Pair<String, List<Grade>>>,

View File

@ -1,6 +1,7 @@
package io.github.wulkanowy.ui.modules.grade package io.github.wulkanowy.ui.modules.grade
import io.github.wulkanowy.data.db.entities.Grade import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeDescriptive
import io.github.wulkanowy.data.db.entities.GradeSummary import io.github.wulkanowy.data.db.entities.GradeSummary
data class GradeSubject( data class GradeSubject(
@ -8,6 +9,7 @@ data class GradeSubject(
val average: Double, val average: Double,
val points: String, val points: String,
val summary: GradeSummary, val summary: GradeSummary,
val descriptive: GradeDescriptive?,
val grades: List<Grade>, val grades: List<Grade>,
val isVulcanAverage: Boolean val isVulcanAverage: Boolean
) )

View File

@ -1,13 +1,22 @@
package io.github.wulkanowy.ui.modules.grade.details package io.github.wulkanowy.ui.modules.grade.details
import io.github.wulkanowy.data.*
import io.github.wulkanowy.data.db.entities.Grade import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.enums.GradeExpandMode import io.github.wulkanowy.data.enums.GradeExpandMode
import io.github.wulkanowy.data.enums.GradeSortingMode.* import io.github.wulkanowy.data.enums.GradeSortingMode.ALPHABETIC
import io.github.wulkanowy.data.enums.GradeSortingMode.AVERAGE
import io.github.wulkanowy.data.enums.GradeSortingMode.DATE
import io.github.wulkanowy.data.flatResourceFlow
import io.github.wulkanowy.data.logResourceStatus
import io.github.wulkanowy.data.onResourceData
import io.github.wulkanowy.data.onResourceError
import io.github.wulkanowy.data.onResourceIntermediate
import io.github.wulkanowy.data.onResourceNotLoading
import io.github.wulkanowy.data.onResourceSuccess
import io.github.wulkanowy.data.repositories.GradeRepository import io.github.wulkanowy.data.repositories.GradeRepository
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.SemesterRepository import io.github.wulkanowy.data.repositories.SemesterRepository
import io.github.wulkanowy.data.repositories.StudentRepository 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.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.ui.modules.grade.GradeAverageProvider import io.github.wulkanowy.ui.modules.grade.GradeAverageProvider
@ -207,20 +216,20 @@ class GradeDetailsPresenter @Inject constructor(
AVERAGE -> gradeSubjects.sortedByDescending { it.average } AVERAGE -> gradeSubjects.sortedByDescending { it.average }
} }
} }
.map { (subject, average, points, _, grades) -> .map { gradeSubject ->
val subItems = grades val subItems = gradeSubject.grades
.sortedByDescending { it.date } .sortedByDescending { it.date }
.map { GradeDetailsItem(it, ViewType.ITEM) } .map { GradeDetailsItem(it, ViewType.ITEM) }
val gradeDetailsItems = listOf( val gradeDetailsItems = listOf(
GradeDetailsItem( GradeDetailsItem(
GradeDetailsHeader( GradeDetailsHeader(
subject = subject, subject = gradeSubject.subject,
average = average, average = gradeSubject.average,
pointsSum = points, pointsSum = gradeSubject.points,
grades = subItems grades = subItems
).apply { ).apply {
newGrades = grades.filter { grade -> !grade.isRead }.size newGrades = gradeSubject.grades.filter { grade -> !grade.isRead }.size
}, ViewType.HEADER }, ViewType.HEADER
) )
) )

View File

@ -2,16 +2,16 @@ package io.github.wulkanowy.ui.modules.grade.summary
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.databinding.ItemGradeSummaryBinding import io.github.wulkanowy.databinding.ItemGradeSummaryBinding
import io.github.wulkanowy.databinding.ScrollableHeaderGradeSummaryBinding import io.github.wulkanowy.databinding.ScrollableHeaderGradeSummaryBinding
import io.github.wulkanowy.sdk.scrapper.grades.isGradeValid import io.github.wulkanowy.sdk.scrapper.grades.isGradeValid
import io.github.wulkanowy.utils.calcFinalAverage import io.github.wulkanowy.utils.calcFinalAverage
import io.github.wulkanowy.utils.ifNullOrBlank
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@ -24,7 +24,7 @@ class GradeSummaryAdapter @Inject constructor(
ITEM(2) ITEM(2)
} }
var items = emptyList<GradeSummary>() var items = emptyList<GradeSummaryItem>()
var onCalculatedHelpClickListener: () -> Unit = {} var onCalculatedHelpClickListener: () -> Unit = {}
@ -44,9 +44,11 @@ class GradeSummaryAdapter @Inject constructor(
ViewType.HEADER.id -> HeaderViewHolder( ViewType.HEADER.id -> HeaderViewHolder(
ScrollableHeaderGradeSummaryBinding.inflate(inflater, parent, false) ScrollableHeaderGradeSummaryBinding.inflate(inflater, parent, false)
) )
ViewType.ITEM.id -> ItemViewHolder( ViewType.ITEM.id -> ItemViewHolder(
ItemGradeSummaryBinding.inflate(inflater, parent, false) ItemGradeSummaryBinding.inflate(inflater, parent, false)
) )
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
} }
@ -60,19 +62,23 @@ class GradeSummaryAdapter @Inject constructor(
private fun bindHeaderViewHolder(binding: ScrollableHeaderGradeSummaryBinding) { private fun bindHeaderViewHolder(binding: ScrollableHeaderGradeSummaryBinding) {
if (items.isEmpty()) return if (items.isEmpty()) return
val gradeSummaries = items
.filter { it.gradeDescriptive == null }
.map { it.gradeSummary }
val context = binding.root.context val context = binding.root.context
val finalItemsCount = items.count { isGradeValid(it.finalGrade) } val finalItemsCount = gradeSummaries.count { isGradeValid(it.finalGrade) }
val calculatedItemsCount = items.count { value -> value.average != 0.0 } val calculatedItemsCount = gradeSummaries.count { value -> value.average != 0.0 }
val allItemsCount = items.count { !it.subject.equals("zachowanie", true) } val allItemsCount = gradeSummaries.count { !it.subject.equals("zachowanie", true) }
val finalAverage = items.calcFinalAverage( val finalAverage = gradeSummaries.calcFinalAverage(
preferencesRepository.gradePlusModifier, preferencesRepository.gradePlusModifier,
preferencesRepository.gradeMinusModifier preferencesRepository.gradeMinusModifier
) )
val calculatedAverage = items.filter { value -> value.average != 0.0 } val calculatedAverage = gradeSummaries.filter { value -> value.average != 0.0 }
.map { values -> values.average } .map { values -> values.average }
.reversed() // fix average precision .reversed() // fix average precision
.average() .average()
.let { if (it.isNaN()) 0.0 else it }
with(binding) { with(binding) {
gradeSummaryScrollableHeaderFinal.text = formatAverage(finalAverage) gradeSummaryScrollableHeaderFinal.text = formatAverage(finalAverage)
@ -95,16 +101,28 @@ class GradeSummaryAdapter @Inject constructor(
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
private fun bindItemViewHolder(binding: ItemGradeSummaryBinding, item: GradeSummary) { private fun bindItemViewHolder(binding: ItemGradeSummaryBinding, item: GradeSummaryItem) {
with(binding) { val (gradeSummary, gradeDescriptive) = item
gradeSummaryItemTitle.text = item.subject
gradeSummaryItemPoints.text = item.pointsSum
gradeSummaryItemAverage.text = formatAverage(item.average, "")
gradeSummaryItemPredicted.text = "${item.predictedGrade} ${item.proposedPoints}".trim()
gradeSummaryItemFinal.text = "${item.finalGrade} ${item.finalPoints}".trim()
gradeSummaryItemPointsContainer.visibility = with(binding) {
if (item.pointsSum.isBlank()) View.GONE else View.VISIBLE gradeSummaryItemTitle.text = gradeSummary.subject
gradeSummaryItemPoints.text = gradeSummary.pointsSum
gradeSummaryItemAverage.text = formatAverage(gradeSummary.average, "")
gradeSummaryItemPredicted.text =
"${gradeSummary.predictedGrade} ${gradeSummary.proposedPoints}".trim()
gradeSummaryItemFinal.text =
"${gradeSummary.finalGrade} ${gradeSummary.finalPoints}".trim()
gradeSummaryItemDescriptive.text = gradeDescriptive?.description.ifNullOrBlank {
root.context.getString(R.string.all_no_data)
}
gradeSummaryItemFinalDivider.isVisible = gradeDescriptive == null
gradeSummaryItemPredictedDivider.isVisible = gradeDescriptive == null
gradeSummaryItemPointsDivider.isVisible = gradeDescriptive == null
gradeSummaryItemPredictedContainer.isVisible = gradeDescriptive == null
gradeSummaryItemFinalContainer.isVisible = gradeDescriptive == null
gradeSummaryItemDescriptiveContainer.isVisible = gradeDescriptive != null
gradeSummaryItemPointsContainer.isVisible = gradeSummary.pointsSum.isNotBlank()
} }
} }

View File

@ -5,12 +5,10 @@ import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.databinding.FragmentGradeSummaryBinding import io.github.wulkanowy.databinding.FragmentGradeSummaryBinding
import io.github.wulkanowy.ui.base.BaseFragment import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.grade.GradeFragment import io.github.wulkanowy.ui.modules.grade.GradeFragment
@ -72,7 +70,7 @@ class GradeSummaryFragment :
} }
} }
override fun updateData(data: List<GradeSummary>) { override fun updateData(data: List<GradeSummaryItem>) {
with(gradeSummaryAdapter) { with(gradeSummaryAdapter) {
items = data items = data
notifyDataSetChanged() notifyDataSetChanged()

View File

@ -0,0 +1,9 @@
package io.github.wulkanowy.ui.modules.grade.summary
import io.github.wulkanowy.data.db.entities.GradeDescriptive
import io.github.wulkanowy.data.db.entities.GradeSummary
data class GradeSummaryItem(
val gradeSummary: GradeSummary,
val gradeDescriptive: GradeDescriptive?
)

View File

@ -1,9 +1,16 @@
package io.github.wulkanowy.ui.modules.grade.summary package io.github.wulkanowy.ui.modules.grade.summary
import io.github.wulkanowy.data.* import io.github.wulkanowy.data.enums.GradeSortingMode.ALPHABETIC
import io.github.wulkanowy.data.db.entities.GradeSummary import io.github.wulkanowy.data.enums.GradeSortingMode.AVERAGE
import io.github.wulkanowy.data.enums.GradeSortingMode import io.github.wulkanowy.data.enums.GradeSortingMode.DATE
import io.github.wulkanowy.data.enums.GradeSortingMode.* 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.onResourceNotLoading
import io.github.wulkanowy.data.onResourceSuccess
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.StudentRepository import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.ui.base.BasePresenter import io.github.wulkanowy.ui.base.BasePresenter
@ -128,7 +135,7 @@ class GradeSummaryPresenter @Inject constructor(
view?.showFinalAverageHelpDialog() view?.showFinalAverageHelpDialog()
} }
private fun createGradeSummaryItems(items: List<GradeSubject>): List<GradeSummary> { private fun createGradeSummaryItems(items: List<GradeSubject>): List<GradeSummaryItem> {
return items return items
.filter { !checkEmpty(it) } .filter { !checkEmpty(it) }
.let { gradeSubjects -> .let { gradeSubjects ->
@ -136,21 +143,32 @@ class GradeSummaryPresenter @Inject constructor(
DATE -> gradeSubjects.sortedByDescending { gradeDetailsWithAverage -> DATE -> gradeSubjects.sortedByDescending { gradeDetailsWithAverage ->
gradeDetailsWithAverage.grades.maxByOrNull { it.date }?.date gradeDetailsWithAverage.grades.maxByOrNull { it.date }?.date
} }
ALPHABETIC -> gradeSubjects.sortedBy { gradeDetailsWithAverage -> ALPHABETIC -> gradeSubjects.sortedBy { gradeDetailsWithAverage ->
gradeDetailsWithAverage.subject.lowercase() gradeDetailsWithAverage.subject.lowercase()
} }
AVERAGE -> gradeSubjects.sortedByDescending { it.average } AVERAGE -> gradeSubjects.sortedByDescending { it.average }
} }
} }
.map { it.summary.copy(average = it.average) } .map {
val gradeSummary = it.summary.copy(average = it.average)
val descriptive = it.descriptive
GradeSummaryItem(
gradeSummary = gradeSummary,
gradeDescriptive = descriptive,
)
}
} }
private fun checkEmpty(gradeSummary: GradeSubject): Boolean { private fun checkEmpty(gradeSummary: GradeSubject): Boolean {
return gradeSummary.run { return gradeSummary.run {
summary.finalGrade.isBlank() summary.finalGrade.isBlank()
&& summary.predictedGrade.isBlank() && summary.predictedGrade.isBlank()
&& average == .0 && average == .0
&& points.isBlank() && points.isBlank()
&& descriptive == null
} }
} }
} }

View File

@ -1,6 +1,5 @@
package io.github.wulkanowy.ui.modules.grade.summary package io.github.wulkanowy.ui.modules.grade.summary
import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.ui.base.BaseView import io.github.wulkanowy.ui.base.BaseView
interface GradeSummaryView : BaseView { interface GradeSummaryView : BaseView {
@ -13,7 +12,7 @@ interface GradeSummaryView : BaseView {
fun initView() fun initView()
fun updateData(data: List<GradeSummary>) fun updateData(data: List<GradeSummaryItem>)
fun resetView() fun resetView()

View File

@ -7,5 +7,6 @@ data class LoginData(
val password: String, val password: String,
val baseUrl: String, val baseUrl: String,
val domainSuffix: String, val domainSuffix: String,
val symbol: String?, val defaultSymbol: String,
val userEnteredSymbol: String? = null,
) : Serializable ) : Serializable

View File

@ -71,7 +71,7 @@ class LoginAdvancedPresenter @Inject constructor(
fun updateUsernameLabel() { fun updateUsernameLabel() {
view?.apply { view?.apply {
setUsernameLabel(if ("vulcan" in formHostValue || "fakelog" in formHostValue) emailLabel else nicknameLabel) setUsernameLabel(if ("vulcan" in formHostValue || "wulkanowy" in formHostValue) emailLabel else nicknameLabel)
} }
} }
@ -79,7 +79,7 @@ class LoginAdvancedPresenter @Inject constructor(
view?.apply { view?.apply {
clearPassError() clearPassError()
clearUsernameError() clearUsernameError()
if (formHostValue.contains("fakelog")) { if (formHostValue.contains("wulkanowy")) {
setDefaultCredentials( setDefaultCredentials(
"jan@fakelog.cf", "jan123", "powiatwulkanowy", "FK100000", "999999" "jan@fakelog.cf", "jan123", "powiatwulkanowy", "FK100000", "999999"
) )
@ -155,7 +155,7 @@ class LoginAdvancedPresenter @Inject constructor(
password = view?.formPassValue.orEmpty().trim(), password = view?.formPassValue.orEmpty().trim(),
baseUrl = view?.formHostValue.orEmpty().trim(), baseUrl = view?.formHostValue.orEmpty().trim(),
domainSuffix = view?.formDomainSuffix.orEmpty().trim(), domainSuffix = view?.formDomainSuffix.orEmpty().trim(),
symbol = view?.formSymbolValue.orEmpty().trim().getNormalizedSymbol(), defaultSymbol = view?.formSymbolValue.orEmpty().trim().getNormalizedSymbol(),
) )
when (it.data.symbols.size) { when (it.data.symbols.size) {
0 -> view?.navigateToSymbol(loginData) 0 -> view?.navigateToSymbol(loginData)

View File

@ -7,6 +7,7 @@ import android.view.View.GONE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.setFragmentResultListener
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.AdminMessage import io.github.wulkanowy.data.db.entities.AdminMessage
@ -14,6 +15,7 @@ import io.github.wulkanowy.data.pojos.RegisterUser
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.databinding.FragmentLoginFormBinding import io.github.wulkanowy.databinding.FragmentLoginFormBinding
import io.github.wulkanowy.ui.base.BaseFragment import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.captcha.CaptchaDialog
import io.github.wulkanowy.ui.modules.dashboard.viewholders.AdminMessageViewHolder import io.github.wulkanowy.ui.modules.dashboard.viewholders.AdminMessageViewHolder
import io.github.wulkanowy.ui.modules.login.LoginActivity import io.github.wulkanowy.ui.modules.login.LoginActivity
import io.github.wulkanowy.ui.modules.login.LoginData import io.github.wulkanowy.ui.modules.login.LoginData
@ -72,6 +74,13 @@ class LoginFormFragment : BaseFragment<FragmentLoginFormBinding>(R.layout.fragme
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentLoginFormBinding.bind(view) binding = FragmentLoginFormBinding.bind(view)
presenter.onAttachView(this) presenter.onAttachView(this)
initializeCaptchaResultObserver()
}
private fun initializeCaptchaResultObserver() {
setFragmentResultListener(CaptchaDialog.CAPTCHA_SUCCESS) { _, _ ->
presenter.onRetryAfterCaptcha()
}
} }
override fun initView() { override fun initView() {
@ -85,6 +94,7 @@ class LoginFormFragment : BaseFragment<FragmentLoginFormBinding>(R.layout.fragme
loginFormUsername.doOnTextChanged { _, _, _, _ -> presenter.onUsernameTextChanged() } loginFormUsername.doOnTextChanged { _, _, _, _ -> presenter.onUsernameTextChanged() }
loginFormPass.doOnTextChanged { _, _, _, _ -> presenter.onPassTextChanged() } loginFormPass.doOnTextChanged { _, _, _, _ -> presenter.onPassTextChanged() }
loginFormHost.setOnItemClickListener { _, _, _, _ -> presenter.onHostSelected() } loginFormHost.setOnItemClickListener { _, _, _, _ -> presenter.onHostSelected() }
loginFormDomainSuffix.doOnTextChanged { _, _, _, _ -> presenter.onDomainSuffixChanged() }
loginFormSignIn.setOnClickListener { presenter.onSignInClick() } loginFormSignIn.setOnClickListener { presenter.onSignInClick() }
loginFormAdvancedButton.setOnClickListener { presenter.onAdvancedLoginClick() } loginFormAdvancedButton.setOnClickListener { presenter.onAdvancedLoginClick() }
loginFormPrivacyLink.setOnClickListener { presenter.onPrivacyLinkClick() } loginFormPrivacyLink.setOnClickListener { presenter.onPrivacyLinkClick() }
@ -179,6 +189,12 @@ class LoginFormFragment : BaseFragment<FragmentLoginFormBinding>(R.layout.fragme
} }
} }
override fun setDomainSuffixInvalid() {
with(binding.loginFormDomainSuffixLayout) {
error = getString(R.string.login_invalid_domain_suffix)
}
}
override fun clearUsernameError() { override fun clearUsernameError() {
binding.loginFormUsernameLayout.error = null binding.loginFormUsernameLayout.error = null
binding.loginFormErrorBox.isVisible = false binding.loginFormErrorBox.isVisible = false
@ -197,6 +213,10 @@ class LoginFormFragment : BaseFragment<FragmentLoginFormBinding>(R.layout.fragme
binding.loginFormErrorBox.isVisible = false binding.loginFormErrorBox.isVisible = false
} }
override fun clearDomainSuffixError() {
binding.loginFormDomainSuffixLayout.error = null
}
override fun showSoftKeyboard() { override fun showSoftKeyboard() {
activity?.showSoftInput() activity?.showSoftInput()
} }

View File

@ -14,6 +14,7 @@ import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.StudentRepository import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.data.resourceFlow import io.github.wulkanowy.data.resourceFlow
import io.github.wulkanowy.domain.adminmessage.GetAppropriateAdminMessageUseCase import io.github.wulkanowy.domain.adminmessage.GetAppropriateAdminMessageUseCase
import io.github.wulkanowy.sdk.scrapper.login.InvalidSymbolException
import io.github.wulkanowy.ui.base.BasePresenter import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.modules.login.LoginData import io.github.wulkanowy.ui.modules.login.LoginData
import io.github.wulkanowy.ui.modules.login.LoginErrorHandler import io.github.wulkanowy.ui.modules.login.LoginErrorHandler
@ -90,7 +91,7 @@ class LoginFormPresenter @Inject constructor(
clearPassError() clearPassError()
clearUsernameError() clearUsernameError()
clearHostError() clearHostError()
if (formHostValue.contains("fakelog")) { if (formHostValue.contains("wulkanowy")) {
setCredentials("jan@fakelog.cf", "jan123") setCredentials("jan@fakelog.cf", "jan123")
} else if (formUsernameValue == "jan@fakelog.cf" && formPassValue == "jan123") { } else if (formUsernameValue == "jan@fakelog.cf" && formPassValue == "jan123") {
setCredentials("", "") setCredentials("", "")
@ -101,6 +102,12 @@ class LoginFormPresenter @Inject constructor(
} }
} }
fun onDomainSuffixChanged() {
view?.apply {
clearDomainSuffixError()
}
}
fun updateCustomDomainSuffixVisibility() { fun updateCustomDomainSuffixVisibility() {
view?.run { view?.run {
showDomainSuffixInput("customSuffix" in formHostValue) showDomainSuffixInput("customSuffix" in formHostValue)
@ -148,14 +155,18 @@ class LoginFormPresenter @Inject constructor(
password = password, password = password,
baseUrl = host, baseUrl = host,
domainSuffix = domainSuffix, domainSuffix = domainSuffix,
symbol = symbol defaultSymbol = symbol
) )
} }
fun onRetryAfterCaptcha() {
onSignInClick()
}
fun onSignInClick() { fun onSignInClick() {
val loginData = getLoginData() val loginData = getLoginData()
if (!validateCredentials(loginData.login, loginData.password, loginData.baseUrl)) return if (!validateCredentials(loginData)) return
resourceFlow { resourceFlow {
studentRepository.getUserSubjectsFromScrapper( studentRepository.getUserSubjectsFromScrapper(
@ -163,7 +174,7 @@ class LoginFormPresenter @Inject constructor(
password = loginData.password, password = loginData.password,
scrapperBaseUrl = loginData.baseUrl, scrapperBaseUrl = loginData.baseUrl,
domainSuffix = loginData.domainSuffix, domainSuffix = loginData.domainSuffix,
symbol = loginData.symbol.orEmpty(), symbol = loginData.defaultSymbol,
) )
} }
.logResourceStatus("login") .logResourceStatus("login")
@ -194,6 +205,9 @@ class LoginFormPresenter @Inject constructor(
} }
.onResourceError { .onResourceError {
loginErrorHandler.dispatch(it) loginErrorHandler.dispatch(it)
if (it is InvalidSymbolException) {
loginErrorHandler.showDefaultMessage(it)
}
lastError = it lastError = it
view?.showContact(true) view?.showContact(true)
analytics.logEvent( analytics.logEvent(
@ -225,24 +239,29 @@ class LoginFormPresenter @Inject constructor(
view?.onRecoverClick() view?.onRecoverClick()
} }
private fun validateCredentials(login: String, password: String, host: String): Boolean { private fun validateCredentials(loginData: LoginData): Boolean {
var isCorrect = true var isCorrect = true
if (login.isEmpty()) { if (loginData.login.isEmpty()) {
view?.setErrorUsernameRequired() view?.setErrorUsernameRequired()
isCorrect = false isCorrect = false
} else { } else {
if ("@" in login && "login" in host) { if ("@" in loginData.login && "login" in loginData.baseUrl) {
view?.setErrorLoginRequired() view?.setErrorLoginRequired()
isCorrect = false isCorrect = false
} }
if ("@" !in login && "email" in host) { if ("@" !in loginData.login && "email" in loginData.baseUrl) {
view?.setErrorEmailRequired() view?.setErrorEmailRequired()
isCorrect = false isCorrect = false
} }
if ("@" in login && "||" !in login && "login" !in host && "email" !in host) {
val emailHost = login.substringAfter("@") val isEmailLogin = "@" in loginData.login
val emailDomain = URL(host).host val isEmailWithLogin = "||" !in loginData.login
val isLoginNotRequired = "login" !in loginData.baseUrl
val isEmailNotRequired = "email" !in loginData.baseUrl
if (isEmailLogin && isEmailWithLogin && isLoginNotRequired && isEmailNotRequired) {
val emailHost = loginData.login.substringAfter("@")
val emailDomain = URL(loginData.baseUrl).host
if (!emailHost.equals(emailDomain, true)) { if (!emailHost.equals(emailDomain, true)) {
view?.setErrorEmailInvalid(domain = emailDomain) view?.setErrorEmailInvalid(domain = emailDomain)
isCorrect = false isCorrect = false
@ -250,16 +269,21 @@ class LoginFormPresenter @Inject constructor(
} }
} }
if (password.isEmpty()) { if (loginData.password.isEmpty()) {
view?.setErrorPassRequired(focus = isCorrect) view?.setErrorPassRequired(focus = isCorrect)
isCorrect = false isCorrect = false
} }
if (password.length < 6 && password.isNotEmpty()) { if (loginData.password.length < 6 && loginData.password.isNotEmpty()) {
view?.setErrorPassInvalid(focus = isCorrect) view?.setErrorPassInvalid(focus = isCorrect)
isCorrect = false isCorrect = false
} }
if (loginData.domainSuffix !in listOf("", "rc", "kurs")) {
view?.setDomainSuffixInvalid()
isCorrect = false
}
return isCorrect return isCorrect
} }
} }

View File

@ -46,12 +46,16 @@ interface LoginFormView : BaseView {
fun setErrorEmailInvalid(domain: String) fun setErrorEmailInvalid(domain: String)
fun setDomainSuffixInvalid()
fun clearUsernameError() fun clearUsernameError()
fun clearPassError() fun clearPassError()
fun clearHostError() fun clearHostError()
fun clearDomainSuffixError()
fun showSoftKeyboard() fun showSoftKeyboard()
fun hideSoftKeyboard() fun hideSoftKeyboard()

View File

@ -38,7 +38,7 @@ class LoginRecoverPresenter @Inject constructor(
fun onHostSelected() { fun onHostSelected() {
view?.run { view?.run {
if ("fakelog" in recoverHostValue) setDefaultCredentials("jan@fakelog.cf") if ("wulkanowy" in recoverHostValue) setDefaultCredentials("jan@fakelog.cf")
clearUsernameError() clearUsernameError()
updateFields() updateFields()
} }
@ -60,7 +60,7 @@ class LoginRecoverPresenter @Inject constructor(
resourceFlow { resourceFlow {
recoverRepository.getReCaptchaSiteKey( recoverRepository.getReCaptchaSiteKey(
host, host,
symbol.ifBlank { "Default" }) symbol.ifBlank { "default" })
}.onEach { }.onEach {
when (it) { when (it) {
is Resource.Loading -> view?.run { is Resource.Loading -> view?.run {
@ -103,7 +103,7 @@ class LoginRecoverPresenter @Inject constructor(
fun onReCaptchaVerified(reCaptchaResponse: String) { fun onReCaptchaVerified(reCaptchaResponse: String) {
val username = view?.recoverNameValue.orEmpty() val username = view?.recoverNameValue.orEmpty()
val host = view?.recoverHostValue.orEmpty() val host = view?.recoverHostValue.orEmpty()
val symbol = view?.formHostSymbol.ifNullOrBlank { "Default" } val symbol = view?.formHostSymbol.ifNullOrBlank { "default" }
resourceFlow { resourceFlow {
recoverRepository.sendRecoverRequest( recoverRepository.sendRecoverRequest(

View File

@ -10,13 +10,11 @@ import io.github.wulkanowy.data.pojos.RegisterUser
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.databinding.FragmentLoginStudentSelectBinding import io.github.wulkanowy.databinding.FragmentLoginStudentSelectBinding
import io.github.wulkanowy.ui.base.BaseFragment import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.ui.modules.login.LoginActivity import io.github.wulkanowy.ui.modules.login.LoginActivity
import io.github.wulkanowy.ui.modules.login.LoginData import io.github.wulkanowy.ui.modules.login.LoginData
import io.github.wulkanowy.ui.modules.login.support.LoginSupportDialog import io.github.wulkanowy.ui.modules.login.support.LoginSupportDialog
import io.github.wulkanowy.ui.modules.login.support.LoginSupportInfo import io.github.wulkanowy.ui.modules.login.support.LoginSupportInfo
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.utils.openEmailClient
import io.github.wulkanowy.utils.openInternetBrowser import io.github.wulkanowy.utils.openInternetBrowser
import io.github.wulkanowy.utils.serializable import io.github.wulkanowy.utils.serializable
import javax.inject.Inject import javax.inject.Inject

View File

@ -111,8 +111,8 @@ class LoginStudentSelectPresenter @Inject constructor(
val notEmptySymbols = registerUser.symbols.filter { it.schools.isNotEmpty() } val notEmptySymbols = registerUser.symbols.filter { it.schools.isNotEmpty() }
val emptySymbols = registerUser.symbols.filter { it.schools.isEmpty() } val emptySymbols = registerUser.symbols.filter { it.schools.isEmpty() }
if (emptySymbols.isNotEmpty() && notEmptySymbols.isNotEmpty() && emptySymbols.any { it.symbol == loginData.symbol }) { if (emptySymbols.isNotEmpty() && notEmptySymbols.isNotEmpty() && emptySymbols.any { it.symbol == loginData.userEnteredSymbol }) {
add(createEmptySymbolItem(emptySymbols.first { it.symbol == loginData.symbol })) add(createEmptySymbolItem(emptySymbols.first { it.symbol == loginData.userEnteredSymbol }))
} }
addAll(createNotEmptySymbolItems(notEmptySymbols, students)) addAll(createNotEmptySymbolItems(notEmptySymbols, students))
@ -317,7 +317,7 @@ class LoginStudentSelectPresenter @Inject constructor(
loginData = loginData, loginData = loginData,
registerUser = registerUser, registerUser = registerUser,
lastErrorMessage = lastError?.message, lastErrorMessage = lastError?.message,
enteredSymbol = loginData.symbol, enteredSymbol = loginData.userEnteredSymbol,
) )
) )
} }

View File

@ -105,7 +105,7 @@ class LoginSupportDialog : BaseDialogFragment<DialogLoginSupportBinding>() {
"${appInfo.systemManufacturer} ${appInfo.systemModel}", "${appInfo.systemManufacturer} ${appInfo.systemModel}",
appInfo.systemVersion.toString(), appInfo.systemVersion.toString(),
"${appInfo.versionName}-${appInfo.buildFlavor}", "${appInfo.versionName}-${appInfo.buildFlavor}",
supportInfo.loginData.baseUrl + "/" + supportInfo.loginData.symbol, supportInfo.loginData.let { "${it.baseUrl}/${it.defaultSymbol}/${it.userEnteredSymbol}" },
preferencesRepository.installationId, preferencesRepository.installationId,
getLastErrorFromStudentSelectScreen(), getLastErrorFromStudentSelectScreen(),
dialogLoginSupportSchoolInput.text.takeIf { !it.isNullOrBlank() } dialogLoginSupportSchoolInput.text.takeIf { !it.isNullOrBlank() }

View File

@ -60,7 +60,7 @@ class LoginSymbolPresenter @Inject constructor(
} }
loginData = loginData.copy( loginData = loginData.copy(
symbol = view?.symbolValue?.getNormalizedSymbol(), userEnteredSymbol = view?.symbolValue?.getNormalizedSymbol(),
) )
resourceFlow { resourceFlow {
studentRepository.getUserSubjectsFromScrapper( studentRepository.getUserSubjectsFromScrapper(
@ -68,7 +68,7 @@ class LoginSymbolPresenter @Inject constructor(
password = loginData.password, password = loginData.password,
scrapperBaseUrl = loginData.baseUrl, scrapperBaseUrl = loginData.baseUrl,
domainSuffix = loginData.domainSuffix, domainSuffix = loginData.domainSuffix,
symbol = loginData.symbol.orEmpty(), symbol = loginData.userEnteredSymbol.orEmpty(),
) )
}.onEach { user -> }.onEach { user ->
registerUser = user.dataOrNull registerUser = user.dataOrNull
@ -93,13 +93,10 @@ class LoginSymbolPresenter @Inject constructor(
else -> { else -> {
val enteredSymbolDetails = user.data.symbols val enteredSymbolDetails = user.data.symbols
.firstOrNull() .firstOrNull()
?.takeIf { it.symbol == loginData.symbol } ?.takeIf { it.symbol == loginData.userEnteredSymbol }
if (enteredSymbolDetails?.error is InvalidSymbolException) { if (enteredSymbolDetails?.error is InvalidSymbolException) {
view?.run { showInvalidSymbolError()
setErrorSymbolInvalid()
showContact(true)
}
} else { } else {
Timber.i("Login with symbol result: Success") Timber.i("Login with symbol result: Success")
view?.navigateToStudentSelect(loginData, requireNotNull(user.data)) view?.navigateToStudentSelect(loginData, requireNotNull(user.data))
@ -128,6 +125,9 @@ class LoginSymbolPresenter @Inject constructor(
loginErrorHandler.dispatch(user.error) loginErrorHandler.dispatch(user.error)
lastError = user.error lastError = user.error
view?.showContact(true) view?.showContact(true)
if (user.error is InvalidSymbolException) {
showInvalidSymbolError()
}
} }
} }
}.onResourceNotLoading { }.onResourceNotLoading {
@ -145,6 +145,13 @@ class LoginSymbolPresenter @Inject constructor(
return normalizedSymbol in definitelyInvalidSymbols return normalizedSymbol in definitelyInvalidSymbols
} }
private fun showInvalidSymbolError() {
view?.run {
setErrorSymbolInvalid()
showContact(true)
}
}
fun onFaqClick() { fun onFaqClick() {
view?.openFaqPage() view?.openFaqPage()
} }

View File

@ -11,10 +11,8 @@ import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.Resource import io.github.wulkanowy.data.dataOrThrow
import io.github.wulkanowy.data.dataOrNull
import io.github.wulkanowy.data.db.SharedPrefProvider import io.github.wulkanowy.data.db.SharedPrefProvider
import io.github.wulkanowy.data.db.entities.LuckyNumber
import io.github.wulkanowy.data.repositories.LuckyNumberRepository import io.github.wulkanowy.data.repositories.LuckyNumberRepository
import io.github.wulkanowy.data.repositories.StudentRepository import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.data.toFirstResult import io.github.wulkanowy.data.toFirstResult
@ -69,8 +67,7 @@ class LuckyNumberWidgetProvider : AppWidgetProvider() {
appWidgetIds?.forEach { widgetId -> appWidgetIds?.forEach { widgetId ->
val studentId = sharedPref.getLong(getStudentWidgetKey(widgetId), 0) val studentId = sharedPref.getLong(getStudentWidgetKey(widgetId), 0)
val luckyNumberResource = getLuckyNumber(studentId, widgetId) val luckyNumber = getLuckyNumber(studentId, widgetId)?.luckyNumber?.toString()
val luckyNumber = luckyNumberResource.dataOrNull?.luckyNumber?.toString()
val remoteView = RemoteViews(context.packageName, R.layout.widget_luckynumber) val remoteView = RemoteViews(context.packageName, R.layout.widget_luckynumber)
.apply { .apply {
setTextViewText(R.id.luckyNumberWidgetValue, luckyNumber ?: "-") setTextViewText(R.id.luckyNumberWidgetValue, luckyNumber ?: "-")
@ -143,18 +140,18 @@ class LuckyNumberWidgetProvider : AppWidgetProvider() {
sharedPref.putLong(getStudentWidgetKey(appWidgetId), it.id) sharedPref.putLong(getStudentWidgetKey(appWidgetId), it.id)
} }
} }
else -> null else -> null
} }
if (currentStudent != null) { if (currentStudent != null) {
luckyNumberRepository.getLuckyNumber(currentStudent, forceRefresh = false) luckyNumberRepository.getLuckyNumber(currentStudent, forceRefresh = false)
.toFirstResult() .toFirstResult()
} else { .dataOrThrow
Resource.Success<LuckyNumber?>(null) } else null
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "An error has occurred in lucky number provider") Timber.e(e, "An error has occurred in lucky number provider")
Resource.Error(e) null
} }
} }

View File

@ -16,6 +16,7 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -30,6 +31,8 @@ import io.github.wulkanowy.databinding.ActivityMainBinding
import io.github.wulkanowy.ui.base.BaseActivity import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.modules.Destination import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.account.accountquick.AccountQuickDialog import io.github.wulkanowy.ui.modules.account.accountquick.AccountQuickDialog
import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.ui.modules.captcha.CaptchaDialog
import io.github.wulkanowy.ui.modules.settings.appearance.menuorder.AppMenuItem import io.github.wulkanowy.ui.modules.settings.appearance.menuorder.AppMenuItem
import io.github.wulkanowy.utils.AnalyticsHelper import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
@ -40,10 +43,17 @@ import io.github.wulkanowy.utils.dpToPx
import io.github.wulkanowy.utils.nickOrName import io.github.wulkanowy.utils.nickOrName
import io.github.wulkanowy.utils.safelyPopFragments import io.github.wulkanowy.utils.safelyPopFragments
import io.github.wulkanowy.utils.setOnViewChangeListener import io.github.wulkanowy.utils.setOnViewChangeListener
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainView, class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainView,
@ -73,6 +83,8 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
private val navController = private val navController =
FragNavController(supportFragmentManager, R.id.main_fragment_container) FragNavController(supportFragmentManager, R.id.main_fragment_container)
private val captchaVerificationEvent = MutableSharedFlow<String?>()
companion object { companion object {
private const val EXTRA_START_DESTINATION = "start_destination_json" private const val EXTRA_START_DESTINATION = "start_destination_json"
@ -144,6 +156,7 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
initializeToolbar() initializeToolbar()
initializeBottomNavigation(startMenuIndex, rootAppMenuItems) initializeBottomNavigation(startMenuIndex, rootAppMenuItems)
initializeNavController(startMenuIndex, rootUpdatedDestinations) initializeNavController(startMenuIndex, rootUpdatedDestinations)
initializeCaptchaVerificationEvent()
} }
private fun initializeNavController( private fun initializeNavController(
@ -323,6 +336,27 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
.show() .show()
} }
@OptIn(FlowPreview::class)
private fun initializeCaptchaVerificationEvent() {
captchaVerificationEvent
.debounce(1.seconds)
.onEach { url ->
Timber.d("Showing captcha dialog for: $url")
showDialogFragment(CaptchaDialog.newInstance(url))
}
.launchIn(lifecycleScope)
}
override fun onCaptchaVerificationRequired(url: String?) {
lifecycleScope.launch {
captchaVerificationEvent.emit(url)
}
}
override fun showAuthDialog() {
showDialogFragment(AuthDialog.newInstance())
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
navController.onSaveInstanceState(outState) navController.onSaveInstanceState(outState)

View File

@ -3,6 +3,7 @@ package io.github.wulkanowy.ui.modules.settings
import android.os.Bundle import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.main.MainView
import timber.log.Timber import timber.log.Timber
@ -24,7 +25,11 @@ class SettingsFragment : PreferenceFragmentCompat(), MainView.TitledView, Settin
override fun showMessage(text: String) {} override fun showMessage(text: String) {}
override fun showExpiredDialog() {} override fun showExpiredCredentialsDialog() {}
override fun onCaptchaVerificationRequired(url: String?) = Unit
override fun showDecryptionFailedDialog() {}
override fun openClearLoginView() {} override fun openClearLoginView() {}

View File

@ -8,7 +8,6 @@ import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseActivity import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.base.ErrorDialog import io.github.wulkanowy.ui.base.ErrorDialog
import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
import javax.inject.Inject import javax.inject.Inject
@ -47,8 +46,16 @@ class AdvancedFragment : PreferenceFragmentCompat(),
(activity as? BaseActivity<*, *>)?.showMessage(text) (activity as? BaseActivity<*, *>)?.showMessage(text)
} }
override fun showExpiredDialog() { override fun showExpiredCredentialsDialog() {
(activity as? BaseActivity<*, *>)?.showExpiredDialog() (activity as? BaseActivity<*, *>)?.showExpiredCredentialsDialog()
}
override fun onCaptchaVerificationRequired(url: String?) {
(activity as? BaseActivity<*, *>)?.onCaptchaVerificationRequired(url)
}
override fun showDecryptionFailedDialog() {
(activity as? BaseActivity<*, *>)?.showDecryptionFailedDialog()
} }
override fun showChangePasswordSnackbar(redirectUrl: String) { override fun showChangePasswordSnackbar(redirectUrl: String) {
@ -64,7 +71,7 @@ class AdvancedFragment : PreferenceFragmentCompat(),
} }
override fun showAuthDialog() { override fun showAuthDialog() {
AuthDialog.newInstance().show(childFragmentManager, "auth_dialog") (activity as? BaseActivity<*, *>)?.showAuthDialog()
} }
override fun onResume() { override fun onResume() {

View File

@ -9,7 +9,6 @@ import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseActivity import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.base.ErrorDialog import io.github.wulkanowy.ui.base.ErrorDialog
import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
import javax.inject.Inject import javax.inject.Inject
@ -63,8 +62,16 @@ class AppearanceFragment : PreferenceFragmentCompat(),
(activity as? BaseActivity<*, *>)?.showMessage(text) (activity as? BaseActivity<*, *>)?.showMessage(text)
} }
override fun showExpiredDialog() { override fun showExpiredCredentialsDialog() {
(activity as? BaseActivity<*, *>)?.showExpiredDialog() (activity as? BaseActivity<*, *>)?.showExpiredCredentialsDialog()
}
override fun onCaptchaVerificationRequired(url: String?) {
(activity as? BaseActivity<*, *>)?.onCaptchaVerificationRequired(url)
}
override fun showDecryptionFailedDialog() {
(activity as? BaseActivity<*, *>)?.showDecryptionFailedDialog()
} }
override fun showChangePasswordSnackbar(redirectUrl: String) { override fun showChangePasswordSnackbar(redirectUrl: String) {
@ -80,7 +87,7 @@ class AppearanceFragment : PreferenceFragmentCompat(),
} }
override fun showAuthDialog() { override fun showAuthDialog() {
AuthDialog.newInstance().show(childFragmentManager, "auth_dialog") (activity as? BaseActivity<*, *>)?.showAuthDialog()
} }
override fun onResume() { override fun onResume() {

View File

@ -21,7 +21,6 @@ import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseActivity import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.base.ErrorDialog import io.github.wulkanowy.ui.base.ErrorDialog
import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.utils.openInternetBrowser import io.github.wulkanowy.utils.openInternetBrowser
@ -133,8 +132,16 @@ class NotificationsFragment : PreferenceFragmentCompat(),
(activity as? BaseActivity<*, *>)?.showMessage(text) (activity as? BaseActivity<*, *>)?.showMessage(text)
} }
override fun showExpiredDialog() { override fun showExpiredCredentialsDialog() {
(activity as? BaseActivity<*, *>)?.showExpiredDialog() (activity as? BaseActivity<*, *>)?.showExpiredCredentialsDialog()
}
override fun onCaptchaVerificationRequired(url: String?) {
(activity as? BaseActivity<*, *>)?.onCaptchaVerificationRequired(url)
}
override fun showDecryptionFailedDialog() {
(activity as? BaseActivity<*, *>)?.showDecryptionFailedDialog()
} }
override fun showChangePasswordSnackbar(redirectUrl: String) { override fun showChangePasswordSnackbar(redirectUrl: String) {
@ -150,7 +157,7 @@ class NotificationsFragment : PreferenceFragmentCompat(),
} }
override fun showAuthDialog() { override fun showAuthDialog() {
AuthDialog.newInstance().show(childFragmentManager, "auth_dialog") (activity as? BaseActivity<*, *>)?.showAuthDialog()
} }
override fun showFixSyncDialog() { override fun showFixSyncDialog() {

View File

@ -10,7 +10,6 @@ import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseActivity import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.base.ErrorDialog import io.github.wulkanowy.ui.base.ErrorDialog
import io.github.wulkanowy.ui.modules.auth.AuthDialog
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.main.MainView
import javax.inject.Inject import javax.inject.Inject
@ -84,8 +83,16 @@ class SyncFragment : PreferenceFragmentCompat(),
} }
} }
override fun showExpiredDialog() { override fun showExpiredCredentialsDialog() {
(activity as? BaseActivity<*, *>)?.showExpiredDialog() (activity as? BaseActivity<*, *>)?.showExpiredCredentialsDialog()
}
override fun onCaptchaVerificationRequired(url: String?) {
(activity as? BaseActivity<*, *>)?.onCaptchaVerificationRequired(url)
}
override fun showDecryptionFailedDialog() {
(activity as? BaseActivity<*, *>)?.showDecryptionFailedDialog()
} }
override fun showChangePasswordSnackbar(redirectUrl: String) { override fun showChangePasswordSnackbar(redirectUrl: String) {
@ -101,7 +108,7 @@ class SyncFragment : PreferenceFragmentCompat(),
} }
override fun showAuthDialog() { override fun showAuthDialog() {
AuthDialog.newInstance().show(childFragmentManager, "auth_dialog") (activity as? BaseActivity<*, *>)?.showAuthDialog()
} }
override fun onResume() { override fun onResume() {

View File

@ -2,9 +2,7 @@ package io.github.wulkanowy.ui.modules.timetable
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import io.github.wulkanowy.data.dataOrNull
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Timetable import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.enums.TimetableGapsMode.BETWEEN_AND_BEFORE_LESSONS import io.github.wulkanowy.data.enums.TimetableGapsMode.BETWEEN_AND_BEFORE_LESSONS
import io.github.wulkanowy.data.enums.TimetableGapsMode.NO_GAPS import io.github.wulkanowy.data.enums.TimetableGapsMode.NO_GAPS
@ -20,8 +18,8 @@ import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.SemesterRepository import io.github.wulkanowy.data.repositories.SemesterRepository
import io.github.wulkanowy.data.repositories.StudentRepository import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.data.repositories.TimetableRepository import io.github.wulkanowy.data.repositories.TimetableRepository
import io.github.wulkanowy.data.toFirstResult import io.github.wulkanowy.domain.timetable.IsStudentHasLessonsOnWeekendUseCase
import io.github.wulkanowy.data.waitForResult import io.github.wulkanowy.domain.timetable.IsWeekendHasLessonsUseCase
import io.github.wulkanowy.ui.base.BasePresenter import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.AnalyticsHelper import io.github.wulkanowy.utils.AnalyticsHelper
@ -31,16 +29,12 @@ import io.github.wulkanowy.utils.isHolidays
import io.github.wulkanowy.utils.isJustFinished import io.github.wulkanowy.utils.isJustFinished
import io.github.wulkanowy.utils.isShowTimeUntil import io.github.wulkanowy.utils.isShowTimeUntil
import io.github.wulkanowy.utils.left import io.github.wulkanowy.utils.left
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.nextOrSameSchoolDay import io.github.wulkanowy.utils.nextOrSameSchoolDay
import io.github.wulkanowy.utils.nextSchoolDay import io.github.wulkanowy.utils.nextSchoolDay
import io.github.wulkanowy.utils.previousSchoolDay import io.github.wulkanowy.utils.previousSchoolDay
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
import io.github.wulkanowy.utils.until import io.github.wulkanowy.utils.until
import kotlinx.coroutines.flow.firstOrNull
import timber.log.Timber import timber.log.Timber
import java.time.DayOfWeek
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDate.now import java.time.LocalDate.now
@ -54,6 +48,8 @@ class TimetablePresenter @Inject constructor(
errorHandler: ErrorHandler, errorHandler: ErrorHandler,
studentRepository: StudentRepository, studentRepository: StudentRepository,
private val timetableRepository: TimetableRepository, private val timetableRepository: TimetableRepository,
private val isStudentHasLessonsOnWeekendUseCase: IsStudentHasLessonsOnWeekendUseCase,
private val isWeekendHasLessonsUseCase: IsWeekendHasLessonsUseCase,
private val semesterRepository: SemesterRepository, private val semesterRepository: SemesterRepository,
private val prefRepository: PreferencesRepository, private val prefRepository: PreferencesRepository,
private val analytics: AnalyticsHelper, private val analytics: AnalyticsHelper,
@ -153,7 +149,7 @@ class TimetablePresenter @Inject constructor(
val student = studentRepository.getCurrentStudent() val student = studentRepository.getCurrentStudent()
val semester = semesterRepository.getCurrentSemester(student) val semester = semesterRepository.getCurrentSemester(student)
checkInitialAndCurrentDate(student, semester) checkInitialAndCurrentDate(semester)
timetableRepository.getTimetable( timetableRepository.getTimetable(
student = student, student = student,
semester = semester, semester = semester,
@ -165,7 +161,7 @@ class TimetablePresenter @Inject constructor(
} }
.logResourceStatus("load timetable data") .logResourceStatus("load timetable data")
.onResourceData { .onResourceData {
isWeekendHasLessons = isWeekendHasLessons || isWeekendHasLessons(it.lessons) isWeekendHasLessons = isWeekendHasLessons || isWeekendHasLessonsUseCase(it.lessons)
view?.run { view?.run {
enableSwipe(true) enableSwipe(true)
@ -197,17 +193,9 @@ class TimetablePresenter @Inject constructor(
.launch() .launch()
} }
private suspend fun checkInitialAndCurrentDate(student: Student, semester: Semester) { private suspend fun checkInitialAndCurrentDate(semester: Semester) {
if (initialDate == null) { if (initialDate == null) {
val lessons = timetableRepository.getTimetable( isWeekendHasLessons = isStudentHasLessonsOnWeekendUseCase(semester)
student = student,
semester = semester,
start = now().monday,
end = now().sunday,
forceRefresh = false,
timetableType = TimetableRepository.TimetableType.NORMAL
).toFirstResult().dataOrNull?.lessons.orEmpty()
isWeekendHasLessons = isWeekendHasLessons(lessons)
initialDate = getInitialDate(semester) initialDate = getInitialDate(semester)
} }
@ -216,15 +204,6 @@ class TimetablePresenter @Inject constructor(
} }
} }
private fun isWeekendHasLessons(
lessons: List<Timetable>,
): Boolean = lessons.any {
it.date.dayOfWeek in listOf(
DayOfWeek.SATURDAY,
DayOfWeek.SUNDAY,
)
}
private fun getInitialDate(semester: Semester): LocalDate { private fun getInitialDate(semester: Semester): LocalDate {
val now = now() val now = now()

View File

@ -11,7 +11,7 @@ import android.view.View.VISIBLE
import android.widget.RemoteViews import android.widget.RemoteViews
import android.widget.RemoteViewsService import android.widget.RemoteViewsService
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.dataOrNull import io.github.wulkanowy.data.dataOrThrow
import io.github.wulkanowy.data.db.SharedPrefProvider import io.github.wulkanowy.data.db.SharedPrefProvider
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
@ -27,6 +27,7 @@ import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Co
import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Companion.getStudentWidgetKey import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Companion.getStudentWidgetKey
import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Companion.getTodayLastLessonEndDateTimeWidgetKey import io.github.wulkanowy.ui.modules.timetablewidget.TimetableWidgetProvider.Companion.getTodayLastLessonEndDateTimeWidgetKey
import io.github.wulkanowy.utils.getCompatColor import io.github.wulkanowy.utils.getCompatColor
import io.github.wulkanowy.utils.getErrorString
import io.github.wulkanowy.utils.getPlural import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -67,25 +68,31 @@ class TimetableWidgetFactory(
override fun onDestroy() {} override fun onDestroy() {}
override fun onDataSetChanged() { override fun onDataSetChanged() {
intent?.extras?.getInt(EXTRA_APPWIDGET_ID)?.let { appWidgetId -> val appWidgetId = intent?.extras?.getInt(EXTRA_APPWIDGET_ID) ?: return
val date = LocalDate.ofEpochDay(sharedPref.getLong(getDateWidgetKey(appWidgetId), 0)) val date = LocalDate.ofEpochDay(sharedPref.getLong(getDateWidgetKey(appWidgetId), 0))
val studentId = sharedPref.getLong(getStudentWidgetKey(appWidgetId), 0) val studentId = sharedPref.getLong(getStudentWidgetKey(appWidgetId), 0)
items = emptyList()
runBlocking {
runCatching { runCatching {
runBlocking { val student = getStudent(studentId) ?: return@runBlocking
val student = getStudent(studentId) ?: return@runBlocking val semester = semesterRepository.getCurrentSemester(student)
val semester = semesterRepository.getCurrentSemester(student) val lessons = getLessons(student, semester, date)
items = createItems( val lastSync = timetableRepository.getLastRefreshTimestamp(semester, date, date)
lessons = getLessons(student, semester, date),
lastSync = timetableRepository.getLastRefreshTimestamp(semester, date, date) createItems(lessons, lastSync)
) }
.onFailure {
items = listOf(TimetableWidgetItem.Error(it))
Timber.e(it, "An error has occurred in timetable widget factory")
}
.onSuccess {
items = it
if (date == LocalDate.now()) { if (date == LocalDate.now()) {
updateTodayLastLessonEnd(appWidgetId) updateTodayLastLessonEnd(appWidgetId)
} }
} }
}.onFailure {
Timber.e(it, "An error has occurred in timetable widget factory")
}
} }
} }
@ -98,7 +105,7 @@ class TimetableWidgetFactory(
student: Student, semester: Semester, date: LocalDate student: Student, semester: Semester, date: LocalDate
): List<Timetable> { ): List<Timetable> {
val timetable = timetableRepository.getTimetable(student, semester, date, date, false) val timetable = timetableRepository.getTimetable(student, semester, date, date, false)
val lessons = timetable.toFirstResult().dataOrNull?.lessons.orEmpty() val lessons = timetable.toFirstResult().dataOrThrow.lessons
return lessons.sortedBy { it.number } return lessons.sortedBy { it.number }
} }
@ -110,6 +117,7 @@ class TimetableWidgetFactory(
BETWEEN_AND_BEFORE_LESSONS -> 0 BETWEEN_AND_BEFORE_LESSONS -> 0
else -> null else -> null
} }
return buildList { return buildList {
lessons.forEach { lessons.forEach {
if (prefRepository.showTimetableGaps != NO_GAPS && prevNum != null && it.number > prevNum!! + 1) { if (prefRepository.showTimetableGaps != NO_GAPS && prevNum != null && it.number > prevNum!! + 1) {
@ -133,15 +141,12 @@ class TimetableWidgetFactory(
sharedPref.putLong(key, todayLastLessonEnd.epochSecond, true) sharedPref.putLong(key, todayLastLessonEnd.epochSecond, true)
} }
companion object {
const val TIME_FORMAT_STYLE = "HH:mm"
}
override fun getViewAt(position: Int): RemoteViews? { override fun getViewAt(position: Int): RemoteViews? {
return when (val item = items.getOrNull(position) ?: return null) { return when (val item = items.getOrNull(position) ?: return null) {
is TimetableWidgetItem.Normal -> getNormalItemRemoteView(item) is TimetableWidgetItem.Normal -> getNormalItemRemoteView(item)
is TimetableWidgetItem.Empty -> getEmptyItemRemoteView(item) is TimetableWidgetItem.Empty -> getEmptyItemRemoteView(item)
is TimetableWidgetItem.Synchronized -> getSynchronizedItemRemoteView(item) is TimetableWidgetItem.Synchronized -> getSynchronizedItemRemoteView(item)
is TimetableWidgetItem.Error -> getErrorItemRemoteView(item)
} }
} }
@ -213,6 +218,18 @@ class TimetableWidgetFactory(
} }
} }
private fun getErrorItemRemoteView(item: TimetableWidgetItem.Error): RemoteViews {
return RemoteViews(
context.packageName,
R.layout.item_widget_timetable_error
).apply {
setTextViewText(
R.id.timetable_widget_item_error_message,
context.resources.getErrorString(item.error)
)
}
}
private fun updateTheme() { private fun updateTheme() {
when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
Configuration.UI_MODE_NIGHT_YES -> { Configuration.UI_MODE_NIGHT_YES -> {
@ -300,4 +317,8 @@ class TimetableWidgetFactory(
synchronizationTime, synchronizationTime,
) )
} }
private companion object {
private const val TIME_FORMAT_STYLE = "HH:mm"
}
} }

View File

@ -17,10 +17,15 @@ sealed class TimetableWidgetItem(val type: TimetableWidgetItemType) {
data class Synchronized( data class Synchronized(
val timestamp: Instant, val timestamp: Instant,
) : TimetableWidgetItem(TimetableWidgetItemType.SYNCHRONIZED) ) : TimetableWidgetItem(TimetableWidgetItemType.SYNCHRONIZED)
data class Error(
val error: Throwable
) : TimetableWidgetItem(TimetableWidgetItemType.ERROR)
} }
enum class TimetableWidgetItemType { enum class TimetableWidgetItemType {
NORMAL, NORMAL,
EMPTY, EMPTY,
SYNCHRONIZED, SYNCHRONIZED,
ERROR,
} }

View File

@ -3,6 +3,8 @@ package io.github.wulkanowy.utils
import android.content.res.Resources import android.content.res.Resources
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.sdk.exception.FeatureNotAvailableException import io.github.wulkanowy.sdk.exception.FeatureNotAvailableException
import io.github.wulkanowy.sdk.scrapper.exception.AccountInactiveException
import io.github.wulkanowy.sdk.scrapper.exception.CloudflareVerificationException
import io.github.wulkanowy.sdk.scrapper.exception.FeatureDisabledException import io.github.wulkanowy.sdk.scrapper.exception.FeatureDisabledException
import io.github.wulkanowy.sdk.scrapper.exception.ScrapperException import io.github.wulkanowy.sdk.scrapper.exception.ScrapperException
import io.github.wulkanowy.sdk.scrapper.exception.ServiceUnavailableException import io.github.wulkanowy.sdk.scrapper.exception.ServiceUnavailableException
@ -32,8 +34,10 @@ fun Resources.getErrorString(error: Throwable): String = when (error) {
is ServiceUnavailableException -> R.string.error_service_unavailable is ServiceUnavailableException -> R.string.error_service_unavailable
is FeatureDisabledException -> R.string.error_feature_disabled is FeatureDisabledException -> R.string.error_feature_disabled
is FeatureNotAvailableException -> R.string.error_feature_not_available is FeatureNotAvailableException -> R.string.error_feature_not_available
is AccountInactiveException -> R.string.error_account_inactive
is VulcanException -> R.string.error_unknown_uonet is VulcanException -> R.string.error_unknown_uonet
is ScrapperException -> R.string.error_unknown_app is ScrapperException -> R.string.error_unknown_app
is CloudflareVerificationException -> R.string.error_cloudflare_captcha
is SSLHandshakeException -> when { is SSLHandshakeException -> when {
error.isCausedByCertificateNotValidNow() -> R.string.error_invalid_device_datetime error.isCausedByCertificateNotValidNow() -> R.string.error_invalid_device_datetime
else -> R.string.error_timeout else -> R.string.error_timeout

View File

@ -1,5 +1,6 @@
package io.github.wulkanowy.utils package io.github.wulkanowy.utils
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import timber.log.Timber import timber.log.Timber
@ -11,6 +12,7 @@ fun Sdk.init(student: Student): Sdk {
schoolSymbol = student.schoolSymbol schoolSymbol = student.schoolSymbol
studentId = student.studentId studentId = student.studentId
classId = student.classId classId = student.classId
emptyCookieJarInterceptor = true
if (Sdk.Mode.valueOf(student.loginMode) == Sdk.Mode.HEBE) { if (Sdk.Mode.valueOf(student.loginMode) == Sdk.Mode.HEBE) {
mobileBaseUrl = student.mobileBaseUrl mobileBaseUrl = student.mobileBaseUrl
@ -29,3 +31,12 @@ fun Sdk.init(student: Student): Sdk {
return this return this
} }
fun Sdk.switchSemester(semester: Semester): Sdk {
return switchDiary(
diaryId = semester.diaryId,
kindergartenDiaryId = semester.kindergartenDiaryId,
schoolYear = semester.schoolYear,
unitId = semester.unitId,
)
}

View File

@ -0,0 +1,70 @@
package io.github.wulkanowy.utils
import android.util.AndroidRuntimeException
import java.net.CookiePolicy
import java.net.CookieStore
import java.net.HttpCookie
import java.net.URI
import android.webkit.CookieManager as WebkitCookieManager
import java.net.CookieManager as JavaCookieManager
class WebkitCookieManagerProxy : JavaCookieManager(null, CookiePolicy.ACCEPT_ALL) {
private val webkitCookieManager: WebkitCookieManager? = getWebkitCookieManager()
/**
* @see [https://stackoverflow.com/a/70354583/6695449]
*/
private fun getWebkitCookieManager(): WebkitCookieManager? {
return try {
WebkitCookieManager.getInstance()
} catch (e: AndroidRuntimeException) {
null
}
}
override fun put(uri: URI?, responseHeaders: Map<String?, List<String?>>?) {
if (uri == null || responseHeaders == null) return
val url = uri.toString()
for (headerKey in responseHeaders.keys) {
if (headerKey == null || !(
headerKey.equals("Set-Cookie2", ignoreCase = true) ||
headerKey.equals("Set-Cookie", ignoreCase = true)
)
) continue
// process each of the headers
for (headerValue in responseHeaders[headerKey].orEmpty()) {
webkitCookieManager?.setCookie(url, headerValue)
}
}
}
override operator fun get(
uri: URI?,
requestHeaders: Map<String?, List<String?>?>?
): Map<String, List<String>> {
require(!(uri == null || requestHeaders == null)) { "Argument is null" }
val res = mutableMapOf<String, List<String>>()
val cookie = webkitCookieManager?.getCookie(uri.toString())
if (cookie != null) res["Cookie"] = listOf(cookie)
return res
}
override fun getCookieStore(): CookieStore {
val cookies = super.getCookieStore()
return object : CookieStore {
override fun add(uri: URI?, cookie: HttpCookie?) = cookies.add(uri, cookie)
override fun get(uri: URI?): List<HttpCookie> = cookies.get(uri)
override fun getCookies(): List<HttpCookie> = cookies.cookies
override fun getURIs(): List<URI> = cookies.urIs
override fun remove(uri: URI?, cookie: HttpCookie?): Boolean =
cookies.remove(uri, cookie)
override fun removeAll(): Boolean {
webkitCookieManager?.removeAllCookies(null) ?: return false
return true
}
}
}
}

View File

@ -16,6 +16,7 @@ import android.util.Base64.DEFAULT
import android.util.Base64.decode import android.util.Base64.decode
import android.util.Base64.encode import android.util.Base64.encode
import android.util.Base64.encodeToString import android.util.Base64.encodeToString
import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber import timber.log.Timber
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@ -33,108 +34,124 @@ import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream import javax.crypto.CipherOutputStream
import javax.crypto.spec.OAEPParameterSpec import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource.PSpecified import javax.crypto.spec.PSource.PSpecified
import javax.inject.Inject
import javax.inject.Singleton
import javax.security.auth.x500.X500Principal import javax.security.auth.x500.X500Principal
private const val KEYSTORE_NAME = "AndroidKeyStore" @Singleton
class Scrambler @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val keyCharset = Charset.forName("UTF-8")
private const val KEY_ALIAS = "wulkanowy_password" private val isKeyPairExists: Boolean
get() = keyStore.getKey(KEY_ALIAS, null) != null
private val KEY_CHARSET = Charset.forName("UTF-8") private val keyStore: KeyStore
get() = KeyStore.getInstance(KEYSTORE_NAME).apply { load(null) }
private val isKeyPairExists: Boolean private val cipher: Cipher
get() = keyStore.getKey(KEY_ALIAS, null) != null get() {
return if (SDK_INT >= M) Cipher.getInstance(
"RSA/ECB/OAEPWithSHA-256AndMGF1Padding",
"AndroidKeyStoreBCWorkaround"
)
else Cipher.getInstance("RSA/ECB/PKCS1Padding", "AndroidOpenSSL")
}
private val keyStore: KeyStore fun encrypt(plainText: String): String {
get() = KeyStore.getInstance(KEYSTORE_NAME).apply { load(null) } if (plainText.isEmpty()) throw ScramblerException("Text to be encrypted is empty")
private val cipher: Cipher return try {
get() { if (!isKeyPairExists) generateKeyPair()
return if (SDK_INT >= M) Cipher.getInstance(
"RSA/ECB/OAEPWithSHA-256AndMGF1Padding", cipher.let {
"AndroidKeyStoreBCWorkaround" if (SDK_INT >= M) {
) OAEPParameterSpec("SHA-256", "MGF1", SHA1, PSpecified.DEFAULT).let { spec ->
else Cipher.getInstance("RSA/ECB/PKCS1Padding", "AndroidOpenSSL") it.init(ENCRYPT_MODE, keyStore.getCertificate(KEY_ALIAS).publicKey, spec)
}
} else it.init(ENCRYPT_MODE, keyStore.getCertificate(KEY_ALIAS).publicKey)
ByteArrayOutputStream().let { output ->
CipherOutputStream(output, it).apply {
write(plainText.toByteArray(keyCharset))
close()
}
encodeToString(output.toByteArray(), DEFAULT)
}
}
} catch (exception: Exception) {
Timber.e(exception, "An error occurred while encrypting text")
String(encode(plainText.toByteArray(keyCharset), DEFAULT), keyCharset)
}
} }
fun encrypt(plainText: String, context: Context): String { fun decrypt(cipherText: String): String {
if (plainText.isEmpty()) throw ScramblerException("Text to be encrypted is empty") if (cipherText.isEmpty()) throw ScramblerException("Text to be encrypted is empty")
return try { return try {
if (!isKeyPairExists) generateKeyPair(context) if (!isKeyPairExists) throw ScramblerException("KeyPair doesn't exist")
cipher.let { cipher.let {
if (SDK_INT >= M) { if (SDK_INT >= M) {
OAEPParameterSpec("SHA-256", "MGF1", SHA1, PSpecified.DEFAULT).let { spec -> OAEPParameterSpec("SHA-256", "MGF1", SHA1, PSpecified.DEFAULT).let { spec ->
it.init(ENCRYPT_MODE, keyStore.getCertificate(KEY_ALIAS).publicKey, spec) it.init(DECRYPT_MODE, keyStore.getKey(KEY_ALIAS, null), spec)
}
} else it.init(DECRYPT_MODE, keyStore.getKey(KEY_ALIAS, null))
CipherInputStream(
ByteArrayInputStream(decode(cipherText, DEFAULT)),
it
).let { input ->
val values = ArrayList<Byte>()
var nextByte: Int
while (run { nextByte = input.read(); nextByte } != -1) {
values.add(nextByte.toByte())
}
val bytes = ByteArray(values.size)
for (i in bytes.indices) {
bytes[i] = values[i]
}
String(bytes, 0, bytes.size, keyCharset)
} }
} else it.init(ENCRYPT_MODE, keyStore.getCertificate(KEY_ALIAS).publicKey) }
} catch (e: Exception) {
throw ScramblerException("An error occurred while decrypting text", e)
}
}
ByteArrayOutputStream().let { output -> private fun generateKeyPair() {
CipherOutputStream(output, it).apply { (if (SDK_INT >= M) {
write(plainText.toByteArray(KEY_CHARSET)) KeyGenParameterSpec.Builder(KEY_ALIAS, PURPOSE_DECRYPT or PURPOSE_ENCRYPT)
close() .setDigests(DIGEST_SHA256, DIGEST_SHA512)
} .setEncryptionPaddings(ENCRYPTION_PADDING_RSA_OAEP)
encodeToString(output.toByteArray(), DEFAULT) .setCertificateSerialNumber(BigInteger.TEN)
.setCertificateSubject(X500Principal("CN=Wulkanowy"))
.build()
} else {
KeyPairGeneratorSpec.Builder(context)
.setAlias(KEY_ALIAS)
.setSubject(X500Principal("CN=Wulkanowy"))
.setSerialNumber(BigInteger.TEN)
.setStartDate(Calendar.getInstance().time)
.setEndDate(Calendar.getInstance().apply { add(YEAR, 99) }.time)
.build()
}).let {
KeyPairGenerator.getInstance("RSA", KEYSTORE_NAME).apply {
initialize(it)
genKeyPair()
} }
} }
} catch (exception: Exception) { Timber.i("A new KeyPair has been generated")
Timber.e(exception, "An error occurred while encrypting text") }
String(encode(plainText.toByteArray(KEY_CHARSET), DEFAULT), KEY_CHARSET)
fun clearKeyPair() {
keyStore.deleteEntry(KEY_ALIAS)
Timber.i("KeyPair has been cleared")
}
private companion object {
private const val KEYSTORE_NAME = "AndroidKeyStore"
private const val KEY_ALIAS = "wulkanowy_password"
} }
} }
fun decrypt(cipherText: String): String {
if (cipherText.isEmpty()) throw ScramblerException("Text to be encrypted is empty")
return try {
if (!isKeyPairExists) throw ScramblerException("KeyPair doesn't exist")
cipher.let {
if (SDK_INT >= M) {
OAEPParameterSpec("SHA-256", "MGF1", SHA1, PSpecified.DEFAULT).let { spec ->
it.init(DECRYPT_MODE, keyStore.getKey(KEY_ALIAS, null), spec)
}
} else it.init(DECRYPT_MODE, keyStore.getKey(KEY_ALIAS, null))
CipherInputStream(ByteArrayInputStream(decode(cipherText, DEFAULT)), it).let { input ->
val values = ArrayList<Byte>()
var nextByte: Int
while (run { nextByte = input.read(); nextByte } != -1) {
values.add(nextByte.toByte())
}
val bytes = ByteArray(values.size)
for (i in bytes.indices) {
bytes[i] = values[i]
}
String(bytes, 0, bytes.size, KEY_CHARSET)
}
}
} catch (e: Exception) {
throw ScramblerException("An error occurred while decrypting text", e)
}
}
private fun generateKeyPair(context: Context) {
(if (SDK_INT >= M) {
KeyGenParameterSpec.Builder(KEY_ALIAS, PURPOSE_DECRYPT or PURPOSE_ENCRYPT)
.setDigests(DIGEST_SHA256, DIGEST_SHA512)
.setEncryptionPaddings(ENCRYPTION_PADDING_RSA_OAEP)
.setCertificateSerialNumber(BigInteger.TEN)
.setCertificateSubject(X500Principal("CN=Wulkanowy"))
.build()
} else {
KeyPairGeneratorSpec.Builder(context)
.setAlias(KEY_ALIAS)
.setSubject(X500Principal("CN=Wulkanowy"))
.setSerialNumber(BigInteger.TEN)
.setStartDate(Calendar.getInstance().time)
.setEndDate(Calendar.getInstance().apply { add(YEAR, 99) }.time)
.build()
}).let {
KeyPairGenerator.getInstance("RSA", KEYSTORE_NAME).apply {
initialize(it)
genKeyPair()
}
}
Timber.i("A new KeyPair has been generated")
}

View File

@ -1,6 +1,7 @@
Wersja 2.3.0 Wersja 2.4.2
— poprawiliśmy kilka usterek przy odświeżaniu danych (ale pewnie nie wszystkie) - naprawiliśmy crash przy przełączaniu uczniów, motywów i języków
— zaktualizowaliśmy sposób pytania o zgodę na personalizowane reklamy - naprawiliśmy crash przy dodawaniu dodatkowych lekcji
- naprawiliśmy obsługę błędów widżetach
Pełna lista zmian: https://github.com/wulkanowy/wulkanowy/releases Pełna lista zmian: https://github.com/wulkanowy/wulkanowy/releases

View File

@ -0,0 +1,10 @@
<vector android:height="24dp"
android:tint="#000000"
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,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>

View File

@ -16,7 +16,7 @@
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/main_toolbar" android:id="@+id/main_toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" /> android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView

Some files were not shown because too many files have changed in this diff Show More