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,10 +132,9 @@ 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(
fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf(
Migration2(),
Migration3(),
Migration4(),
@ -167,19 +167,21 @@ abstract class AppDatabase : RoomDatabase() {
Migration31(),
Migration32(),
Migration33(),
Migration34()
Migration34(),
Migration35(appInfo)
)
}
fun newInstance(context: Context, sharedPrefProvider: SharedPrefProvider): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "wulkanowy_database")
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))
.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,47 +55,59 @@ 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)
}
}
} ?: throw NoCurrentStudentException()
suspend fun getStudentById(id: Long, decryptPass: Boolean = true): Student {
val student = studentDb.loadById(id) ?: throw NoCurrentStudentException()
suspend fun getCurrentStudent(decryptPass: Boolean = true) =
withContext(dispatchers.backgroundThread) {
studentDb.loadCurrent()?.apply {
if (decryptPass && Sdk.Mode.valueOf(loginMode) != Sdk.Mode.API) {
password = decrypt(password)
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): 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)
}
}
return student
}
suspend fun saveStudents(studentsWithSemesters: List<StudentWithSemesters>): List<Long> {
semesterDb.insertSemesters(studentsWithSemesters.flatMap { it.semesters })
return withContext(dispatchers.backgroundThread) {
studentDb.insertAll(studentsWithSemesters.map { it.student }.map {
val semesters = studentsWithSemesters.flatMap { it.semesters }
val students = studentsWithSemesters.map { it.student }
.map {
it.apply {
if (Sdk.Mode.valueOf(it.loginMode) != Sdk.Mode.API) {
it.copy(password = encrypt(it.password, context))
} else it
})
password = withContext(dispatchers.backgroundThread) {
encrypt(password, context)
}
}
}
}
semesterDb.insertSemesters(semesters)
return studentDb.insertAll(students)
}
suspend fun switchStudent(studentWithSemesters: StudentWithSemesters) {
with(studentDb) {
@ -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,6 +1,10 @@
<?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">
<androidx.constraintlayout.widget.ConstraintLayout xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
@ -36,7 +40,6 @@
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">
@ -51,6 +54,38 @@
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"
@ -66,7 +101,7 @@
android:text="@string/all_save"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountEditDetailsNick" />
app:layout_constraintTop_toBottomOf="@id/account_edit_colors" />
<com.google.android.material.button.MaterialButton
android:id="@+id/accountEditDetailsCancel"
@ -83,5 +118,6 @@
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>
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