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

Add avatars (#1146)

This commit is contained in:
Rafał Borcz 2021-03-03 00:34:25 +01:00 committed by GitHub
parent 412057b512
commit 9e2985864a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 2929 additions and 406 deletions

View File

@ -166,6 +166,7 @@ dependencies {
implementation "com.google.android.material:material:1.3.0"
implementation "com.github.wulkanowy:material-chips-input:2.1.1"
implementation "com.github.PhilJay:MPAndroidChart:v3.1.0"
implementation 'com.mikhaellopez:circularimageview:4.2.0'
implementation "androidx.work:work-runtime-ktx:$work_manager"
playImplementation "androidx.work:work-gcm:$work_manager"

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import io.github.wulkanowy.data.db.AppDatabase
import io.github.wulkanowy.data.db.SharedPrefProvider
import io.github.wulkanowy.utils.AppInfo
import org.junit.Rule
abstract class AbstractMigrationTest {
@ -24,12 +25,16 @@ abstract class AbstractMigrationTest {
fun getMigratedRoomDatabase(): AppDatabase {
val context = ApplicationProvider.getApplicationContext<Context>()
val database = Room.databaseBuilder(ApplicationProvider.getApplicationContext(),
AppDatabase::class.java, dbName)
.addMigrations(*AppDatabase.getMigrations(SharedPrefProvider(PreferenceManager
.getDefaultSharedPreferences(context)))
val database = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java,
dbName
).addMigrations(
*AppDatabase.getMigrations(
SharedPrefProvider(PreferenceManager.getDefaultSharedPreferences(context)),
AppInfo()
)
.build()
).build()
// close the database and release any stream resources when the test finishes
helper.closeWhenFinished(database)
return database

View File

@ -0,0 +1,60 @@
package io.github.wulkanowy.data.db.migrations
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import io.github.wulkanowy.utils.AppInfo
import kotlinx.coroutines.runBlocking
import org.junit.Test
import kotlin.random.Random
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class Migration35Test : AbstractMigrationTest() {
@Test
fun addRandomAvatarColorsForStudents() {
with(helper.createDatabase(dbName, 34)) {
createStudent(this, 1)
createStudent(this, 2)
}
helper.runMigrationsAndValidate(dbName, 35, true, Migration35(AppInfo()))
val db = getMigratedRoomDatabase()
val students = runBlocking { db.studentDao.loadAll() }
assertEquals(2, students.size)
assertTrue { students[0].avatarColor in AppInfo().defaultColorsForAvatar }
assertTrue { students[1].avatarColor in AppInfo().defaultColorsForAvatar }
}
private fun createStudent(db: SupportSQLiteDatabase, id: Long) {
db.insert("Students", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply {
put("id", id)
put("scrapper_base_url", "https://fakelog.cf")
put("mobile_base_url", "")
put("login_mode", "SCRAPPER")
put("login_type", "STANDARD")
put("certificate_key", "")
put("private_key", "")
put("is_parent", false)
put("email", "jan@fakelog.cf")
put("password", "******")
put("symbol", "Default")
put("school_short", "")
put("class_name", "")
put("student_id", Random.nextInt())
put("class_id", Random.nextInt())
put("school_id", "123")
put("school_name", "Wulkan first class school")
put("is_current", false)
put("registration_date", "0")
put("user_login_id", Random.nextInt())
put("student_name", "")
put("user_name", "")
put("nick", "")
})
}
}

View File

@ -1,11 +1,13 @@
package io.github.wulkanowy
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
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.multidex.MultiDex
import androidx.work.Configuration
@ -46,8 +48,10 @@ class WulkanowyApp : Application(), Configuration.Provider {
MultiDex.install(this)
}
@SuppressLint("UnsafeOptInUsageWarning")
override fun onCreate() {
super.onCreate()
FragmentManager.enableNewStateManager(false)
initializeAppLanguage()
themeManager.applyDefaultTheme()

View File

@ -17,6 +17,7 @@ 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 timber.log.Timber
import javax.inject.Singleton
@ -60,7 +61,8 @@ internal class RepositoryModule {
fun provideDatabase(
@ApplicationContext context: Context,
sharedPrefProvider: SharedPrefProvider,
) = AppDatabase.newInstance(context, sharedPrefProvider)
appInfo: AppInfo
) = AppDatabase.newInstance(context, sharedPrefProvider, appInfo)
@Singleton
@Provides

View File

@ -6,7 +6,6 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.RoomDatabase.JournalMode.TRUNCATE
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import io.github.wulkanowy.data.db.dao.AttendanceDao
import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao
import io.github.wulkanowy.data.db.dao.CompletedLessonsDao
@ -86,12 +85,14 @@ import io.github.wulkanowy.data.db.migrations.Migration31
import io.github.wulkanowy.data.db.migrations.Migration32
import io.github.wulkanowy.data.db.migrations.Migration33
import io.github.wulkanowy.data.db.migrations.Migration34
import io.github.wulkanowy.data.db.migrations.Migration35
import io.github.wulkanowy.data.db.migrations.Migration4
import io.github.wulkanowy.data.db.migrations.Migration5
import io.github.wulkanowy.data.db.migrations.Migration6
import io.github.wulkanowy.data.db.migrations.Migration7
import io.github.wulkanowy.data.db.migrations.Migration8
import io.github.wulkanowy.data.db.migrations.Migration9
import io.github.wulkanowy.utils.AppInfo
import javax.inject.Singleton
@Singleton
@ -131,54 +132,55 @@ import javax.inject.Singleton
abstract class AppDatabase : RoomDatabase() {
companion object {
const val VERSION_SCHEMA = 34
const val VERSION_SCHEMA = 35
fun getMigrations(sharedPrefProvider: SharedPrefProvider): Array<Migration> {
return arrayOf(
Migration2(),
Migration3(),
Migration4(),
Migration5(),
Migration6(),
Migration7(),
Migration8(),
Migration9(),
Migration10(),
Migration11(),
Migration12(),
Migration13(),
Migration14(),
Migration15(),
Migration16(),
Migration17(),
Migration18(),
Migration19(sharedPrefProvider),
Migration20(),
Migration21(),
Migration22(),
Migration23(),
Migration24(),
Migration25(),
Migration26(),
Migration27(),
Migration28(),
Migration29(),
Migration30(),
Migration31(),
Migration32(),
Migration33(),
Migration34()
)
}
fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf(
Migration2(),
Migration3(),
Migration4(),
Migration5(),
Migration6(),
Migration7(),
Migration8(),
Migration9(),
Migration10(),
Migration11(),
Migration12(),
Migration13(),
Migration14(),
Migration15(),
Migration16(),
Migration17(),
Migration18(),
Migration19(sharedPrefProvider),
Migration20(),
Migration21(),
Migration22(),
Migration23(),
Migration24(),
Migration25(),
Migration26(),
Migration27(),
Migration28(),
Migration29(),
Migration30(),
Migration31(),
Migration32(),
Migration33(),
Migration34(),
Migration35(appInfo)
)
fun newInstance(context: Context, sharedPrefProvider: SharedPrefProvider): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "wulkanowy_database")
.setJournalMode(TRUNCATE)
.fallbackToDestructiveMigrationFrom(VERSION_SCHEMA + 1)
.fallbackToDestructiveMigrationOnDowngrade()
.addMigrations(*getMigrations(sharedPrefProvider))
.build()
}
fun newInstance(
context: Context,
sharedPrefProvider: SharedPrefProvider,
appInfo: AppInfo
) = Room.databaseBuilder(context, AppDatabase::class.java, "wulkanowy_database")
.setJournalMode(TRUNCATE)
.fallbackToDestructiveMigrationFrom(VERSION_SCHEMA + 1)
.fallbackToDestructiveMigrationOnDowngrade()
.addMigrations(*getMigrations(sharedPrefProvider, appInfo))
.build()
}
abstract val studentDao: StudentDao

View File

@ -8,7 +8,7 @@ import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.StudentNick
import io.github.wulkanowy.data.db.entities.StudentNickAndAvatar
import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import javax.inject.Singleton
@ -23,13 +23,13 @@ interface StudentDao {
suspend fun delete(student: Student)
@Update(entity = Student::class)
suspend fun update(studentNick: StudentNick)
suspend fun update(studentNickAndAvatar: StudentNickAndAvatar)
@Query("SELECT * FROM Students WHERE is_current = 1")
suspend fun loadCurrent(): Student?
@Query("SELECT * FROM Students WHERE id = :id")
suspend fun loadById(id: Int): Student?
suspend fun loadById(id: Long): Student?
@Query("SELECT * FROM Students")
suspend fun loadAll(): List<Student>

View File

@ -10,7 +10,7 @@ import java.time.LocalDateTime
data class Message(
@ColumnInfo(name = "student_id")
val studentId: Int,
val studentId: Long,
@ColumnInfo(name = "real_id")
val realId: Int,

View File

@ -81,4 +81,7 @@ data class Student(
var id: Long = 0
var nick = ""
@ColumnInfo(name = "avatar_color")
var avatarColor = 0L
}

View File

@ -1,13 +1,17 @@
package io.github.wulkanowy.data.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
@Entity
data class StudentNick(
data class StudentNickAndAvatar(
val nick: String
val nick: String,
@ColumnInfo(name = "avatar_color")
var avatarColor: Long
) : Serializable {

View File

@ -0,0 +1,24 @@
package io.github.wulkanowy.data.db.migrations
import androidx.core.database.getLongOrNull
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import io.github.wulkanowy.utils.AppInfo
class Migration35(private val appInfo: AppInfo) : Migration(34, 35) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE Students ADD COLUMN `avatar_color` INTEGER NOT NULL DEFAULT 0")
val studentsCursor = database.query("SELECT * FROM Students")
while (studentsCursor.moveToNext()) {
val studentId = studentsCursor.getLongOrNull(0)
database.execSQL(
"""UPDATE Students
SET avatar_color = ${appInfo.defaultColorsForAvatar.random()}
WHERE id = $studentId"""
)
}
}
}

View File

@ -4,14 +4,14 @@ import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.db.entities.MessageAttachment
import io.github.wulkanowy.data.db.entities.Recipient
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.sdk.pojo.Recipient as SdkRecipient
import io.github.wulkanowy.sdk.pojo.MessageAttachment as SdkMessageAttachment
import java.time.LocalDateTime
import io.github.wulkanowy.sdk.pojo.Message as SdkMessage
import io.github.wulkanowy.sdk.pojo.MessageAttachment as SdkMessageAttachment
import io.github.wulkanowy.sdk.pojo.Recipient as SdkRecipient
fun List<SdkMessage>.mapToEntities(student: Student) = map {
Message(
studentId = student.id.toInt(),
studentId = student.id,
realId = it.id ?: 0,
messageId = it.messageId ?: 0,
sender = it.sender?.name.orEmpty(),

View File

@ -5,7 +5,7 @@ import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import java.time.LocalDateTime
import io.github.wulkanowy.sdk.pojo.Student as SdkStudent
fun List<SdkStudent>.mapToEntities(password: String = "") = map {
fun List<SdkStudent>.mapToEntities(password: String = "", colors: List<Long>) = map {
StudentWithSemesters(
student = Student(
email = it.email,
@ -28,8 +28,10 @@ fun List<SdkStudent>.mapToEntities(password: String = "") = map {
mobileBaseUrl = it.mobileBaseUrl,
privateKey = it.privateKey,
certificateKey = it.certificateKey,
loginMode = it.loginMode.name
),
loginMode = it.loginMode.name,
).apply {
avatarColor = colors.random()
},
semesters = it.semesters.mapToEntities(it.studentId)
)
}

View File

@ -5,11 +5,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.data.db.dao.SemesterDao
import io.github.wulkanowy.data.db.dao.StudentDao
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.StudentNick
import io.github.wulkanowy.data.db.entities.StudentNickAndAvatar
import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import io.github.wulkanowy.data.exceptions.NoCurrentStudentException
import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.utils.DispatchersProvider
import io.github.wulkanowy.utils.security.decrypt
import io.github.wulkanowy.utils.security.encrypt
@ -23,7 +24,8 @@ class StudentRepository @Inject constructor(
private val dispatchers: DispatchersProvider,
private val studentDb: StudentDao,
private val semesterDb: SemesterDao,
private val sdk: Sdk
private val sdk: Sdk,
private val appInfo: AppInfo
) {
suspend fun isStudentSaved() = getSavedStudents(false).isNotEmpty()
@ -35,7 +37,8 @@ class StudentRepository @Inject constructor(
symbol: String,
token: String
): List<StudentWithSemesters> =
sdk.getStudentsFromMobileApi(token, pin, symbol, "").mapToEntities()
sdk.getStudentsFromMobileApi(token, pin, symbol, "")
.mapToEntities(colors = appInfo.defaultColorsForAvatar)
suspend fun getStudentsScrapper(
email: String,
@ -44,7 +47,7 @@ class StudentRepository @Inject constructor(
symbol: String
): List<StudentWithSemesters> =
sdk.getStudentsFromScrapper(email, password, scrapperBaseUrl, symbol)
.mapToEntities(password)
.mapToEntities(password, appInfo.defaultColorsForAvatar)
suspend fun getStudentsHybrid(
email: String,
@ -52,46 +55,58 @@ class StudentRepository @Inject constructor(
scrapperBaseUrl: String,
symbol: String
): List<StudentWithSemesters> =
sdk.getStudentsHybrid(email, password, scrapperBaseUrl, "", symbol).mapToEntities(password)
sdk.getStudentsHybrid(email, password, scrapperBaseUrl, "", symbol)
.mapToEntities(password, appInfo.defaultColorsForAvatar)
suspend fun getSavedStudents(decryptPass: Boolean = true) =
withContext(dispatchers.backgroundThread) {
studentDb.loadStudentsWithSemesters().map {
studentDb.loadStudentsWithSemesters()
.map {
it.apply {
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = decrypt(student.password)
student.password = withContext(dispatchers.backgroundThread) {
decrypt(student.password)
}
}
}
}
}
suspend fun getStudentById(id: Int) = withContext(dispatchers.backgroundThread) {
studentDb.loadById(id)?.apply {
if (Sdk.Mode.valueOf(loginMode) != Sdk.Mode.API) {
password = decrypt(password)
suspend fun getStudentById(id: Long, decryptPass: Boolean = true): Student {
val student = studentDb.loadById(id) ?: throw NoCurrentStudentException()
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = withContext(dispatchers.backgroundThread) {
decrypt(student.password)
}
}
} ?: throw NoCurrentStudentException()
return student
}
suspend fun getCurrentStudent(decryptPass: Boolean = true) =
withContext(dispatchers.backgroundThread) {
studentDb.loadCurrent()?.apply {
if (decryptPass && Sdk.Mode.valueOf(loginMode) != Sdk.Mode.API) {
password = decrypt(password)
}
suspend fun getCurrentStudent(decryptPass: Boolean = true): Student {
val student = studentDb.loadCurrent() ?: throw NoCurrentStudentException()
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = withContext(dispatchers.backgroundThread) {
decrypt(student.password)
}
} ?: throw NoCurrentStudentException()
}
return student
}
suspend fun saveStudents(studentsWithSemesters: List<StudentWithSemesters>): List<Long> {
semesterDb.insertSemesters(studentsWithSemesters.flatMap { it.semesters })
val semesters = studentsWithSemesters.flatMap { it.semesters }
val students = studentsWithSemesters.map { it.student }
.map {
it.apply {
if (Sdk.Mode.valueOf(it.loginMode) != Sdk.Mode.API) {
password = withContext(dispatchers.backgroundThread) {
encrypt(password, context)
}
}
}
}
return withContext(dispatchers.backgroundThread) {
studentDb.insertAll(studentsWithSemesters.map { it.student }.map {
if (Sdk.Mode.valueOf(it.loginMode) != Sdk.Mode.API) {
it.copy(password = encrypt(it.password, context))
} else it
})
}
semesterDb.insertSemesters(semesters)
return studentDb.insertAll(students)
}
suspend fun switchStudent(studentWithSemesters: StudentWithSemesters) {
@ -103,5 +118,6 @@ class StudentRepository @Inject constructor(
suspend fun logoutStudent(student: Student) = studentDb.delete(student)
suspend fun updateStudentNick(studentNick: StudentNick) = studentDb.update(studentNick)
suspend fun updateStudentNickAndAvatar(studentNickAndAvatar: StudentNickAndAvatar) =
studentDb.update(studentNickAndAvatar)
}

View File

@ -1,16 +1,17 @@
package io.github.wulkanowy.ui.modules.account
import android.annotation.SuppressLint
import android.graphics.PorterDuff
import android.view.LayoutInflater
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import io.github.wulkanowy.databinding.HeaderAccountBinding
import io.github.wulkanowy.databinding.ItemAccountBinding
import io.github.wulkanowy.utils.createNameInitialsDrawable
import io.github.wulkanowy.utils.getThemeAttrColor
import io.github.wulkanowy.utils.nickOrName
import javax.inject.Inject
@ -72,9 +73,13 @@ class AccountAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView.V
binding: ItemAccountBinding,
studentWithSemesters: StudentWithSemesters
) {
val context = binding.root.context
val student = studentWithSemesters.student
val semesters = studentWithSemesters.semesters
val diary = semesters.maxByOrNull { it.semesterId }
val avatar = context.createNameInitialsDrawable(student.nickOrName, student.avatarColor)
val checkBackgroundColor =
context.getThemeAttrColor(if (isAccountQuickDialogMode) R.attr.colorBackgroundFloating else R.attr.colorSurface)
val isDuplicatedStudent = items.filter {
if (it.value !is StudentWithSemesters) return@filter false
val studentToCompare = it.value.student
@ -87,15 +92,17 @@ class AccountAdapter @Inject constructor() : RecyclerView.Adapter<RecyclerView.V
with(binding) {
accountItemName.text = "${student.nickOrName} ${diary?.diaryName.orEmpty()}"
accountItemSchool.text = studentWithSemesters.student.schoolName
accountItemAccountType.setText(if (student.isParent) R.string.account_type_parent else R.string.account_type_student)
accountItemAccountType.visibility = if (isDuplicatedStudent) VISIBLE else GONE
accountItemImage.setImageDrawable(avatar)
with(accountItemImage) {
val colorImage =
if (student.isCurrent) context.getThemeAttrColor(R.attr.colorPrimary)
else context.getThemeAttrColor(R.attr.colorOnSurface, 153)
with(accountItemAccountType) {
setText(if (student.isParent) R.string.account_type_parent else R.string.account_type_student)
isVisible = isDuplicatedStudent
}
setColorFilter(colorImage, PorterDuff.Mode.SRC_IN)
with(accountItemCheck) {
isVisible = student.isCurrent
borderColor = checkBackgroundColor
circleColor = checkBackgroundColor
}
root.setOnClickListener { onClickListener(studentWithSemesters) }

View File

@ -36,23 +36,20 @@ class AccountFragment : BaseFragment<FragmentAccountBinding>(R.layout.fragment_a
override var subtitleString = ""
override val isViewEmpty get() = accountAdapter.items.isEmpty()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
@Suppress("UNCHECKED_CAST")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentAccountBinding.bind(view)
presenter.onAttachView(this)
}
override fun initView() {
binding.accountErrorRetry.setOnClickListener { presenter.onRetry() }
binding.accountErrorDetails.setOnClickListener { presenter.onDetailsClick() }
binding.accountRecycler.apply {
layoutManager = LinearLayoutManager(context)
adapter = accountAdapter
@ -60,9 +57,7 @@ class AccountFragment : BaseFragment<FragmentAccountBinding>(R.layout.fragment_a
accountAdapter.onClickListener = presenter::onItemSelected
with(binding) {
accountAdd.setOnClickListener { presenter.onAddSelected() }
}
binding.accountAdd.setOnClickListener { presenter.onAddSelected() }
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -84,28 +79,7 @@ class AccountFragment : BaseFragment<FragmentAccountBinding>(R.layout.fragment_a
override fun openAccountDetailsView(studentWithSemesters: StudentWithSemesters) {
(activity as? MainActivity)?.pushView(
AccountDetailsFragment.newInstance(
studentWithSemesters
)
AccountDetailsFragment.newInstance(studentWithSemesters)
)
}
override fun showErrorView(show: Boolean) {
binding.accountError.visibility = if (show) View.VISIBLE else View.GONE
}
override fun setErrorDetails(message: String) {
binding.accountErrorMessage.text = message
}
override fun showProgress(show: Boolean) {
binding.accountProgress.visibility = if (show) View.VISIBLE else View.GONE
}
override fun showContent(show: Boolean) {
with(binding) {
accountRecycler.visibility = if (show) View.VISIBLE else View.GONE
accountAdd.visibility = if (show) View.VISIBLE else View.GONE
}
}
}

View File

@ -5,7 +5,6 @@ import io.github.wulkanowy.data.db.entities.StudentWithSemesters
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.afterLoading
import io.github.wulkanowy.utils.flowWithResource
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
@ -16,28 +15,13 @@ class AccountPresenter @Inject constructor(
studentRepository: StudentRepository,
) : BasePresenter<AccountView>(errorHandler, studentRepository) {
private lateinit var lastError: Throwable
override fun onAttachView(view: AccountView) {
super.onAttachView(view)
view.initView()
Timber.i("Account view was initialized")
errorHandler.showErrorMessage = ::showErrorViewOnError
loadData()
}
fun onRetry() {
view?.run {
showErrorView(false)
showProgress(true)
}
loadData()
}
fun onDetailsClick() {
view?.showErrorDetailsDialog(lastError)
}
fun onAddSelected() {
Timber.i("Select add account")
view?.openLoginView()
@ -47,6 +31,24 @@ class AccountPresenter @Inject constructor(
view?.openAccountDetailsView(studentWithSemesters)
}
private fun loadData() {
flowWithResource { studentRepository.getSavedStudents(false) }
.onEach {
when (it.status) {
Status.LOADING -> Timber.i("Loading account data started")
Status.SUCCESS -> {
Timber.i("Loading account result: Success")
view?.updateData(createAccountItems(it.data!!))
}
Status.ERROR -> {
Timber.i("Loading account result: An exception occurred")
errorHandler.dispatch(it.error!!)
}
}
}
.launch("load")
}
private fun createAccountItems(items: List<StudentWithSemesters>): List<AccountItem<*>> {
return items.groupBy {
Account("${it.student.userName} (${it.student.email})", it.student.isParent)
@ -60,45 +62,4 @@ class AccountPresenter @Inject constructor(
}
.flatten()
}
private fun loadData() {
flowWithResource { studentRepository.getSavedStudents(false) }
.onEach {
when (it.status) {
Status.LOADING -> {
Timber.i("Loading account data started")
view?.run {
showProgress(true)
showContent(false)
}
}
Status.SUCCESS -> {
Timber.i("Loading account result: Success")
view?.updateData(createAccountItems(it.data!!))
view?.run {
showContent(true)
showErrorView(false)
}
}
Status.ERROR -> {
Timber.i("Loading account result: An exception occurred")
errorHandler.dispatch(it.error!!)
}
}
}
.afterLoading { view?.showProgress(false) }
.launch()
}
private fun showErrorViewOnError(message: String, error: Throwable) {
view?.run {
if (isViewEmpty) {
lastError = error
setErrorDetails(message)
showErrorView(true)
showContent(false)
showProgress(false)
} else showError(message, error)
}
}
}

View File

@ -5,8 +5,6 @@ import io.github.wulkanowy.ui.base.BaseView
interface AccountView : BaseView {
val isViewEmpty: Boolean
fun initView()
fun updateData(data: List<AccountItem<*>>)
@ -14,13 +12,4 @@ interface AccountView : BaseView {
fun openLoginView()
fun openAccountDetailsView(studentWithSemesters: StudentWithSemesters)
fun showErrorView(show: Boolean)
fun setErrorDetails(message: String)
fun showProgress(show: Boolean)
fun showContent(show: Boolean)
}

View File

@ -7,6 +7,7 @@ import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.get
import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Student
@ -18,6 +19,7 @@ import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.ui.modules.studentinfo.StudentInfoFragment
import io.github.wulkanowy.ui.modules.studentinfo.StudentInfoView
import io.github.wulkanowy.utils.createNameInitialsDrawable
import io.github.wulkanowy.utils.nickOrName
import javax.inject.Inject
@ -88,8 +90,15 @@ class AccountDetailsFragment :
override fun showAccountData(student: Student) {
with(binding) {
accountDetailsCheck.isVisible = student.isCurrent
accountDetailsName.text = student.nickOrName
accountDetailsSchool.text = student.schoolName
accountDetailsAvatar.setImageDrawable(
requireContext().createNameInitialsDrawable(
student.nickOrName,
student.avatarColor
)
)
}
}

View File

@ -21,7 +21,7 @@ class AccountDetailsPresenter @Inject constructor(
private val syncManager: SyncManager
) : BasePresenter<AccountDetailsView>(errorHandler, studentRepository) {
private lateinit var studentWithSemesters: StudentWithSemesters
private var studentWithSemesters: StudentWithSemesters? = null
private lateinit var lastError: Throwable
@ -69,10 +69,10 @@ class AccountDetailsPresenter @Inject constructor(
}
Status.SUCCESS -> {
Timber.i("Loading account details view result: Success")
studentWithSemesters = it.data!!
studentWithSemesters = it.data
view?.run {
showAccountData(studentWithSemesters.student)
enableSelectStudentButton(!studentWithSemesters.student.isCurrent)
showAccountData(studentWithSemesters!!.student)
enableSelectStudentButton(!studentWithSemesters!!.student.isCurrent)
showContent(true)
showErrorView(false)
}
@ -88,17 +88,23 @@ class AccountDetailsPresenter @Inject constructor(
}
fun onAccountEditSelected() {
view?.showAccountEditDetailsDialog(studentWithSemesters.student)
studentWithSemesters?.let {
view?.showAccountEditDetailsDialog(it.student)
}
}
fun onStudentInfoSelected(infoType: StudentInfoView.Type) {
view?.openStudentInfoView(infoType, studentWithSemesters)
studentWithSemesters?.let {
view?.openStudentInfoView(infoType, it)
}
}
fun onStudentSelect() {
Timber.i("Select student ${studentWithSemesters.student.id}")
if (studentWithSemesters == null) return
flowWithResource { studentRepository.switchStudent(studentWithSemesters) }
Timber.i("Select student ${studentWithSemesters!!.student.id}")
flowWithResource { studentRepository.switchStudent(studentWithSemesters!!) }
.onEach {
when (it.status) {
Status.LOADING -> Timber.i("Attempt to change a student")
@ -122,8 +128,10 @@ class AccountDetailsPresenter @Inject constructor(
}
fun onLogoutConfirm() {
if (studentWithSemesters == null) return
flowWithResource {
val studentToLogout = studentWithSemesters.student
val studentToLogout = studentWithSemesters!!.student
studentRepository.logoutStudent(studentToLogout)
val students = studentRepository.getSavedStudents(false)
@ -143,7 +151,7 @@ class AccountDetailsPresenter @Inject constructor(
syncManager.stopSyncWorker()
openClearLoginView()
}
studentWithSemesters.student.isCurrent -> {
studentWithSemesters!!.student.isCurrent -> {
Timber.i("Logout result: Logout student and switch to another")
recreateMainView()
}

View File

@ -0,0 +1,90 @@
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
import androidx.recyclerview.widget.RecyclerView
import io.github.wulkanowy.databinding.ItemAccountEditColorBinding
import javax.inject.Inject
class AccountEditColorAdapter @Inject constructor() :
RecyclerView.Adapter<AccountEditColorAdapter.ViewHolder>() {
var items = listOf<Int>()
var selectedColor = 0
override fun getItemCount() = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
ItemAccountEditColorBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
@SuppressLint("RestrictedApi", "NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
with(holder.binding) {
accountEditItemColor.setImageDrawable(GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(item)
})
accountEditItemColorContainer.foreground = item.createForegroundDrawable()
accountEditCheck.isVisible = selectedColor == item
root.setOnClickListener {
val oldSelectedPosition = items.indexOf(selectedColor)
selectedColor = item
notifyItemChanged(oldSelectedPosition)
notifyItemChanged(position)
}
}
}
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 inline val Int.rippleColor: Int
get() {
val hsv = FloatArray(3)
Color.colorToHSV(this, hsv)
hsv[2] = hsv[2] * 0.5f
return Color.HSVToColor(hsv)
}
class ViewHolder(val binding: ItemAccountEditColorBinding) :
RecyclerView.ViewHolder(binding.root)
}

View File

@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.GridLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.databinding.DialogAccountEditBinding
@ -16,6 +17,9 @@ class AccountEditDialog : BaseDialogFragment<DialogAccountEditBinding>(), Accoun
@Inject
lateinit var presenter: AccountEditPresenter
@Inject
lateinit var accountEditColorAdapter: AccountEditColorAdapter
companion object {
private const val ARGUMENT_KEY = "student_with_semesters"
@ -48,8 +52,30 @@ class AccountEditDialog : BaseDialogFragment<DialogAccountEditBinding>(), Accoun
with(binding) {
accountEditDetailsCancel.setOnClickListener { dismiss() }
accountEditDetailsSave.setOnClickListener {
presenter.changeStudentNick(binding.accountEditDetailsNickText.text.toString())
presenter.changeStudentNickAndAvatar(
binding.accountEditDetailsNickText.text.toString(),
accountEditColorAdapter.selectedColor
)
}
with(binding.accountEditColors) {
layoutManager = GridLayoutManager(context, 4)
adapter = accountEditColorAdapter
}
}
}
override fun updateSelectedColorData(color: Int) {
with(accountEditColorAdapter) {
selectedColor = color
notifyDataSetChanged()
}
}
override fun updateColorsData(colors: List<Int>) {
with(accountEditColorAdapter) {
items = colors
notifyDataSetChanged()
}
}

View File

@ -2,10 +2,11 @@ package io.github.wulkanowy.ui.modules.account.accountedit
import io.github.wulkanowy.data.Status
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.StudentNick
import io.github.wulkanowy.data.db.entities.StudentNickAndAvatar
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.AppInfo
import io.github.wulkanowy.utils.afterLoading
import io.github.wulkanowy.utils.flowWithResource
import kotlinx.coroutines.flow.onEach
@ -13,12 +14,15 @@ import timber.log.Timber
import javax.inject.Inject
class AccountEditPresenter @Inject constructor(
private val appInfo: AppInfo,
errorHandler: ErrorHandler,
studentRepository: StudentRepository
) : BasePresenter<AccountEditView>(errorHandler, studentRepository) {
lateinit var student: Student
private val colors = appInfo.defaultColorsForAvatar.map { it.toInt() }
fun onAttachView(view: AccountEditView, student: Student) {
super.onAttachView(view)
this.student = student
@ -28,27 +32,49 @@ class AccountEditPresenter @Inject constructor(
showCurrentNick(student.nick.trim())
}
Timber.i("Account edit dialog view was initialized")
loadData()
view.updateColorsData(colors)
}
fun changeStudentNick(nick: String) {
private fun loadData() {
flowWithResource {
studentRepository.getStudentById(student.id, false).avatarColor
}.onEach { resource ->
when (resource.status) {
Status.LOADING -> Timber.i("Attempt to load student")
Status.SUCCESS -> {
view?.updateSelectedColorData(resource.data?.toInt()!!)
Timber.i("Attempt to load student: Success")
}
Status.ERROR -> {
Timber.i("Attempt to load student: An exception occurred")
errorHandler.dispatch(resource.error!!)
}
}
}.launch("load_data")
}
fun changeStudentNickAndAvatar(nick: String, avatarColor: Int) {
flowWithResource {
val studentNick =
StudentNick(nick = nick.trim()).apply { id = student.id }
studentRepository.updateStudentNick(studentNick)
StudentNickAndAvatar(nick = nick.trim(), avatarColor = avatarColor.toLong())
.apply { id = student.id }
studentRepository.updateStudentNickAndAvatar(studentNick)
}.onEach {
when (it.status) {
Status.LOADING -> Timber.i("Attempt to change a student nick")
Status.LOADING -> Timber.i("Attempt to change a student nick and avatar")
Status.SUCCESS -> {
Timber.i("Change a student nick result: Success")
Timber.i("Change a student nick and avatar result: Success")
view?.recreateMainView()
}
Status.ERROR -> {
Timber.i("Change a student result: An exception occurred")
Timber.i("Change a student nick and avatar result: An exception occurred")
errorHandler.dispatch(it.error!!)
}
}
}
.afterLoading { view?.popView() }
.launch()
.launch("update_student")
}
}

View File

@ -11,4 +11,8 @@ interface AccountEditView : BaseView {
fun recreateMainView()
fun showCurrentNick(nick: String)
fun updateSelectedColorData(color: Int)
fun updateColorsData(colors: List<Int>)
}

View File

@ -6,6 +6,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import io.github.wulkanowy.databinding.DialogAccountQuickBinding
import io.github.wulkanowy.ui.base.BaseDialogFragment
import io.github.wulkanowy.ui.modules.account.AccountAdapter
@ -24,7 +25,15 @@ class AccountQuickDialog : BaseDialogFragment<DialogAccountQuickBinding>(), Acco
lateinit var presenter: AccountQuickPresenter
companion object {
fun newInstance() = AccountQuickDialog()
private const val STUDENTS_ARGUMENT_KEY = "students"
fun newInstance(studentsWithSemesters: List<StudentWithSemesters>) =
AccountQuickDialog().apply {
arguments = Bundle().apply {
putSerializable(STUDENTS_ARGUMENT_KEY, studentsWithSemesters.toTypedArray())
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -38,8 +47,12 @@ class AccountQuickDialog : BaseDialogFragment<DialogAccountQuickBinding>(), Acco
savedInstanceState: Bundle?
) = DialogAccountQuickBinding.inflate(inflater).apply { binding = this }.root
@Suppress("UNCHECKED_CAST")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
presenter.onAttachView(this)
val studentsWithSemesters =
(requireArguments()[STUDENTS_ARGUMENT_KEY] as Array<StudentWithSemesters>).toList()
presenter.onAttachView(this, studentsWithSemesters)
}
override fun initView() {

View File

@ -17,11 +17,15 @@ class AccountQuickPresenter @Inject constructor(
studentRepository: StudentRepository
) : BasePresenter<AccountQuickView>(errorHandler, studentRepository) {
override fun onAttachView(view: AccountQuickView) {
private lateinit var studentsWithSemesters: List<StudentWithSemesters>
fun onAttachView(view: AccountQuickView, studentsWithSemesters: List<StudentWithSemesters>) {
super.onAttachView(view)
this.studentsWithSemesters = studentsWithSemesters
view.initView()
Timber.i("Account quick dialog view was initialized")
loadData()
view.updateData(createAccountItems(studentsWithSemesters))
}
fun onManagerSelected() {
@ -57,22 +61,6 @@ class AccountQuickPresenter @Inject constructor(
.launch("switch")
}
private fun loadData() {
flowWithResource { studentRepository.getSavedStudents(false) }.onEach {
when (it.status) {
Status.LOADING -> Timber.i("Loading account data started")
Status.SUCCESS -> {
Timber.i("Loading account result: Success")
view?.updateData(createAccountItems(it.data!!))
}
Status.ERROR -> {
Timber.i("Loading account result: An exception occurred")
errorHandler.dispatch(it.error!!)
}
}
}.launch()
}
private fun createAccountItems(items: List<StudentWithSemesters>) = items.map {
AccountItem(it, AccountItem.ViewType.ITEM)
}

View File

@ -28,6 +28,8 @@ import com.ncapdevi.fragnav.FragNavController
import com.ncapdevi.fragnav.FragNavController.Companion.HIDE
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import io.github.wulkanowy.databinding.ActivityMainBinding
import io.github.wulkanowy.ui.base.BaseActivity
import io.github.wulkanowy.ui.modules.account.accountquick.AccountQuickDialog
@ -43,8 +45,10 @@ import io.github.wulkanowy.ui.modules.timetable.TimetableFragment
import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.utils.UpdateHelper
import io.github.wulkanowy.utils.createNameInitialsDrawable
import io.github.wulkanowy.utils.dpToPx
import io.github.wulkanowy.utils.getThemeAttrColor
import io.github.wulkanowy.utils.nickOrName
import io.github.wulkanowy.utils.safelyPopFragments
import io.github.wulkanowy.utils.setOnViewChangeListener
import timber.log.Timber
@ -65,6 +69,8 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
@Inject
lateinit var appInfo: AppInfo
private var accountMenu: MenuItem? = null
private val overlayProvider by lazy { ElevationOverlayProvider(this) }
private val navController =
@ -192,6 +198,9 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.action_menu_main, menu)
accountMenu = menu?.findItem(R.id.mainMenuAccount)
presenter.onActionMenuCreated()
return true
}
@ -288,8 +297,8 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
supportActionBar?.setDisplayHomeAsUpEnabled(show)
}
override fun showAccountPicker() {
navController.showDialogFragment(AccountQuickDialog.newInstance())
override fun showAccountPicker(studentWithSemesters: List<StudentWithSemesters>) {
navController.showDialogFragment(AccountQuickDialog.newInstance(studentWithSemesters))
}
override fun showActionBarElevation(show: Boolean) {
@ -323,6 +332,13 @@ class MainActivity : BaseActivity<MainPresenter, ActivityMainBinding>(), MainVie
presenter.onBackPressed { super.onBackPressed() }
}
override fun showStudentAvatar(student: Student) {
accountMenu?.run {
icon = createNameInitialsDrawable(student.nickOrName, student.avatarColor, 0.44f)
title = getString(R.string.main_account_picker)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
navController.onSaveInstanceState(outState)

View File

@ -1,5 +1,7 @@
package io.github.wulkanowy.ui.modules.main
import io.github.wulkanowy.data.Status
import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.services.sync.SyncManager
@ -9,6 +11,8 @@ import io.github.wulkanowy.ui.modules.main.MainView.Section.GRADE
import io.github.wulkanowy.ui.modules.main.MainView.Section.MESSAGE
import io.github.wulkanowy.ui.modules.main.MainView.Section.SCHOOL
import io.github.wulkanowy.utils.AnalyticsHelper
import io.github.wulkanowy.utils.flowWithResource
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import javax.inject.Inject
@ -17,9 +21,11 @@ class MainPresenter @Inject constructor(
studentRepository: StudentRepository,
private val prefRepository: PreferencesRepository,
private val syncManager: SyncManager,
private val analytics: AnalyticsHelper
private val analytics: AnalyticsHelper,
) : BasePresenter<MainView>(errorHandler, studentRepository) {
var studentsWitSemesters: List<StudentWithSemesters>? = null
fun onAttachView(view: MainView, initMenu: MainView.Section?) {
super.onAttachView(view)
view.apply {
@ -35,6 +41,28 @@ class MainPresenter @Inject constructor(
analytics.logEvent("app_open", "destination" to initMenu?.name)
}
fun onActionMenuCreated() {
if (!studentsWitSemesters.isNullOrEmpty()) {
showCurrentStudentAvatar()
return
}
flowWithResource { studentRepository.getSavedStudents(false) }
.onEach { resource ->
when (resource.status) {
Status.LOADING -> Timber.i("Loading student avatar data started")
Status.SUCCESS -> {
studentsWitSemesters = resource.data
showCurrentStudentAvatar()
}
Status.ERROR -> {
Timber.i("Loading student avatar result: An exception occurred")
errorHandler.dispatch(resource.error!!)
}
}
}.launch("avatar")
}
fun onViewChange(section: MainView.Section?) {
view?.apply {
showActionBarElevation(section != GRADE && section != MESSAGE && section != SCHOOL)
@ -48,8 +76,10 @@ class MainPresenter @Inject constructor(
}
fun onAccountManagerSelected(): Boolean {
if (studentsWitSemesters.isNullOrEmpty()) return true
Timber.i("Select account manager")
view?.showAccountPicker()
view?.showAccountPicker(studentsWitSemesters!!)
return true
}
@ -81,6 +111,13 @@ class MainPresenter @Inject constructor(
} == true
}
private fun showCurrentStudentAvatar() {
val currentStudent =
studentsWitSemesters!!.single { it.student.isCurrent }.student
view?.showStudentAvatar(currentStudent)
}
private fun getProperViewIndexes(initMenu: MainView.Section?): Pair<Int, Int> {
return when (initMenu?.id) {
in 0..3 -> initMenu!!.id to -1

View File

@ -1,5 +1,7 @@
package io.github.wulkanowy.ui.modules.main
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.StudentWithSemesters
import io.github.wulkanowy.ui.base.BaseView
interface MainView : BaseView {
@ -22,7 +24,7 @@ interface MainView : BaseView {
fun showHomeArrow(show: Boolean)
fun showAccountPicker()
fun showAccountPicker(studentWithSemesters: List<StudentWithSemesters>)
fun showActionBarElevation(show: Boolean)
@ -36,6 +38,8 @@ interface MainView : BaseView {
fun popView(depth: Int = 1)
fun showStudentAvatar(student: Student)
interface MainChildView {
fun onFragmentReselected()

View File

@ -35,8 +35,8 @@ open class AppInfo @Inject constructor() {
open val systemLanguage: String
get() = Resources.getSystem().configuration.locale.language
open val defaultColorsForAvatar = listOf(
0xe57373, 0xf06292, 0xba68c8, 0x9575cd, 0x7986cb, 0x64b5f6, 0x4fc3f7, 0x4dd0e1, 0x4db6ac,
0x81c784, 0xaed581, 0xff8a65, 0xd4e157, 0xffd54f, 0xffb74d, 0xa1887f, 0x90a4ae
)
val defaultColorsForAvatar = listOf(
0xd32f2f, 0xE64A19, 0xFFA000, 0xAFB42B, 0x689F38, 0x388E3C, 0x00796B, 0x0097A7,
0x1976D2, 0x3647b5, 0x6236c9, 0x9225c1, 0xC2185B, 0x616161, 0x455A64, 0x7a5348
).map { (it and 0x00ffffff or (255 shl 24)).toLong() }
}

View File

@ -1,8 +1,15 @@
package io.github.wulkanowy.utils
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import android.net.Uri
import android.text.TextPaint
import android.util.DisplayMetrics.DENSITY_DEFAULT
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
@ -10,6 +17,9 @@ import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.applyCanvas
import androidx.core.graphics.drawable.RoundedBitmapDrawable
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import io.github.wulkanowy.BuildConfig.APPLICATION_ID
@ColorInt
@ -30,7 +40,8 @@ fun Context.getThemeAttrColor(@AttrRes colorAttr: Int, alpha: Int): Int {
@ColorInt
fun Context.getCompatColor(@ColorRes colorRes: Int) = ContextCompat.getColor(this, colorRes)
fun Context.getCompatDrawable(@DrawableRes drawableRes: Int) = ContextCompat.getDrawable(this, drawableRes)
fun Context.getCompatDrawable(@DrawableRes drawableRes: Int) =
ContextCompat.getDrawable(this, drawableRes)
fun Context.openInternetBrowser(uri: String, onActivityNotFound: (uri: String) -> Unit) {
Intent.parseUri(uri, 0).let {
@ -45,7 +56,13 @@ fun Context.openAppInMarket(onActivityNotFound: (uri: String) -> Unit) {
}
}
fun Context.openEmailClient(chooserTitle: String, email: String, subject: String, body: String, onActivityNotFound: () -> Unit = {}) {
fun Context.openEmailClient(
chooserTitle: String,
email: String,
subject: String,
body: String,
onActivityNotFound: () -> Unit = {}
) {
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:")).apply {
putExtra(Intent.EXTRA_EMAIL, arrayOf(email))
putExtra(Intent.EXTRA_SUBJECT, subject)
@ -85,3 +102,39 @@ fun Context.shareText(text: String, subject: String?) {
}
fun Context.dpToPx(dp: Float) = dp * resources.displayMetrics.densityDpi / DENSITY_DEFAULT
@SuppressLint("DefaultLocale")
fun Context.createNameInitialsDrawable(
text: String,
backgroundColor: Long,
scaleFactory: Float = 1f
): RoundedBitmapDrawable {
val words = text.split(" ")
val firstCharFirstWord = words.getOrNull(0)?.firstOrNull() ?: ""
val firstCharSecondWord = words.getOrNull(1)?.firstOrNull() ?: ""
val initials = "$firstCharFirstWord$firstCharSecondWord".toUpperCase()
val bounds = Rect()
val dimension = this.dpToPx(64f * scaleFactory).toInt()
val textPaint = TextPaint().apply {
typeface = Typeface.SANS_SERIF
color = Color.WHITE
textAlign = Paint.Align.CENTER
isAntiAlias = true
textSize = this@createNameInitialsDrawable.dpToPx(30f * scaleFactory)
getTextBounds(initials, 0, initials.length, bounds)
}
val xCoordinate = (dimension / 2).toFloat()
val yCoordinate = (dimension / 2 + (bounds.bottom - bounds.top) / 2).toFloat()
val bitmap = Bitmap.createBitmap(dimension, dimension, Bitmap.Config.ARGB_8888)
.applyCanvas {
drawColor(backgroundColor.toInt())
drawText(initials, 0, initials.length, xCoordinate, yCoordinate, textPaint)
}
return RoundedBitmapDrawableFactory.create(this.resources, bitmap)
.apply { isCircular = true }
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
</vector>

View File

@ -1,87 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<View
android:layout_width="280dp"
android:layout_height="1dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/accountEditDetailsHeader"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:text="@string/account_edit_header"
android:textSize="21sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountEditDetailsNick"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="28dp"
android:layout_marginEnd="24dp"
android:hint="@string/account_edit_nick_hint"
app:endIconMode="clear_text"
app:errorEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountEditDetailsHeader">
<View
android:layout_width="280dp"
android:layout_height="1dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<requestFocus />
<TextView
android:id="@+id/accountEditDetailsHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:text="@string/account_edit_header"
android:textSize="21sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/accountEditDetailsNickText"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inputType="textPersonName"
android:maxLength="20" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountEditDetailsNick"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="28dp"
android:layout_marginEnd="24dp"
android:hint="@string/account_edit_nick_hint"
app:endIconMode="clear_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountEditDetailsHeader">
<com.google.android.material.button.MaterialButton
android:id="@+id/accountEditDetailsSave"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="88dp"
android:layout_height="36dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:text="@string/all_save"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountEditDetailsNick" />
<requestFocus />
<com.google.android.material.button.MaterialButton
android:id="@+id/accountEditDetailsCancel"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="88dp"
android:layout_height="36dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:text="@android:string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/accountEditDetailsSave"
app:layout_constraintTop_toBottomOf="@id/accountEditDetailsNick" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/accountEditDetailsNickText"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inputType="textPersonName"
android:maxLength="20" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/accountEditDetailsSecondHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="28dp"
android:layout_marginEnd="24dp"
android:text="Wybierz kolor avatara"
android:textSize="21sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountEditDetailsNick" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/account_edit_colors"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="14dp"
android:layout_marginEnd="24dp"
android:overScrollMode="never"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountEditDetailsSecondHeader"
tools:itemCount="12"
tools:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
tools:listitem="@layout/item_account_edit_color"
tools:spanCount="4" />
<com.google.android.material.button.MaterialButton
android:id="@+id/accountEditDetailsSave"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="88dp"
android:layout_height="36dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:text="@string/all_save"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/account_edit_colors" />
<com.google.android.material.button.MaterialButton
android:id="@+id/accountEditDetailsCancel"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="88dp"
android:layout_height="36dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:text="@android:string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/accountEditDetailsSave"
app:layout_constraintTop_toBottomOf="@id/account_edit_colors" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View File

@ -2,7 +2,7 @@
<RelativeLayout 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="300dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout

View File

@ -5,16 +5,6 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/account_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/accountRecycler"
android:layout_width="match_parent"
@ -36,55 +26,4 @@
android:insetBottom="0dp"
android:text="@string/account_add_new"
app:layout_constraintBottom_toBottomOf="parent" />
<LinearLayout
android:id="@+id/account_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="invisible"
tools:ignore="UseCompoundDrawables"
tools:visibility="gone">
<ImageView
android:layout_width="100dp"
android:layout_height="100dp"
app:srcCompat="@drawable/ic_all_account"
app:tint="?colorOnBackground"
tools:ignore="contentDescription" />
<TextView
android:id="@+id/account_error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:padding="8dp"
android:text="@string/error_unknown"
android:textSize="20sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/account_error_details"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:text="@string/all_details" />
<com.google.android.material.button.MaterialButton
android:id="@+id/account_error_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/all_retry" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -21,9 +21,9 @@
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="invisible"
android:visibility="gone"
tools:ignore="UseCompoundDrawables"
tools:visibility="visible">
tools:visibility="gone">
<ImageView
android:layout_width="100dp"
@ -69,7 +69,9 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/account_details_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible">
<ScrollView
android:layout_width="match_parent"
@ -90,9 +92,22 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_all_account"
app:tint="?colorPrimary"
tools:ignore="ContentDescription" />
tools:ignore="ContentDescription"
tools:src="@tools:sample/avatars" />
<com.mikhaellopez.circularimageview.CircularImageView
android:id="@+id/account_details_check"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="70dp"
android:layout_marginTop="70dp"
app:civ_border_color="?colorSurface"
app:civ_border_width="1dp"
app:civ_circle_color="?colorSurface"
app:layout_constraintStart_toStartOf="@id/accountDetailsAvatar"
app:layout_constraintTop_toTopOf="@id/accountDetailsAvatar"
app:srcCompat="@drawable/ic_all_round_check"
app:tint="?colorPrimary" />
<TextView
android:id="@+id/accountDetailsName"

View File

@ -18,15 +18,27 @@
android:layout_height="40dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_all_account"
tools:ignore="ContentDescription"
tools:tint="@color/colorPrimary" />
tools:src="@tools:sample/avatars" />
<com.mikhaellopez.circularimageview.CircularImageView
android:id="@+id/account_item_check"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="28dp"
android:layout_marginTop="28dp"
app:civ_border_width="1dp"
app:layout_constraintStart_toStartOf="@id/accountItemImage"
app:layout_constraintTop_toTopOf="@id/accountItemImage"
app:srcCompat="@drawable/ic_all_round_check"
app:tint="?colorPrimary" />
<TextView
android:id="@+id/accountItemName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
@ -41,6 +53,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="3dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
@ -56,6 +69,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="3dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"

View File

@ -0,0 +1,27 @@
<FrameLayout 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:id="@+id/account_edit_item_color_container"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="10dp"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/account_edit_item_color"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="ContentDescription"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/account_edit_check"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
app:srcCompat="@drawable/ic_check"
app:tint="@android:color/white"
tools:ignore="ContentDescription" />
</FrameLayout>

View File

@ -23,5 +23,4 @@
android:layout_margin="10dp"
android:background="?android:windowBackground"
tools:context=".ui.modules.grade.statistics.GradeStatisticsAdapter" />
</LinearLayout>

View File

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@+id/mainMenuAccount"
android:icon="@drawable/ic_all_account"
android:orderInCategory="2"
android:title="@string/main_account_picker"
app:iconTint="@color/material_on_surface_emphasis_medium"
app:showAsAction="always" />
app:showAsAction="always"
tools:ignore="MenuTitle" />
</menu>

View File

@ -5,6 +5,7 @@ import io.github.wulkanowy.data.db.dao.SemesterDao
import io.github.wulkanowy.data.db.dao.StudentDao
import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.sdk.pojo.Student
import io.github.wulkanowy.utils.AppInfo
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
@ -30,7 +31,14 @@ class StudentTest {
@Before
fun initApi() {
MockKAnnotations.init(this)
studentRepository = StudentRepository(mockk(), TestDispatchersProvider(), studentDb, semesterDb, mockSdk)
studentRepository = StudentRepository(
mockk(),
TestDispatchersProvider(),
studentDb,
semesterDb,
mockSdk,
AppInfo()
)
}
@Test

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists