Add admin messages (#1553)

This commit is contained in:
Rafał Borcz 2021-10-13 23:58:24 +02:00 committed by GitHub
parent d6918077bf
commit e3122127c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2868 additions and 70 deletions

View File

@ -66,12 +66,14 @@ android {
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release signingConfig signingConfigs.release
buildConfigField "String", "MESSAGES_BASE_URL", "\"https://messages.wulkanowy.net.pl\""
} }
debug { debug {
resValue "string", "app_name", "Wulkanowy DEV " + defaultConfig.versionCode resValue "string", "app_name", "Wulkanowy DEV " + defaultConfig.versionCode
applicationIdSuffix ".dev" applicationIdSuffix ".dev"
versionNameSuffix "-dev" versionNameSuffix "-dev"
ext.enableCrashlytics = project.hasProperty("enableFirebase") ext.enableCrashlytics = project.hasProperty("enableFirebase")
buildConfigField "String", "MESSAGES_BASE_URL", "\"https://messages.wulkanowy.net.pl\""
} }
} }
@ -170,7 +172,7 @@ ext {
} }
dependencies { dependencies {
implementation "io.github.wulkanowy:sdk:1.3.0" implementation "io.github.wulkanowy:sdk:4efd64264b"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
@ -212,6 +214,10 @@ dependencies {
implementation 'com.github.ncapdevi:FragNav:3.3.0' implementation 'com.github.ncapdevi:FragNav:3.3.0'
implementation "com.github.YarikSOffice:lingver:1.3.0" implementation "com.github.YarikSOffice:lingver:1.3.0"
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 "com.jakewharton.timber:timber:5.0.1"
implementation "at.favre.lib:slf4j-timber:1.0.1" implementation "at.favre.lib:slf4j-timber:1.0.1"
implementation 'com.github.bastienpaulfr:Treessence:1.0.5' implementation 'com.github.bastienpaulfr:Treessence:1.0.5'
@ -234,7 +240,7 @@ dependencies {
releaseImplementation "com.github.ChuckerTeam.Chucker:library-no-op:$chucker" releaseImplementation "com.github.ChuckerTeam.Chucker:library-no-op:$chucker"
debugImplementation "com.github.ChuckerTeam.Chucker:library:$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 "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:$mockk" testImplementation "io.mockk:mockk:$mockk"

File diff suppressed because it is too large Load Diff

View File

@ -2,62 +2,100 @@ package io.github.wulkanowy.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.AssetManager
import android.content.res.Resources
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.chuckerteam.chucker.api.RetentionManager import com.chuckerteam.chucker.api.RetentionManager
import com.fredporciuncula.flow.preferences.FlowSharedPreferences import com.fredporciuncula.flow.preferences.FlowSharedPreferences
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent 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.AppDatabase
import io.github.wulkanowy.data.db.SharedPrefProvider import io.github.wulkanowy.data.db.SharedPrefProvider
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json 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 timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
internal class RepositoryModule { internal class DataModule {
@Singleton @Singleton
@Provides @Provides
fun provideSdk(chuckerCollector: ChuckerCollector, @ApplicationContext context: Context): Sdk { fun provideSdk(chuckerInterceptor: ChuckerInterceptor) =
return Sdk().apply { Sdk().apply {
androidVersion = android.os.Build.VERSION.RELEASE androidVersion = android.os.Build.VERSION.RELEASE
buildTag = android.os.Build.MODEL buildTag = android.os.Build.MODEL
setSimpleHttpLogger { Timber.d(it) } setSimpleHttpLogger { Timber.d(it) }
// for debug only // for debug only
addInterceptor( addInterceptor(chuckerInterceptor, network = true)
ChuckerInterceptor.Builder(context)
.collector(chuckerCollector)
.alwaysReadResponseBody(true)
.build(), network = true
)
} }
}
@Singleton @Singleton
@Provides @Provides
fun provideChuckerCollector( fun provideChuckerCollector(
@ApplicationContext context: Context, @ApplicationContext context: Context,
prefRepository: PreferencesRepository prefRepository: PreferencesRepository
): ChuckerCollector { ) = ChuckerCollector(
return ChuckerCollector( context = context,
context = context, showNotification = prefRepository.isDebugNotificationEnable,
showNotification = prefRepository.isDebugNotificationEnable, retentionPeriod = RetentionManager.Period.ONE_HOUR
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 @Singleton
@Provides @Provides
@ -67,14 +105,6 @@ internal class RepositoryModule {
appInfo: AppInfo appInfo: AppInfo
) = AppDatabase.newInstance(context, sharedPrefProvider, 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 @Singleton
@Provides @Provides
fun provideSharedPref(@ApplicationContext context: Context): SharedPreferences = fun provideSharedPref(@ApplicationContext context: Context): SharedPreferences =
@ -208,4 +238,8 @@ internal class RepositoryModule {
@Singleton @Singleton
@Provides @Provides
fun provideNotificationDao(database: AppDatabase) = database.notificationDao 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
import androidx.room.RoomDatabase.JournalMode.TRUNCATE import androidx.room.RoomDatabase.JournalMode.TRUNCATE
import androidx.room.TypeConverters 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.AttendanceDao
import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao
import io.github.wulkanowy.data.db.dao.CompletedLessonsDao 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.TimetableAdditionalDao
import io.github.wulkanowy.data.db.dao.TimetableDao import io.github.wulkanowy.data.db.dao.TimetableDao
import io.github.wulkanowy.data.db.dao.TimetableHeaderDao import io.github.wulkanowy.data.db.dao.TimetableHeaderDao
import io.github.wulkanowy.data.db.entities.AdminMessage
import io.github.wulkanowy.data.db.entities.Attendance import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.db.entities.AttendanceSummary import io.github.wulkanowy.data.db.entities.AttendanceSummary
import io.github.wulkanowy.data.db.entities.CompletedLesson import io.github.wulkanowy.data.db.entities.CompletedLesson
@ -97,8 +99,9 @@ import io.github.wulkanowy.data.db.migrations.Migration37
import io.github.wulkanowy.data.db.migrations.Migration38 import io.github.wulkanowy.data.db.migrations.Migration38
import io.github.wulkanowy.data.db.migrations.Migration39 import io.github.wulkanowy.data.db.migrations.Migration39
import io.github.wulkanowy.data.db.migrations.Migration4 import io.github.wulkanowy.data.db.migrations.Migration4
import io.github.wulkanowy.data.db.migrations.Migration41
import io.github.wulkanowy.data.db.migrations.Migration40 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.Migration5 import io.github.wulkanowy.data.db.migrations.Migration5
import io.github.wulkanowy.data.db.migrations.Migration6 import io.github.wulkanowy.data.db.migrations.Migration6
import io.github.wulkanowy.data.db.migrations.Migration7 import io.github.wulkanowy.data.db.migrations.Migration7
@ -138,7 +141,8 @@ import javax.inject.Singleton
StudentInfo::class, StudentInfo::class,
TimetableHeader::class, TimetableHeader::class,
SchoolAnnouncement::class, SchoolAnnouncement::class,
Notification::class Notification::class,
AdminMessage::class
], ],
version = AppDatabase.VERSION_SCHEMA, version = AppDatabase.VERSION_SCHEMA,
exportSchema = true exportSchema = true
@ -147,7 +151,7 @@ import javax.inject.Singleton
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
companion object { companion object {
const val VERSION_SCHEMA = 41 const val VERSION_SCHEMA = 42
fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf( fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf(
Migration2(), Migration2(),
@ -189,7 +193,8 @@ abstract class AppDatabase : RoomDatabase() {
Migration38(), Migration38(),
Migration39(), Migration39(),
Migration40(), Migration40(),
Migration41() Migration41(),
Migration42()
) )
fun newInstance( fun newInstance(
@ -261,4 +266,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract val schoolAnnouncementDao: SchoolAnnouncementDao abstract val schoolAnnouncementDao: SchoolAnnouncementDao
abstract val notificationDao: NotificationDao abstract val notificationDao: NotificationDao
abstract val adminMessagesDao: AdminMessageDao
} }

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

@ -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

@ -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,53 @@
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.getRefreshKey
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(getRefreshKey(cacheKey, student)) || 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,6 +1,7 @@
package io.github.wulkanowy.data.repositories package io.github.wulkanowy.data.repositories
import android.content.res.AssetManager import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.data.pojos.Contributor import io.github.wulkanowy.data.pojos.Contributor
import io.github.wulkanowy.utils.DispatchersProvider import io.github.wulkanowy.utils.DispatchersProvider
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -12,7 +13,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class AppCreatorRepository @Inject constructor( class AppCreatorRepository @Inject constructor(
private val assets: AssetManager, @ApplicationContext private val context: Context,
private val dispatchers: DispatchersProvider, private val dispatchers: DispatchersProvider,
private val json: Json, private val json: Json,
) { ) {
@ -20,7 +21,7 @@ class AppCreatorRepository @Inject constructor(
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun getAppCreators() = withContext(dispatchers.backgroundThread) { suspend fun getAppCreators() = withContext(dispatchers.backgroundThread) {
val inputStream = assets.open("contributors.json").buffered() val inputStream = context.assets.open("contributors.json").buffered()
json.decodeFromStream<List<Contributor>>(inputStream) json.decodeFromStream<List<Contributor>>(inputStream)
} }
} }

View File

@ -209,6 +209,7 @@ class PreferencesRepository @Inject constructor(
.map { set -> .map { set ->
set.map { DashboardItem.Tile.valueOf(it) } set.map { DashboardItem.Tile.valueOf(it) }
.plus(DashboardItem.Tile.ACCOUNT) .plus(DashboardItem.Tile.ACCOUNT)
.plus(DashboardItem.Tile.ADMIN_MESSAGE)
.toSet() .toSet()
} }
@ -216,6 +217,7 @@ class PreferencesRepository @Inject constructor(
get() = selectedDashboardTilesPreference.get() get() = selectedDashboardTilesPreference.get()
.map { DashboardItem.Tile.valueOf(it) } .map { DashboardItem.Tile.valueOf(it) }
.plus(DashboardItem.Tile.ACCOUNT) .plus(DashboardItem.Tile.ACCOUNT)
.plus(DashboardItem.Tile.ADMIN_MESSAGE)
.toSet() .toSet()
set(value) { set(value) {
val filteredValue = value.filterNot { it == DashboardItem.Tile.ACCOUNT } val filteredValue = value.filterNot { it == DashboardItem.Tile.ACCOUNT }

View File

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

View File

@ -1,6 +1,8 @@
package io.github.wulkanowy.ui.modules.dashboard package io.github.wulkanowy.ui.modules.dashboard
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Handler import android.os.Handler
import android.os.Looper 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.Timetable
import io.github.wulkanowy.data.db.entities.TimetableHeader import io.github.wulkanowy.data.db.entities.TimetableHeader
import io.github.wulkanowy.databinding.ItemDashboardAccountBinding import io.github.wulkanowy.databinding.ItemDashboardAccountBinding
import io.github.wulkanowy.databinding.ItemDashboardAdminMessageBinding
import io.github.wulkanowy.databinding.ItemDashboardAnnouncementsBinding import io.github.wulkanowy.databinding.ItemDashboardAnnouncementsBinding
import io.github.wulkanowy.databinding.ItemDashboardConferencesBinding import io.github.wulkanowy.databinding.ItemDashboardConferencesBinding
import io.github.wulkanowy.databinding.ItemDashboardExamsBinding import io.github.wulkanowy.databinding.ItemDashboardExamsBinding
@ -63,6 +66,8 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
var onConferencesTileClickListener: () -> Unit = {} var onConferencesTileClickListener: () -> Unit = {}
var onAdminMessageClickListener: (String?) -> Unit = {}
val items = mutableListOf<DashboardItem>() val items = mutableListOf<DashboardItem>()
fun submitList(newItems: List<DashboardItem>) { fun submitList(newItems: List<DashboardItem>) {
@ -109,6 +114,9 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
DashboardItem.Type.CONFERENCES.ordinal -> ConferencesViewHolder( DashboardItem.Type.CONFERENCES.ordinal -> ConferencesViewHolder(
ItemDashboardConferencesBinding.inflate(inflater, parent, false) ItemDashboardConferencesBinding.inflate(inflater, parent, false)
) )
DashboardItem.Type.ADMIN_MESSAGE.ordinal -> AdminMessageViewHolder(
ItemDashboardAdminMessageBinding.inflate(inflater, parent, false)
)
else -> throw IllegalArgumentException() else -> throw IllegalArgumentException()
} }
} }
@ -123,6 +131,7 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
is AnnouncementsViewHolder -> bindAnnouncementsViewHolder(holder, position) is AnnouncementsViewHolder -> bindAnnouncementsViewHolder(holder, position)
is ExamsViewHolder -> bindExamsViewHolder(holder, position) is ExamsViewHolder -> bindExamsViewHolder(holder, position)
is ConferencesViewHolder -> bindConferencesViewHolder(holder, position) is ConferencesViewHolder -> bindConferencesViewHolder(holder, position)
is AdminMessageViewHolder -> bindAdminMessage(holder, position)
} }
} }
@ -697,6 +706,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) : class AccountViewHolder(val binding: ItemDashboardAccountBinding) :
RecyclerView.ViewHolder(binding.root) RecyclerView.ViewHolder(binding.root)
@ -736,6 +773,9 @@ class DashboardAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView
val adapter by lazy { DashboardConferencesAdapter() } val adapter by lazy { DashboardConferencesAdapter() }
} }
class AdminMessageViewHolder(val binding: ItemDashboardAdminMessageBinding) :
RecyclerView.ViewHolder(binding.root)
private class DiffCallback( private class DiffCallback(
private val newList: List<DashboardItem>, private val newList: List<DashboardItem>,
private val oldList: 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.DefaultItemAnimator
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.databinding.FragmentDashboardBinding 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.ui.modules.timetable.TimetableFragment
import io.github.wulkanowy.utils.capitalise import io.github.wulkanowy.utils.capitalise
import io.github.wulkanowy.utils.getThemeAttrColor import io.github.wulkanowy.utils.getThemeAttrColor
import io.github.wulkanowy.utils.openInternetBrowser
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
@ -97,6 +99,13 @@ class DashboardFragment : BaseFragment<FragmentDashboardBinding>(R.layout.fragme
onConferencesTileClickListener = { onConferencesTileClickListener = {
mainActivity.pushView(ConferenceFragment.newInstance()) mainActivity.pushView(ConferenceFragment.newInstance())
} }
onAdminMessageClickListener = presenter::onAdminMessageSelected
registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
binding.dashboardRecycler.scrollToPosition(0)
}
})
} }
with(binding) { with(binding) {
@ -188,6 +197,10 @@ class DashboardFragment : BaseFragment<FragmentDashboardBinding>(R.layout.fragme
(requireActivity() as MainActivity).pushView(NotificationsCenterFragment.newInstance()) (requireActivity() as MainActivity).pushView(NotificationsCenterFragment.newInstance())
} }
override fun openInternetBrowser(url: String) {
requireContext().openInternetBrowser(url)
}
override fun onDestroyView() { override fun onDestroyView() {
dashboardAdapter.clearTimers() dashboardAdapter.clearTimers()
presenter.onDetachView() presenter.onDetachView()

View File

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

View File

@ -21,7 +21,7 @@ class DashboardItemMoveCallback(
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder viewHolder: RecyclerView.ViewHolder
): Int { ): Int {
val dragFlags = if (viewHolder.bindingAdapterPosition != 0) { val dragFlags = if (!viewHolder.isAdminMessageOrAccountItem) {
ItemTouchHelper.UP or ItemTouchHelper.DOWN ItemTouchHelper.UP or ItemTouchHelper.DOWN
} else 0 } else 0
@ -32,7 +32,7 @@ class DashboardItemMoveCallback(
recyclerView: RecyclerView, recyclerView: RecyclerView,
current: RecyclerView.ViewHolder, current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder target: RecyclerView.ViewHolder
) = target.bindingAdapterPosition != 0 ) = !target.isAdminMessageOrAccountItem
override fun onMove( override fun onMove(
recyclerView: RecyclerView, recyclerView: RecyclerView,
@ -52,4 +52,7 @@ class DashboardItemMoveCallback(
onUserInteractionEndListener(dashboardAdapter.items.toList()) 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.LuckyNumber
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.enums.MessageFolder 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.AttendanceSummaryRepository
import io.github.wulkanowy.data.repositories.ConferenceRepository import io.github.wulkanowy.data.repositories.ConferenceRepository
import io.github.wulkanowy.data.repositories.ExamRepository import io.github.wulkanowy.data.repositories.ExamRepository
@ -50,7 +51,8 @@ class DashboardPresenter @Inject constructor(
private val examRepository: ExamRepository, private val examRepository: ExamRepository,
private val conferenceRepository: ConferenceRepository, private val conferenceRepository: ConferenceRepository,
private val preferencesRepository: PreferencesRepository, private val preferencesRepository: PreferencesRepository,
private val schoolAnnouncementRepository: SchoolAnnouncementRepository private val schoolAnnouncementRepository: SchoolAnnouncementRepository,
private val adminMessageRepository: AdminMessageRepository
) : BasePresenter<DashboardView>(errorHandler, studentRepository) { ) : BasePresenter<DashboardView>(errorHandler, studentRepository) {
private val dashboardItemLoadedList = mutableListOf<DashboardItem>() private val dashboardItemLoadedList = mutableListOf<DashboardItem>()
@ -179,6 +181,7 @@ class DashboardPresenter @Inject constructor(
loadConferences(student, forceRefresh) loadConferences(student, forceRefresh)
} }
DashboardItem.Type.ADS -> TODO() DashboardItem.Type.ADS -> TODO()
DashboardItem.Type.ADMIN_MESSAGE -> loadAdminMessage(student, forceRefresh)
} }
} }
} }
@ -225,6 +228,10 @@ class DashboardPresenter @Inject constructor(
}.toSet() }.toSet()
} }
fun onAdminMessageSelected(url: String?) {
url?.let { view?.openInternetBrowser(it) }
}
private fun loadHorizontalGroup(student: Student, forceRefresh: Boolean) { private fun loadHorizontalGroup(student: Student, forceRefresh: Boolean) {
flow { flow {
val semester = semesterRepository.getCurrentSemester(student) val semester = semesterRepository.getCurrentSemester(student)
@ -567,6 +574,38 @@ class DashboardPresenter @Inject constructor(
}.launch("dashboard_conferences") }.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) { private fun updateData(dashboardItem: DashboardItem, forceRefresh: Boolean) {
val isForceRefreshError = forceRefresh && dashboardItem.error != null val isForceRefreshError = forceRefresh && dashboardItem.error != null
val isFirstRunDataLoadedError = val isFirstRunDataLoadedError =
@ -579,6 +618,11 @@ class DashboardPresenter @Inject constructor(
sortDashboardItems() sortDashboardItems()
if (dashboardItem is DashboardItem.AdminMessages && !dashboardItem.isDataLoaded) {
dashboardItemsToLoad = dashboardItemsToLoad - DashboardItem.Type.ADMIN_MESSAGE
dashboardItemLoadedList.removeAll { it.type == DashboardItem.Type.ADMIN_MESSAGE }
}
if (forceRefresh) { if (forceRefresh) {
updateForceRefreshData(dashboardItem) updateForceRefreshData(dashboardItem)
} else { } else {
@ -644,7 +688,9 @@ class DashboardPresenter @Inject constructor(
itemsLoadedList: List<DashboardItem>, itemsLoadedList: List<DashboardItem>,
forceRefresh: Boolean 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 = val isAccountItemError =
itemsLoadedList.find { it.type == DashboardItem.Type.ACCOUNT }?.error != null itemsLoadedList.find { it.type == DashboardItem.Type.ACCOUNT }?.error != null
val isGeneralError = val isGeneralError =
@ -676,10 +722,13 @@ class DashboardPresenter @Inject constructor(
val dashboardItemsPosition = preferencesRepository.dashboardItemsPosition val dashboardItemsPosition = preferencesRepository.dashboardItemsPosition
dashboardItemLoadedList.sortBy { tile -> dashboardItemLoadedList.sortBy { tile ->
dashboardItemsPosition?.getOrDefault( val defaultPosition = if (tile is DashboardItem.AdminMessages) {
tile.type, -1
} else {
tile.type.ordinal + 100 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 popViewToRoot()
fun openNotificationsCenterView() fun openNotificationsCenterView()
fun openInternetBrowser(url: String)
} }

View File

@ -1,7 +1,8 @@
package io.github.wulkanowy.ui.modules.login package io.github.wulkanowy.ui.modules.login
import android.content.res.Resources import android.content.Context
import android.database.sqlite.SQLiteConstraintException import android.database.sqlite.SQLiteConstraintException
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.sdk.mobile.exception.InvalidPinException import io.github.wulkanowy.sdk.mobile.exception.InvalidPinException
import io.github.wulkanowy.sdk.mobile.exception.InvalidSymbolException import io.github.wulkanowy.sdk.mobile.exception.InvalidSymbolException
@ -11,7 +12,8 @@ import io.github.wulkanowy.sdk.scrapper.login.BadCredentialsException
import io.github.wulkanowy.ui.base.ErrorHandler import io.github.wulkanowy.ui.base.ErrorHandler
import javax.inject.Inject import javax.inject.Inject
class LoginErrorHandler @Inject constructor(resources: Resources) : ErrorHandler(resources) { class LoginErrorHandler @Inject constructor(@ApplicationContext context: Context) :
ErrorHandler(context) {
var onBadCredentials: (String?) -> Unit = {} var onBadCredentials: (String?) -> Unit = {}
@ -24,6 +26,7 @@ class LoginErrorHandler @Inject constructor(resources: Resources) : ErrorHandler
var onStudentDuplicate: (String) -> Unit = {} var onStudentDuplicate: (String) -> Unit = {}
override fun proceed(error: Throwable) { override fun proceed(error: Throwable) {
val resources = context.resources
when (error) { when (error) {
is BadCredentialsException -> onBadCredentials(error.message) is BadCredentialsException -> onBadCredentials(error.message)
is SQLiteConstraintException -> onStudentDuplicate(resources.getString(R.string.login_duplicate_student)) is SQLiteConstraintException -> onStudentDuplicate(resources.getString(R.string.login_duplicate_student))

View File

@ -1,13 +1,15 @@
package io.github.wulkanowy.ui.modules.login.recover package io.github.wulkanowy.ui.modules.login.recover
import android.content.res.Resources import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.sdk.scrapper.exception.InvalidCaptchaException import io.github.wulkanowy.sdk.scrapper.exception.InvalidCaptchaException
import io.github.wulkanowy.sdk.scrapper.exception.InvalidEmailException import io.github.wulkanowy.sdk.scrapper.exception.InvalidEmailException
import io.github.wulkanowy.sdk.scrapper.exception.NoAccountFoundException import io.github.wulkanowy.sdk.scrapper.exception.NoAccountFoundException
import io.github.wulkanowy.ui.base.ErrorHandler import io.github.wulkanowy.ui.base.ErrorHandler
import javax.inject.Inject import javax.inject.Inject
class RecoverErrorHandler @Inject constructor(resources: Resources) : ErrorHandler(resources) { class RecoverErrorHandler @Inject constructor(@ApplicationContext context: Context) :
ErrorHandler(context) {
var onInvalidUsername: (String) -> Unit = {} var onInvalidUsername: (String) -> Unit = {}
@ -15,7 +17,8 @@ class RecoverErrorHandler @Inject constructor(resources: Resources) : ErrorHandl
override fun proceed(error: Throwable) { override fun proceed(error: Throwable) {
when (error) { when (error) {
is InvalidEmailException, is NoAccountFoundException -> onInvalidUsername(error.localizedMessage.orEmpty()) is InvalidEmailException,
is NoAccountFoundException -> onInvalidUsername(error.localizedMessage.orEmpty())
is InvalidCaptchaException -> onInvalidCaptcha(error.localizedMessage.orEmpty(), error) is InvalidCaptchaException -> onInvalidCaptcha(error.localizedMessage.orEmpty(), error)
else -> super.proceed(error) else -> super.proceed(error)
} }

View File

@ -1,11 +1,13 @@
package io.github.wulkanowy.ui.modules.timetable.completed package io.github.wulkanowy.ui.modules.timetable.completed
import android.content.res.Resources import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.sdk.scrapper.exception.FeatureDisabledException import io.github.wulkanowy.sdk.scrapper.exception.FeatureDisabledException
import io.github.wulkanowy.ui.base.ErrorHandler import io.github.wulkanowy.ui.base.ErrorHandler
import javax.inject.Inject import javax.inject.Inject
class CompletedLessonsErrorHandler @Inject constructor(resources: Resources) : ErrorHandler(resources) { class CompletedLessonsErrorHandler @Inject constructor(@ApplicationContext context: Context) :
ErrorHandler(context) {
var onFeatureDisabled: () -> Unit = {} var onFeatureDisabled: () -> Unit = {}

View File

@ -1,35 +1,31 @@
package io.github.wulkanowy.utils package io.github.wulkanowy.utils
import android.content.res.Resources import android.content.res.Resources
import android.os.Build.MANUFACTURER import android.os.Build
import android.os.Build.MODEL import io.github.wulkanowy.BuildConfig
import android.os.Build.VERSION.SDK_INT
import io.github.wulkanowy.BuildConfig.BUILD_TIMESTAMP
import io.github.wulkanowy.BuildConfig.DEBUG
import io.github.wulkanowy.BuildConfig.FLAVOR
import io.github.wulkanowy.BuildConfig.VERSION_CODE
import io.github.wulkanowy.BuildConfig.VERSION_NAME
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
open class AppInfo @Inject constructor() { open class AppInfo @Inject constructor() {
open val isDebug get() = DEBUG open val isDebug get() = BuildConfig.DEBUG
open val versionCode get() = VERSION_CODE open val versionCode get() = BuildConfig.VERSION_CODE
open val buildTimestamp get() = BUILD_TIMESTAMP open val buildTimestamp get() = BuildConfig.BUILD_TIMESTAMP
open val buildFlavor get() = FLAVOR open val buildFlavor get() = BuildConfig.FLAVOR
open val versionName get() = VERSION_NAME open val versionName get() = BuildConfig.VERSION_NAME
open val systemVersion get() = SDK_INT open val systemVersion get() = Build.VERSION.SDK_INT
open val systemManufacturer: String get() = MANUFACTURER open val systemManufacturer: String get() = Build.MANUFACTURER
open val systemModel: String get() = MODEL open val systemModel: String get() = Build.MODEL
open val messagesBaseUrl = BuildConfig.MESSAGES_BASE_URL
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
open val systemLanguage: String open val systemLanguage: String

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,5.99L19.53,19L4.47,19L12,5.99M2.74,18c-0.77,1.33 0.19,3 1.73,3h15.06c1.54,0 2.5,-1.67 1.73,-3L13.73,4.99c-0.77,-1.33 -2.69,-1.33 -3.46,0L2.74,18zM11,11v2c0,0.55 0.45,1 1,1s1,-0.45 1,-1v-2c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1zM11,16h2v2h-2z" />
</vector>

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="6dp"
app:cardElevation="4dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/dashboard_admin_message_item_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/dashboard_admin_message_item_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_error"
app:layout_constraintBottom_toBottomOf="@id/dashboard_admin_message_item_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/dashboard_admin_message_item_title"
tools:ignore="ContentDescription"
tools:tint="@android:color/black" />
<TextView
android:id="@+id/dashboard_admin_message_item_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:textStyle="bold"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/dashboard_admin_message_item_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/lorem" />
<TextView
android:id="@+id/dashboard_admin_message_item_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/dashboard_admin_message_item_title"
app:lineHeight="20dp"
tools:maxLines="5"
tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -8,6 +8,7 @@
<item name="colorError">@color/colorErrorLight</item> <item name="colorError">@color/colorErrorLight</item>
<item name="colorDivider">@color/colorDividerInverse</item> <item name="colorDivider">@color/colorDividerInverse</item>
<item name="colorSwipeRefresh">@color/colorSwipeRefreshDark</item> <item name="colorSwipeRefresh">@color/colorSwipeRefreshDark</item>
<item name="colorMessageMedium">@color/dashboard_message_medium_light</item>
<item name="android:windowBackground">?colorSurface</item> <item name="android:windowBackground">?colorSurface</item>
<item name="android:textColor">?android:textColorPrimary</item> <item name="android:textColor">?android:textColorPrimary</item>
<item name="android:navigationBarColor">@color/colorNavigationBarLight</item> <item name="android:navigationBarColor">@color/colorNavigationBarLight</item>

View File

@ -40,7 +40,7 @@
<item>https://vulcan.net.pl/?login</item> <item>https://vulcan.net.pl/?login</item>
<item>https://vulcan.net.pl/?login</item> <item>https://vulcan.net.pl/?login</item>
<item>https://vulcan.net.pl/?login</item> <item>https://vulcan.net.pl/?login</item>
<item>http://fakelog.tk/?email</item> <item>http://fakelog.cf/?email</item>
</string-array> </string-array>
<string-array name="hosts_symbols"> <string-array name="hosts_symbols">
<item>Default</item> <item>Default</item>

View File

@ -3,4 +3,5 @@
<attr name="colorDivider" format="color" /> <attr name="colorDivider" format="color" />
<attr name="colorSwipeRefresh" format="color" /> <attr name="colorSwipeRefresh" format="color" />
<attr name="colorTimetableChange" format="color" /> <attr name="colorTimetableChange" format="color" />
<attr name="colorMessageMedium" format="color" />
</resources> </resources>

View File

@ -12,6 +12,9 @@
<color name="colorStatusBarLight">#1C1C1C</color> <color name="colorStatusBarLight">#1C1C1C</color>
<color name="colorStatusBarBlack">#0D0D0D</color> <color name="colorStatusBarBlack">#0D0D0D</color>
<color name="dashboard_message_medium_light">#FFD980</color>
<color name="dashboard_message_medium_dark">#ffd54f</color>
<color name="timetable_change_light">#ffd54f</color> <color name="timetable_change_light">#ffd54f</color>
<color name="timetable_change_dark">#ff8f00</color> <color name="timetable_change_dark">#ff8f00</color>

View File

@ -11,6 +11,7 @@
<item name="colorError">@color/colorError</item> <item name="colorError">@color/colorError</item>
<item name="colorDivider">@color/colorDivider</item> <item name="colorDivider">@color/colorDivider</item>
<item name="colorSwipeRefresh">@color/colorSwipeRefresh</item> <item name="colorSwipeRefresh">@color/colorSwipeRefresh</item>
<item name="colorMessageMedium">@color/dashboard_message_medium_dark</item>
<item name="android:statusBarColor">@android:color/darker_gray</item> <item name="android:statusBarColor">@android:color/darker_gray</item>
<item name="android:textColor">?android:textColorPrimary</item> <item name="android:textColor">?android:textColorPrimary</item>
<item name="android:preferenceStyle">@style/PreferenceThemeOverlay</item> <item name="android:preferenceStyle">@style/PreferenceThemeOverlay</item>