Окончательный, по моему мнению, фикс запуска постоянного уведомления.

Добавлена возможность просмотра расписания конкретного преподавателя.
This commit is contained in:
2024-11-03 07:18:33 +04:00
parent 637a66a647
commit c9bb2b6b1e
31 changed files with 768 additions and 178 deletions

View File

@@ -33,8 +33,8 @@ android {
applicationId = "ru.n08i40k.polytechnic.next" applicationId = "ru.n08i40k.polytechnic.next"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 19 versionCode = 20
versionName = "2.0.3" versionName = "2.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -128,6 +128,9 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View File

@@ -1,8 +1,10 @@
package ru.n08i40k.polytechnic.next.data.schedule package ru.n08i40k.polytechnic.next.data.schedule
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.Group import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
interface ScheduleRepository { interface ScheduleRepository {
suspend fun getGroup(): MyResult<Group> suspend fun getGroup(): MyResult<GroupOrTeacher>
suspend fun getTeacher(name: String): MyResult<GroupOrTeacher>
} }

View File

@@ -11,7 +11,7 @@ import kotlinx.datetime.toInstant
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.model.Day import ru.n08i40k.polytechnic.next.model.Day
import ru.n08i40k.polytechnic.next.model.Group import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.model.Lesson import ru.n08i40k.polytechnic.next.model.Lesson
import ru.n08i40k.polytechnic.next.model.LessonTime import ru.n08i40k.polytechnic.next.model.LessonTime
import ru.n08i40k.polytechnic.next.model.LessonType import ru.n08i40k.polytechnic.next.model.LessonType
@@ -31,14 +31,15 @@ private fun genBreak(start: Instant, end: Instant): Lesson {
start, start,
end end
), ),
subGroups = listOf() subGroups = listOf(),
group = null
) )
} }
class FakeScheduleRepository : ScheduleRepository { class FakeScheduleRepository : ScheduleRepository {
@Suppress("SpellCheckingInspection") @Suppress("SpellCheckingInspection")
companion object { companion object {
val exampleGroup = Group( val exampleGroup = GroupOrTeacher(
name = "ИС-214/23", days = arrayListOf( name = "ИС-214/23", days = arrayListOf(
Day( Day(
name = "Понедельник", name = "Понедельник",
@@ -52,7 +53,8 @@ class FakeScheduleRepository : ScheduleRepository {
genLocalDateTime(8, 30), genLocalDateTime(8, 30),
genLocalDateTime(8, 40), genLocalDateTime(8, 40),
), ),
subGroups = listOf() subGroups = listOf(),
group = null
), ),
genBreak( genBreak(
genLocalDateTime(8, 40), genLocalDateTime(8, 40),
@@ -66,7 +68,8 @@ class FakeScheduleRepository : ScheduleRepository {
genLocalDateTime(8, 45), genLocalDateTime(8, 45),
genLocalDateTime(9, 15), genLocalDateTime(9, 15),
), ),
subGroups = listOf() subGroups = listOf(),
group = null
), ),
genBreak( genBreak(
genLocalDateTime(9, 15), genLocalDateTime(9, 15),
@@ -86,7 +89,8 @@ class FakeScheduleRepository : ScheduleRepository {
number = 1, number = 1,
cabinet = "43" cabinet = "43"
) )
) ),
group = null
), ),
genBreak( genBreak(
genLocalDateTime(10, 45), genLocalDateTime(10, 45),
@@ -111,7 +115,8 @@ class FakeScheduleRepository : ScheduleRepository {
number = 2, number = 2,
cabinet = "44" cabinet = "44"
), ),
) ),
group = null
), ),
genBreak( genBreak(
genLocalDateTime(12, 15), genLocalDateTime(12, 15),
@@ -136,7 +141,118 @@ class FakeScheduleRepository : ScheduleRepository {
number = 2, number = 2,
cabinet = "41" cabinet = "41"
), ),
) ),
group = null
),
)
)
)
)
val exampleTeacher = GroupOrTeacher(
name = "Хомченко Н.Е.", days = arrayListOf(
Day(
name = "Понедельник",
date = LocalDateTime.now().toInstant(TimeZone.currentSystemDefault()),
lessons = listOf(
Lesson(
type = LessonType.ADDITIONAL,
defaultRange = null,
name = "Линейка",
time = LessonTime(
genLocalDateTime(8, 30),
genLocalDateTime(8, 40),
),
subGroups = listOf(),
group = "ИС-214/23"
),
genBreak(
genLocalDateTime(8, 40),
genLocalDateTime(8, 45),
),
Lesson(
type = LessonType.ADDITIONAL,
defaultRange = null,
name = "Разговор о важном",
time = LessonTime(
genLocalDateTime(8, 45),
genLocalDateTime(9, 15),
),
subGroups = listOf(),
group = "ИС-214/23"
),
genBreak(
genLocalDateTime(9, 15),
genLocalDateTime(9, 25),
),
Lesson(
type = LessonType.DEFAULT,
defaultRange = listOf(1, 1),
name = "МДК.05.01 Проектирование и дизайн информационных систем",
time = LessonTime(
genLocalDateTime(9, 25),
genLocalDateTime(10, 45),
),
subGroups = listOf(
SubGroup(
teacher = "Ивашова А.Н.",
number = 1,
cabinet = "43"
)
),
group = "ИС-214/23"
),
genBreak(
genLocalDateTime(10, 45),
genLocalDateTime(10, 55),
),
Lesson(
type = LessonType.DEFAULT,
defaultRange = listOf(2, 2),
name = "Основы проектирования баз данных",
time = LessonTime(
genLocalDateTime(10, 55),
genLocalDateTime(12, 15),
),
subGroups = listOf(
SubGroup(
teacher = "Чинарева Е.А.",
number = 1,
cabinet = "21"
),
SubGroup(
teacher = "Ивашова А.Н.",
number = 2,
cabinet = "44"
),
),
group = "ИС-214/23"
),
genBreak(
genLocalDateTime(12, 15),
genLocalDateTime(12, 35),
),
Lesson(
type = LessonType.DEFAULT,
defaultRange = listOf(3, 3),
name = "Операционные системы и среды",
time = LessonTime(
genLocalDateTime(12, 35),
genLocalDateTime(13, 55),
),
subGroups = listOf(
SubGroup(
teacher = "Сергачева А.О.",
number = 1,
cabinet = "42"
),
SubGroup(
teacher = "Воронцева Н.В.",
number = 2,
cabinet = "41"
),
),
group = "ИС-214/23"
), ),
) )
) )
@@ -144,11 +260,12 @@ class FakeScheduleRepository : ScheduleRepository {
) )
} }
private val group = MutableStateFlow<Group?>(exampleGroup) private val group = MutableStateFlow<GroupOrTeacher?>(exampleGroup)
private val teacher = MutableStateFlow<GroupOrTeacher?>(exampleTeacher)
private var updateCounter: Int = 0 private var updateCounter: Int = 0
override suspend fun getGroup(): MyResult<Group> { override suspend fun getGroup(): MyResult<GroupOrTeacher> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
delay(1500) delay(1500)
if (updateCounter++ % 3 == 0) MyResult.Failure( if (updateCounter++ % 3 == 0) MyResult.Failure(
@@ -157,4 +274,14 @@ class FakeScheduleRepository : ScheduleRepository {
else MyResult.Success(group.value!!) else MyResult.Success(group.value!!)
} }
} }
override suspend fun getTeacher(name: String): MyResult<GroupOrTeacher> {
return withContext(Dispatchers.IO) {
delay(1500)
if (updateCounter++ % 3 == 0) MyResult.Failure(
IllegalStateException()
)
else MyResult.Success(teacher.value!!)
}
}
} }

View File

@@ -5,12 +5,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.model.Group import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGet import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGet
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetTeacher
import ru.n08i40k.polytechnic.next.network.tryFuture import ru.n08i40k.polytechnic.next.network.tryFuture
class RemoteScheduleRepository(private val context: Context) : ScheduleRepository { class RemoteScheduleRepository(private val context: Context) : ScheduleRepository {
override suspend fun getGroup(): MyResult<Group> = override suspend fun getGroup(): MyResult<GroupOrTeacher> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val response = tryFuture { val response = tryFuture {
ScheduleGet( ScheduleGet(
@@ -25,4 +26,21 @@ class RemoteScheduleRepository(private val context: Context) : ScheduleRepositor
is MyResult.Success -> MyResult.Success(response.data.group) is MyResult.Success -> MyResult.Success(response.data.group)
} }
} }
override suspend fun getTeacher(name: String): MyResult<GroupOrTeacher> =
withContext(Dispatchers.IO) {
val response = tryFuture {
ScheduleGetTeacher(
context,
name,
it,
it
)
}
when (response) {
is MyResult.Failure -> response
is MyResult.Success -> MyResult.Success(response.data.teacher)
}
}
} }

View File

@@ -15,11 +15,13 @@ import ru.n08i40k.polytechnic.next.utils.now
@Parcelize @Parcelize
@Suppress("unused", "MemberVisibilityCanBePrivate") @Suppress("unused", "MemberVisibilityCanBePrivate")
@Serializable @Serializable
class Day( data class Day(
val name: String, val name: String,
@Serializable(with = InstantAsLongSerializer::class) @Serializable(with = InstantAsLongSerializer::class)
@SerialName("date") @SerialName("date")
private val dateMillis: Long, private val dateMillis: Long,
val lessons: List<Lesson> val lessons: List<Lesson>
) : Parcelable { ) : Parcelable {
constructor(name: String, date: Instant, lessons: List<Lesson>) : this( constructor(name: String, date: Instant, lessons: List<Lesson>) : this(
@@ -29,30 +31,19 @@ class Day(
val date: Instant val date: Instant
get() = Instant.fromEpochMilliseconds(dateMillis) get() = Instant.fromEpochMilliseconds(dateMillis)
fun distanceToNextByLocalDateTime(from: LocalDateTime): Pair<Int, Int>? { fun distanceToNext(from: LocalDateTime): Pair<Int, Int>? {
val toIdx = lessons val nextIndex = lessons.map { it.time.start }.indexOfFirst { it.dateTime >= from }
.map { it.time.start }
.indexOfFirst { it.dateTime > from }
if (toIdx == -1) if (nextIndex == -1) return null
return null
return Pair(toIdx, lessons[toIdx].time.start.dayMinutes - from.dayMinutes) return Pair(nextIndex, lessons[nextIndex].time.start.dayMinutes - from.dayMinutes)
} }
fun distanceToNextByIdx(from: Int? = null): Pair<Int, Int>? { fun distanceToNext(fromIndex: Int? = null): Pair<Int, Int>? {
val fromLesson = if (from != null) lessons[from] else null val fromLesson = fromIndex?.let { lessons[fromIndex] }
val fromTime = fromLesson?.time?.end?.dateTime ?: LocalDateTime.now()
if (from != null && fromLesson == null) return distanceToNext(fromTime)
throw NullPointerException("Lesson (by given index) and it's time should be non-null!")
val fromTime =
if (from != null)
fromLesson!!.time.end.dateTime
else
LocalDateTime.now()
return distanceToNextByLocalDateTime(fromTime)
} }
// current // current
@@ -63,8 +54,7 @@ class Day(
for (lessonIdx in lessons.indices) { for (lessonIdx in lessons.indices) {
val lesson = lessons[lessonIdx] val lesson = lessons[lessonIdx]
if (lesson.time.start.dateTime <= now && now < lesson.time.end.dateTime) if (lesson.time.start.dateTime <= now && now < lesson.time.end.dateTime) return lessonIdx
return lessonIdx
} }
return null return null

View File

@@ -8,7 +8,7 @@ import java.util.Calendar
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
@Parcelize @Parcelize
@Serializable @Serializable
data class Group( data class GroupOrTeacher(
val name: String, val name: String,
val days: List<Day> val days: List<Day>
) : Parcelable { ) : Parcelable {

View File

@@ -15,6 +15,7 @@ data class Lesson(
val defaultRange: List<Int>?, val defaultRange: List<Int>?,
val name: String?, val name: String?,
val time: LessonTime, val time: LessonTime,
val group: String? = null,
val subGroups: List<SubGroup> val subGroups: List<SubGroup>
) : Parcelable { ) : Parcelable {
val duration: Int val duration: Int
@@ -26,6 +27,10 @@ data class Lesson(
} }
fun getNameAndCabinetsShort(context: Context): String { fun getNameAndCabinetsShort(context: Context): String {
val name =
if (type == LessonType.BREAK) context.getString(R.string.lesson_break)
else this.name
val limitedName = name!! limit 15 val limitedName = name!! limit 15
val cabinets = subGroups.map { it.cabinet } val cabinets = subGroups.map { it.cabinet }

View File

@@ -4,7 +4,7 @@ import android.content.Context
import com.android.volley.Response import com.android.volley.Response
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.model.Group import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.network.request.CachedRequest import ru.n08i40k.polytechnic.next.network.request.CachedRequest
class ScheduleGet( class ScheduleGet(
@@ -18,13 +18,10 @@ class ScheduleGet(
{ listener.onResponse(Json.decodeFromString(it)) }, { listener.onResponse(Json.decodeFromString(it)) },
errorListener errorListener
) { ) {
@Serializable
data class RequestDto(val name: String)
@Serializable @Serializable
data class ResponseDto( data class ResponseDto(
val updatedAt: String, val updatedAt: String,
val group: Group, val group: GroupOrTeacher,
val updated: ArrayList<Int>, val updated: ArrayList<Int>,
) )
} }

View File

@@ -0,0 +1,28 @@
package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.network.request.CachedRequest
class ScheduleGetTeacher(
context: Context,
teacher: String,
listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null
) : CachedRequest(
context,
Method.GET,
"v2/schedule/teacher/$teacher",
{ listener.onResponse(Json.decodeFromString(it)) },
errorListener
) {
@Serializable
data class ResponseDto(
val updatedAt: String,
val teacher: GroupOrTeacher,
val updated: ArrayList<Int>,
)
}

View File

@@ -0,0 +1,24 @@
package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.network.RequestBase
class ScheduleGetTeacherNames(
context: Context,
listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null
) : RequestBase(
context,
Method.GET,
"v2/schedule/teacher-names",
{ listener.onResponse(Json.decodeFromString(it)) },
errorListener
) {
@Serializable
data class ResponseDto(
val names: ArrayList<String>,
)
}

View File

@@ -16,20 +16,19 @@ import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.Day import ru.n08i40k.polytechnic.next.model.Day
import ru.n08i40k.polytechnic.next.model.Group import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.model.Lesson
import ru.n08i40k.polytechnic.next.utils.dayMinutes import ru.n08i40k.polytechnic.next.utils.dayMinutes
import ru.n08i40k.polytechnic.next.utils.fmtAsClock import ru.n08i40k.polytechnic.next.utils.fmtAsClock
import ru.n08i40k.polytechnic.next.utils.getDayMinutes import ru.n08i40k.polytechnic.next.utils.getDayMinutes
import ru.n08i40k.polytechnic.next.utils.now
import java.util.Calendar import java.util.Calendar
import java.util.logging.Logger import java.util.logging.Logger
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
class CurrentLessonViewService : Service() { class CurrentLessonViewService : Service() {
companion object { companion object {
private const val NOTIFICATION_STATUS_ID = 1337 private const val NOTIFICATION_STATUS_ID = 1337
private const val NOTIFICATION_END_ID = NOTIFICATION_STATUS_ID + 1 private const val NOTIFICATION_END_ID = NOTIFICATION_STATUS_ID + 1
private const val UPDATE_INTERVAL = 60_000L private const val UPDATE_INTERVAL = 1_000L
suspend fun startService(application: PolytechnicApplication) { suspend fun startService(application: PolytechnicApplication) {
if (!application.hasNotificationPermission()) if (!application.hasNotificationPermission())
@@ -59,61 +58,32 @@ class CurrentLessonViewService : Service() {
} }
} }
private var day: Day? = null private lateinit var day: Day
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val updateRunnable = object : Runnable { private val updateRunnable = object : Runnable {
override fun run() { override fun run() {
val logger = Logger.getLogger("CLV.updateRunnable") val (currentIndex, currentLesson) = day.currentKV ?: (null to null)
val (nextIndex, _) = day.distanceToNext(currentIndex)
?: (null to null)
if (day == null || day!!.lessons.isEmpty()) { val nextLesson = nextIndex?.let { day.lessons[nextIndex] }
logger.warning("Stopping, because day is null or empty!")
stopSelf()
return
}
val currentMinutes = Calendar.getInstance()
.get(Calendar.HOUR_OF_DAY) * 60 + Calendar.getInstance()
.get(Calendar.MINUTE)
val currentLessonEntry = day!!.currentKV
val currentLessonIdx: Int? = currentLessonEntry?.first
val currentLesson: Lesson? = currentLessonEntry?.second
val nextLessonEntry = day!!.distanceToNextByIdx(currentLessonIdx)
val nextLesson =
if (nextLessonEntry == null)
null
else
day!!.lessons[nextLessonEntry.first]
if (currentLesson == null && nextLesson == null) { if (currentLesson == null && nextLesson == null) {
val notification = NotificationCompat onLessonsEnd()
.Builder(applicationContext, NotificationChannels.LESSON_VIEW)
.setSmallIcon(R.drawable.schedule)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentTitle(getString(R.string.lessons_end_notification_title))
.setContentText(getString(R.string.lessons_end_notification_description))
.build()
getNotificationManager().notify(NOTIFICATION_END_ID, notification)
stopSelf()
return return
} }
val firstLessonIdx = handler.postDelayed(this, UPDATE_INTERVAL)
day!!.distanceToNextByLocalDateTime(LocalDateTime(1, 1, 1, 0, 0))?.first
?: throw NullPointerException("Is this even real?")
val distanceToFirst = day!!.lessons[firstLessonIdx]!!.time!!.start.dayMinutes - currentMinutes
val currentLessonDelay = val context = this@CurrentLessonViewService
if (currentLesson == null) // Если эта пара - перемена, то конец перемены через (результат getDistanceToNext) val currentMinutes = LocalDateTime.now().dayMinutes
nextLessonEntry!!.second
else // Если эта пара - обычная пара, то конец пары через (конец этой пары - текущее кол-во минут) val distanceToFirst = day.first!!.time.start.dayMinutes - currentMinutes
currentLesson.time!!.end.dayMinutes - currentMinutes
val currentLessonName = val currentLessonName =
currentLesson?.getNameAndCabinetsShort(this@CurrentLessonViewService) currentLesson?.getNameAndCabinetsShort(context)
?: run { ?: run {
if (distanceToFirst > 0) if (distanceToFirst > 0)
getString(R.string.lessons_not_started) getString(R.string.lessons_not_started)
@@ -122,41 +92,42 @@ class CurrentLessonViewService : Service() {
} }
val nextLessonName = val nextLessonName =
if (currentLesson == null) // Если текущая пара - перемена nextLesson?.getNameAndCabinetsShort(context) ?: getString(R.string.lessons_end)
nextLesson!!.getNameAndCabinetsShort(this@CurrentLessonViewService)
else if (nextLesson == null) // Если текущая пара - последняя
getString(R.string.lessons_end)
else // Если после текущей пары есть ещё пара(ы)
getString(R.string.lesson_break)
val nextLessonTotal = val nextLessonIn =
if (currentLesson == null) (currentLesson?.time?.end ?: nextLesson!!.time.start).dayMinutes
nextLesson!!.time!!.start
else
currentLesson.time!!.end
val notification = createNotification( val notification = createNotification(
getString( getString(
if (distanceToFirst > 0) if (distanceToFirst > 0) R.string.waiting_for_day_start_notification_title
R.string.waiting_for_day_start_notification_title else R.string.lesson_going_notification_title,
else (nextLessonIn - currentMinutes) / 60,
R.string.lesson_going_notification_title, (nextLessonIn - currentMinutes) % 60
currentLessonDelay / 60,
currentLessonDelay % 60
), ),
getString( getString(
R.string.lesson_going_notification_description, R.string.lesson_going_notification_description,
currentLessonName, currentLessonName,
nextLessonTotal.dayMinutes.fmtAsClock(), nextLessonIn.fmtAsClock(),
nextLessonName, nextLessonName,
) )
) )
getNotificationManager().notify(NOTIFICATION_STATUS_ID, notification) getNotificationManager().notify(NOTIFICATION_STATUS_ID, notification)
handler.postDelayed(this, UPDATE_INTERVAL)
} }
} }
private fun onLessonsEnd() {
val notification = NotificationCompat
.Builder(applicationContext, NotificationChannels.LESSON_VIEW)
.setSmallIcon(R.drawable.schedule)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentTitle(getString(R.string.lessons_end_notification_title))
.setContentText(getString(R.string.lessons_end_notification_description))
.build()
getNotificationManager().notify(NOTIFICATION_END_ID, notification)
stopSelf()
}
private fun createNotification( private fun createNotification(
title: String? = null, title: String? = null,
description: String? = null description: String? = null
@@ -176,7 +147,7 @@ class CurrentLessonViewService : Service() {
return getSystemService(NOTIFICATION_SERVICE) as NotificationManager return getSystemService(NOTIFICATION_SERVICE) as NotificationManager
} }
private fun updateSchedule(group: Group?) { private fun updateSchedule(group: GroupOrTeacher?) {
val logger = Logger.getLogger("CLV") val logger = Logger.getLogger("CLV")
if (group == null) { if (group == null) {
@@ -223,7 +194,7 @@ class CurrentLessonViewService : Service() {
updateSchedule( updateSchedule(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("group", Group::class.java) intent.getParcelableExtra("group", GroupOrTeacher::class.java)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
intent.getParcelableExtra("group") intent.getParcelableExtra("group")

View File

@@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Person
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import ru.n08i40k.polytechnic.next.R import ru.n08i40k.polytechnic.next.R
@@ -19,6 +20,7 @@ object Constants {
val bottomNavItem = listOf( val bottomNavItem = listOf(
BottomNavItem(R.string.profile, Icons.Filled.AccountCircle, "profile"), BottomNavItem(R.string.profile, Icons.Filled.AccountCircle, "profile"),
BottomNavItem(R.string.replacer, Icons.Filled.Create, "replacer", true), BottomNavItem(R.string.replacer, Icons.Filled.Create, "replacer", true),
BottomNavItem(R.string.schedule, Icons.Filled.DateRange, "schedule") BottomNavItem(R.string.schedule, Icons.Filled.DateRange, "schedule"),
BottomNavItem(R.string.teachers, Icons.Filled.Person, "teacher-schedule")
) )
} }

View File

@@ -70,12 +70,14 @@ import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Download
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Telegram import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Telegram
import ru.n08i40k.polytechnic.next.ui.main.profile.ProfileScreen import ru.n08i40k.polytechnic.next.ui.main.profile.ProfileScreen
import ru.n08i40k.polytechnic.next.ui.main.replacer.ReplacerScreen import ru.n08i40k.polytechnic.next.ui.main.replacer.ReplacerScreen
import ru.n08i40k.polytechnic.next.ui.main.schedule.ScheduleScreen import ru.n08i40k.polytechnic.next.ui.main.schedule.group.GroupScheduleScreen
import ru.n08i40k.polytechnic.next.ui.main.schedule.teacher.TeacherScheduleScreen
import ru.n08i40k.polytechnic.next.ui.model.GroupScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState
import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel
import ru.n08i40k.polytechnic.next.ui.model.RemoteConfigViewModel import ru.n08i40k.polytechnic.next.ui.model.RemoteConfigViewModel
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerViewModel import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerViewModel
import ru.n08i40k.polytechnic.next.ui.model.ScheduleViewModel import ru.n08i40k.polytechnic.next.ui.model.TeacherScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.model.profileViewModel import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
@@ -83,7 +85,8 @@ import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
private fun NavHostContainer( private fun NavHostContainer(
navController: NavHostController, navController: NavHostController,
padding: PaddingValues, padding: PaddingValues,
scheduleViewModel: ScheduleViewModel, groupScheduleViewModel: GroupScheduleViewModel,
teacherScheduleViewModel: TeacherScheduleViewModel,
scheduleReplacerViewModel: ScheduleReplacerViewModel? scheduleReplacerViewModel: ScheduleReplacerViewModel?
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -121,7 +124,15 @@ private fun NavHostContainer(
} }
composable("schedule") { composable("schedule") {
ScheduleScreen(scheduleViewModel) { scheduleViewModel.refreshGroup() } GroupScheduleScreen(groupScheduleViewModel) { groupScheduleViewModel.refresh() }
}
composable("teacher-schedule") {
TeacherScheduleScreen(teacherScheduleViewModel) {
if (it.isNotEmpty()) teacherScheduleViewModel.fetch(
it
)
}
} }
if (scheduleReplacerViewModel != null) { if (scheduleReplacerViewModel != null) {
@@ -283,8 +294,12 @@ fun MainScreen(
) )
// schedule view model // schedule view model
val scheduleViewModel = val groupScheduleViewModel =
hiltViewModel<ScheduleViewModel>(LocalContext.current as ComponentActivity) hiltViewModel<GroupScheduleViewModel>(LocalContext.current as ComponentActivity)
// teacher view model
val teacherScheduleViewModel =
hiltViewModel<TeacherScheduleViewModel>(LocalContext.current as ComponentActivity)
// schedule replacer view model // schedule replacer view model
val profileUiState by profileViewModel.uiState.collectAsStateWithLifecycle() val profileUiState by profileViewModel.uiState.collectAsStateWithLifecycle()
@@ -312,7 +327,8 @@ fun MainScreen(
NavHostContainer( NavHostContainer(
navController, navController,
paddingValues, paddingValues,
scheduleViewModel, groupScheduleViewModel,
teacherScheduleViewModel,
scheduleReplacerViewModel scheduleReplacerViewModel
) )
} }

View File

@@ -38,7 +38,7 @@ import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
import ru.n08i40k.polytechnic.next.model.Profile import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.settings.settingsDataStore import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import ru.n08i40k.polytechnic.next.ui.model.ScheduleViewModel import ru.n08i40k.polytechnic.next.ui.model.GroupScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.model.profileViewModel import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
@Preview(showBackground = true) @Preview(showBackground = true)
@@ -176,8 +176,8 @@ internal fun ProfileCard(profile: Profile = FakeProfileRepository.exampleProfile
} }
if (groupChanging) { if (groupChanging) {
val scheduleViewModel = val groupScheduleViewModel =
hiltViewModel<ScheduleViewModel>(LocalContext.current as ComponentActivity) hiltViewModel<GroupScheduleViewModel>(LocalContext.current as ComponentActivity)
ChangeGroupDialog( ChangeGroupDialog(
context, context,
@@ -194,7 +194,7 @@ internal fun ProfileCard(profile: Profile = FakeProfileRepository.exampleProfile
.clear() .clear()
} }
context.profileViewModel!!.refreshProfile { context.profileViewModel!!.refreshProfile {
scheduleViewModel.refreshGroup() groupScheduleViewModel.refresh()
} }
} }
) { groupChanging = false } ) { groupChanging = false }

View File

@@ -102,7 +102,7 @@ fun DayCard(
text = stringResource(when (distance) { text = stringResource(when (distance) {
-1 -> R.string.yesterday -1 -> R.string.yesterday
0 -> R.string.today 0 -> R.string.today
1 -> R.string.tommorow 1 -> R.string.tomorrow
else -> throw RuntimeException() else -> throw RuntimeException()
}), }),
) )

View File

@@ -17,7 +17,7 @@ import androidx.compose.ui.util.lerp
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import ru.n08i40k.polytechnic.next.R import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository
import ru.n08i40k.polytechnic.next.model.Group import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import ru.n08i40k.polytechnic.next.ui.widgets.NotificationCard import ru.n08i40k.polytechnic.next.ui.widgets.NotificationCard
import ru.n08i40k.polytechnic.next.utils.dateTime import ru.n08i40k.polytechnic.next.utils.dateTime
import ru.n08i40k.polytechnic.next.utils.now import ru.n08i40k.polytechnic.next.utils.now
@@ -25,9 +25,9 @@ import java.util.Calendar
import java.util.logging.Level import java.util.logging.Level
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
private fun isScheduleOutdated(group: Group): Boolean { private fun isScheduleOutdated(groupOrTeacher: GroupOrTeacher): Boolean {
val nowDateTime = LocalDateTime.now() val nowDateTime = LocalDateTime.now()
val lastDay = group.days.lastOrNull() ?: return true val lastDay = groupOrTeacher.days.lastOrNull() ?: return true
val lastLesson = lastDay.last ?: return true val lastLesson = lastDay.last ?: return true
return nowDateTime > lastLesson.time.end.dateTime return nowDateTime > lastLesson.time.end.dateTime
@@ -35,17 +35,17 @@ private fun isScheduleOutdated(group: Group): Boolean {
@Preview @Preview
@Composable @Composable
fun DayPager(group: Group = FakeScheduleRepository.exampleGroup) { fun DayPager(groupOrTeacher: GroupOrTeacher = FakeScheduleRepository.exampleGroup) {
val currentDay = (Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 2) val currentDay = (Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 2)
val calendarDay = if (currentDay == -1) 6 else currentDay val calendarDay = if (currentDay == -1) 6 else currentDay
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = calendarDay initialPage = calendarDay
.coerceAtMost(group.days.size - 1), .coerceAtMost(groupOrTeacher.days.size - 1),
pageCount = { group.days.size }) pageCount = { groupOrTeacher.days.size })
Column { Column {
if (isScheduleOutdated(group)) { if (isScheduleOutdated(groupOrTeacher)) {
NotificationCard( NotificationCard(
level = Level.WARNING, level = Level.WARNING,
title = stringResource(R.string.outdated_schedule) title = stringResource(R.string.outdated_schedule)
@@ -73,7 +73,7 @@ fun DayPager(group: Group = FakeScheduleRepository.exampleGroup) {
start = 0.5f, stop = 1f, fraction = 1f - offset.coerceIn(0f, 1f) start = 0.5f, stop = 1f, fraction = 1f - offset.coerceIn(0f, 1f)
) )
}, },
day = group.days[page], day = groupOrTeacher.days[page],
distance = page - currentDay distance = page - currentDay
) )
} }

View File

@@ -117,6 +117,7 @@ private fun LessonViewRow(
timeFormat: LessonTimeFormat = LessonTimeFormat.FROM_TO, timeFormat: LessonTimeFormat = LessonTimeFormat.FROM_TO,
name: String = "Test", name: String = "Test",
subGroups: List<SubGroup> = listOf(), subGroups: List<SubGroup> = listOf(),
group: String? = "ИС-214/23",
cardColors: CardColors = CardDefaults.cardColors(), cardColors: CardColors = CardDefaults.cardColors(),
verticalPadding: Dp = 10.dp, verticalPadding: Dp = 10.dp,
now: Boolean = true, now: Boolean = true,
@@ -201,6 +202,16 @@ private fun LessonViewRow(
color = contentColor color = contentColor
) )
if (group != null) {
Text(
text = group,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = contentColor
)
}
for (subGroup in subGroups) { for (subGroup in subGroups) {
Text( Text(
text = subGroup.teacher, text = subGroup.teacher,
@@ -212,8 +223,10 @@ private fun LessonViewRow(
} }
Column(modifier = Modifier.wrapContentWidth()) { Column(modifier = Modifier.wrapContentWidth()) {
if (subGroups.size != 1) if (subGroups.size != 1) {
Text(text = "") for (i in 0..<(if (group != null) 2 else 1))
Text(text = "")
}
for (subGroup in subGroups) { for (subGroup in subGroups) {
Text( Text(
text = subGroup.cabinet, text = subGroup.cabinet,
@@ -244,6 +257,7 @@ fun FreeLessonRow(
LessonTimeFormat.ONLY_MINUTES_DURATION, LessonTimeFormat.ONLY_MINUTES_DURATION,
stringResource(R.string.lesson_break), stringResource(R.string.lesson_break),
lesson.subGroups, lesson.subGroups,
lesson.group,
cardColors, cardColors,
2.5.dp, 2.5.dp,
now now
@@ -264,6 +278,7 @@ fun LessonRow(
LessonTimeFormat.FROM_TO, LessonTimeFormat.FROM_TO,
lesson.name!!, lesson.name!!,
lesson.subGroups, lesson.subGroups,
lesson.group,
cardColors, cardColors,
5.dp, 5.dp,
now now

View File

@@ -1,4 +1,4 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule package ru.n08i40k.polytechnic.next.ui.main.schedule.group
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -26,9 +26,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import ru.n08i40k.polytechnic.next.R import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MockAppContainer import ru.n08i40k.polytechnic.next.data.MockAppContainer
import ru.n08i40k.polytechnic.next.ui.main.schedule.DayPager
import ru.n08i40k.polytechnic.next.ui.model.GroupScheduleUiState
import ru.n08i40k.polytechnic.next.ui.model.GroupScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
import ru.n08i40k.polytechnic.next.ui.model.ScheduleUiState
import ru.n08i40k.polytechnic.next.ui.model.ScheduleViewModel
@Composable @Composable
private fun rememberUpdatedLifecycleOwner(): LifecycleOwner { private fun rememberUpdatedLifecycleOwner(): LifecycleOwner {
@@ -38,11 +39,11 @@ private fun rememberUpdatedLifecycleOwner(): LifecycleOwner {
@Preview(showBackground = true, showSystemUi = true) @Preview(showBackground = true, showSystemUi = true)
@Composable @Composable
fun ScheduleScreen( fun GroupScheduleScreen(
scheduleViewModel: ScheduleViewModel = ScheduleViewModel(MockAppContainer(LocalContext.current)), groupScheduleViewModel: GroupScheduleViewModel = GroupScheduleViewModel(MockAppContainer(LocalContext.current)),
onRefresh: () -> Unit = {} onRefresh: () -> Unit = {}
) { ) {
val uiState by scheduleViewModel.uiState.collectAsStateWithLifecycle() val uiState by groupScheduleViewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState) { LaunchedEffect(uiState) {
delay(120_000) delay(120_000)
onRefresh() onRefresh()
@@ -69,25 +70,25 @@ fun ScheduleScreen(
LoadingContent( LoadingContent(
empty = when (uiState) { empty = when (uiState) {
is ScheduleUiState.NoSchedule -> uiState.isLoading is GroupScheduleUiState.NoData -> uiState.isLoading
is ScheduleUiState.HasSchedule -> false is GroupScheduleUiState.HasData -> false
}, },
loading = uiState.isLoading, loading = uiState.isLoading,
onRefresh = onRefresh, onRefresh = onRefresh,
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { ) {
when (uiState) { when (uiState) {
is ScheduleUiState.HasSchedule -> { is GroupScheduleUiState.HasData -> {
Column { Column {
val hasSchedule = uiState as ScheduleUiState.HasSchedule val hasData = uiState as GroupScheduleUiState.HasData
UpdateInfo(hasSchedule.lastUpdateAt, hasSchedule.updateDates) UpdateInfo(hasData.lastUpdateAt, hasData.updateDates)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
DayPager(hasSchedule.group) DayPager(hasData.group)
} }
} }
is ScheduleUiState.NoSchedule -> { is GroupScheduleUiState.NoData -> {
if (!uiState.isLoading) { if (!uiState.isLoading) {
TextButton(onClick = onRefresh, modifier = Modifier.fillMaxSize()) { TextButton(onClick = onRefresh, modifier = Modifier.fillMaxSize()) {
Text(stringResource(R.string.reload), textAlign = TextAlign.Center) Text(stringResource(R.string.reload), textAlign = TextAlign.Center)

View File

@@ -1,4 +1,4 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule package ru.n08i40k.polytechnic.next.ui.main.schedule.group
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View File

@@ -1,4 +1,4 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule package ru.n08i40k.polytechnic.next.ui.main.schedule.group
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement

View File

@@ -0,0 +1,90 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule.teacher
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBox(
title: String,
onSearchAttempt: (String) -> Unit,
variants: List<String>,
) {
var value by remember { mutableStateOf("") }
val searchableVariants =
remember(variants.size) { variants.map { it.replace(" ", "").replace(".", "").lowercase() } }
val filteredVariants = remember(searchableVariants, value) {
searchableVariants.filter { it.contains(value) }
}
var dropdownExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = dropdownExpanded,
onExpandedChange = {}
) {
Row(modifier = Modifier.fillMaxWidth()) {
TextField(
modifier = Modifier
.fillMaxWidth()
.onFocusChanged {
if (it.hasFocus)
dropdownExpanded = true
}
.menuAnchor(MenuAnchorType.PrimaryEditable, true),
label = { Text(title) },
value = value,
onValueChange = {
value = it
dropdownExpanded = true
},
trailingIcon = {
IconButton(onClick = { onSearchAttempt(value) }) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Search"
)
}
},
singleLine = true,
)
}
ExposedDropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
filteredVariants.forEach {
val fullVariant = variants[searchableVariants.indexOf(it)]
DropdownMenuItem(
text = { Text(fullVariant) },
onClick = {
value = fullVariant
onSearchAttempt(value)
dropdownExpanded = false
}
)
}
}
}
}

View File

@@ -0,0 +1,114 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule.teacher
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MockAppContainer
import ru.n08i40k.polytechnic.next.ui.main.schedule.DayPager
import ru.n08i40k.polytechnic.next.ui.model.TeacherScheduleUiState
import ru.n08i40k.polytechnic.next.ui.model.TeacherScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
@Composable
private fun rememberUpdatedLifecycleOwner(): LifecycleOwner {
val lifecycleOwner = LocalLifecycleOwner.current
return remember { lifecycleOwner }
}
@Preview(showBackground = true, showSystemUi = true)
@Composable
fun TeacherScheduleScreen(
teacherScheduleViewModel: TeacherScheduleViewModel = TeacherScheduleViewModel(
MockAppContainer(
LocalContext.current
)
),
fetch: (String) -> Unit = {}
) {
var teacherName by remember { mutableStateOf("") }
val uiState by teacherScheduleViewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
delay(120_000)
fetch(teacherName)
}
val lifecycleOwner = rememberUpdatedLifecycleOwner()
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
fetch(teacherName)
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxSize()) {
TeacherSearchBox(onSearchAttempt = {
teacherName = it
fetch(it)
})
Spacer(Modifier.height(10.dp))
LoadingContent(
empty = when (uiState) {
is TeacherScheduleUiState.NoData -> uiState.isLoading
is TeacherScheduleUiState.HasData -> false
},
loading = uiState.isLoading,
) {
when (uiState) {
is TeacherScheduleUiState.HasData -> {
Column {
val hasData = uiState as TeacherScheduleUiState.HasData
DayPager(hasData.teacher)
}
}
is TeacherScheduleUiState.NoData -> {
if (!uiState.isLoading) {
Text(
modifier = Modifier.fillMaxSize(),
text = stringResource(R.string.teacher_not_selected),
textAlign = TextAlign.Center
)
}
}
}
}
}
}

View File

@@ -0,0 +1,46 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule.teacher
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetTeacherNames
@Composable
private fun getTeacherNames(context: Context): ArrayList<String> {
val teacherNames = remember { arrayListOf<String>() }
LaunchedEffect(teacherNames) {
ScheduleGetTeacherNames(context, {
teacherNames.clear()
teacherNames.addAll(it.names)
}, {
teacherNames.clear()
}).send()
}
return teacherNames
}
@Preview(showBackground = true)
@Composable
fun TeacherSearchBox(
onSearchAttempt: (String) -> Unit = {},
) {
val teachers = getTeacherNames(LocalContext.current)
val focusManager = LocalFocusManager.current
SearchBox(
stringResource(R.string.teacher_name),
{
focusManager.clearFocus(true)
onSearchAttempt(it)
},
teachers,
)
}

View File

@@ -12,55 +12,55 @@ import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.UpdateDates import ru.n08i40k.polytechnic.next.UpdateDates
import ru.n08i40k.polytechnic.next.data.AppContainer import ru.n08i40k.polytechnic.next.data.AppContainer
import ru.n08i40k.polytechnic.next.data.MyResult import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.Group import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
sealed interface ScheduleUiState { sealed interface GroupScheduleUiState {
val isLoading: Boolean val isLoading: Boolean
data class NoSchedule( data class NoData(
override val isLoading: Boolean override val isLoading: Boolean
) : ScheduleUiState ) : GroupScheduleUiState
data class HasSchedule( data class HasData(
val group: Group, val group: GroupOrTeacher,
val updateDates: UpdateDates, val updateDates: UpdateDates,
val lastUpdateAt: Long, val lastUpdateAt: Long,
override val isLoading: Boolean override val isLoading: Boolean
) : ScheduleUiState ) : GroupScheduleUiState
} }
private data class ScheduleViewModelState( private data class GroupScheduleViewModelState(
val group: Group? = null, val group: GroupOrTeacher? = null,
val updateDates: UpdateDates? = null, val updateDates: UpdateDates? = null,
val lastUpdateAt: Long = 0, val lastUpdateAt: Long = 0,
val isLoading: Boolean = false val isLoading: Boolean = false
) { ) {
fun toUiState(): ScheduleUiState = if (group == null) { fun toUiState(): GroupScheduleUiState = if (group == null) {
ScheduleUiState.NoSchedule(isLoading) GroupScheduleUiState.NoData(isLoading)
} else { } else {
ScheduleUiState.HasSchedule(group, updateDates!!, lastUpdateAt, isLoading) GroupScheduleUiState.HasData(group, updateDates!!, lastUpdateAt, isLoading)
} }
} }
@HiltViewModel @HiltViewModel
class ScheduleViewModel @Inject constructor( class GroupScheduleViewModel @Inject constructor(
appContainer: AppContainer appContainer: AppContainer
) : ViewModel() { ) : ViewModel() {
private val scheduleRepository = appContainer.scheduleRepository private val scheduleRepository = appContainer.scheduleRepository
private val networkCacheRepository = appContainer.networkCacheRepository private val networkCacheRepository = appContainer.networkCacheRepository
private val viewModelState = MutableStateFlow(ScheduleViewModelState(isLoading = true)) private val viewModelState = MutableStateFlow(GroupScheduleViewModelState(isLoading = true))
val uiState = viewModelState val uiState = viewModelState
.map(ScheduleViewModelState::toUiState) .map(GroupScheduleViewModelState::toUiState)
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState()) .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState())
init { init {
refreshGroup() refresh()
} }
fun refreshGroup() { fun refresh() {
viewModelState.update { it.copy(isLoading = true) } viewModelState.update { it.copy(isLoading = true) }
viewModelScope.launch { viewModelScope.launch {

View File

@@ -0,0 +1,100 @@
package ru.n08i40k.polytechnic.next.ui.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.UpdateDates
import ru.n08i40k.polytechnic.next.data.AppContainer
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.GroupOrTeacher
import java.util.Date
import javax.inject.Inject
sealed interface TeacherScheduleUiState {
val isLoading: Boolean
data class NoData(
override val isLoading: Boolean
) : TeacherScheduleUiState
data class HasData(
val teacher: GroupOrTeacher,
val updateDates: UpdateDates,
val lastUpdateAt: Long,
override val isLoading: Boolean
) : TeacherScheduleUiState
}
private data class TeacherScheduleViewModelState(
val teacher: GroupOrTeacher? = null,
val updateDates: UpdateDates? = null,
val lastUpdateAt: Long = 0,
val isLoading: Boolean = false
) {
fun toUiState(): TeacherScheduleUiState = if (teacher == null) {
TeacherScheduleUiState.NoData(isLoading)
} else {
TeacherScheduleUiState.HasData(teacher, updateDates!!, lastUpdateAt, isLoading)
}
}
@HiltViewModel
class TeacherScheduleViewModel @Inject constructor(
appContainer: AppContainer
) : ViewModel() {
private val scheduleRepository = appContainer.scheduleRepository
private val networkCacheRepository = appContainer.networkCacheRepository
private val viewModelState = MutableStateFlow(TeacherScheduleViewModelState(isLoading = true))
val uiState = viewModelState
.map(TeacherScheduleViewModelState::toUiState)
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState())
init {
fetch(null)
}
fun fetch(name: String?) {
if (name == null) {
viewModelState.update {
it.copy(
teacher = null,
isLoading = false
)
}
return
}
viewModelState.update { it.copy(isLoading = true) }
viewModelScope.launch {
val result = scheduleRepository.getTeacher(name)
viewModelState.update {
when (result) {
is MyResult.Success -> {
val updateDates = networkCacheRepository.getUpdateDates()
it.copy(
teacher = result.data,
updateDates = updateDates,
lastUpdateAt = Date().time,
isLoading = false
)
}
is MyResult.Failure -> it.copy(
teacher = null,
isLoading = false
)
}
}
}
}
}

View File

@@ -37,6 +37,24 @@ fun LoadingContent(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoadingContent(
empty: Boolean,
emptyContent: @Composable () -> Unit = { FullScreenLoading() },
loading: Boolean,
content: @Composable () -> Unit
) {
if (empty) {
emptyContent()
return
}
PullToRefreshBox(isRefreshing = loading, onRefresh = {}) {
content()
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun FullScreenLoading() { fun FullScreenLoading() {

View File

@@ -43,6 +43,9 @@ val Instant.dayMinutes: Int
val LocalDateTime.dayMinutes: Int val LocalDateTime.dayMinutes: Int
get() = this.hour * 60 + this.minute get() = this.hour * 60 + this.minute
fun LocalDateTime.Companion.fromEpochMilliseconds(milliseconds: Long): LocalDateTime =
Instant.fromEpochMilliseconds(milliseconds).dateTime
val Instant.dateTime: LocalDateTime val Instant.dateTime: LocalDateTime
get() = this.toLocalDateTime(TimeZone.currentSystemDefault()) get() = this.toLocalDateTime(TimeZone.currentSystemDefault())

View File

@@ -75,5 +75,9 @@
<string name="no_connection">Нет подключения к интернету!</string> <string name="no_connection">Нет подключения к интернету!</string>
<string name="today">Сегодня</string> <string name="today">Сегодня</string>
<string name="yesterday">Вчера</string> <string name="yesterday">Вчера</string>
<string name="tommorow">Завтра</string> <string name="tomorrow">Завтра</string>
<string name="teachers">Преподаватели</string>
<string name="teacher_name">ФИО преподавателя</string>
<string name="teacher_not_selected">Преподаватель не выбран или вы допустили ошибку в его ФИО.</string>
<string name="failed_to_fetch_teacher_names">Не удалось получить список ФИО преподавателей!</string>
</resources> </resources>

View File

@@ -75,5 +75,9 @@
<string name="no_connection">No internet connection!</string> <string name="no_connection">No internet connection!</string>
<string name="today">Today</string> <string name="today">Today</string>
<string name="yesterday">Yesterday</string> <string name="yesterday">Yesterday</string>
<string name="tommorow">Tommorow</string> <string name="tomorrow">Tomorrow</string>
<string name="teachers">Teachers</string>
<string name="teacher_name">Teacher name</string>
<string name="teacher_not_selected">Teacher not selected or you made mistake in his name.</string>
<string name="failed_to_fetch_teacher_names">Failed to fetch teacher names!</string>
</resources> </resources>

View File

@@ -1,17 +1,27 @@
package ru.n08i40k.polytechnic.next package ru.n08i40k.polytechnic.next
import android.content.Context
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository
import org.junit.Assert.* @RunWith(MockitoJUnitRunner.Silent::class)
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest { class ExampleUnitTest {
@Test @Test
fun addition_isCorrect() { fun getNameAndCabinetsShort_isNotThrow() {
assertEquals(4, 2 + 2) val mockContext = mock<Context> {
on { getString(R.string.in_gym_lc) } doReturn "с"
on { getString(R.string.lesson_break) } doReturn "Перемена"
}
val group = FakeScheduleRepository.exampleGroup
for (day in group.days) {
for (lesson in day.lessons) {
lesson.getNameAndCabinetsShort(mockContext)
}
}
} }
} }

View File

@@ -14,6 +14,7 @@ kotlinxSerializationJson = "1.7.3"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3" activityCompose = "1.9.3"
composeBom = "2024.10.01" composeBom = "2024.10.01"
mockitoKotlin = "5.4.0"
protobufLite = "3.0.1" protobufLite = "3.0.1"
volley = "1.2.1" volley = "1.2.1"
datastore = "1.1.1" datastore = "1.1.1"
@@ -45,6 +46,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.1" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.1" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
protobuf-lite = { module = "com.google.protobuf:protobuf-lite", version.ref = "protobufLite" } protobuf-lite = { module = "com.google.protobuf:protobuf-lite", version.ref = "protobufLite" }
volley = { group = "com.android.volley", name = "volley", version.ref = "volley" } volley = { group = "com.android.volley", name = "volley", version.ref = "volley" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }