1
0
mirror of https://github.com/wulkanowy/wulkanowy.git synced 2024-09-20 06:49:08 -05:00

Merge branch 'release/1.4.0'

This commit is contained in:
Mikołaj Pich 2021-11-16 22:49:53 +01:00
commit 9066bce0d5
247 changed files with 11368 additions and 1676 deletions

View File

@ -1,5 +1,6 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
@ -14,23 +15,22 @@ apply from: 'sonarqube.gradle'
apply from: 'hooks.gradle'
android {
compileSdkVersion 30
compileSdkVersion 31
defaultConfig {
applicationId "io.github.wulkanowy"
testApplicationId "io.github.tests.wulkanowy"
minSdkVersion 21
targetSdkVersion 30
versionCode 97
versionName "1.3.0"
targetSdkVersion 31
versionCode 98
versionName "1.4.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
resValue "string", "app_name", "Wulkanowy"
buildConfigField "long", "BUILD_TIMESTAMP", String.valueOf(System.currentTimeMillis())
manifestPlaceholders = [
firebase_enabled: project.hasProperty("enableFirebase")
firebase_enabled: project.hasProperty("enableFirebase"),
admob_project_id: ""
]
javaCompileOptions {
annotationProcessorOptions {
@ -40,6 +40,14 @@ android {
]
}
}
buildConfigField "String", "SINGLE_SUPPORT_AD_ID", "null"
if (System.env.SET_BUILD_TIMESTAMP) {
buildConfigField "long", "BUILD_TIMESTAMP", String.valueOf(System.currentTimeMillis())
} else {
buildConfigField "long", "BUILD_TIMESTAMP", "1486235849000"
}
}
sourceSets {
@ -62,12 +70,14 @@ android {
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
buildConfigField "String", "MESSAGES_BASE_URL", "\"https://messages.wulkanowy.net.pl\""
}
debug {
resValue "string", "app_name", "Wulkanowy DEV " + defaultConfig.versionCode
resValue "string", "app_name", "Wulkanowy DEV"
applicationIdSuffix ".dev"
versionNameSuffix "-dev"
ext.enableCrashlytics = project.hasProperty("enableFirebase")
buildConfigField "String", "MESSAGES_BASE_URL", "\"https://messages.wulkanowy.net.pl\""
}
}
@ -76,23 +86,21 @@ android {
productFlavors {
hms {
dimension "platform"
manifestPlaceholders = [
install_channel: "AppGallery"
]
manifestPlaceholders = [install_channel: "AppGallery"]
}
play {
dimension "platform"
manifestPlaceholders = [
install_channel: "Google Play"
install_channel : "Google Play",
admob_project_id: System.getenv("ADMOB_PROJECT_ID") ?: "ca-app-pub-3940256099942544~3347511713"
]
buildConfigField "String", "SINGLE_SUPPORT_AD_ID", "\"${System.getenv("SINGLE_SUPPORT_AD_ID") ?: "ca-app-pub-3940256099942544/5354046379"}\""
}
fdroid {
dimension "platform"
manifestPlaceholders = [
install_channel: "F-Droid"
]
manifestPlaceholders = [install_channel: "F-Droid"]
}
}
@ -141,8 +149,8 @@ kapt {
play {
defaultToAppBundles = false
track = 'production'
updatePriority = 3
track = 'beta'
updatePriority = 1
enabled.set(false)
}
@ -157,27 +165,28 @@ huaweiPublish {
}
ext {
work_manager = "2.6.0"
work_manager = "2.7.0"
android_hilt = "1.0.0"
room = "2.3.0"
chucker = "3.5.2"
mockk = "1.12.0"
moshi = "1.12.0"
coroutines = "1.5.2"
}
dependencies {
implementation "io.github.wulkanowy:sdk:1.3.0"
implementation "io.github.wulkanowy:sdk:1.4.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"
implementation "androidx.core:core-ktx:1.6.0"
implementation "androidx.activity:activity-ktx:1.3.1"
implementation "androidx.appcompat:appcompat:1.3.1"
implementation "androidx.appcompat:appcompat-resources:1.3.1"
implementation "androidx.fragment:fragment-ktx:1.3.6"
implementation "androidx.annotation:annotation:1.2.0"
implementation "androidx.core:core-ktx:1.7.0"
implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.appcompat:appcompat:1.4.0-rc01"
implementation "androidx.fragment:fragment-ktx:1.4.0-rc01"
implementation "androidx.annotation:annotation:1.3.0"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.recyclerview:recyclerview:1.2.1"
@ -193,7 +202,7 @@ dependencies {
implementation "androidx.work:work-runtime-ktx:$work_manager"
playImplementation "androidx.work:work-gcm:$work_manager"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0"
implementation "androidx.room:room-runtime:$room"
implementation "androidx.room:room-ktx:$room"
@ -207,40 +216,41 @@ dependencies {
implementation 'com.github.ncapdevi:FragNav:3.3.0'
implementation "com.github.YarikSOffice:lingver:1.3.0"
implementation "com.squareup.moshi:moshi:$moshi"
implementation "com.squareup.moshi:moshi-adapters:$moshi"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi"
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0"
implementation "com.squareup.okhttp3:logging-interceptor:4.9.2"
implementation "com.jakewharton.timber:timber:5.0.1"
implementation "at.favre.lib:slf4j-timber:1.0.1"
implementation 'com.github.bastienpaulfr:Treessence:1.0.4'
implementation 'com.github.bastienpaulfr:Treessence:1.0.5'
implementation "com.mikepenz:aboutlibraries-core:$about_libraries"
implementation "io.coil-kt:coil:1.3.2"
implementation "io.coil-kt:coil:1.4.0"
implementation "io.github.wulkanowy:AppKillerManager:3.0.0"
implementation 'me.xdrop:fuzzywuzzy:1.3.1'
implementation 'com.fredporciuncula:flow-preferences:1.5.0'
playImplementation platform('com.google.firebase:firebase-bom:28.4.1')
playImplementation platform('com.google.firebase:firebase-bom:29.0.0')
playImplementation 'com.google.firebase:firebase-analytics-ktx'
playImplementation 'com.google.firebase:firebase-messaging:'
playImplementation 'com.google.firebase:firebase-crashlytics:'
playImplementation 'com.google.android.play:core:1.10.2'
playImplementation 'com.google.android.play:core-ktx:1.8.1'
playImplementation 'com.google.android.gms:play-services-ads:20.4.0'
hmsImplementation 'com.huawei.hms:hianalytics:6.2.0.301'
hmsImplementation 'com.huawei.agconnect:agconnect-crash:1.6.0.300'
hmsImplementation 'com.huawei.hms:hianalytics:6.3.0.303'
hmsImplementation 'com.huawei.agconnect:agconnect-crash:1.6.1.300'
releaseImplementation "com.github.ChuckerTeam.Chucker:library-no-op:$chucker"
debugImplementation "com.github.ChuckerTeam.Chucker:library:$chucker"
debugImplementation 'com.github.amitshekhariitbhu.Android-Debug-Database:debug-db:v1.0.6'
debugImplementation 'com.github.amitshekhariitbhu.Android-Debug-Database:debug-db:1.0.6'
testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:$mockk"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines"
testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
testImplementation 'org.robolectric:robolectric:4.6.1'
testImplementation 'org.robolectric:robolectric:4.7'
testImplementation "androidx.test:runner:1.4.0"
testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "androidx.test:core:1.4.0"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -38,10 +38,10 @@
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="false"
android:theme="@style/WulkanowyTheme"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning,UnusedAttribute">
<activity
android:name=".ui.modules.splash.SplashActivity"
@ -106,7 +106,8 @@
</service>
<service
android:name=".services.messaging.AppMessagingService"
android:exported="false">
android:exported="false"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
@ -152,44 +153,44 @@
android:resource="@xml/provider_paths" />
</provider>
<meta-data
android:name="install_channel"
android:value="${install_channel}" />
<!-- workaround for https://github.com/firebase/firebase-android-sdk/issues/473 enabled:false -->
<!-- https://firebase.googleblog.com/2017/03/take-control-of-your-firebase-init-on.html -->
<provider
android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider"
android:enabled="${firebase_enabled}"
android:exported="false" />
android:exported="false"
tools:ignore="MissingClass" />
<meta-data
android:name="install_channel"
android:value="${install_channel}" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="${firebase_enabled}" />
<meta-data
android:name="google_analytics_adid_collection_enabled"
android:value="${firebase_enabled}" />
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="${firebase_enabled}" />
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="${firebase_enabled}" />
<meta-data
android:name="firebase_inapp_messaging_auto_data_collection_enabled"
android:value="${firebase_enabled}" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_all" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="push_channel" />
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="${admob_project_id}" />
<meta-data
android:name="com.google.android.gms.ads.DELAY_APP_MEASUREMENT_INIT"
android:value="true" />
</application>
</manifest>

View File

@ -1,12 +1,10 @@
package io.github.wulkanowy
import android.annotation.SuppressLint
import android.app.Application
import android.util.Log.DEBUG
import android.util.Log.INFO
import android.util.Log.VERBOSE
import android.webkit.WebView
import androidx.fragment.app.FragmentManager
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.yariksoffice.lingver.Lingver
@ -41,10 +39,8 @@ class WulkanowyApp : Application(), Configuration.Provider {
@Inject
lateinit var analyticsHelper: AnalyticsHelper
@SuppressLint("UnsafeOptInUsageWarning")
override fun onCreate() {
super.onCreate()
FragmentManager.enableNewStateManager(false)
initializeAppLanguage()
themeManager.applyDefaultTheme()
initLogging()

View File

@ -2,62 +2,100 @@ package io.github.wulkanowy.data
import android.content.Context
import android.content.SharedPreferences
import android.content.res.AssetManager
import android.content.res.Resources
import androidx.preference.PreferenceManager
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.chuckerteam.chucker.api.RetentionManager
import com.fredporciuncula.flow.preferences.FlowSharedPreferences
import com.squareup.moshi.Moshi
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import io.github.wulkanowy.data.api.AdminMessageService
import io.github.wulkanowy.data.db.AppDatabase
import io.github.wulkanowy.data.db.SharedPrefProvider
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AppInfo
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal class RepositoryModule {
internal class DataModule {
@Singleton
@Provides
fun provideSdk(chuckerCollector: ChuckerCollector, @ApplicationContext context: Context): Sdk {
return Sdk().apply {
fun provideSdk(chuckerInterceptor: ChuckerInterceptor) =
Sdk().apply {
androidVersion = android.os.Build.VERSION.RELEASE
buildTag = android.os.Build.MODEL
setSimpleHttpLogger { Timber.d(it) }
// for debug only
addInterceptor(
ChuckerInterceptor.Builder(context)
.collector(chuckerCollector)
.alwaysReadResponseBody(true)
.build(), network = true
)
addInterceptor(chuckerInterceptor, network = true)
}
}
@Singleton
@Provides
fun provideChuckerCollector(
@ApplicationContext context: Context,
prefRepository: PreferencesRepository
): ChuckerCollector {
return ChuckerCollector(
context = context,
showNotification = prefRepository.isDebugNotificationEnable,
retentionPeriod = RetentionManager.Period.ONE_HOUR
)
}
) = ChuckerCollector(
context = context,
showNotification = prefRepository.isDebugNotificationEnable,
retentionPeriod = RetentionManager.Period.ONE_HOUR
)
@Singleton
@Provides
fun provideChuckerInterceptor(
@ApplicationContext context: Context,
chuckerCollector: ChuckerCollector
) = ChuckerInterceptor.Builder(context)
.collector(chuckerCollector)
.alwaysReadResponseBody(true)
.build()
@Singleton
@Provides
fun provideOkHttpClient(chuckerInterceptor: ChuckerInterceptor): OkHttpClient =
OkHttpClient.Builder()
.addNetworkInterceptor(chuckerInterceptor)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
@OptIn(ExperimentalSerializationApi::class)
@Singleton
@Provides
fun provideRetrofit(
okHttpClient: OkHttpClient,
json: Json,
appInfo: AppInfo
): Retrofit = Retrofit.Builder()
.baseUrl(appInfo.messagesBaseUrl)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Singleton
@Provides
fun provideAdminMessageService(retrofit: Retrofit): AdminMessageService = retrofit.create()
@Singleton
@Provides
@ -67,14 +105,6 @@ internal class RepositoryModule {
appInfo: AppInfo
) = AppDatabase.newInstance(context, sharedPrefProvider, appInfo)
@Singleton
@Provides
fun provideResources(@ApplicationContext context: Context): Resources = context.resources
@Singleton
@Provides
fun provideAssets(@ApplicationContext context: Context): AssetManager = context.assets
@Singleton
@Provides
fun provideSharedPref(@ApplicationContext context: Context): SharedPreferences =
@ -88,7 +118,9 @@ internal class RepositoryModule {
@Singleton
@Provides
fun provideMoshi() = Moshi.Builder().build()
fun provideJson() = Json {
ignoreUnknownKeys = true
}
@Singleton
@Provides
@ -206,4 +238,8 @@ internal class RepositoryModule {
@Singleton
@Provides
fun provideNotificationDao(database: AppDatabase) = database.notificationDao
@Singleton
@Provides
fun provideAdminMessageDao(database: AppDatabase) = database.adminMessagesDao
}

View File

@ -0,0 +1,12 @@
package io.github.wulkanowy.data.api
import io.github.wulkanowy.data.db.entities.AdminMessage
import retrofit2.http.GET
import javax.inject.Singleton
@Singleton
interface AdminMessageService {
@GET("/v1.json")
suspend fun getAdminMessages(): List<AdminMessage>
}

View File

@ -6,6 +6,7 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.RoomDatabase.JournalMode.TRUNCATE
import androidx.room.TypeConverters
import io.github.wulkanowy.data.db.dao.AdminMessageDao
import io.github.wulkanowy.data.db.dao.AttendanceDao
import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao
import io.github.wulkanowy.data.db.dao.CompletedLessonsDao
@ -35,6 +36,7 @@ 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
@ -98,6 +100,9 @@ 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.Migration5
import io.github.wulkanowy.data.db.migrations.Migration6
import io.github.wulkanowy.data.db.migrations.Migration7
@ -137,7 +142,8 @@ import javax.inject.Singleton
StudentInfo::class,
TimetableHeader::class,
SchoolAnnouncement::class,
Notification::class
Notification::class,
AdminMessage::class
],
version = AppDatabase.VERSION_SCHEMA,
exportSchema = true
@ -146,7 +152,7 @@ import javax.inject.Singleton
abstract class AppDatabase : RoomDatabase() {
companion object {
const val VERSION_SCHEMA = 40
const val VERSION_SCHEMA = 43
fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf(
Migration2(),
@ -187,7 +193,10 @@ abstract class AppDatabase : RoomDatabase() {
Migration37(),
Migration38(),
Migration39(),
Migration40()
Migration40(),
Migration41(sharedPrefProvider),
Migration42(),
Migration43()
)
fun newInstance(
@ -259,4 +268,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract val schoolAnnouncementDao: SchoolAnnouncementDao
abstract val notificationDao: NotificationDao
abstract val adminMessagesDao: AdminMessageDao
}

View File

@ -1,9 +1,10 @@
package io.github.wulkanowy.data.db
import androidx.room.TypeConverter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import io.github.wulkanowy.data.db.adapters.PairAdapterFactory
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
@ -13,15 +14,7 @@ import java.util.Date
class Converters {
private val moshi by lazy { Moshi.Builder().add(PairAdapterFactory).build() }
private val integerListAdapter by lazy {
moshi.adapter<List<Int>>(Types.newParameterizedType(List::class.java, Integer::class.java))
}
private val stringListPairAdapter by lazy {
moshi.adapter<List<Pair<String, String>>>(Types.newParameterizedType(List::class.java, Pair::class.java, String::class.java, String::class.java))
}
private val json = Json
@TypeConverter
fun timestampToDate(value: Long?): LocalDate? = value?.run {
@ -51,21 +44,25 @@ class Converters {
@TypeConverter
fun intListToJson(list: List<Int>): String {
return integerListAdapter.toJson(list)
return json.encodeToString(list)
}
@TypeConverter
fun jsonToIntList(value: String): List<Int> {
return integerListAdapter.fromJson(value).orEmpty()
return json.decodeFromString(value)
}
@TypeConverter
fun stringPairListToJson(list: List<Pair<String, String>>): String {
return stringListPairAdapter.toJson(list)
return json.encodeToString(list)
}
@TypeConverter
fun jsonToStringPairList(value: String): List<Pair<String, String>> {
return stringListPairAdapter.fromJson(value).orEmpty()
return try {
json.decodeFromString(value)
} catch (e: SerializationException) {
emptyList() // handle errors from old gson Pair serialized data
}
}
}

View File

@ -22,11 +22,14 @@ class SharedPrefProvider @Inject constructor(
fun getString(key: String) = sharedPref.getString(key, null)
fun getString(key: String, defaultValue: String): String = sharedPref.getString(key, defaultValue) ?: defaultValue
fun getString(key: String, defaultValue: String): String =
sharedPref.getString(key, defaultValue) ?: defaultValue
fun getBoolean(key: String, defaultValue: Boolean): Boolean = sharedPref.getBoolean(key, defaultValue)
fun getBoolean(key: String, defaultValue: Boolean): Boolean =
sharedPref.getBoolean(key, defaultValue)
fun putBoolean(key: String, value: Boolean, sync: Boolean = false) = sharedPref.edit(sync) { putBoolean(key, value) }
fun putBoolean(key: String, value: Boolean, sync: Boolean = false) =
sharedPref.edit(sync) { putBoolean(key, value) }
fun putString(key: String, value: String?, sync: Boolean = false) {
sharedPref.edit(sync) { putString(key, value) }

View File

@ -1,68 +0,0 @@
package io.github.wulkanowy.data.db.adapters
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
object PairAdapterFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: MutableSet<out Annotation>, moshi: Moshi): JsonAdapter<*>? {
if (type !is ParameterizedType || List::class.java != type.rawType) return null
if (type.actualTypeArguments[0] != Pair::class.java) return null
val listType = Types.newParameterizedType(List::class.java, Map::class.java, String::class.java)
val listAdapter = moshi.adapter<List<Map<String, String>>>(listType)
val mapType = Types.newParameterizedType(MutableMap::class.java, String::class.java, String::class.java)
val mapAdapter = moshi.adapter<Map<String, String>>(mapType)
return PairAdapter(listAdapter, mapAdapter)
}
private class PairAdapter(
private val listAdapter: JsonAdapter<List<Map<String, String>>>,
private val mapAdapter: JsonAdapter<Map<String, String>>,
) : JsonAdapter<List<Pair<String, String>>>() {
override fun toJson(writer: JsonWriter, value: List<Pair<String, String>>?) {
writer.beginArray()
value?.forEach {
writer.beginObject()
writer.name("first").value(it.first)
writer.name("second").value(it.second)
writer.endObject()
}
writer.endArray()
}
override fun fromJson(reader: JsonReader): List<Pair<String, String>>? {
return if (reader.peek() == JsonReader.Token.BEGIN_OBJECT) deserializeMoshiMap(reader)
else deserializeGsonPair(reader)
}
// for compatibility with 0.21.0
private fun deserializeMoshiMap(reader: JsonReader): List<Pair<String, String>>? {
val map = mapAdapter.fromJson(reader) ?: return null
return map.entries.map {
it.key to it.value
}
}
private fun deserializeGsonPair(reader: JsonReader): List<Pair<String, String>>? {
val list = listAdapter.fromJson(reader) ?: return null
return list.map {
require(it.size == 2) {
"pair with more or less than two elements: $list"
}
it["first"].orEmpty() to it["second"].orEmpty()
}
}
}
}

View File

@ -0,0 +1,25 @@
package io.github.wulkanowy.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import io.github.wulkanowy.data.db.entities.AdminMessage
import kotlinx.coroutines.flow.Flow
import javax.inject.Singleton
@Singleton
@Dao
abstract class AdminMessageDao : BaseDao<AdminMessage> {
@Query("SELECT * FROM AdminMessages")
abstract fun loadAll(): Flow<List<AdminMessage>>
@Transaction
open suspend fun removeOldAndSaveNew(
oldMessages: List<AdminMessage>,
newMessages: List<AdminMessage>
) {
deleteAll(oldMessages)
insertAll(newMessages)
}
}

View File

@ -11,6 +11,11 @@ import javax.inject.Singleton
@Dao
interface AttendanceDao : BaseDao<Attendance> {
@Query("SELECT * FROM Attendance WHERE diary_id = :diaryId AND student_id = :studentId AND date >= :from AND date <= :end")
fun loadAll(diaryId: Int, studentId: Int, from: LocalDate, end: LocalDate): Flow<List<Attendance>>
@Query("SELECT * FROM Attendance WHERE diary_id = :diaryId AND student_id = :studentId AND date >= :start AND date <= :end")
fun loadAll(
diaryId: Int,
studentId: Int,
start: LocalDate,
end: LocalDate
): Flow<List<Attendance>>
}

View File

@ -0,0 +1,37 @@
package io.github.wulkanowy.data.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
@Serializable
@Entity(tableName = "AdminMessages")
data class AdminMessage(
@PrimaryKey
val id: Int,
val title: String,
val content: String,
@ColumnInfo(name = "version_name")
val versionMin: Int? = null,
@ColumnInfo(name = "version_max")
val versionMax: Int? = null,
@ColumnInfo(name = "target_register_host")
val targetRegisterHost: String? = null,
@ColumnInfo(name = "target_flavor")
val targetFlavor: String? = null,
@ColumnInfo(name = "destination_url")
val destinationUrl: String? = null,
val priority: String,
val type: String
)

View File

@ -47,4 +47,7 @@ data class Attendance(
@PrimaryKey(autoGenerate = true)
var id: Long = 0
@ColumnInfo(name = "is_notified")
var isNotified: Boolean = true
}

View File

@ -40,4 +40,7 @@ data class Homework(
@ColumnInfo(name = "is_notified")
var isNotified: Boolean = true
@ColumnInfo(name = "is_added_by_user")
var isAddedByUser: Boolean = false
}

View File

@ -3,10 +3,9 @@ package io.github.wulkanowy.data.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.JsonClass
import java.io.Serializable
@JsonClass(generateAdapter = true)
@kotlinx.serialization.Serializable
@Entity(tableName = "Recipients")
data class Recipient(

View File

@ -50,4 +50,7 @@ data class Timetable(
@PrimaryKey(autoGenerate = true)
var id: Long = 0
@ColumnInfo(name = "is_notified")
var isNotified: Boolean = true
}

View File

@ -0,0 +1,21 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import io.github.wulkanowy.data.db.SharedPrefProvider
import io.github.wulkanowy.ui.modules.grade.GradeExpandMode
class Migration41(private val sharedPrefProvider: SharedPrefProvider) : Migration(40, 41) {
override fun migrate(database: SupportSQLiteDatabase) {
migrateSharedPreferences()
database.execSQL("ALTER TABLE Homework ADD COLUMN is_added_by_user INTEGER NOT NULL DEFAULT 0")
}
private fun migrateSharedPreferences() {
if (sharedPrefProvider.getBoolean("pref_key_expand_grade", false)) {
sharedPrefProvider.putString("pref_key_expand_grade_mode", GradeExpandMode.ALWAYS_EXPANDED.value)
}
sharedPrefProvider.delete("pref_key_expand_grade")
}
}

View File

@ -0,0 +1,24 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration42 : Migration(41, 42) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `AdminMessages` (
`id` INTEGER NOT NULL,
`title` TEXT NOT NULL,
`content` TEXT NOT NULL,
`version_name` INTEGER,
`version_max` INTEGER,
`target_register_host` TEXT,
`target_flavor` TEXT,
`destination_url` TEXT,
`priority` TEXT NOT NULL,
`type` TEXT NOT NULL,
PRIMARY KEY(`id`))"""
)
}
}

View File

@ -0,0 +1,12 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration43 : Migration(42, 43) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE Timetable ADD COLUMN is_notified INTEGER NOT NULL DEFAULT 1")
database.execSQL("ALTER TABLE Attendance ADD COLUMN is_notified INTEGER NOT NULL DEFAULT 1")
}
}

View File

@ -1,8 +1,8 @@
package io.github.wulkanowy.data.pojos
import com.squareup.moshi.JsonClass
import kotlinx.serialization.Serializable
@JsonClass(generateAdapter = true)
@Serializable
class Contributor(
val displayName: String,
val githubUsername: String

View File

@ -1,9 +1,9 @@
package io.github.wulkanowy.data.pojos
import com.squareup.moshi.JsonClass
import io.github.wulkanowy.ui.modules.message.send.RecipientChipItem
import kotlinx.serialization.Serializable
@JsonClass(generateAdapter = true)
@Serializable
data class MessageDraft(
val recipients: List<RecipientChipItem>,
val subject: String,

View File

@ -1,36 +1,19 @@
package io.github.wulkanowy.data.pojos
import androidx.annotation.DrawableRes
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import android.content.Intent
import io.github.wulkanowy.services.sync.notifications.NotificationType
import io.github.wulkanowy.ui.modules.main.MainView
sealed interface NotificationData {
data class NotificationData(
val intentToStart: Intent,
val title: String,
val content: String
)
data class GroupNotificationData(
val notificationDataList: List<NotificationData>,
val title: String,
val content: String,
val intentToStart: Intent,
val type: NotificationType
val startMenu: MainView.Section
val icon: Int
val titleStringRes: Int
val contentStringRes: Int
}
)
data class MultipleNotificationsData(
override val type: NotificationType,
override val startMenu: MainView.Section,
@DrawableRes override val icon: Int,
@PluralsRes override val titleStringRes: Int,
@PluralsRes override val contentStringRes: Int,
@PluralsRes val summaryStringRes: Int,
val lines: List<String>,
) : NotificationData
data class OneNotificationData(
override val type: NotificationType,
override val startMenu: MainView.Section,
@DrawableRes override val icon: Int,
@StringRes override val titleStringRes: Int,
@StringRes override val contentStringRes: Int,
val contentValues: List<String>,
) : NotificationData

View File

@ -0,0 +1,52 @@
package io.github.wulkanowy.data.repositories
import io.github.wulkanowy.data.api.AdminMessageService
import io.github.wulkanowy.data.db.dao.AdminMessageDao
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.networkBoundResource
import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AdminMessageRepository @Inject constructor(
private val adminMessageService: AdminMessageService,
private val adminMessageDao: AdminMessageDao,
private val appInfo: AppInfo,
private val refreshHelper: AutoRefreshHelper,
) {
private val saveFetchResultMutex = Mutex()
private val cacheKey = "admin_messages"
suspend fun getAdminMessages(student: Student, forceRefresh: Boolean) = networkBoundResource(
mutex = saveFetchResultMutex,
query = { adminMessageDao.loadAll() },
fetch = { adminMessageService.getAdminMessages() },
shouldFetch = {
refreshHelper.shouldBeRefreshed(cacheKey) || forceRefresh
},
saveFetchResult = { oldItems, newItems ->
adminMessageDao.removeOldAndSaveNew(oldItems, newItems)
refreshHelper.updateLastRefreshTimestamp(cacheKey)
},
showSavedOnLoading = false,
mapResult = { adminMessages ->
adminMessages.filter { adminMessage ->
val isCorrectRegister = adminMessage.targetRegisterHost?.let {
student.scrapperBaseUrl.contains(it, true)
} ?: true
val isCorrectFlavor =
adminMessage.targetFlavor?.equals(appInfo.buildFlavor, true) ?: true
val isCorrectMaxVersion =
adminMessage.versionMax?.let { it >= appInfo.versionCode } ?: true
val isCorrectMinVersion =
adminMessage.versionMin?.let { it <= appInfo.versionCode } ?: true
isCorrectRegister && isCorrectFlavor && isCorrectMaxVersion && isCorrectMinVersion
}.maxByOrNull { it.id }
}
)
}

View File

@ -1,25 +1,27 @@
package io.github.wulkanowy.data.repositories
import android.content.res.AssetManager
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.data.pojos.Contributor
import io.github.wulkanowy.utils.DispatchersProvider
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppCreatorRepository @Inject constructor(
private val assets: AssetManager,
private val dispatchers: DispatchersProvider
@ApplicationContext private val context: Context,
private val dispatchers: DispatchersProvider,
private val json: Json,
) {
@OptIn(ExperimentalSerializationApi::class)
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun getAppCreators() = withContext(dispatchers.backgroundThread) {
val moshi = Moshi.Builder().build()
val type = Types.newParameterizedType(List::class.java, Contributor::class.java)
val adapter = moshi.adapter<List<Contributor>>(type)
adapter.fromJson(assets.open("contributors.json").bufferedReader().use { it.readText() })
suspend fun getAppCreators() = withContext(dispatchers.io) {
val inputStream = context.assets.open("contributors.json").buffered()
json.decodeFromStream<List<Contributor>>(inputStream)
}
}

View File

@ -14,6 +14,7 @@ import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import java.time.LocalDate
import java.time.LocalDateTime
@ -38,6 +39,7 @@ class AttendanceRepository @Inject constructor(
start: LocalDate,
end: LocalDate,
forceRefresh: Boolean,
notify: Boolean = false,
) = networkBoundResource(
mutex = saveFetchResultMutex,
shouldFetch = {
@ -56,13 +58,28 @@ class AttendanceRepository @Inject constructor(
},
saveFetchResult = { old, new ->
attendanceDb.deleteAll(old uniqueSubtract new)
attendanceDb.insertAll(new uniqueSubtract old)
val attendanceToAdd = (new uniqueSubtract old).map { newAttendance ->
newAttendance.apply { if (notify) isNotified = false }
}
attendanceDb.insertAll(attendanceToAdd)
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester, start, end))
},
filterResult = { it.filter { item -> item.date in start..end } }
)
fun getAttendanceFromDatabase(
semester: Semester,
start: LocalDate,
end: LocalDate
): Flow<List<Attendance>> {
return attendanceDb.loadAll(semester.diaryId, semester.studentId, start, end)
}
suspend fun updateTimetable(timetable: List<Attendance>) {
return attendanceDb.updateAll(timetable)
}
suspend fun excuseForAbsence(
student: Student, semester: Semester,
absenceList: List<Attendance>, reason: String? = null

View File

@ -61,8 +61,9 @@ class HomeworkRepository @Inject constructor(
val homeWorkToSave = (new uniqueSubtract old).onEach {
if (notify) it.isNotified = false
}
val filteredOld = old.filterNot { it.isAddedByUser }
homeworkDb.deleteAll(old uniqueSubtract new)
homeworkDb.deleteAll(filteredOld uniqueSubtract new)
homeworkDb.insertAll(homeWorkToSave)
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester, start, end))
@ -79,4 +80,8 @@ class HomeworkRepository @Inject constructor(
homeworkDb.loadAll(semester.semesterId, semester.studentId, start.monday, end.sunday)
suspend fun updateHomework(homework: List<Homework>) = homeworkDb.updateAll(homework)
suspend fun saveHomework(homework: Homework) = homeworkDb.insertAll(listOf(homework))
suspend fun deleteHomework(homework: Homework) = homeworkDb.deleteAll(listOf(homework))
}

View File

@ -15,24 +15,23 @@ class LoggerRepository @Inject constructor(
suspend fun getLastLogLines() = getLastModified().readText().split("\n")
suspend fun getLogFiles() = withContext(dispatchers.backgroundThread) {
File(context.filesDir.absolutePath).listFiles(File::isFile)?.filter {
it.name.endsWith(".log")
}!!
suspend fun getLogFiles() = withContext(dispatchers.io) {
File(context.filesDir.absolutePath).listFiles(File::isFile)
?.filter { it.name.endsWith(".log") }!!
}
private suspend fun getLastModified(): File {
return withContext(dispatchers.backgroundThread) {
var lastModifiedTime = Long.MIN_VALUE
var chosenFile: File? = null
File(context.filesDir.absolutePath).listFiles(File::isFile)?.forEach { file ->
private suspend fun getLastModified() = withContext(dispatchers.io) {
var lastModifiedTime = Long.MIN_VALUE
var chosenFile: File? = null
File(context.filesDir.absolutePath).listFiles(File::isFile)
?.forEach { file ->
if (file.lastModified() > lastModifiedTime) {
lastModifiedTime = file.lastModified()
chosenFile = file
}
}
if (chosenFile == null) throw FileNotFoundException("Log file not found")
chosenFile!!
}
chosenFile ?: throw FileNotFoundException("Log file not found")
}
}

View File

@ -1,7 +1,6 @@
package io.github.wulkanowy.data.repositories
import android.content.Context
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.Resource
@ -18,7 +17,6 @@ import io.github.wulkanowy.data.enums.MessageFolder.RECEIVED
import io.github.wulkanowy.data.mappers.mapFromEntities
import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.data.pojos.MessageDraft
import io.github.wulkanowy.data.pojos.MessageDraftJsonAdapter
import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.sdk.pojo.Folder
import io.github.wulkanowy.sdk.pojo.SentMessage
@ -29,6 +27,9 @@ import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.time.LocalDateTime.now
import javax.inject.Inject
@ -42,7 +43,7 @@ class MessageRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val refreshHelper: AutoRefreshHelper,
private val sharedPrefProvider: SharedPrefProvider,
private val moshi: Moshi,
private val json: Json,
) {
private val saveFetchResultMutex = Mutex()
@ -168,9 +169,9 @@ class MessageRepository @Inject constructor(
var draftMessage: MessageDraft?
get() = sharedPrefProvider.getString(context.getString(R.string.pref_key_message_send_draft))
?.let { MessageDraftJsonAdapter(moshi).fromJson(it) }
?.let { json.decodeFromString(it) }
set(value) = sharedPrefProvider.putString(
context.getString(R.string.pref_key_message_send_draft),
value?.let { MessageDraftJsonAdapter(moshi).toJson(it) }
value?.let { json.encodeToString(it) }
)
}

View File

@ -5,20 +5,23 @@ import android.content.SharedPreferences
import androidx.core.content.edit
import com.fredporciuncula.flow.preferences.FlowSharedPreferences
import com.fredporciuncula.flow.preferences.Preference
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.sdk.toLocalDate
import io.github.wulkanowy.ui.modules.dashboard.DashboardItem
import io.github.wulkanowy.ui.modules.grade.GradeAverageMode
import io.github.wulkanowy.ui.modules.grade.GradeExpandMode
import io.github.wulkanowy.ui.modules.grade.GradeSortingMode
import io.github.wulkanowy.utils.toLocalDateTime
import io.github.wulkanowy.utils.toTimestamp
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.ClassCastException
import java.lang.IllegalStateException
import java.time.LocalDate
import java.time.LocalDateTime
import javax.inject.Inject
@ -27,16 +30,12 @@ import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class PreferencesRepository @Inject constructor(
@ApplicationContext val context: Context,
private val sharedPref: SharedPreferences,
private val flowSharedPref: FlowSharedPreferences,
@ApplicationContext val context: Context,
moshi: Moshi
private val json: Json,
) {
@OptIn(ExperimentalStdlibApi::class)
private val dashboardItemsPositionAdapter: JsonAdapter<Map<DashboardItem.Type, Int>> =
moshi.adapter()
val startMenuIndex: Int
get() = getString(R.string.pref_key_start_menu, R.string.pref_default_startup).toInt()
@ -60,8 +59,13 @@ class PreferencesRepository @Inject constructor(
R.bool.pref_default_grade_average_force_calc
)
val isGradeExpandable: Boolean
get() = !getBoolean(R.string.pref_key_expand_grade, R.bool.pref_default_expand_grade)
val gradeExpandMode: GradeExpandMode
get() = GradeExpandMode.getByValue(
getString(
R.string.pref_key_expand_grade_mode,
R.string.pref_default_expand_grade_mode
)
)
val showAllSubjectsOnStatisticsList: Boolean
get() = getBoolean(
@ -197,14 +201,14 @@ class PreferencesRepository @Inject constructor(
var dashboardItemsPosition: Map<DashboardItem.Type, Int>?
get() {
val json = sharedPref.getString(PREF_KEY_DASHBOARD_ITEMS_POSITION, null) ?: return null
val value = sharedPref.getString(PREF_KEY_DASHBOARD_ITEMS_POSITION, null) ?: return null
return dashboardItemsPositionAdapter.fromJson(json)
return json.decodeFromString(value)
}
set(value) = sharedPref.edit {
putString(
PREF_KEY_DASHBOARD_ITEMS_POSITION,
dashboardItemsPositionAdapter.toJson(value)
json.encodeToString(value)
)
}
@ -213,6 +217,7 @@ class PreferencesRepository @Inject constructor(
.map { set ->
set.map { DashboardItem.Tile.valueOf(it) }
.plus(DashboardItem.Tile.ACCOUNT)
.plus(DashboardItem.Tile.ADMIN_MESSAGE)
.toSet()
}
@ -220,6 +225,7 @@ class PreferencesRepository @Inject constructor(
get() = selectedDashboardTilesPreference.get()
.map { DashboardItem.Tile.valueOf(it) }
.plus(DashboardItem.Tile.ACCOUNT)
.plus(DashboardItem.Tile.ADMIN_MESSAGE)
.toSet()
set(value) {
val filteredValue = value.filterNot { it == DashboardItem.Tile.ACCOUNT }
@ -267,6 +273,9 @@ class PreferencesRepository @Inject constructor(
private fun getBoolean(id: String, default: Int) =
sharedPref.getBoolean(id, context.resources.getBoolean(default))
private fun getBoolean(id: Int, default: Boolean) =
sharedPref.getBoolean(context.getString(id), default)
private companion object {
private const val PREF_KEY_DASHBOARD_ITEMS_POSITION = "dashboard_items_position"

View File

@ -26,7 +26,7 @@ class SemesterRepository @Inject constructor(
student: Student,
forceRefresh: Boolean = false,
refreshOnNoCurrent: Boolean = false
) = withContext(dispatchers.backgroundThread) {
) = withContext(dispatchers.io) {
val semesters = semesterDb.loadAll(student.studentId, student.classId)
if (isShouldFetch(student, semesters, forceRefresh, refreshOnNoCurrent)) {
@ -64,7 +64,7 @@ class SemesterRepository @Inject constructor(
}
suspend fun getCurrentSemester(student: Student, forceRefresh: Boolean = false) =
withContext(dispatchers.backgroundThread) {
withContext(dispatchers.io) {
getSemesters(student, forceRefresh).getCurrentOrLast()
}
}

View File

@ -66,7 +66,7 @@ class StudentRepository @Inject constructor(
.map {
it.apply {
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = withContext(dispatchers.backgroundThread) {
student.password = withContext(dispatchers.io) {
decrypt(student.password)
}
}
@ -77,7 +77,7 @@ class StudentRepository @Inject constructor(
val student = studentDb.loadById(id) ?: throw NoCurrentStudentException()
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = withContext(dispatchers.backgroundThread) {
student.password = withContext(dispatchers.io) {
decrypt(student.password)
}
}
@ -88,7 +88,7 @@ class StudentRepository @Inject constructor(
val student = studentDb.loadCurrent() ?: throw NoCurrentStudentException()
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = withContext(dispatchers.backgroundThread) {
student.password = withContext(dispatchers.io) {
decrypt(student.password)
}
}
@ -101,7 +101,7 @@ class StudentRepository @Inject constructor(
.map {
it.apply {
if (Sdk.Mode.valueOf(it.loginMode) != Sdk.Mode.API) {
password = withContext(dispatchers.backgroundThread) {
password = withContext(dispatchers.io) {
encrypt(password, context)
}
}

View File

@ -47,6 +47,7 @@ class TimetableRepository @Inject constructor(
end: LocalDate,
forceRefresh: Boolean,
refreshAdditional: Boolean = false,
notify: Boolean = false
) = networkBoundResource(
mutex = saveFetchResultMutex,
shouldFetch = { (timetable, additional, headers) ->
@ -67,7 +68,7 @@ class TimetableRepository @Inject constructor(
timetableFull.mapToEntities(semester)
},
saveFetchResult = { timetableOld, timetableNew ->
refreshTimetable(student, timetableOld.lessons, timetableNew.lessons)
refreshTimetable(student, timetableOld.lessons, timetableNew.lessons, notify)
refreshAdditional(timetableOld.additional, timetableNew.additional)
refreshDayHeaders(timetableOld.headers, timetableNew.headers)
@ -117,13 +118,28 @@ class TimetableRepository @Inject constructor(
}
}
fun getTimetableFromDatabase(
semester: Semester,
from: LocalDate,
end: LocalDate
): Flow<List<Timetable>> {
return timetableDb.loadAll(semester.diaryId, semester.studentId, from, end)
}
suspend fun updateTimetable(timetable: List<Timetable>) {
return timetableDb.updateAll(timetable)
}
private suspend fun refreshTimetable(
student: Student,
lessonsOld: List<Timetable>,
lessonsNew: List<Timetable>,
notify: Boolean
) {
val lessonsToRemove = lessonsOld uniqueSubtract lessonsNew
val lessonsToAdd = lessonsNew uniqueSubtract lessonsOld
val lessonsToAdd = (lessonsNew uniqueSubtract lessonsOld).map { new ->
new.apply { if (notify) isNotified = false }
}
timetableDb.deleteAll(lessonsToRemove)
timetableDb.insertAll(lessonsToAdd)

View File

@ -15,6 +15,7 @@ import dagger.multibindings.IntoSet
import io.github.wulkanowy.services.sync.channels.Channel
import io.github.wulkanowy.services.sync.channels.DebugChannel
import io.github.wulkanowy.services.sync.channels.LuckyNumberChannel
import io.github.wulkanowy.services.sync.channels.NewAttendanceChannel
import io.github.wulkanowy.services.sync.channels.NewConferencesChannel
import io.github.wulkanowy.services.sync.channels.NewExamChannel
import io.github.wulkanowy.services.sync.channels.NewGradesChannel
@ -23,6 +24,7 @@ import io.github.wulkanowy.services.sync.channels.NewMessagesChannel
import io.github.wulkanowy.services.sync.channels.NewNotesChannel
import io.github.wulkanowy.services.sync.channels.NewSchoolAnnouncementsChannel
import io.github.wulkanowy.services.sync.channels.PushChannel
import io.github.wulkanowy.services.sync.channels.TimetableChangeChannel
import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel
import io.github.wulkanowy.services.sync.works.AttendanceSummaryWork
import io.github.wulkanowy.services.sync.works.AttendanceWork
@ -167,4 +169,12 @@ abstract class ServicesModule {
@Binds
@IntoSet
abstract fun provideUpcomingLessonsChannel(channel: UpcomingLessonsChannel): Channel
@Binds
@IntoSet
abstract fun provideChangeTimetableChannel(channel: TimetableChangeChannel): Channel
@Binds
@IntoSet
abstract fun provideNewAttendanceChannel(channel: NewAttendanceChannel): Channel
}

View File

@ -1,7 +1,6 @@
package io.github.wulkanowy.services.alarm
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.os.Build
@ -15,8 +14,9 @@ import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.services.HiltBroadcastReceiver
import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel.Companion.CHANNEL_ID
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.PendingIntentCompat
import io.github.wulkanowy.utils.flowWithResource
import io.github.wulkanowy.utils.getCompatColor
import io.github.wulkanowy.utils.toLocalDateTime
@ -41,7 +41,7 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
const val NOTIFICATION_TYPE_UPCOMING = 2
const val NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION = 3
const val NOTIFICATION_ID = "id"
const val NOTIFICATION_ID = 2137
const val STUDENT_NAME = "student_name"
const val STUDENT_ID = "student_id"
@ -71,11 +71,10 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
private fun prepareNotification(context: Context, intent: Intent) {
val type = intent.getIntExtra(LESSON_TYPE, 0)
val notificationId = intent.getIntExtra(NOTIFICATION_ID, MainView.Section.TIMETABLE.id)
val isPersistent = preferencesRepository.isUpcomingLessonsNotificationsPersistent
if (type == NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION) {
return NotificationManagerCompat.from(context).cancel(notificationId)
return NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
val studentId = intent.getIntExtra(STUDENT_ID, 0)
@ -92,7 +91,8 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
Timber.d("TimetableNotification receive: type: $type, subject: $subject, start: ${start.toLocalDateTime()}, student: $studentId")
showNotification(context, notificationId, isPersistent, studentName,
showNotification(
context, isPersistent, studentName,
if (type == NOTIFICATION_TYPE_CURRENT) end else start, end - start,
context.getString(
if (type == NOTIFICATION_TYPE_CURRENT) R.string.timetable_now else R.string.timetable_next,
@ -109,7 +109,6 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
private fun showNotification(
context: Context,
notificationId: Int,
isPersistent: Boolean,
studentName: String?,
countDown: Long,
@ -118,12 +117,13 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
next: String?
) {
NotificationManagerCompat.from(context)
.notify(notificationId, NotificationCompat.Builder(context, CHANNEL_ID)
.notify(NOTIFICATION_ID, NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(next)
.setAutoCancel(false)
.setWhen(countDown)
.setOngoing(isPersistent)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.apply {
if (Build.VERSION.SDK_INT >= N) setUsesChronometer(true)
}
@ -137,9 +137,9 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
.setContentIntent(
PendingIntent.getActivity(
context,
MainView.Section.TIMETABLE.id,
MainActivity.getStartIntent(context, MainView.Section.TIMETABLE, true),
FLAG_UPDATE_CURRENT
NOTIFICATION_ID,
SplashActivity.getStartIntent(context, Destination.Timetable()),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
.build()

View File

@ -3,7 +3,6 @@ package io.github.wulkanowy.services.alarm
import android.app.AlarmManager
import android.app.AlarmManager.RTC_WAKEUP
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import androidx.core.app.AlarmManagerCompat
@ -25,8 +24,8 @@ import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companio
import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.NOTIFICATION_TYPE_UPCOMING
import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.STUDENT_ID
import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.STUDENT_NAME
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.utils.DispatchersProvider
import io.github.wulkanowy.utils.PendingIntentCompat
import io.github.wulkanowy.utils.nickOrName
import io.github.wulkanowy.utils.toTimestamp
import kotlinx.coroutines.withContext
@ -54,7 +53,7 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
suspend fun cancelScheduled(lessons: List<Timetable>, student: Student) {
val studentId = student.studentId
withContext(dispatchersProvider.backgroundThread) {
withContext(dispatchersProvider.io) {
lessons.sortedBy { it.start }.forEachIndexed { index, lesson ->
val upcomingTime = getUpcomingLessonTime(index, lessons, lesson)
cancelScheduledTo(
@ -73,13 +72,19 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
private fun cancelScheduledTo(range: ClosedRange<LocalDateTime>, requestCode: Int) {
if (now() in range) cancelNotification()
alarmManager.cancel(
PendingIntent.getBroadcast(context, requestCode, Intent(), FLAG_UPDATE_CURRENT)
PendingIntent.getBroadcast(
context,
requestCode,
Intent(),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
}
fun cancelNotification() =
NotificationManagerCompat.from(context).cancel(MainView.Section.TIMETABLE.id)
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
suspend fun scheduleNotifications(lessons: List<Timetable>, student: Student) {
if (!preferencesRepository.isUpcomingLessonsNotificationsEnable) {
@ -91,7 +96,7 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
return
}
withContext(dispatchersProvider.backgroundThread) {
withContext(dispatchersProvider.io) {
lessons.groupBy { it.date }
.map { it.value.sortedBy { lesson -> lesson.start } }
.map { it.filter { lesson -> lesson.isStudentPlan } }
@ -156,9 +161,8 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
AlarmManagerCompat.setExactAndAllowWhileIdle(
alarmManager, RTC_WAKEUP, time.toTimestamp(),
PendingIntent.getBroadcast(context, getRequestCode(time, studentId), intent.also {
it.putExtra(NOTIFICATION_ID, MainView.Section.TIMETABLE.id)
it.putExtra(LESSON_TYPE, notificationType)
}, FLAG_UPDATE_CURRENT)
}, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE)
)
Timber.d(
"TimetableNotification scheduled: type: $notificationType, subject: ${

View File

@ -0,0 +1,90 @@
package io.github.wulkanowy.services.shortcuts
import android.content.Context
import android.content.Intent
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShortcutsHelper @Inject constructor(@ApplicationContext private val context: Context) {
private val destinations = mapOf(
"grade" to Destination.Grade,
"attendance" to Destination.Attendance,
"exam" to Destination.Exam,
"timetable" to Destination.Timetable()
)
init {
initializeShortcuts()
}
fun getDestination(intent: Intent) =
destinations[intent.getStringExtra(EXTRA_SHORTCUT_DESTINATION_ID)]
private fun initializeShortcuts() {
val shortcutsInfo = listOf(
ShortcutInfoCompat.Builder(context, "grade_shortcut")
.setShortLabel(context.getString(R.string.grade_title))
.setLongLabel(context.getString(R.string.grade_title))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_grade))
.setIntent(SplashActivity.getStartIntent(context)
.apply {
action = Intent.ACTION_VIEW
putExtra(EXTRA_SHORTCUT_DESTINATION_ID, "grade")
}
)
.build(),
ShortcutInfoCompat.Builder(context, "attendance_shortcut")
.setShortLabel(context.getString(R.string.attendance_title))
.setLongLabel(context.getString(R.string.attendance_title))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_attendance))
.setIntent(SplashActivity.getStartIntent(context)
.apply {
action = Intent.ACTION_VIEW
putExtra(EXTRA_SHORTCUT_DESTINATION_ID, "attendance")
}
)
.build(),
ShortcutInfoCompat.Builder(context, "exam_shortcut")
.setShortLabel(context.getString(R.string.exam_title))
.setLongLabel(context.getString(R.string.exam_title))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_exam))
.setIntent(SplashActivity.getStartIntent(context)
.apply {
action = Intent.ACTION_VIEW
putExtra(EXTRA_SHORTCUT_DESTINATION_ID, "exam")
}
)
.build(),
ShortcutInfoCompat.Builder(context, "timetable_shortcut")
.setShortLabel(context.getString(R.string.timetable_title))
.setLongLabel(context.getString(R.string.timetable_title))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_timetable))
.setIntent(SplashActivity.getStartIntent(context)
.apply {
action = Intent.ACTION_VIEW
putExtra(EXTRA_SHORTCUT_DESTINATION_ID, "timetable")
}
)
.build()
)
shortcutsInfo.forEach { ShortcutManagerCompat.pushDynamicShortcut(context, it) }
}
private companion object {
private const val EXTRA_SHORTCUT_DESTINATION_ID = "shortcut_destination_id"
}
}

View File

@ -74,7 +74,7 @@ class SyncManager @Inject constructor(
}
}
fun startOneTimeSyncWorker(): Flow<WorkInfo> {
fun startOneTimeSyncWorker(): Flow<WorkInfo?> {
val work = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(
Data.Builder()

View File

@ -19,11 +19,11 @@ import io.github.wulkanowy.sdk.exception.FeatureNotAvailableException
import io.github.wulkanowy.sdk.scrapper.exception.FeatureDisabledException
import io.github.wulkanowy.services.sync.channels.DebugChannel
import io.github.wulkanowy.services.sync.works.Work
import io.github.wulkanowy.utils.DispatchersProvider
import io.github.wulkanowy.utils.getCompatColor
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.random.Random
@HiltWorker
@ -34,13 +34,14 @@ class SyncWorker @AssistedInject constructor(
private val semesterRepository: SemesterRepository,
private val works: Set<@JvmSuppressWildcards Work>,
private val preferencesRepository: PreferencesRepository,
private val notificationManager: NotificationManagerCompat
private val notificationManager: NotificationManagerCompat,
private val dispatchersProvider: DispatchersProvider
) : CoroutineWorker(appContext, workerParameters) {
override suspend fun doWork() = coroutineScope {
override suspend fun doWork() = withContext(dispatchersProvider.io) {
Timber.i("SyncWorker is starting")
if (!studentRepository.isCurrentStudentSet()) return@coroutineScope Result.failure()
if (!studentRepository.isCurrentStudentSet()) return@withContext Result.failure()
val student = studentRepository.getCurrentStudent()
val semester = semesterRepository.getCurrentSemester(student, true)
@ -50,12 +51,12 @@ class SyncWorker @AssistedInject constructor(
Timber.i("${work::class.java.simpleName} is starting")
work.doWork(student, semester)
Timber.i("${work::class.java.simpleName} result: Success")
preferencesRepository.lasSyncDate = LocalDateTime.now(ZoneId.systemDefault())
null
} catch (e: Throwable) {
Timber.w("${work::class.java.simpleName} result: An exception ${e.message} occurred")
if (e is FeatureDisabledException || e is FeatureNotAvailableException) null
else {
if (e is FeatureDisabledException || e is FeatureNotAvailableException) {
null
} else {
Timber.e(e)
e
}
@ -70,13 +71,16 @@ class SyncWorker @AssistedInject constructor(
)
}
exceptions.isNotEmpty() -> Result.retry()
else -> Result.success()
else -> {
preferencesRepository.lasSyncDate = LocalDateTime.now()
Result.success()
}
}
if (preferencesRepository.isDebugNotificationEnable) notify(result)
Timber.i("SyncWorker result: $result")
result
return@withContext result
}
private fun notify(result: Result) {

View File

@ -0,0 +1,36 @@
package io.github.wulkanowy.services.sync.channels
import android.annotation.TargetApi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import javax.inject.Inject
@TargetApi(26)
class NewAttendanceChannel @Inject constructor(
private val notificationManager: NotificationManagerCompat,
@ApplicationContext private val context: Context
) : Channel {
companion object {
const val CHANNEL_ID = "new_attendance_channel"
}
override fun create() {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.channel_new_attendance),
NotificationManager.IMPORTANCE_HIGH
)
.apply {
enableLights(true)
enableVibration(true)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
})
}
}

View File

@ -0,0 +1,36 @@
package io.github.wulkanowy.services.sync.channels
import android.annotation.TargetApi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import javax.inject.Inject
@TargetApi(26)
class TimetableChangeChannel @Inject constructor(
private val notificationManager: NotificationManagerCompat,
@ApplicationContext private val context: Context
) : Channel {
companion object {
const val CHANNEL_ID = "change_timetable_channel"
}
override fun create() {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.channel_change_timetable),
NotificationManager.IMPORTANCE_HIGH
)
.apply {
enableLights(true)
enableVibration(true)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
})
}
}

View File

@ -10,12 +10,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Notification
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotificationsData
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.data.pojos.OneNotificationData
import io.github.wulkanowy.data.repositories.NotificationRepository
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.utils.PendingIntentCompat
import io.github.wulkanowy.utils.getCompatBitmap
import io.github.wulkanowy.utils.getCompatColor
import io.github.wulkanowy.utils.nickOrName
@ -26,120 +25,156 @@ import kotlin.random.Random
class AppNotificationManager @Inject constructor(
private val notificationManager: NotificationManagerCompat,
@ApplicationContext private val context: Context,
private val appInfo: AppInfo,
private val studentRepository: StudentRepository,
private val notificationRepository: NotificationRepository
) {
suspend fun sendNotification(notificationData: NotificationData, student: Student) =
when (notificationData) {
is OneNotificationData -> sendOneNotification(notificationData, student)
is MultipleNotificationsData -> sendMultipleNotifications(notificationData, student)
}
private suspend fun sendOneNotification(
notificationData: OneNotificationData,
student: Student
) {
val content = context.getString(
notificationData.contentStringRes,
*notificationData.contentValues.toTypedArray()
)
val title = context.getString(notificationData.titleStringRes)
val notification = getDefaultNotificationBuilder(notificationData)
.setContentTitle(title)
.setContentText(content)
.setStyle(
NotificationCompat.BigTextStyle()
.setSummaryText(student.nickOrName)
.bigText(content)
)
.build()
notificationManager.notify(Random.nextInt(Int.MAX_VALUE), notification)
saveNotification(title, content, notificationData, student)
}
private suspend fun sendMultipleNotifications(
notificationData: MultipleNotificationsData,
student: Student
) {
val groupType = notificationData.type.group ?: return
val group = "${groupType}_${student.id}"
val groupId = student.id * 100 + notificationData.type.ordinal
notificationData.lines.forEach { item ->
val title = context.resources.getQuantityString(notificationData.titleStringRes, 1)
val notification = getDefaultNotificationBuilder(notificationData)
.setContentTitle(title)
.setContentText(item)
.setStyle(
NotificationCompat.BigTextStyle()
.setSummaryText(student.nickOrName)
.bigText(item)
)
.setGroup(group)
.build()
notificationManager.notify(Random.nextInt(Int.MAX_VALUE), notification)
saveNotification(title, item, notificationData, student)
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
val summaryNotification = getDefaultNotificationBuilder(notificationData)
.setSmallIcon(notificationData.icon)
.setGroup(group)
.setStyle(NotificationCompat.InboxStyle().setSummaryText(student.nickOrName))
.setGroupSummary(true)
.build()
notificationManager.notify(groupId.toInt(), summaryNotification)
}
@SuppressLint("InlinedApi")
private fun getDefaultNotificationBuilder(notificationData: NotificationData): NotificationCompat.Builder {
val pendingIntentsFlags = if (appInfo.systemVersion >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
return NotificationCompat.Builder(context, notificationData.type.channel)
.setLargeIcon(context.getCompatBitmap(notificationData.icon, R.color.colorPrimary))
suspend fun sendSingleNotification(
notificationData: NotificationData,
notificationType: NotificationType,
student: Student
) {
val notification = NotificationCompat.Builder(context, notificationType.channel)
.setLargeIcon(context.getCompatBitmap(notificationType.icon, R.color.colorPrimary))
.setSmallIcon(R.drawable.ic_stat_all)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(context.getCompatColor(R.color.colorPrimary))
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setContentIntent(
PendingIntent.getActivity(
context,
notificationData.startMenu.id,
MainActivity.getStartIntent(context, notificationData.startMenu, true),
pendingIntentsFlags
Random.nextInt(),
notificationData.intentToStart,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
.setContentTitle(notificationData.title)
.setContentText(notificationData.content)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(notificationData.content)
.also { builder ->
if (shouldShowStudentName()) {
builder.setSummaryText(student.nickOrName)
}
}
)
.build()
notificationManager.notify(Random.nextInt(), notification)
saveNotification(notificationData, notificationType, student)
}
@SuppressLint("InlinedApi")
suspend fun sendMultipleNotifications(
groupNotificationData: GroupNotificationData,
student: Student
) {
val notificationType = groupNotificationData.type
val groupType = notificationType.group ?: return
val group = "${groupType}_${student.id}"
sendSummaryNotification(groupNotificationData, group, student)
groupNotificationData.notificationDataList.forEach { notificationData ->
val notification = NotificationCompat.Builder(context, notificationType.channel)
.setLargeIcon(context.getCompatBitmap(notificationType.icon, R.color.colorPrimary))
.setSmallIcon(R.drawable.ic_stat_all)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(context.getCompatColor(R.color.colorPrimary))
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setContentIntent(
PendingIntent.getActivity(
context,
Random.nextInt(),
notificationData.intentToStart,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
.setContentTitle(notificationData.title)
.setContentText(notificationData.content)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(notificationData.content)
.also { builder ->
if (shouldShowStudentName()) {
builder.setSummaryText(student.nickOrName)
}
}
)
.setGroup(group)
.build()
notificationManager.notify(Random.nextInt(), notification)
saveNotification(notificationData, groupNotificationData.type, student)
}
}
private suspend fun sendSummaryNotification(
groupNotificationData: GroupNotificationData,
group: String,
student: Student
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
val summaryNotification =
NotificationCompat.Builder(context, groupNotificationData.type.channel)
.setContentTitle(groupNotificationData.title)
.setContentText(groupNotificationData.content)
.setSmallIcon(groupNotificationData.type.icon)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(context.getCompatColor(R.color.colorPrimary))
.setStyle(
NotificationCompat.InboxStyle()
.also { builder ->
if (shouldShowStudentName()) {
builder.setSummaryText(student.nickOrName)
}
groupNotificationData.notificationDataList.forEach {
builder.addLine(it.content)
}
}
)
.setContentIntent(
PendingIntent.getActivity(
context,
Random.nextInt(),
groupNotificationData.intentToStart,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
.setLocalOnly(true)
.setGroup(group)
.setGroupSummary(true)
.build()
val groupId = student.id * 100 + groupNotificationData.type.ordinal
notificationManager.notify(groupId.toInt(), summaryNotification)
}
private suspend fun saveNotification(
title: String,
content: String,
notificationData: NotificationData,
notificationType: NotificationType,
student: Student
) {
val notificationEntity = Notification(
studentId = student.id,
title = title,
content = content,
type = notificationData.type,
title = notificationData.title,
content = notificationData.content,
type = notificationType,
date = LocalDateTime.now()
)
notificationRepository.saveNotification(notificationEntity)
}
}
private suspend fun shouldShowStudentName(): Boolean =
studentRepository.getSavedStudents(decryptPass = false).size > 1
}

View File

@ -0,0 +1,124 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate
import java.time.LocalDateTime
import javax.inject.Inject
class ChangeTimetableNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context,
) {
suspend fun notify(items: List<Timetable>, student: Student) {
val currentTime = LocalDateTime.now()
val changedLessons = items.filter { (it.canceled || it.changes) && it.start > currentTime }
val notificationDataList = changedLessons.groupBy { it.date }
.map { (date, lessons) ->
getNotificationContents(date, lessons).map {
NotificationData(
title = context.getPlural(
R.plurals.timetable_notify_new_items_title,
1
),
content = it,
intentToStart = SplashActivity.getStartIntent(
context = context,
destination = Destination.Timetable(date)
)
)
}
}
.flatten()
.ifEmpty { return }
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(
R.plurals.timetable_notify_new_items_title,
changedLessons.size
),
content = context.getPlural(
R.plurals.timetable_notify_new_items_group,
changedLessons.size,
changedLessons.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Timetable()),
type = NotificationType.CHANGE_TIMETABLE
)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
private fun getNotificationContents(date: LocalDate, lessons: List<Timetable>): List<String> {
val formattedDate = date.toFormattedString("EEE dd.MM")
return if (lessons.size > 2) {
listOf(
context.getPlural(
R.plurals.timetable_notify_new_items,
lessons.size,
formattedDate,
lessons.size,
)
)
} else {
lessons.map {
buildString {
append(
context.getString(
R.string.timetable_notify_lesson,
formattedDate,
it.number,
it.subject
)
)
if (it.roomOld.isNotBlank()) {
appendLine()
append(
context.getString(
R.string.timetable_notify_change_room,
it.roomOld,
it.room
)
)
}
if (it.teacherOld.isNotBlank() && it.teacher != it.teacherOld) {
appendLine()
append(
context.getString(
R.string.timetable_notify_change_teacher,
it.teacherOld,
it.teacher
)
)
}
if (it.subjectOld.isNotBlank()) {
appendLine()
append(
context.getString(
R.string.timetable_notify_change_subject,
it.subjectOld,
it.subject
)
)
}
if (it.info.isNotBlank()) {
appendLine()
append(it.info)
}
}
}
}
}
}

View File

@ -0,0 +1,55 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.descriptionRes
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString
import javax.inject.Inject
class NewAttendanceNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(items: List<Attendance>, student: Student) {
val lines = items.filterNot { it.presence || it.name == "UNKNOWN" }
.map {
val description = context.getString(it.descriptionRes)
"${it.date.toFormattedString("dd.MM")} - ${it.subject}: $description"
}
.ifEmpty { return }
val notificationDataList = lines.map {
NotificationData(
title = context.getPlural(R.plurals.attendance_notify_new_items_title, 1),
content = it,
intentToStart = SplashActivity.getStartIntent(context, Destination.Attendance)
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(
R.plurals.attendance_notify_new_items_title,
notificationDataList.size
),
content = context.getPlural(
R.plurals.attendance_notify_new_items,
notificationDataList.size,
notificationDataList.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Attendance),
type = NotificationType.NEW_ATTENDANCE
)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,34 +1,52 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Conference
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotificationsData
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDateTime
import javax.inject.Inject
class NewConferenceNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(items: List<Conference>, student: Student) {
val today = LocalDateTime.now()
val lines = items.filter { !it.date.isBefore(today) }.map {
"${it.date.toFormattedString("dd.MM")} - ${it.title}: ${it.subject}"
}.ifEmpty { return }
val lines = items.filter { !it.date.isBefore(today) }
.map {
"${it.date.toFormattedString("dd.MM")} - ${it.title}: ${it.subject}"
}
.ifEmpty { return }
val notification = MultipleNotificationsData(
type = NotificationType.NEW_CONFERENCE,
icon = R.drawable.ic_more_conferences,
titleStringRes = R.plurals.conference_notify_new_item_title,
contentStringRes = R.plurals.conference_notify_new_items,
summaryStringRes = R.plurals.conference_number_item,
startMenu = MainView.Section.CONFERENCE,
lines = lines
val notificationDataList = lines.map {
NotificationData(
title = context.getPlural(R.plurals.conference_notify_new_item_title, 1),
content = it,
intentToStart = SplashActivity.getStartIntent(context, Destination.Conference)
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.conference_notify_new_item_title, lines.size),
content = context.getPlural(
R.plurals.conference_notify_new_items,
lines.size,
lines.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Conference),
type = NotificationType.NEW_CONFERENCE
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,34 +1,52 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Exam
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotificationsData
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate
import javax.inject.Inject
class NewExamNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(items: List<Exam>, student: Student) {
val today = LocalDate.now()
val lines = items.filter { !it.date.isBefore(today) }.map {
"${it.date.toFormattedString("dd.MM")} - ${it.subject}: ${it.description}"
}.ifEmpty { return }
val lines = items.filter { !it.date.isBefore(today) }
.map {
"${it.date.toFormattedString("dd.MM")} - ${it.subject}: ${it.description}"
}
.ifEmpty { return }
val notification = MultipleNotificationsData(
type = NotificationType.NEW_EXAM,
icon = R.drawable.ic_main_exam,
titleStringRes = R.plurals.exam_notify_new_item_title,
contentStringRes = R.plurals.exam_notify_new_item_content,
summaryStringRes = R.plurals.exam_number_item,
startMenu = MainView.Section.EXAM,
lines = lines
val notificationDataList = lines.map {
NotificationData(
title = context.getPlural(R.plurals.exam_notify_new_item_title, 1),
content = it,
intentToStart = SplashActivity.getStartIntent(context, Destination.Exam),
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.exam_notify_new_item_title, lines.size),
content = context.getPlural(
R.plurals.exam_notify_new_item_content,
lines.size,
lines.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Exam),
type = NotificationType.NEW_EXAM
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,62 +1,88 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotificationsData
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import javax.inject.Inject
class NewGradeNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notifyDetails(items: List<Grade>, student: Student) {
val notification = MultipleNotificationsData(
type = NotificationType.NEW_GRADE_DETAILS,
icon = R.drawable.ic_stat_grade,
titleStringRes = R.plurals.grade_new_items,
contentStringRes = R.plurals.grade_notify_new_items,
summaryStringRes = R.plurals.grade_number_item,
startMenu = MainView.Section.GRADE,
lines = items.map {
"${it.subject}: ${it.entry}"
}
val notificationDataList = items.map {
NotificationData(
title = context.getPlural(R.plurals.grade_new_items, 1),
content = "${it.subject}: ${it.entry}",
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.grade_new_items, items.size),
content = context.getPlural(R.plurals.grade_notify_new_items, items.size, items.size),
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
type = NotificationType.NEW_GRADE_DETAILS
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
suspend fun notifyPredicted(items: List<GradeSummary>, student: Student) {
val notification = MultipleNotificationsData(
type = NotificationType.NEW_GRADE_PREDICTED,
icon = R.drawable.ic_stat_grade,
titleStringRes = R.plurals.grade_new_items_predicted,
contentStringRes = R.plurals.grade_notify_new_items_predicted,
summaryStringRes = R.plurals.grade_number_item,
startMenu = MainView.Section.GRADE,
lines = items.map {
"${it.subject}: ${it.predictedGrade}"
}
val notificationDataList = items.map {
NotificationData(
title = context.getPlural(R.plurals.grade_new_items_predicted, 1),
content = "${it.subject}: ${it.predictedGrade}",
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.grade_new_items_predicted, items.size),
content = context.getPlural(
R.plurals.grade_notify_new_items_predicted,
items.size,
items.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
type = NotificationType.NEW_GRADE_PREDICTED
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
suspend fun notifyFinal(items: List<GradeSummary>, student: Student) {
val notification = MultipleNotificationsData(
type = NotificationType.NEW_GRADE_FINAL,
icon = R.drawable.ic_stat_grade,
titleStringRes = R.plurals.grade_new_items_final,
contentStringRes = R.plurals.grade_notify_new_items_final,
summaryStringRes = R.plurals.grade_number_item,
startMenu = MainView.Section.GRADE,
lines = items.map {
"${it.subject}: ${it.finalGrade}"
}
val notificationDataList = items.map {
NotificationData(
title = context.getPlural(R.plurals.grade_new_items_final, 1),
content = "${it.subject}: ${it.finalGrade}",
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.grade_new_items_final, items.size),
content = context.getPlural(
R.plurals.grade_notify_new_items_final,
items.size,
items.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
type = NotificationType.NEW_GRADE_FINAL
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,34 +1,52 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Homework
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotificationsData
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate
import javax.inject.Inject
class NewHomeworkNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(items: List<Homework>, student: Student) {
val today = LocalDate.now()
val lines = items.filter { !it.date.isBefore(today) }.map {
"${it.date.toFormattedString("dd.MM")} - ${it.subject}: ${it.content}"
}.ifEmpty { return }
val lines = items.filter { !it.date.isBefore(today) }
.map {
"${it.date.toFormattedString("dd.MM")} - ${it.subject}: ${it.content}"
}
.ifEmpty { return }
val notification = MultipleNotificationsData(
val notificationDataList = lines.map {
NotificationData(
title = context.getPlural(R.plurals.homework_notify_new_item_title, 1),
content = it,
intentToStart = SplashActivity.getStartIntent(context, Destination.Homework),
)
}
val groupNotificationData = GroupNotificationData(
title = context.getPlural(R.plurals.homework_notify_new_item_title, lines.size),
content = context.getPlural(
R.plurals.homework_notify_new_item_content,
lines.size,
lines.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Homework),
type = NotificationType.NEW_HOMEWORK,
icon = R.drawable.ic_more_homework,
titleStringRes = R.plurals.homework_notify_new_item_title,
contentStringRes = R.plurals.homework_notify_new_item_content,
summaryStringRes = R.plurals.homework_number_item,
startMenu = MainView.Section.HOMEWORK,
lines = lines
notificationDataList = notificationDataList
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,26 +1,34 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.LuckyNumber
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.OneNotificationData
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import javax.inject.Inject
class NewLuckyNumberNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(item: LuckyNumber, student: Student) {
val notification = OneNotificationData(
type = NotificationType.NEW_LUCKY_NUMBER,
icon = R.drawable.ic_stat_luckynumber,
titleStringRes = R.string.lucky_number_notify_new_item_title,
contentStringRes = R.string.lucky_number_notify_new_item,
startMenu = MainView.Section.LUCKY_NUMBER,
contentValues = listOf(item.luckyNumber.toString())
)
suspend fun notify(item: LuckyNumber, student: Student) {
val notificationData = NotificationData(
title = context.getString(R.string.lucky_number_notify_new_item_title),
content = context.getString(
R.string.lucky_number_notify_new_item,
item.luckyNumber.toString()
),
intentToStart = SplashActivity.getStartIntent(context, Destination.LuckyNumber)
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendSingleNotification(
notificationData = notificationData,
notificationType = NotificationType.NEW_LUCKY_NUMBER,
student = student
)
}
}

View File

@ -1,29 +1,39 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotificationsData
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import javax.inject.Inject
class NewMessageNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(items: List<Message>, student: Student) {
val notification = MultipleNotificationsData(
type = NotificationType.NEW_MESSAGE,
icon = R.drawable.ic_stat_message,
titleStringRes = R.plurals.message_new_items,
contentStringRes = R.plurals.message_notify_new_items,
summaryStringRes = R.plurals.message_number_item,
startMenu = MainView.Section.MESSAGE,
lines = items.map {
"${it.sender}: ${it.subject}"
}
val notificationDataList = items.map {
NotificationData(
title = context.getPlural(R.plurals.message_new_items, 1),
content = "${it.sender}: ${it.subject}",
intentToStart = SplashActivity.getStartIntent(context, Destination.Message),
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.message_new_items, items.size),
content = context.getPlural(R.plurals.message_notify_new_items, items.size, items.size),
intentToStart = SplashActivity.getStartIntent(context, Destination.Message),
type = NotificationType.NEW_MESSAGE
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,42 +1,46 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Note
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotificationsData
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.sdk.scrapper.notes.NoteCategory
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import javax.inject.Inject
class NewNoteNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(items: List<Note>, student: Student) {
val notification = MultipleNotificationsData(
type = NotificationType.NEW_NOTE,
icon = R.drawable.ic_stat_note,
titleStringRes = when (NoteCategory.getByValue(items.first().categoryType)) {
val notificationDataList = items.map {
val titleRes = when (NoteCategory.getByValue(it.categoryType)) {
NoteCategory.POSITIVE -> R.plurals.praise_new_items
NoteCategory.NEUTRAL -> R.plurals.neutral_note_new_items
else -> R.plurals.note_new_items
},
contentStringRes = when (NoteCategory.getByValue(items.first().categoryType)) {
NoteCategory.POSITIVE -> R.plurals.praise_notify_new_items
NoteCategory.NEUTRAL -> R.plurals.neutral_note_notify_new_items
else -> R.plurals.note_notify_new_items
},
summaryStringRes = when (NoteCategory.getByValue(items.first().categoryType)) {
NoteCategory.POSITIVE -> R.plurals.praise_number_item
NoteCategory.NEUTRAL -> R.plurals.neutral_note_number_item
else -> R.plurals.note_number_item
},
startMenu = MainView.Section.NOTE,
lines = items.map {
"${it.teacher}: ${it.category}"
}
NotificationData(
title = context.getPlural(titleRes, 1),
content = "${it.teacher}: ${it.category}",
intentToStart = SplashActivity.getStartIntent(context, Destination.Note),
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
intentToStart = SplashActivity.getStartIntent(context, Destination.Note),
title = context.getPlural(R.plurals.note_new_items, items.size),
content = context.getPlural(R.plurals.note_notify_new_items, items.size, items.size),
type = NotificationType.NEW_NOTE
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,29 +1,54 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.SchoolAnnouncement
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotificationsData
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import javax.inject.Inject
class NewSchoolAnnouncementNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(items: List<SchoolAnnouncement>, student: Student) {
val notification = MultipleNotificationsData(
type = NotificationType.NEW_ANNOUNCEMENT,
icon = R.drawable.ic_all_about,
titleStringRes = R.plurals.school_announcement_notify_new_item_title,
contentStringRes = R.plurals.school_announcement_notify_new_items,
summaryStringRes = R.plurals.school_announcement_number_item,
startMenu = MainView.Section.SCHOOL_ANNOUNCEMENT,
lines = items.map {
"${it.subject}: ${it.content}"
}
suspend fun notify(items: List<SchoolAnnouncement>, student: Student) {
val notificationDataList = items.map {
NotificationData(
intentToStart = SplashActivity.getStartIntent(
context = context,
destination = Destination.SchoolAnnouncement
),
title = context.getPlural(
R.plurals.school_announcement_notify_new_item_title,
1
),
content = "${it.subject}: ${it.content}"
)
}
val groupNotificationData = GroupNotificationData(
type = NotificationType.NEW_ANNOUNCEMENT,
intentToStart = SplashActivity.getStartIntent(
context = context,
destination = Destination.SchoolAnnouncement
),
title = context.getPlural(
R.plurals.school_announcement_notify_new_item_title,
items.size
),
content = context.getPlural(
R.plurals.school_announcement_notify_new_items,
items.size,
items.size
),
notificationDataList = notificationDataList
)
appNotificationManager.sendNotification(notification, student)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,6 +1,8 @@
package io.github.wulkanowy.services.sync.notifications
import io.github.wulkanowy.R
import io.github.wulkanowy.services.sync.channels.LuckyNumberChannel
import io.github.wulkanowy.services.sync.channels.NewAttendanceChannel
import io.github.wulkanowy.services.sync.channels.NewConferencesChannel
import io.github.wulkanowy.services.sync.channels.NewExamChannel
import io.github.wulkanowy.services.sync.channels.NewGradesChannel
@ -9,17 +11,76 @@ import io.github.wulkanowy.services.sync.channels.NewMessagesChannel
import io.github.wulkanowy.services.sync.channels.NewNotesChannel
import io.github.wulkanowy.services.sync.channels.NewSchoolAnnouncementsChannel
import io.github.wulkanowy.services.sync.channels.PushChannel
import io.github.wulkanowy.services.sync.channels.TimetableChangeChannel
enum class NotificationType(val group: String?, val channel: String) {
NEW_CONFERENCE("new_conferences_group", NewConferencesChannel.CHANNEL_ID),
NEW_EXAM("new_exam_group", NewExamChannel.CHANNEL_ID),
NEW_GRADE_DETAILS("new_grade_details_group", NewGradesChannel.CHANNEL_ID),
NEW_GRADE_PREDICTED("new_grade_predicted_group", NewGradesChannel.CHANNEL_ID),
NEW_GRADE_FINAL("new_grade_final_group", NewGradesChannel.CHANNEL_ID),
NEW_HOMEWORK("new_homework_group", NewHomeworkChannel.CHANNEL_ID),
NEW_LUCKY_NUMBER("lucky_number_group", LuckyNumberChannel.CHANNEL_ID),
NEW_MESSAGE("new_message_group", NewMessagesChannel.CHANNEL_ID),
NEW_NOTE("new_notes_group", NewNotesChannel.CHANNEL_ID),
NEW_ANNOUNCEMENT("new_school_announcements_group", NewSchoolAnnouncementsChannel.CHANNEL_ID),
PUSH(null, PushChannel.CHANNEL_ID)
enum class NotificationType(
val group: String?,
val channel: String,
val icon: Int
) {
NEW_CONFERENCE(
group = "new_conferences_group",
channel = NewConferencesChannel.CHANNEL_ID,
icon = R.drawable.ic_more_conferences,
),
NEW_EXAM(
group = "new_exam_group",
channel = NewExamChannel.CHANNEL_ID,
icon = R.drawable.ic_main_exam
),
NEW_GRADE_DETAILS(
group = "new_grade_details_group",
channel = NewGradesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_grade,
),
NEW_GRADE_PREDICTED(
group = "new_grade_predicted_group",
channel = NewGradesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_grade,
),
NEW_GRADE_FINAL(
group = "new_grade_final_group",
channel = NewGradesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_grade,
),
NEW_HOMEWORK(
group = "new_homework_group",
channel = NewHomeworkChannel.CHANNEL_ID,
icon = R.drawable.ic_more_homework,
),
NEW_LUCKY_NUMBER(
group = null,
channel = LuckyNumberChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_luckynumber,
),
NEW_MESSAGE(
group = "new_message_group",
channel = NewMessagesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_message,
),
NEW_NOTE(
group = "new_notes_group",
channel = NewNotesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_note
),
NEW_ANNOUNCEMENT(
group = "new_school_announcements_group",
channel = NewSchoolAnnouncementsChannel.CHANNEL_ID,
icon = R.drawable.ic_all_about
),
CHANGE_TIMETABLE(
group = "change_timetable_group",
channel = TimetableChangeChannel.CHANNEL_ID,
icon = R.drawable.ic_main_timetable
),
NEW_ATTENDANCE(
group = "new_attendance_group",
channel = NewAttendanceChannel.CHANNEL_ID,
icon = R.drawable.ic_main_attendance
),
PUSH(
group = null,
channel = PushChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_all
)
}

View File

@ -3,18 +3,40 @@ package io.github.wulkanowy.services.sync.works
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.AttendanceRepository
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.services.sync.notifications.NewAttendanceNotification
import io.github.wulkanowy.utils.previousOrSameSchoolDay
import io.github.wulkanowy.utils.waitForResult
import kotlinx.coroutines.flow.first
import java.time.LocalDate.now
import javax.inject.Inject
class AttendanceWork @Inject constructor(
private val attendanceRepository: AttendanceRepository
private val attendanceRepository: AttendanceRepository,
private val newAttendanceNotification: NewAttendanceNotification,
private val preferencesRepository: PreferencesRepository
) : Work {
override suspend fun doWork(student: Student, semester: Semester) {
attendanceRepository.getAttendance(student, semester, now().monday, now().sunday, true)
attendanceRepository.getAttendance(
student = student,
semester = semester,
start = now().previousOrSameSchoolDay,
end = now().previousOrSameSchoolDay,
forceRefresh = true,
notify = preferencesRepository.isNotificationsEnable
)
.waitForResult()
attendanceRepository.getAttendanceFromDatabase(semester, now().minusDays(7), now())
.first()
.filterNot { it.isNotified }
.let {
if (it.isNotEmpty()) newAttendanceNotification.notify(it, student)
attendanceRepository.updateTimetable(it.onEach { attendance ->
attendance.isNotified = true
})
}
}
}

View File

@ -5,8 +5,7 @@ import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.HomeworkRepository
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.services.sync.notifications.NewHomeworkNotification
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.nextOrSameSchoolDay
import io.github.wulkanowy.utils.waitForResult
import kotlinx.coroutines.flow.first
import java.time.LocalDate.now
@ -22,13 +21,13 @@ class HomeworkWork @Inject constructor(
homeworkRepository.getHomework(
student = student,
semester = semester,
start = now().monday,
end = now().sunday,
start = now().nextOrSameSchoolDay,
end = now().nextOrSameSchoolDay,
forceRefresh = true,
notify = preferencesRepository.isNotificationsEnable
).waitForResult()
homeworkRepository.getHomeworkFromDatabase(semester, now().monday, now().sunday).first()
homeworkRepository.getHomeworkFromDatabase(semester, now(), now().plusDays(7)).first()
.filter { !it.isNotified }.let {
if (it.isNotEmpty()) newHomeworkNotification.notify(it, student)

View File

@ -2,18 +2,41 @@ package io.github.wulkanowy.services.sync.works
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.TimetableRepository
import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.services.sync.notifications.ChangeTimetableNotification
import io.github.wulkanowy.utils.nextOrSameSchoolDay
import io.github.wulkanowy.utils.waitForResult
import kotlinx.coroutines.flow.first
import java.time.LocalDate.now
import javax.inject.Inject
class TimetableWork @Inject constructor(
private val timetableRepository: TimetableRepository
private val timetableRepository: TimetableRepository,
private val changeTimetableNotification: ChangeTimetableNotification,
private val preferencesRepository: PreferencesRepository
) : Work {
override suspend fun doWork(student: Student, semester: Semester) {
timetableRepository.getTimetable(student, semester, now().monday, now().sunday, true).waitForResult()
timetableRepository.getTimetable(
student = student,
semester = semester,
start = now().nextOrSameSchoolDay,
end = now().nextOrSameSchoolDay,
forceRefresh = true,
notify = preferencesRepository.isNotificationsEnable
)
.waitForResult()
timetableRepository.getTimetableFromDatabase(semester, now(), now().plusDays(7))
.first()
.filterNot { it.isNotified }
.let {
if (it.isNotEmpty()) changeTimetableNotification.notify(it, student)
timetableRepository.updateTimetable(it.onEach { timetable ->
timetable.isNotified = true
})
}
}
}

View File

@ -1,14 +1,11 @@
package io.github.wulkanowy.ui.base
import android.app.ActivityManager
import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.viewbinding.ViewBinding
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
@ -40,7 +37,6 @@ abstract class BaseActivity<T : BasePresenter<out BaseView>, VB : ViewBinding> :
themeManager.applyActivityTheme(this)
super.onCreate(savedInstanceState)
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleLogger, true)
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
@Suppress("DEPRECATION")
setTaskDescription(
@ -83,8 +79,8 @@ abstract class BaseActivity<T : BasePresenter<out BaseView>, VB : ViewBinding> :
}
override fun openClearLoginView() {
startActivity(LoginActivity.getStartIntent(this)
.apply { addFlags(FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK) })
startActivity(LoginActivity.getStartIntent(this))
finishAffinity()
}
override fun onDestroy() {

View File

@ -2,32 +2,33 @@ package io.github.wulkanowy.ui.base
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
//TODO Use ViewPager2
class BaseFragmentPagerAdapter(private val fragmentManager: FragmentManager) :
FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
class BaseFragmentPagerAdapter(
private val fragmentManager: FragmentManager,
private val pagesCount: Int,
lifecycle: Lifecycle,
) : FragmentStateAdapter(fragmentManager, lifecycle), TabLayoutMediator.TabConfigurationStrategy {
private val pages = mutableMapOf<Fragment, String?>()
lateinit var itemFactory: (position: Int) -> Fragment
var titleFactory: (position: Int) -> String? = { "" }
var containerId = 0
fun getFragmentInstance(position: Int): Fragment? {
require(containerId != 0) { "Container id is 0" }
return fragmentManager.findFragmentByTag("android:switcher:$containerId:$position")
return fragmentManager.findFragmentByTag("f$position")
}
fun addFragments(fragments: List<Fragment>) {
fragments.forEach { pages[it] = null }
override fun createFragment(position: Int): Fragment = itemFactory(position)
override fun getItemCount() = pagesCount
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.text = titleFactory(position)
}
fun addFragmentsWithTitle(pages: Map<Fragment, String>) {
this.pages.putAll(pages)
}
override fun getItem(position: Int) = pages.keys.elementAt(position)
override fun getCount() = pages.size
override fun getPageTitle(position: Int) = pages.values.elementAt(position)
}

View File

@ -6,29 +6,27 @@ import io.github.wulkanowy.utils.flowWithResource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
open class BasePresenter<T : BaseView>(
protected val errorHandler: ErrorHandler,
protected val studentRepository: StudentRepository
) : CoroutineScope {
) {
private val job = SupervisorJob()
private var job = Job()
protected val presenterScope = CoroutineScope(job + Dispatchers.Main)
private val jobs = mutableMapOf<String, Job>()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private val childrenJobs = mutableMapOf<String, Job>()
var view: T? = null
open fun onAttachView(view: T) {
job = Job()
this.view = view
errorHandler.apply {
showErrorMessage = view::showError
@ -64,22 +62,22 @@ open class BasePresenter<T : BaseView>(
}
fun <T> Flow<T>.launch(individualJobTag: String = "load"): Job {
jobs[individualJobTag]?.cancel()
val job = catch { errorHandler.dispatch(it) }.launchIn(this@BasePresenter)
jobs[individualJobTag] = job
childrenJobs[individualJobTag]?.cancel()
val job = catch { errorHandler.dispatch(it) }.launchIn(presenterScope)
childrenJobs[individualJobTag] = job
Timber.d("Job $individualJobTag launched in ${this@BasePresenter.javaClass.simpleName}: $job")
return job
}
fun cancelJobs(vararg names: String) {
names.forEach {
jobs[it]?.cancel()
childrenJobs[it]?.cancel()
}
}
open fun onDetachView() {
view = null
job.cancel()
job.cancelChildren()
errorHandler.clear()
view = null
}
}

View File

@ -11,6 +11,7 @@ import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.appcompat.app.AlertDialog
import androidx.core.content.getSystemService
import androidx.core.view.isGone
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.databinding.DialogErrorBinding
@ -24,8 +25,6 @@ import io.github.wulkanowy.utils.openEmailClient
import io.github.wulkanowy.utils.openInternetBrowser
import okhttp3.internal.http2.StreamResetException
import java.io.InterruptedIOException
import java.io.PrintWriter
import java.io.StringWriter
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@ -64,26 +63,26 @@ class ErrorDialog : BaseDialogFragment<DialogErrorBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val stringWriter = StringWriter().apply {
error.printStackTrace(PrintWriter(this))
}
val errorStacktrace = error.stackTraceToString()
with(binding) {
errorDialogContent.text = stringWriter.toString()
errorDialogContent.text = errorStacktrace.replace(": ${error.localizedMessage}", "")
with(errorDialogHorizontalScroll) {
post { fullScroll(HorizontalScrollView.FOCUS_LEFT) }
}
errorDialogCopy.setOnClickListener {
val clip = ClipData.newPlainText("wulkanowy", stringWriter.toString())
val clip = ClipData.newPlainText("Error details", errorStacktrace)
activity?.getSystemService<ClipboardManager>()?.setPrimaryClip(clip)
Toast.makeText(context, R.string.all_copied, LENGTH_LONG).show()
}
errorDialogCancel.setOnClickListener { dismiss() }
errorDialogReport.setOnClickListener {
openConfirmDialog { openEmailClient(stringWriter.toString()) }
openConfirmDialog { openEmailClient(errorStacktrace) }
}
errorDialogMessage.text = resources.getString(error)
errorDialogHumanizedMessage.text = resources.getString(error)
errorDialogErrorMessage.text = error.localizedMessage
errorDialogErrorMessage.isGone = error.localizedMessage.isNullOrBlank()
errorDialogReport.isEnabled = when (error) {
is UnknownHostException,
is InterruptedIOException,

View File

@ -1,6 +1,7 @@
package io.github.wulkanowy.ui.base
import android.content.res.Resources
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.data.exceptions.NoCurrentStudentException
import io.github.wulkanowy.sdk.scrapper.login.BadCredentialsException
import io.github.wulkanowy.sdk.scrapper.login.PasswordChangeRequiredException
@ -9,7 +10,7 @@ import io.github.wulkanowy.utils.security.ScramblerException
import timber.log.Timber
import javax.inject.Inject
open class ErrorHandler @Inject constructor(protected val resources: Resources) {
open class ErrorHandler @Inject constructor(@ApplicationContext protected val context: Context) {
var showErrorMessage: (String, Throwable) -> Unit = { _, _ -> }
@ -25,7 +26,7 @@ open class ErrorHandler @Inject constructor(protected val resources: Resources)
}
protected open fun proceed(error: Throwable) {
showErrorMessage(resources.getString(error), error)
showErrorMessage(context.resources.getString(error), error)
when (error) {
is PasswordChangeRequiredException -> onPasswordChangeRequired(error.redirectUrl)
is ScramblerException, is BadCredentialsException -> onSessionExpired()

View File

@ -41,14 +41,15 @@ class ThemeManager @Inject constructor(private val preferencesRepository: Prefer
)
}
private fun isThemeApplicable(activity: AppCompatActivity): Boolean {
return activity.packageManager
private fun isThemeApplicable(activity: AppCompatActivity) =
activity.packageManager
.getPackageInfo(activity.packageName, GET_ACTIVITIES)
.activities.singleOrNull { it.name == activity::class.java.canonicalName }
?.theme.let {
.activities
.singleOrNull { it.name == activity::class.java.canonicalName }
?.theme
.let {
it == R.style.WulkanowyTheme_Black || it == R.style.WulkanowyTheme_NoActionBar
|| it == R.style.WulkanowyTheme_Login || it == R.style.WulkanowyTheme_Login_Black
|| it == R.style.WulkanowyTheme_MessageSend || it == R.style.WulkanowyTheme_MessageSend_Black
}
}
}

View File

@ -0,0 +1,136 @@
package io.github.wulkanowy.ui.modules
import androidx.fragment.app.Fragment
import io.github.wulkanowy.ui.modules.attendance.AttendanceFragment
import io.github.wulkanowy.ui.modules.conference.ConferenceFragment
import io.github.wulkanowy.ui.modules.dashboard.DashboardFragment
import io.github.wulkanowy.ui.modules.exam.ExamFragment
import io.github.wulkanowy.ui.modules.grade.GradeFragment
import io.github.wulkanowy.ui.modules.homework.HomeworkFragment
import io.github.wulkanowy.ui.modules.luckynumber.LuckyNumberFragment
import io.github.wulkanowy.ui.modules.message.MessageFragment
import io.github.wulkanowy.ui.modules.more.MoreFragment
import io.github.wulkanowy.ui.modules.note.NoteFragment
import io.github.wulkanowy.ui.modules.schoolandteachers.school.SchoolFragment
import io.github.wulkanowy.ui.modules.schoolannouncement.SchoolAnnouncementFragment
import io.github.wulkanowy.ui.modules.timetable.TimetableFragment
import java.io.Serializable
import java.time.LocalDate
sealed interface Destination : Serializable {
/*
Type in children classes have to be as getter to avoid null in enums
https://stackoverflow.com/questions/68866453/kotlin-enum-val-is-returning-null-despite-being-set-at-compile-time
*/
val type: Type
val fragment: Fragment
enum class Type(val defaultDestination: Destination) {
DASHBOARD(Dashboard),
GRADE(Grade),
ATTENDANCE(Attendance),
EXAM(Exam),
TIMETABLE(Timetable()),
HOMEWORK(Homework),
NOTE(Note),
CONFERENCE(Conference),
SCHOOL_ANNOUNCEMENT(SchoolAnnouncement),
SCHOOL(School),
LUCKY_NUMBER(More),
MORE(More),
MESSAGE(Message);
}
object Dashboard : Destination {
override val type get() = Type.DASHBOARD
override val fragment get() = DashboardFragment.newInstance()
}
object Grade : Destination {
override val type get() = Type.GRADE
override val fragment get() = GradeFragment.newInstance()
}
object Attendance : Destination {
override val type get() = Type.ATTENDANCE
override val fragment get() = AttendanceFragment.newInstance()
}
object Exam : Destination {
override val type get() = Type.EXAM
override val fragment get() = ExamFragment.newInstance()
}
data class Timetable(val date: LocalDate? = null) : Destination {
override val type get() = Type.TIMETABLE
override val fragment get() = TimetableFragment.newInstance(date)
}
object Homework : Destination {
override val type get() = Type.HOMEWORK
override val fragment get() = HomeworkFragment.newInstance()
}
object Note : Destination {
override val type get() = Type.NOTE
override val fragment get() = NoteFragment.newInstance()
}
object Conference : Destination {
override val type get() = Type.CONFERENCE
override val fragment get() = ConferenceFragment.newInstance()
}
object SchoolAnnouncement : Destination {
override val type get() = Type.SCHOOL_ANNOUNCEMENT
override val fragment get() = SchoolAnnouncementFragment.newInstance()
}
object School : Destination {
override val type get() = Type.SCHOOL
override val fragment get() = SchoolFragment.newInstance()
}
object LuckyNumber : Destination {
override val type get() = Type.LUCKY_NUMBER
override val fragment get() = LuckyNumberFragment.newInstance()
}
object More : Destination {
override val type get() = Type.MORE
override val fragment get() = MoreFragment.newInstance()
}
object Message : Destination {
override val type get() = Type.MESSAGE
override val fragment get() = MessageFragment.newInstance()
}
}

View File

@ -82,18 +82,20 @@ class AboutPresenter @Inject constructor(
private fun loadData() {
view?.run {
updateData(listOfNotNull(
versionRes,
creatorsRes,
feedbackRes,
faqRes,
discordRes,
facebookRes,
twitterRes,
homepageRes,
licensesRes,
privacyRes
))
updateData(
listOfNotNull(
versionRes,
creatorsRes,
feedbackRes,
faqRes,
discordRes,
facebookRes,
twitterRes,
homepageRes,
licensesRes,
privacyRes
)
)
}
}
}

View File

@ -31,7 +31,7 @@ class LicensePresenter @Inject constructor(
private fun loadData() {
flowWithResource {
withContext(dispatchers.backgroundThread) {
withContext(dispatchers.io) {
view?.appLibraries.orEmpty()
}
}.onEach {

View File

@ -3,12 +3,9 @@ package io.github.wulkanowy.ui.modules.account.accountedit
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.RippleDrawable
import android.graphics.drawable.StateListDrawable
import android.os.Build
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
@ -52,30 +49,13 @@ class AccountEditColorAdapter @Inject constructor() :
}
}
private fun Int.createForegroundDrawable(): Drawable =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val mask = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.BLACK)
}
RippleDrawable(ColorStateList.valueOf(this.rippleColor), null, mask)
} else {
val foreground = StateListDrawable().apply {
alpha = 80
setEnterFadeDuration(250)
setExitFadeDuration(250)
}
val mask = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(this@createForegroundDrawable.rippleColor)
}
foreground.apply {
addState(intArrayOf(android.R.attr.state_pressed), mask)
addState(intArrayOf(), ColorDrawable(Color.TRANSPARENT))
}
private fun Int.createForegroundDrawable(): Drawable {
val mask = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.BLACK)
}
return RippleDrawable(ColorStateList.valueOf(this.rippleColor), null, mask)
}
private inline val Int.rippleColor: Int
get() {

View File

@ -9,7 +9,7 @@ import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.enums.SentExcuseStatus
import io.github.wulkanowy.databinding.ItemAttendanceBinding
import io.github.wulkanowy.utils.description
import io.github.wulkanowy.utils.descriptionRes
import io.github.wulkanowy.utils.isExcusableOrNotExcused
import javax.inject.Inject
@ -36,7 +36,7 @@ class AttendanceAdapter @Inject constructor() :
with(holder.binding) {
attendanceItemNumber.text = item.number.toString()
attendanceItemSubject.text = item.subject
attendanceItemDescription.setText(item.description)
attendanceItemDescription.setText(item.descriptionRes)
attendanceItemAlert.visibility = item.run { if (absence && !excused) View.VISIBLE else View.INVISIBLE }
attendanceItemNumber.visibility = View.GONE
attendanceItemExcuseInfo.visibility = View.GONE
@ -46,7 +46,7 @@ class AttendanceAdapter @Inject constructor() :
onExcuseCheckboxSelect(item, checked)
}
when (if (item.excuseStatus != null) SentExcuseStatus.valueOf(item.excuseStatus) else null) {
when (item.excuseStatus?.let { SentExcuseStatus.valueOf(it)}) {
SentExcuseStatus.WAITING -> {
attendanceItemExcuseInfo.setImageResource(R.drawable.ic_excuse_waiting)
attendanceItemExcuseInfo.visibility = View.VISIBLE

View File

@ -7,7 +7,7 @@ import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.databinding.DialogAttendanceBinding
import io.github.wulkanowy.utils.description
import io.github.wulkanowy.utils.descriptionRes
import io.github.wulkanowy.utils.lifecycleAwareVariable
import io.github.wulkanowy.utils.toFormattedString
@ -45,7 +45,7 @@ class AttendanceDialog : DialogFragment() {
with(binding) {
attendanceDialogSubjectValue.text = attendance.subject
attendanceDialogDescriptionValue.setText(attendance.description)
attendanceDialogDescriptionValue.setText(attendance.descriptionRes)
attendanceDialogDateValue.text = attendance.date.toFormattedString()
attendanceDialogNumberValue.text = attendance.number.toString()
attendanceDialogClose.setOnClickListener { dismiss() }

View File

@ -12,6 +12,7 @@ import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.MaterialDatePicker
@ -121,9 +122,7 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
attendanceSwipe.setOnRefreshListener(presenter::onSwipeRefresh)
attendanceSwipe.setColorSchemeColors(requireContext().getThemeAttrColor(R.attr.colorPrimary))
attendanceSwipe.setProgressBackgroundColorSchemeColor(
requireContext().getThemeAttrColor(
R.attr.colorSwipeRefresh
)
requireContext().getThemeAttrColor(R.attr.colorSwipeRefresh)
)
attendanceErrorRetry.setOnClickListener { presenter.onRetry() }
attendanceErrorDetails.setOnClickListener { presenter.onDetailsClick() }
@ -134,7 +133,7 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
attendanceExcuseButton.setOnClickListener { presenter.onExcuseButtonClick() }
attendanceNavContainer.setElevationCompat(requireContext().dpToPx(8f))
attendanceNavContainer.elevation = requireContext().dpToPx(8f)
}
}
@ -218,7 +217,7 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
}
override fun showExcuseButton(show: Boolean) {
binding.attendanceExcuseButton.visibility = if (show) VISIBLE else GONE
binding.attendanceExcuseButton.isVisible = show
}
override fun showAttendanceDialog(lesson: Attendance) {
@ -289,12 +288,16 @@ class AttendanceFragment : BaseFragment<FragmentAttendanceBinding>(R.layout.frag
}
override fun showExcuseCheckboxes(show: Boolean) {
attendanceAdapter.apply {
with(attendanceAdapter) {
excuseActionMode = show
notifyDataSetChanged()
}
}
override fun showDayNavigation(show: Boolean) {
binding.attendanceNavContainer.isVisible = show
}
override fun finishActionMode() {
actionMode?.finish()
}

View File

@ -174,6 +174,8 @@ class AttendancePresenter @Inject constructor(
view?.apply {
showExcuseCheckboxes(true)
showExcuseButton(false)
enableSwipe(false)
showDayNavigation(false)
}
attendanceToExcuseList.clear()
return true
@ -183,6 +185,8 @@ class AttendancePresenter @Inject constructor(
view?.apply {
showExcuseCheckboxes(false)
showExcuseButton(true)
enableSwipe(true)
showDayNavigation(true)
}
}
@ -259,9 +263,8 @@ class AttendancePresenter @Inject constructor(
showEmpty(filteredAttendance.isEmpty())
showErrorView(false)
showContent(filteredAttendance.isNotEmpty())
showExcuseButton(filteredAttendance.any { item ->
(!isParent && isVulcanExcusedFunctionEnabled) || (isParent && item.isExcusableOrNotExcused)
})
val anyExcusables = filteredAttendance.any { it.isExcusableOrNotExcused }
showExcuseButton(anyExcusables && (isParent || isVulcanExcusedFunctionEnabled))
}
analytics.logEvent(
"load_data",

View File

@ -60,6 +60,8 @@ interface AttendanceView : BaseView {
fun showExcuseCheckboxes(show: Boolean)
fun showDayNavigation(show: Boolean)
fun finishActionMode()
fun popView()

View File

@ -71,7 +71,7 @@ class AttendanceSummaryFragment :
setOnItemSelectedListener<TextView> { presenter.onSubjectSelected(it?.text?.toString()) }
}
binding.attendanceSummarySubjectsContainer.setElevationCompat(requireContext().dpToPx(1f))
binding.attendanceSummarySubjectsContainer.elevation = requireContext().dpToPx(1f)
}
override fun updateSubjects(data: ArrayList<String>) {

View File

@ -1,6 +1,8 @@
package io.github.wulkanowy.ui.modules.dashboard
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
@ -18,6 +20,7 @@ import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.db.entities.TimetableHeader
import io.github.wulkanowy.databinding.ItemDashboardAccountBinding
import io.github.wulkanowy.databinding.ItemDashboardAdminMessageBinding
import io.github.wulkanowy.databinding.ItemDashboardAnnouncementsBinding
import io.github.wulkanowy.databinding.ItemDashboardConferencesBinding
import io.github.wulkanowy.databinding.ItemDashboardExamsBinding
@ -63,6 +66,8 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
var onConferencesTileClickListener: () -> Unit = {}
var onAdminMessageClickListener: (String?) -> Unit = {}
val items = mutableListOf<DashboardItem>()
fun submitList(newItems: List<DashboardItem>) {
@ -109,6 +114,9 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
DashboardItem.Type.CONFERENCES.ordinal -> ConferencesViewHolder(
ItemDashboardConferencesBinding.inflate(inflater, parent, false)
)
DashboardItem.Type.ADMIN_MESSAGE.ordinal -> AdminMessageViewHolder(
ItemDashboardAdminMessageBinding.inflate(inflater, parent, false)
)
else -> throw IllegalArgumentException()
}
}
@ -123,6 +131,7 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
is AnnouncementsViewHolder -> bindAnnouncementsViewHolder(holder, position)
is ExamsViewHolder -> bindExamsViewHolder(holder, position)
is ConferencesViewHolder -> bindConferencesViewHolder(holder, position)
is AdminMessageViewHolder -> bindAdminMessage(holder, position)
}
}
@ -290,7 +299,8 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
val currentDayHeader =
timetableFull?.headers.orEmpty().singleOrNull { it.date == currentDate }
val tomorrowTimetable = timetableFull?.lessons.orEmpty()
val tomorrowTimetable = timetableFull?.lessons
.orEmpty()
.filter { it.date == currentDate.plusDays(1) }
.filterNot { it.canceled }
val tomorrowDayHeader =
@ -301,26 +311,31 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
dateToNavigate = currentDate
updateLessonView(item, currentTimetable, binding)
binding.dashboardLessonsItemTitleTomorrow.isVisible = false
binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible = false
}
tomorrowTimetable.isNotEmpty() -> {
dateToNavigate = tomorrowDate
updateLessonView(item, tomorrowTimetable, binding)
binding.dashboardLessonsItemTitleTomorrow.isVisible = true
binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible = false
}
currentDayHeader != null && currentDayHeader.content.isNotBlank() -> {
dateToNavigate = currentDate
updateLessonView(item, emptyList(), binding, currentDayHeader)
binding.dashboardLessonsItemTitleTomorrow.isVisible = false
binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible = false
}
tomorrowDayHeader != null && tomorrowDayHeader.content.isNotBlank() -> {
dateToNavigate = tomorrowDate
updateLessonView(item, emptyList(), binding, tomorrowDayHeader)
binding.dashboardLessonsItemTitleTomorrow.isVisible = true
binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible = false
}
else -> {
dateToNavigate = tomorrowDate
dateToNavigate = currentDate
updateLessonView(item, emptyList(), binding)
binding.dashboardLessonsItemTitleTomorrow.isVisible =
binding.dashboardLessonsItemTitleTomorrow.isVisible = false
binding.dashboardLessonsItemTitleTodayAndTomorrow.isVisible =
!(item.isLoading && item.error == null)
}
}
@ -692,6 +707,34 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
}
}
private fun bindAdminMessage(adminMessageViewHolder: AdminMessageViewHolder, position: Int) {
val item = (items[position] as DashboardItem.AdminMessages).adminMessage ?: return
val context = adminMessageViewHolder.binding.root.context
val (backgroundColor, textColor) = when (item.priority) {
"HIGH" -> {
context.getThemeAttrColor(R.attr.colorPrimary) to
context.getThemeAttrColor(R.attr.colorOnPrimary)
}
"MEDIUM" -> {
context.getThemeAttrColor(R.attr.colorMessageMedium) to Color.BLACK
}
else -> null to context.getThemeAttrColor(R.attr.colorOnSurface)
}
with(adminMessageViewHolder.binding) {
dashboardAdminMessageItemTitle.text = item.title
dashboardAdminMessageItemTitle.setTextColor(textColor)
dashboardAdminMessageItemDescription.text = item.content
dashboardAdminMessageItemDescription.setTextColor(textColor)
dashboardAdminMessageItemIcon.setColorFilter(textColor)
root.setCardBackgroundColor(backgroundColor?.let { ColorStateList.valueOf(it) })
item.destinationUrl?.let { url ->
root.setOnClickListener { onAdminMessageClickListener(url) }
}
}
}
class AccountViewHolder(val binding: ItemDashboardAccountBinding) :
RecyclerView.ViewHolder(binding.root)
@ -731,6 +774,9 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
val adapter by lazy { DashboardConferencesAdapter() }
}
class AdminMessageViewHolder(val binding: ItemDashboardAdminMessageBinding) :
RecyclerView.ViewHolder(binding.root)
private class DiffCallback(
private val newList: List<DashboardItem>,
private val oldList: List<DashboardItem>

View File

@ -10,6 +10,7 @@ import androidx.core.view.isVisible
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.databinding.FragmentDashboardBinding
@ -29,6 +30,7 @@ import io.github.wulkanowy.ui.modules.schoolannouncement.SchoolAnnouncementFragm
import io.github.wulkanowy.ui.modules.timetable.TimetableFragment
import io.github.wulkanowy.utils.capitalise
import io.github.wulkanowy.utils.getThemeAttrColor
import io.github.wulkanowy.utils.openInternetBrowser
import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate
import javax.inject.Inject
@ -97,6 +99,13 @@ class DashboardFragment : BaseFragment<FragmentDashboardBinding>(R.layout.fragme
onConferencesTileClickListener = {
mainActivity.pushView(ConferenceFragment.newInstance())
}
onAdminMessageClickListener = presenter::onAdminMessageSelected
registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
binding.dashboardRecycler.scrollToPosition(0)
}
})
}
with(binding) {
@ -188,6 +197,10 @@ class DashboardFragment : BaseFragment<FragmentDashboardBinding>(R.layout.fragme
(requireActivity() as MainActivity).pushView(NotificationsCenterFragment.newInstance())
}
override fun openInternetBrowser(url: String) {
requireContext().openInternetBrowser(url)
}
override fun onDestroyView() {
dashboardAdapter.clearTimers()
presenter.onDetachView()

View File

@ -1,5 +1,6 @@
package io.github.wulkanowy.ui.modules.dashboard
import io.github.wulkanowy.data.db.entities.AdminMessage
import io.github.wulkanowy.data.db.entities.Conference
import io.github.wulkanowy.data.db.entities.Exam
import io.github.wulkanowy.data.db.entities.Grade
@ -16,6 +17,15 @@ sealed class DashboardItem(val type: Type) {
abstract val isDataLoaded: Boolean
data class AdminMessages(
val adminMessage: AdminMessage? = null,
override val error: Throwable? = null,
override val isLoading: Boolean = false
) : DashboardItem(Type.ADMIN_MESSAGE) {
override val isDataLoaded get() = adminMessage != null
}
data class Account(
val student: Student? = null,
override val error: Throwable? = null,
@ -96,6 +106,7 @@ sealed class DashboardItem(val type: Type) {
}
enum class Type {
ADMIN_MESSAGE,
ACCOUNT,
HORIZONTAL_GROUP,
LESSONS,
@ -108,6 +119,7 @@ sealed class DashboardItem(val type: Type) {
}
enum class Tile {
ADMIN_MESSAGE,
ACCOUNT,
LUCKY_NUMBER,
MESSAGES,
@ -123,6 +135,7 @@ sealed class DashboardItem(val type: Type) {
}
fun DashboardItem.Tile.toDashboardItemType() = when (this) {
DashboardItem.Tile.ADMIN_MESSAGE -> DashboardItem.Type.ADMIN_MESSAGE
DashboardItem.Tile.ACCOUNT -> DashboardItem.Type.ACCOUNT
DashboardItem.Tile.LUCKY_NUMBER -> DashboardItem.Type.HORIZONTAL_GROUP
DashboardItem.Tile.MESSAGES -> DashboardItem.Type.HORIZONTAL_GROUP

View File

@ -21,7 +21,7 @@ class DashboardItemMoveCallback(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val dragFlags = if (viewHolder.bindingAdapterPosition != 0) {
val dragFlags = if (!viewHolder.isAdminMessageOrAccountItem) {
ItemTouchHelper.UP or ItemTouchHelper.DOWN
} else 0
@ -32,7 +32,7 @@ class DashboardItemMoveCallback(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
) = target.bindingAdapterPosition != 0
) = !target.isAdminMessageOrAccountItem
override fun onMove(
recyclerView: RecyclerView,
@ -52,4 +52,7 @@ class DashboardItemMoveCallback(
onUserInteractionEndListener(dashboardAdapter.items.toList())
}
private val RecyclerView.ViewHolder.isAdminMessageOrAccountItem: Boolean
get() = this is DashboardAdapter.AdminMessageViewHolder || this is DashboardAdapter.AccountViewHolder
}

View File

@ -5,6 +5,7 @@ import io.github.wulkanowy.data.Status
import io.github.wulkanowy.data.db.entities.LuckyNumber
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.enums.MessageFolder
import io.github.wulkanowy.data.repositories.AdminMessageRepository
import io.github.wulkanowy.data.repositories.AttendanceSummaryRepository
import io.github.wulkanowy.data.repositories.ConferenceRepository
import io.github.wulkanowy.data.repositories.ExamRepository
@ -50,7 +51,8 @@ class DashboardPresenter @Inject constructor(
private val examRepository: ExamRepository,
private val conferenceRepository: ConferenceRepository,
private val preferencesRepository: PreferencesRepository,
private val schoolAnnouncementRepository: SchoolAnnouncementRepository
private val schoolAnnouncementRepository: SchoolAnnouncementRepository,
private val adminMessageRepository: AdminMessageRepository
) : BasePresenter<DashboardView>(errorHandler, studentRepository) {
private val dashboardItemLoadedList = mutableListOf<DashboardItem>()
@ -149,7 +151,7 @@ class DashboardPresenter @Inject constructor(
tileList: List<DashboardItem.Type>,
forceRefresh: Boolean
) {
launch {
presenterScope.launch {
Timber.i("Loading dashboard account data started")
val student = runCatching { studentRepository.getCurrentStudent(true) }
.onFailure {
@ -179,6 +181,7 @@ class DashboardPresenter @Inject constructor(
loadConferences(student, forceRefresh)
}
DashboardItem.Type.ADS -> TODO()
DashboardItem.Type.ADMIN_MESSAGE -> loadAdminMessage(student, forceRefresh)
}
}
}
@ -225,6 +228,10 @@ class DashboardPresenter @Inject constructor(
}.toSet()
}
fun onAdminMessageSelected(url: String?) {
url?.let { view?.openInternetBrowser(it) }
}
private fun loadHorizontalGroup(student: Student, forceRefresh: Boolean) {
flow {
val semester = semesterRepository.getCurrentSemester(student)
@ -309,18 +316,17 @@ class DashboardPresenter @Inject constructor(
gradeRepository.getGrades(student, semester, forceRefresh)
}.map { originalResource ->
val filteredSubjectWithGrades = originalResource.data?.first.orEmpty()
.filter { grade ->
grade.date.isAfter(LocalDate.now().minusDays(7))
}
.groupBy { grade -> grade.subject }
val filteredSubjectWithGrades = originalResource.data?.first
.orEmpty()
.filter { it.date >= LocalDate.now().minusDays(7) }
.groupBy { it.subject }
.mapValues { entry ->
entry.value
.take(5)
.sortedBy { grade -> grade.date }
.sortedByDescending { it.date }
}
.toList()
.sortedBy { subjectWithGrades -> subjectWithGrades.second[0].date }
.sortedByDescending { (_, grades) -> grades[0].date }
.toMap()
Resource(
@ -424,9 +430,9 @@ class DashboardPresenter @Inject constructor(
}.map { homeworkResource ->
val currentDate = LocalDate.now()
val filteredHomework = homeworkResource.data?.filter {
(it.date.isAfter(currentDate) || it.date == currentDate) && !it.isDone
}
val filteredHomework = homeworkResource.data
?.filter { (it.date.isAfter(currentDate) || it.date == currentDate) && !it.isDone }
?.sortedBy { it.date }
homeworkResource.copy(data = filteredHomework)
}.onEach {
@ -567,6 +573,38 @@ class DashboardPresenter @Inject constructor(
}.launch("dashboard_conferences")
}
private fun loadAdminMessage(student: Student, forceRefresh: Boolean) {
flowWithResourceIn { adminMessageRepository.getAdminMessages(student, forceRefresh) }
.onEach {
when (it.status) {
Status.LOADING -> {
Timber.i("Loading dashboard admin message data started")
if (forceRefresh) return@onEach
updateData(DashboardItem.AdminMessages(), forceRefresh)
}
Status.SUCCESS -> {
Timber.i("Loading dashboard admin message result: Success")
updateData(
dashboardItem = DashboardItem.AdminMessages(adminMessage = it.data),
forceRefresh = forceRefresh
)
}
Status.ERROR -> {
Timber.i("Loading dashboard admin message result: An exception occurred")
errorHandler.dispatch(it.error!!)
updateData(
dashboardItem = DashboardItem.AdminMessages(
adminMessage = it.data,
error = it.error
),
forceRefresh = forceRefresh
)
}
}
}
.launch("dashboard_admin_messages")
}
private fun updateData(dashboardItem: DashboardItem, forceRefresh: Boolean) {
val isForceRefreshError = forceRefresh && dashboardItem.error != null
val isFirstRunDataLoadedError =
@ -579,6 +617,13 @@ class DashboardPresenter @Inject constructor(
sortDashboardItems()
if (dashboardItem is DashboardItem.AdminMessages && !dashboardItem.isDataLoaded) {
dashboardItemsToLoad = dashboardItemsToLoad - DashboardItem.Type.ADMIN_MESSAGE
dashboardTileLoadedList = dashboardTileLoadedList - DashboardItem.Tile.ADMIN_MESSAGE
dashboardItemLoadedList.removeAll { it.type == DashboardItem.Type.ADMIN_MESSAGE }
}
if (forceRefresh) {
updateForceRefreshData(dashboardItem)
} else {
@ -610,9 +655,12 @@ class DashboardPresenter @Inject constructor(
}
private fun updateForceRefreshData(dashboardItem: DashboardItem) {
val isNotLoadedAdminMessage =
dashboardItem is DashboardItem.AdminMessages && !dashboardItem.isDataLoaded
with(dashboardItemRefreshLoadedList) {
removeAll { it.type == dashboardItem.type }
add(dashboardItem)
if (!isNotLoadedAdminMessage) add(dashboardItem)
}
val isRefreshItemLoaded =
@ -644,7 +692,9 @@ class DashboardPresenter @Inject constructor(
itemsLoadedList: List<DashboardItem>,
forceRefresh: Boolean
) {
val filteredItems = itemsLoadedList.filterNot { it.type == DashboardItem.Type.ACCOUNT }
val filteredItems = itemsLoadedList.filterNot {
it.type == DashboardItem.Type.ACCOUNT || it.type == DashboardItem.Type.ADMIN_MESSAGE
}
val isAccountItemError =
itemsLoadedList.find { it.type == DashboardItem.Type.ACCOUNT }?.error != null
val isGeneralError =
@ -676,10 +726,13 @@ class DashboardPresenter @Inject constructor(
val dashboardItemsPosition = preferencesRepository.dashboardItemsPosition
dashboardItemLoadedList.sortBy { tile ->
dashboardItemsPosition?.getOrDefault(
tile.type,
val defaultPosition = if (tile is DashboardItem.AdminMessages) {
-1
} else {
tile.type.ordinal + 100
) ?: tile.type.ordinal
}
dashboardItemsPosition?.getOrDefault(tile.type, defaultPosition) ?: tile.type.ordinal
}
}
}

View File

@ -25,4 +25,6 @@ interface DashboardView : BaseView {
fun popViewToRoot()
fun openNotificationsCenterView()
fun openInternetBrowser(url: String)
}

View File

@ -3,6 +3,8 @@ package io.github.wulkanowy.ui.modules.debug.notification
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.services.sync.notifications.ChangeTimetableNotification
import io.github.wulkanowy.services.sync.notifications.NewAttendanceNotification
import io.github.wulkanowy.services.sync.notifications.NewConferenceNotification
import io.github.wulkanowy.services.sync.notifications.NewExamNotification
import io.github.wulkanowy.services.sync.notifications.NewGradeNotification
@ -13,6 +15,7 @@ import io.github.wulkanowy.services.sync.notifications.NewNoteNotification
import io.github.wulkanowy.services.sync.notifications.NewSchoolAnnouncementNotification
import io.github.wulkanowy.ui.base.BasePresenter
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.debugConferenceItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugExamItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugGradeDetailsItems
@ -22,6 +25,7 @@ import io.github.wulkanowy.ui.modules.debug.notification.mock.debugLuckyNumber
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugMessageItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugNoteItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugSchoolAnnouncementItems
import io.github.wulkanowy.ui.modules.debug.notification.mock.debugTimetableItems
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -37,6 +41,8 @@ class NotificationDebugPresenter @Inject constructor(
private val newNoteNotification: NewNoteNotification,
private val newSchoolAnnouncementNotification: NewSchoolAnnouncementNotification,
private val newLuckyNumberNotification: NewLuckyNumberNotification,
private val changeTimetableNotification: ChangeTimetableNotification,
private val newAttendanceNotification: NewAttendanceNotification,
) : BasePresenter<NotificationDebugView>(errorHandler, studentRepository) {
private val items = listOf(
@ -64,6 +70,12 @@ class NotificationDebugPresenter @Inject constructor(
NotificationDebugItem(R.string.note_title) { n ->
withStudent { newNoteNotification.notify(debugNoteItems.take(n), it) }
},
NotificationDebugItem(R.string.attendance_title) { n ->
withStudent { newAttendanceNotification.notify(debugAttendanceItems.take(n), it) }
},
NotificationDebugItem(R.string.timetable_title) { n ->
withStudent { changeTimetableNotification.notify(debugTimetableItems.take(n), it) }
},
NotificationDebugItem(R.string.school_announcement_title) { n ->
withStudent {
newSchoolAnnouncementNotification.notify(debugSchoolAnnouncementItems.take(n), it)
@ -88,7 +100,7 @@ class NotificationDebugPresenter @Inject constructor(
}
private fun withStudent(block: suspend (Student) -> Unit) {
launch {
presenterScope.launch {
block(studentRepository.getCurrentStudent(false))
}
}

View File

@ -0,0 +1,35 @@
package io.github.wulkanowy.ui.modules.debug.notification.mock
import io.github.wulkanowy.data.db.entities.Attendance
import java.time.LocalDate
val debugAttendanceItems = listOf(
generateAttendance("Matematyka", "PRESENCE"),
generateAttendance("Język angielski", "UNEXCUSED_LATENESS"),
generateAttendance("Geografia", "ABSENCE_UNEXCUSED"),
generateAttendance("Sieci komputerowe", "ABSENCE_EXCUSED"),
generateAttendance("Systemy operacyjne", "EXCUSED_LATENESS"),
generateAttendance("Język niemiecki", "ABSENCE_UNEXCUSED"),
generateAttendance("Biologia", "ABSENCE_UNEXCUSED"),
generateAttendance("Chemia", "ABSENCE_EXCUSED"),
generateAttendance("Fizyka", "ABSENCE_UNEXCUSED"),
generateAttendance("Matematyka", "ABSENCE_EXCUSED"),
)
private fun generateAttendance(subject: String, name: String) = Attendance(
subject = subject,
studentId = 0,
diaryId = 0,
date = LocalDate.now(),
timeId = 0,
number = 1,
name = name,
presence = false,
absence = false,
exemption = false,
lateness = false,
excused = false,
deleted = false,
excusable = false,
excuseStatus = ""
)

View File

@ -0,0 +1,39 @@
package io.github.wulkanowy.ui.modules.debug.notification.mock
import io.github.wulkanowy.data.db.entities.Timetable
import java.time.LocalDate
import java.time.LocalDateTime
import kotlin.random.Random
val debugTimetableItems = listOf(
generateTimetable("Matematyka", "12", "01"),
generateTimetable("Język angielski", "23", "12"),
generateTimetable("Geografia", "34", "23"),
generateTimetable("Sieci komputerowe", "45", "34"),
generateTimetable("Systemy operacyjne", "56", "45"),
generateTimetable("Język niemiecki", "67", "56"),
generateTimetable("Biologia", "78", "67"),
generateTimetable("Chemia", "89", "78"),
generateTimetable("Fizyka", "90", "89"),
generateTimetable("Matematyka", "01", "90"),
)
private fun generateTimetable(subject: String, room: String, roomOld: String) = Timetable(
subject = subject,
studentId = 0,
diaryId = 0,
date = LocalDate.now().minusDays(Random.nextLong(0, 8)),
number = 1,
start = LocalDateTime.now().plusHours(1),
end = LocalDateTime.now(),
subjectOld = "",
group = "",
room = room,
roomOld = roomOld,
teacher = "Wtorkowska Renata",
teacherOld = "",
info = "",
isStudentPlan = true,
changes = true,
canceled = true
)

View File

@ -64,7 +64,7 @@ class ExamFragment : BaseFragment<FragmentExamBinding>(R.layout.fragment_exam),
examPreviousButton.setOnClickListener { presenter.onPreviousWeek() }
examNextButton.setOnClickListener { presenter.onNextWeek() }
examNavContainer.setElevationCompat(requireContext().dpToPx(8f))
examNavContainer.elevation = requireContext().dpToPx(8f)
}
}

View File

@ -0,0 +1,9 @@
package io.github.wulkanowy.ui.modules.grade
enum class GradeExpandMode(val value: String) {
ONE("one"), UNLIMITED("any"), ALWAYS_EXPANDED("always");
companion object {
fun getByValue(value: String) = values().firstOrNull { it.value == value } ?: ONE
}
}

View File

@ -8,6 +8,7 @@ import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import androidx.appcompat.app.AlertDialog
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Semester
@ -29,7 +30,13 @@ class GradeFragment : BaseFragment<FragmentGradeBinding>(R.layout.fragment_grade
@Inject
lateinit var presenter: GradePresenter
private val pagerAdapter by lazy { BaseFragmentPagerAdapter(childFragmentManager) }
private val pagerAdapter by lazy {
BaseFragmentPagerAdapter(
fragmentManager = childFragmentManager,
pagesCount = 3,
lifecycle = lifecycle,
)
}
private var semesterSwitchMenu: MenuItem? = null
@ -62,28 +69,35 @@ class GradeFragment : BaseFragment<FragmentGradeBinding>(R.layout.fragment_grade
}
override fun initView() {
with(pagerAdapter) {
containerId = binding.gradeViewPager.id
addFragmentsWithTitle(
mapOf(
GradeDetailsFragment.newInstance() to getString(R.string.all_details),
GradeSummaryFragment.newInstance() to getString(R.string.grade_menu_summary),
GradeStatisticsFragment.newInstance() to getString(R.string.grade_menu_statistics)
)
)
}
with(binding.gradeViewPager) {
adapter = pagerAdapter
offscreenPageLimit = 3
setOnSelectPageListener(presenter::onPageSelected)
}
with(binding.gradeTabLayout) {
setupWithViewPager(binding.gradeViewPager)
setElevationCompat(context.dpToPx(4f))
with(pagerAdapter) {
containerId = binding.gradeViewPager.id
titleFactory = {
when (it) {
0 -> getString(R.string.all_details)
1 -> getString(R.string.grade_menu_summary)
2 -> getString(R.string.grade_menu_statistics)
else -> throw IllegalStateException()
}
}
itemFactory = {
when (it) {
0 -> GradeDetailsFragment.newInstance()
1 -> GradeSummaryFragment.newInstance()
2 -> GradeStatisticsFragment.newInstance()
else -> throw IllegalStateException()
}
}
TabLayoutMediator(binding.gradeTabLayout, binding.gradeViewPager, this).attach()
}
binding.gradeTabLayout.elevation = requireContext().dpToPx(4f)
with(binding) {
gradeErrorRetry.setOnClickListener { presenter.onRetry() }
gradeErrorDetails.setOnClickListener { presenter.onDetailsClick() }

View File

@ -101,7 +101,6 @@ class GradePresenter @Inject constructor(
private fun loadData() {
flowWithResource {
val student = studentRepository.getCurrentStudent()
delay(200)
semesterRepository.getSemesters(student, refreshOnNoCurrent = true)
}.onEach {
when (it.status) {

View File

@ -5,6 +5,7 @@ import android.content.res.Resources
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
@ -13,9 +14,11 @@ import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.databinding.HeaderGradeDetailsBinding
import io.github.wulkanowy.databinding.ItemGradeDetailsBinding
import io.github.wulkanowy.ui.base.BaseExpandableAdapter
import io.github.wulkanowy.ui.modules.grade.GradeExpandMode
import io.github.wulkanowy.utils.getBackgroundColor
import io.github.wulkanowy.utils.toFormattedString
import timber.log.Timber
import java.util.BitSet
import javax.inject.Inject
class GradeDetailsAdapter @Inject constructor() : BaseExpandableAdapter<RecyclerView.ViewHolder>() {
@ -24,19 +27,20 @@ class GradeDetailsAdapter @Inject constructor() : BaseExpandableAdapter<Recycler
private var items = mutableListOf<GradeDetailsItem>()
private var expandedPosition = NO_POSITION
private val expandedPositions = BitSet(items.size)
private var isExpandable = false
private var expandMode = GradeExpandMode.ONE
var onClickListener: (Grade, position: Int) -> Unit = { _, _ -> }
var colorTheme = ""
fun setDataItems(data: List<GradeDetailsItem>, isExpanded: Boolean = isExpandable) {
fun setDataItems(data: List<GradeDetailsItem>, expandMode: GradeExpandMode = this.expandMode) {
headers = data.filter { it.viewType == ViewType.HEADER }.toMutableList()
items = if (isExpanded) headers else data.toMutableList()
isExpandable = isExpanded
expandedPosition = NO_POSITION
items =
(if (expandMode != GradeExpandMode.ALWAYS_EXPANDED) headers else data).toMutableList()
this.expandMode = expandMode
expandedPositions.clear()
}
fun updateDetailsItem(position: Int, grade: Grade) {
@ -48,7 +52,7 @@ class GradeDetailsAdapter @Inject constructor() : BaseExpandableAdapter<Recycler
val candidates = headers.filter { (it.value as GradeDetailsHeader).subject == subject }
if (candidates.size > 1) {
Timber.e("Header with subject $subject found ${candidates.size} times! Expanded: $expandedPosition. Items: $candidates")
Timber.e("Header with subject $subject found ${candidates.size} times! Expanded: $expandedPositions. Items: $candidates")
}
return candidates.first()
@ -64,9 +68,9 @@ class GradeDetailsAdapter @Inject constructor() : BaseExpandableAdapter<Recycler
}
fun collapseAll() {
if (expandedPosition != -1) {
refreshList(headers)
expandedPosition = NO_POSITION
if (!expandedPositions.isEmpty) {
refreshList(headers.toMutableList())
expandedPositions.clear()
}
}
@ -86,8 +90,12 @@ class GradeDetailsAdapter @Inject constructor() : BaseExpandableAdapter<Recycler
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
ViewType.HEADER.id -> HeaderViewHolder(HeaderGradeDetailsBinding.inflate(inflater, parent, false))
ViewType.ITEM.id -> ItemViewHolder(ItemGradeDetailsBinding.inflate(inflater, parent, false))
ViewType.HEADER.id -> HeaderViewHolder(
HeaderGradeDetailsBinding.inflate(inflater, parent, false)
)
ViewType.ITEM.id -> ItemViewHolder(
ItemGradeDetailsBinding.inflate(inflater, parent, false)
)
else -> throw IllegalStateException()
}
}
@ -106,46 +114,91 @@ class GradeDetailsAdapter @Inject constructor() : BaseExpandableAdapter<Recycler
}
}
private fun bindHeaderViewHolder(holder: HeaderViewHolder, header: GradeDetailsHeader, position: Int) {
val headerPosition = headers.indexOf(items[position])
val adapterPosition = holder.bindingAdapterPosition
private fun bindHeaderViewHolder(
holder: HeaderViewHolder,
header: GradeDetailsHeader,
position: Int
) {
val context = holder.binding.root.context
val item = items[position]
val headerPosition = headers.indexOf(item)
with(holder.binding) {
gradeHeaderDivider.visibility = if (adapterPosition == 0) View.GONE else View.VISIBLE
gradeHeaderDivider.isVisible = holder.bindingAdapterPosition != 0
with(gradeHeaderSubject) {
text = header.subject
maxLines = if (headerPosition == expandedPosition) 2 else 1
maxLines = if (expandedPositions[headerPosition]) 2 else 1
}
gradeHeaderAverage.text = formatAverage(header.average, root.context.resources)
gradeHeaderPointsSum.text = root.context.getString(R.string.grade_points_sum, header.pointsSum)
gradeHeaderPointsSum.visibility = if (!header.pointsSum.isNullOrEmpty()) View.VISIBLE else View.GONE
gradeHeaderNumber.text = root.context.resources.getQuantityString(R.plurals.grade_number_item, header.grades.size, header.grades.size)
gradeHeaderNote.visibility = if (header.newGrades > 0) View.VISIBLE else View.GONE
if (header.newGrades > 0) gradeHeaderNote.text = header.newGrades.toString(10)
gradeHeaderPointsSum.text =
context.getString(R.string.grade_points_sum, header.pointsSum)
gradeHeaderPointsSum.isVisible = !header.pointsSum.isNullOrEmpty()
gradeHeaderNumber.text = context.resources.getQuantityString(
R.plurals.grade_number_item,
header.grades.size,
header.grades.size
)
gradeHeaderNote.isVisible = header.newGrades > 0
gradeHeaderContainer.isEnabled = isExpandable
if (header.newGrades > 0) {
gradeHeaderNote.text = header.newGrades.toString()
}
gradeHeaderContainer.isEnabled = expandMode != GradeExpandMode.ALWAYS_EXPANDED
gradeHeaderContainer.setOnClickListener {
expandedPosition = if (expandedPosition == adapterPosition) -1 else adapterPosition
if (expandedPosition != NO_POSITION) {
refreshList(headers.toMutableList().apply {
addAll(headerPosition + 1, header.grades)
})
scrollToHeaderWithSubItems(headerPosition, header.grades.size)
} else {
refreshList(headers)
}
expandGradeHeader(headerPosition, header, holder)
}
}
}
private fun formatAverage(average: Double?, resources: Resources): String {
return if (average == null || average == .0) resources.getString(R.string.grade_no_average)
else resources.getString(R.string.grade_average, average)
private fun expandGradeHeader(
headerPosition: Int,
header: GradeDetailsHeader,
holder: HeaderViewHolder
) {
if (expandMode == GradeExpandMode.ONE) {
val isHeaderExpanded = expandedPositions[headerPosition]
expandedPositions.clear()
if (!isHeaderExpanded) {
val updatedItemList = headers.toMutableList()
.apply { addAll(headerPosition + 1, header.grades) }
expandedPositions.set(headerPosition)
refreshList(updatedItemList)
scrollToHeaderWithSubItems(headerPosition, header.grades.size)
} else {
refreshList(headers.toMutableList())
}
} else if (expandMode == GradeExpandMode.UNLIMITED) {
val headerAdapterPosition = holder.bindingAdapterPosition
val isHeaderExpanded = expandedPositions[headerPosition]
expandedPositions.flip(headerPosition)
if (!isHeaderExpanded) {
val updatedList = items.toMutableList()
.apply { addAll(headerAdapterPosition + 1, header.grades) }
refreshList(updatedList)
scrollToHeaderWithSubItems(headerAdapterPosition, header.grades.size)
} else {
val startPosition = headerAdapterPosition + 1
val updatedList = items.toMutableList()
.apply {
subList(startPosition, startPosition + header.grades.size).clear()
}
refreshList(updatedList)
}
}
}
@SuppressLint("SetTextI18n")
private fun bindItemViewHolder(holder: ItemViewHolder, grade: Grade) {
val context = holder.binding.root.context
with(holder.binding) {
gradeItemValue.run {
text = grade.entry
@ -154,26 +207,37 @@ class GradeDetailsAdapter @Inject constructor() : BaseExpandableAdapter<Recycler
gradeItemDescription.text = when {
grade.description.isNotBlank() -> grade.description
grade.gradeSymbol.isNotBlank() -> grade.gradeSymbol
else -> root.context.getString(R.string.all_no_description)
else -> context.getString(R.string.all_no_description)
}
gradeItemDate.text = grade.date.toFormattedString()
gradeItemWeight.text = "${root.context.getString(R.string.grade_weight)}: ${grade.weight}"
gradeItemWeight.text = "${context.getString(R.string.grade_weight)}: ${grade.weight}"
gradeItemNote.visibility = if (!grade.isRead) View.VISIBLE else View.GONE
root.setOnClickListener {
holder.bindingAdapterPosition.let { if (it != NO_POSITION) onClickListener(grade, it) }
holder.bindingAdapterPosition.let {
if (it != NO_POSITION) onClickListener(grade, it)
}
}
}
}
private fun formatAverage(average: Double?, resources: Resources) =
if (average == null || average == .0) {
resources.getString(R.string.grade_no_average)
} else {
resources.getString(R.string.grade_average, average)
}
private class HeaderViewHolder(val binding: HeaderGradeDetailsBinding) :
RecyclerView.ViewHolder(binding.root)
private class ItemViewHolder(val binding: ItemGradeDetailsBinding) :
RecyclerView.ViewHolder(binding.root)
class GradeDetailsDiffUtil(private val old: List<GradeDetailsItem>, private val new: List<GradeDetailsItem>) :
DiffUtil.Callback() {
private class GradeDetailsDiffUtil(
private val old: List<GradeDetailsItem>,
private val new: List<GradeDetailsItem>
) : DiffUtil.Callback() {
override fun getOldListSize() = old.size

View File

@ -12,6 +12,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.ui.modules.grade.GradeExpandMode
import io.github.wulkanowy.databinding.FragmentGradeDetailsBinding
import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.grade.GradeFragment
@ -79,10 +80,10 @@ class GradeDetailsFragment :
else false
}
override fun updateData(data: List<GradeDetailsItem>, isGradeExpandable: Boolean, gradeColorTheme: String) {
override fun updateData(data: List<GradeDetailsItem>, expandMode: GradeExpandMode, gradeColorTheme: String) {
with(gradeDetailsAdapter) {
colorTheme = gradeColorTheme
setDataItems(data, isGradeExpandable)
setDataItems(data, expandMode)
notifyDataSetChanged()
}
}

View File

@ -9,6 +9,7 @@ import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.ui.modules.grade.GradeAverageProvider
import io.github.wulkanowy.ui.modules.grade.GradeExpandMode
import io.github.wulkanowy.ui.modules.grade.GradeSortingMode.ALPHABETIC
import io.github.wulkanowy.ui.modules.grade.GradeSortingMode.DATE
import io.github.wulkanowy.ui.modules.grade.GradeSubject
@ -16,6 +17,7 @@ import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.afterLoading
import io.github.wulkanowy.utils.flowWithResource
import io.github.wulkanowy.utils.flowWithResourceIn
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
@ -46,8 +48,8 @@ class GradeDetailsPresenter @Inject constructor(
fun onParentViewLoadData(semesterId: Int, forceRefresh: Boolean) {
currentSemesterId = semesterId
loadData(semesterId, forceRefresh)
if (!forceRefresh) view?.showErrorView(false)
loadData(semesterId, forceRefresh)
}
fun onGradeItemSelected(grade: Grade, position: Int) {
@ -113,7 +115,7 @@ class GradeDetailsPresenter @Inject constructor(
fun onParentViewReselected() {
view?.run {
if (!isViewEmpty) {
if (preferencesRepository.isGradeExpandable) collapseAllItems()
if (preferencesRepository.gradeExpandMode != GradeExpandMode.ALWAYS_EXPANDED) collapseAllItems()
scrollToStart()
}
}
@ -157,7 +159,7 @@ class GradeDetailsPresenter @Inject constructor(
showContent(true)
updateData(
data = items,
isGradeExpandable = preferencesRepository.isGradeExpandable,
expandMode = preferencesRepository.gradeExpandMode,
gradeColorTheme = preferencesRepository.gradeColorTheme
)
notifyParentDataLoaded(semesterId)
@ -175,7 +177,7 @@ class GradeDetailsPresenter @Inject constructor(
showContent(items.isNotEmpty())
updateData(
data = items,
isGradeExpandable = preferencesRepository.isGradeExpandable,
expandMode = preferencesRepository.gradeExpandMode,
gradeColorTheme = preferencesRepository.gradeColorTheme
)
}
@ -197,6 +199,9 @@ class GradeDetailsPresenter @Inject constructor(
enableSwipe(true)
notifyParentDataLoaded(semesterId)
}
}.catch {
errorHandler.dispatch(it)
view?.notifyParentDataLoaded(semesterId)
}.launch()
}
@ -213,6 +218,7 @@ class GradeDetailsPresenter @Inject constructor(
setErrorDetails(message)
showErrorView(true)
showEmpty(false)
showProgress(false)
} else showError(message, error)
}
}
@ -235,14 +241,24 @@ class GradeDetailsPresenter @Inject constructor(
.sortedByDescending { it.date }
.map { GradeDetailsItem(it, ViewType.ITEM) }
listOf(GradeDetailsItem(GradeDetailsHeader(
subject = subject,
average = average,
pointsSum = points,
grades = subItems
).apply {
newGrades = grades.filter { grade -> !grade.isRead }.size
}, ViewType.HEADER)) + if (preferencesRepository.isGradeExpandable) emptyList() else subItems
val gradeDetailsItems = listOf(
GradeDetailsItem(
GradeDetailsHeader(
subject = subject,
average = average,
pointsSum = points,
grades = subItems
).apply {
newGrades = grades.filter { grade -> !grade.isRead }.size
}, ViewType.HEADER
)
)
if (preferencesRepository.gradeExpandMode == GradeExpandMode.ALWAYS_EXPANDED) {
gradeDetailsItems + subItems
} else {
gradeDetailsItems
}
}.flatten()
}

View File

@ -1,6 +1,7 @@
package io.github.wulkanowy.ui.modules.grade.details
import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.ui.modules.grade.GradeExpandMode
import io.github.wulkanowy.ui.base.BaseView
interface GradeDetailsView : BaseView {
@ -9,7 +10,7 @@ interface GradeDetailsView : BaseView {
fun initView()
fun updateData(data: List<GradeDetailsItem>, isGradeExpandable: Boolean, gradeColorTheme: String)
fun updateData(data: List<GradeDetailsItem>, expandMode: GradeExpandMode, gradeColorTheme: String)
fun updateItem(item: Grade, position: Int)

View File

@ -68,7 +68,7 @@ class GradeStatisticsFragment :
}
with(binding) {
gradeStatisticsSubjectsContainer.setElevationCompat(requireContext().dpToPx(1f))
gradeStatisticsSubjectsContainer.elevation = requireContext().dpToPx(1f)
gradeStatisticsSwipe.setOnRefreshListener(presenter::onSwipeRefresh)
gradeStatisticsSwipe.setColorSchemeColors(requireContext().getThemeAttrColor(R.attr.colorPrimary))

View File

@ -10,6 +10,7 @@ import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Homework
import io.github.wulkanowy.databinding.FragmentHomeworkBinding
import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.homework.add.HomeworkAddDialog
import io.github.wulkanowy.ui.modules.homework.details.HomeworkDetailsDialog
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.ui.modules.main.MainView
@ -64,7 +65,9 @@ class HomeworkFragment : BaseFragment<FragmentHomeworkBinding>(R.layout.fragment
homeworkPreviousButton.setOnClickListener { presenter.onPreviousDay() }
homeworkNextButton.setOnClickListener { presenter.onNextDay() }
homeworkNavContainer.setElevationCompat(requireContext().dpToPx(8f))
openAddHomeworkButton.setOnClickListener { presenter.onHomeworkAddButtonClicked() }
homeworkNavContainer.elevation = requireContext().dpToPx(8f)
}
}
@ -122,10 +125,14 @@ class HomeworkFragment : BaseFragment<FragmentHomeworkBinding>(R.layout.fragment
binding.homeworkNextButton.visibility = if (show) VISIBLE else View.INVISIBLE
}
override fun showTimetableDialog(homework: Homework) {
override fun showHomeworkDialog(homework: Homework) {
(activity as? MainActivity)?.showDialogFragment(HomeworkDetailsDialog.newInstance(homework))
}
override fun showAddHomeworkDialog() {
(activity as? MainActivity)?.showDialogFragment(HomeworkAddDialog())
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putLong(SAVED_DATE_KEY, presenter.currentDate.toEpochDay())

View File

@ -78,7 +78,11 @@ class HomeworkPresenter @Inject constructor(
fun onHomeworkItemSelected(homework: Homework) {
Timber.i("Select homework item ${homework.id}")
view?.showTimetableDialog(homework)
view?.showHomeworkDialog(homework)
}
fun onHomeworkAddButtonClicked() {
view?.showAddHomeworkDialog()
}
private fun setBaseDateOnHolidays() {

View File

@ -33,5 +33,7 @@ interface HomeworkView : BaseView {
fun showNextButton(show: Boolean)
fun showTimetableDialog(homework: Homework)
fun showHomeworkDialog(homework: Homework)
fun showAddHomeworkDialog()
}

View File

@ -0,0 +1,124 @@
package io.github.wulkanowy.ui.modules.homework.add
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doOnTextChanged
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.MaterialDatePicker
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.databinding.DialogHomeworkAddBinding
import io.github.wulkanowy.ui.base.BaseDialogFragment
import io.github.wulkanowy.utils.toFormattedString
import io.github.wulkanowy.utils.toLocalDateTime
import io.github.wulkanowy.utils.toTimestamp
import java.time.LocalDate
import javax.inject.Inject
@AndroidEntryPoint
class HomeworkAddDialog : BaseDialogFragment<DialogHomeworkAddBinding>(), HomeworkAddView {
@Inject
lateinit var presenter: HomeworkAddPresenter
private var date: LocalDate? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, 0)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = DialogHomeworkAddBinding.inflate(inflater).apply { binding = this }.root
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
presenter.onAttachView(this)
}
override fun initView() {
with(binding) {
homeworkDialogSubjectEdit.doOnTextChanged { _, _, _, _ ->
homeworkDialogSubject.error = null
homeworkDialogSubject.isErrorEnabled = false
}
homeworkDialogDateEdit.doOnTextChanged { _, _, _, _ ->
homeworkDialogDate.error = null
homeworkDialogDate.isErrorEnabled = false
}
homeworkDialogContentEdit.doOnTextChanged { _, _, _, _ ->
homeworkDialogContent.error = null
homeworkDialogContent.isErrorEnabled = false
}
homeworkDialogClose.setOnClickListener { dismiss() }
homeworkDialogDateEdit.setOnClickListener { presenter.showDatePicker(date) }
homeworkDialogAdd.setOnClickListener {
presenter.onAddHomeworkClicked(
subject = homeworkDialogSubjectEdit.text?.toString(),
teacher = homeworkDialogTeacherEdit.text?.toString(),
date = homeworkDialogDateEdit.text?.toString(),
content = homeworkDialogContentEdit.text?.toString()
)
}
}
}
override fun showSuccessMessage() {
showMessage(getString(R.string.homework_add_success))
}
override fun setErrorSubjectRequired() {
with(binding.homeworkDialogSubject) {
isErrorEnabled = true
error = getString(R.string.error_field_required)
}
}
override fun setErrorDateRequired() {
with(binding.homeworkDialogDate) {
isErrorEnabled = true
error = getString(R.string.error_field_required)
}
}
override fun setErrorContentRequired() {
with(binding.homeworkDialogContent) {
isErrorEnabled = true
error = getString(R.string.error_field_required)
}
}
override fun closeDialog() {
dismiss()
}
override fun showDatePickerDialog(currentDate: LocalDate) {
val constraintsBuilder = CalendarConstraints.Builder().apply {
setStart(LocalDate.now().toEpochDay())
}
val datePicker =
MaterialDatePicker.Builder.datePicker()
.setCalendarConstraints(constraintsBuilder.build())
.setSelection(currentDate.toTimestamp())
.build()
datePicker.addOnPositiveButtonClickListener {
date = it.toLocalDateTime().toLocalDate()
binding.homeworkDialogDate.editText?.setText(date!!.toFormattedString())
}
if (!parentFragmentManager.isStateSaved) {
datePicker.show(this.parentFragmentManager, null)
}
}
override fun onDestroyView() {
presenter.onDetachView()
super.onDestroyView()
}
}

View File

@ -0,0 +1,92 @@
package io.github.wulkanowy.ui.modules.homework.add
import io.github.wulkanowy.data.Status
import io.github.wulkanowy.data.db.entities.Homework
import io.github.wulkanowy.data.repositories.HomeworkRepository
import io.github.wulkanowy.data.repositories.SemesterRepository
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.flowWithResource
import io.github.wulkanowy.utils.toLocalDate
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.time.LocalDate
import javax.inject.Inject
class HomeworkAddPresenter @Inject constructor(
errorHandler: ErrorHandler,
studentRepository: StudentRepository,
private val homeworkRepository: HomeworkRepository,
private val semesterRepository: SemesterRepository
) : BasePresenter<HomeworkAddView>(errorHandler, studentRepository) {
override fun onAttachView(view: HomeworkAddView) {
super.onAttachView(view)
view.initView()
Timber.i("Homework details view was initialized")
}
fun showDatePicker(date: LocalDate?) {
view?.showDatePickerDialog(date ?: LocalDate.now())
}
fun onAddHomeworkClicked(subject: String?, teacher: String?, date: String?, content: String?) {
var isError = false
if (subject.isNullOrBlank()) {
view?.setErrorSubjectRequired()
isError = true
}
if (date.isNullOrBlank()) {
view?.setErrorDateRequired()
isError = true
}
if (content.isNullOrBlank()) {
view?.setErrorContentRequired()
isError = true
}
if (!isError) {
saveHomework(subject!!, teacher.orEmpty(), date!!.toLocalDate(), content!!)
}
}
private fun saveHomework(subject: String, teacher: String, date: LocalDate, content: String) {
flowWithResource {
val student = studentRepository.getCurrentStudent()
val semester = semesterRepository.getCurrentSemester(student)
val entryDate = LocalDate.now()
homeworkRepository.saveHomework(
Homework(
semesterId = semester.semesterId,
studentId = student.studentId,
date = date,
entryDate = entryDate,
subject = subject,
content = content,
teacher = teacher,
teacherSymbol = "",
attachments = emptyList(),
).apply { isAddedByUser = true }
)
}.onEach {
when (it.status) {
Status.LOADING -> Timber.i("Homework insert start")
Status.SUCCESS -> {
Timber.i("Homework insert: Success")
view?.run {
showSuccessMessage()
closeDialog()
}
}
Status.ERROR -> {
Timber.i("Homework insert result: An exception occurred")
errorHandler.dispatch(it.error!!)
}
}
}.launch("add_homework")
}
}

View File

@ -0,0 +1,21 @@
package io.github.wulkanowy.ui.modules.homework.add
import io.github.wulkanowy.ui.base.BaseView
import java.time.LocalDate
interface HomeworkAddView : BaseView {
fun initView()
fun showSuccessMessage()
fun setErrorSubjectRequired()
fun setErrorDateRequired()
fun setErrorContentRequired()
fun closeDialog()
fun showDatePickerDialog(currentDate: LocalDate)
}

View File

@ -5,10 +5,12 @@ import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Homework
import io.github.wulkanowy.databinding.ItemHomeworkDialogAttachmentBinding
import io.github.wulkanowy.databinding.ItemHomeworkDialogAttachmentsHeaderBinding
import io.github.wulkanowy.databinding.ItemHomeworkDialogDetailsBinding
import io.github.wulkanowy.utils.ifNullOrBlank
import io.github.wulkanowy.utils.toFormattedString
import javax.inject.Inject
@ -37,6 +39,8 @@ class HomeworkDetailsAdapter @Inject constructor() :
var onFullScreenExitClickListener = {}
var onDeleteClickListener: (homework: Homework) -> Unit = {}
override fun getItemCount() = 1 + if (attachments.isNotEmpty()) attachments.size + 1 else 0
override fun getItemViewType(position: Int) = when (position) {
@ -49,9 +53,15 @@ class HomeworkDetailsAdapter @Inject constructor() :
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
ViewType.ATTACHMENTS_HEADER.id -> AttachmentsHeaderViewHolder(ItemHomeworkDialogAttachmentsHeaderBinding.inflate(inflater, parent, false))
ViewType.ATTACHMENT.id -> AttachmentViewHolder(ItemHomeworkDialogAttachmentBinding.inflate(inflater, parent, false))
else -> DetailsViewHolder(ItemHomeworkDialogDetailsBinding.inflate(inflater, parent, false))
ViewType.ATTACHMENTS_HEADER.id -> AttachmentsHeaderViewHolder(
ItemHomeworkDialogAttachmentsHeaderBinding.inflate(inflater, parent, false)
)
ViewType.ATTACHMENT.id -> AttachmentViewHolder(
ItemHomeworkDialogAttachmentBinding.inflate(inflater, parent, false)
)
else -> DetailsViewHolder(
ItemHomeworkDialogDetailsBinding.inflate(inflater, parent, false)
)
}
}
@ -63,12 +73,15 @@ class HomeworkDetailsAdapter @Inject constructor() :
}
private fun bindDetailsViewHolder(holder: DetailsViewHolder) {
val noDataString = holder.binding.root.context.getString(R.string.all_no_data)
with(holder.binding) {
homeworkDialogDate.text = homework?.date?.toFormattedString()
homeworkDialogEntryDate.text = homework?.entryDate?.toFormattedString()
homeworkDialogSubject.text = homework?.subject
homeworkDialogTeacher.text = homework?.teacher
homeworkDialogContent.text = homework?.content
homeworkDialogSubject.text = homework?.subject.ifNullOrBlank { noDataString }
homeworkDialogTeacher.text = homework?.teacher.ifNullOrBlank { noDataString }
homeworkDialogContent.text = homework?.content.ifNullOrBlank { noDataString }
homeworkDialogDelete.visibility = if (homework?.isAddedByUser == true) VISIBLE else GONE
homeworkDialogFullScreen.visibility = if (isHomeworkFullscreen) GONE else VISIBLE
homeworkDialogFullScreenExit.visibility = if (isHomeworkFullscreen) VISIBLE else GONE
homeworkDialogFullScreen.setOnClickListener {
@ -81,6 +94,9 @@ class HomeworkDetailsAdapter @Inject constructor() :
homeworkDialogFullScreenExit.visibility = GONE
onFullScreenExitClickListener()
}
homeworkDialogDelete.setOnClickListener {
onDeleteClickListener(homework!!)
}
}
}

View File

@ -25,6 +25,9 @@ class HomeworkDetailsDialog : BaseDialogFragment<DialogHomeworkBinding>(), Homew
@Inject
lateinit var detailsAdapter: HomeworkDetailsAdapter
override val homeworkDeleteSuccess: String
get() = getString(R.string.homework_delete_success)
private lateinit var homework: Homework
companion object {
@ -82,12 +85,17 @@ class HomeworkDetailsDialog : BaseDialogFragment<DialogHomeworkBinding>(), Homew
dialog?.window?.setLayout(WRAP_CONTENT, WRAP_CONTENT)
presenter.isHomeworkFullscreen = false
}
onDeleteClickListener = { homework -> presenter.deleteHomework(homework) }
isHomeworkFullscreen = presenter.isHomeworkFullscreen
homework = this@HomeworkDetailsDialog.homework
}
}
}
override fun closeDialog() {
dismiss()
}
override fun updateMarkAsDoneLabel(isDone: Boolean) {
binding.homeworkDialogRead.text =
view?.context?.getString(if (isDone) R.string.homework_mark_as_undone else R.string.homework_mark_as_done)

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