Удалёно большинство классов относящихся к CustomLessonViewService:
- AlarmReceiver
- BootCompletedBroadcastReceiver
- ScheduleClvAlarm
- StartClvService

CustomLessonViewService теперь запускается сервером в определённое время.

Переработаны формы авторизации и регистрации.

В форме регистрации теперь можно выбрать свою группу из выпадающего списка, а не вводить вручную.

Исправлен недочёт, из-за которого можно было вернуться к форме авторизации нажимая кнопку назад (или делать свайп для того же эффекта).

Немного изменён логотип приложения.

Изменена иконка уведомлений на самодельную.
This commit is contained in:
2024-10-13 20:14:40 +04:00
parent 2a7e63dce4
commit 8ed9ce17e7
66 changed files with 1486 additions and 1067 deletions

View File

@@ -15,8 +15,27 @@
<option name="projectNumber" value="946974192625" />
</ConnectionSetting>
</option>
<option name="devices">
<list>
<DeviceSetting>
<option name="deviceType" value="Phone" />
<option name="displayName" value="Xiaomi (2311DRK48G)" />
<option name="manufacturer" value="Xiaomi" />
<option name="model" value="2311DRK48G" />
</DeviceSetting>
</list>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="versions">
<list>
<VersionSetting>
<option name="buildVersion" value="13" />
<option name="displayName" value="1.7.1 (13)" />
<option name="displayVersion" value="1.7.1" />
</VersionSetting>
</list>
</option>
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>

View File

@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-10-10T18:25:08.106861775Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=482a22d" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View File

@@ -33,8 +33,8 @@ android {
applicationId = "ru.n08i40k.polytechnic.next"
minSdk = 26
targetSdk = 35
versionCode = 13
versionName = "1.7.1"
versionCode = 15
versionName = "1.8.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -12,31 +12,17 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- For schedule CLV service-->
<!-- <uses-permission android:name="android.permission.USE_EXACT_ALARM" />-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".PolytechnicApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.PolytechnicNext"
tools:targetApi="35">
<receiver
android:name=".receiver.BootCompletedBroadcastReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name=".receiver.AlarmReceiver" />
<service
android:name=".service.MyFirebaseMessagingService"
android:exported="false">

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,20 +1,13 @@
package ru.n08i40k.polytechnic.next
import android.Manifest
import android.app.AlarmManager
import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import dagger.hilt.android.HiltAndroidApp
import ru.n08i40k.polytechnic.next.data.AppContainer
import ru.n08i40k.polytechnic.next.model.Group
import ru.n08i40k.polytechnic.next.receiver.AlarmReceiver
import ru.n08i40k.polytechnic.next.utils.or
import java.util.Calendar
import javax.inject.Inject
@HiltAndroidApp
@@ -34,84 +27,4 @@ class PolytechnicApplication : Application() {
|| ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED)
}
private fun getDate(group: Group): Calendar? {
val javaCalendar = Calendar.getInstance()
val currentMinutes = javaCalendar.get(Calendar.HOUR_OF_DAY) * 60 +
javaCalendar.get(Calendar.MINUTE)
var startDayIdx = javaCalendar.get(Calendar.DAY_OF_WEEK) - 2
println("Current day is $startDayIdx")
val currentDay = group.days[startDayIdx]
if (currentDay != null) {
val firstLesson = currentDay.first
if (firstLesson == null || firstLesson.time.start < currentMinutes) {
println("Current day already started or ended!")
++startDayIdx
}
}
for (dayIdx in startDayIdx..5) {
println("Trying $dayIdx day...")
val day = group.days[dayIdx] ?: continue
println("Day isn't null")
val firstLesson = day.first ?: continue
println("Day isn't empty")
val executeMinutes = (firstLesson.time.start - 15).coerceAtLeast(0)
println("Schedule minutes at $executeMinutes")
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, dayIdx + 2) // sunday is first + index from 0
set(Calendar.HOUR_OF_DAY, executeMinutes / 60)
set(Calendar.MINUTE, executeMinutes % 60)
set(Calendar.SECOND, 0)
// set(Calendar.MINUTE, get(Calendar.MINUTE) + 1)
}
}
return null
}
fun scheduleClvService(group: Group) {
// -1 = вс
// 0 = пн
// 1 = вт
// 2 = ср
// 3 = чт
// 4 = пт
// 5 = сб
println("Getting date...")
val date = getDate(group) ?: return
println("Alarm on this week!")
val alarmManager = applicationContext
.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
val pendingIntent =
Intent(applicationContext, AlarmReceiver::class.java).let {
PendingIntent.getBroadcast(
applicationContext,
IntentRequestCodes.ALARM_CLV,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
if (alarmManager != null)
println("Alarm manager isn't null.")
alarmManager?.cancel(pendingIntent)
alarmManager?.set(
AlarmManager.RTC_WAKEUP,
date.timeInMillis,
pendingIntent
)
}
}

View File

@@ -2,30 +2,18 @@ package ru.n08i40k.polytechnic.next.data.schedule.impl
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository
import ru.n08i40k.polytechnic.next.model.Group
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGet
import ru.n08i40k.polytechnic.next.network.tryFuture
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
class RemoteScheduleRepository(private val context: Context) : ScheduleRepository {
override suspend fun getGroup(): MyResult<Group> {
return withContext(Dispatchers.IO) {
val groupName = runBlocking {
context.settingsDataStore.data.map { settings -> settings.group }.first()
}
if (groupName.isEmpty())
return@withContext MyResult.Failure(IllegalArgumentException("No group name provided!"))
override suspend fun getGroup(): MyResult<Group> =
withContext(Dispatchers.IO) {
val response = tryFuture {
ScheduleGet(
ScheduleGet.RequestDto(groupName),
context,
it,
it
@@ -37,5 +25,4 @@ class RemoteScheduleRepository(private val context: Context) : ScheduleRepositor
is MyResult.Success -> MyResult.Success(response.data.group)
}
}
}
}

View File

@@ -20,7 +20,6 @@ open class RequestBase(
override fun getHeaders(): MutableMap<String, String> {
val headers = mutableMapOf<String, String>()
headers["Content-Type"] = "application/json; charset=utf-8"
headers["version"] = "1"
return headers
}

View File

@@ -26,4 +26,11 @@ fun <T> tryGet(future: RequestFuture<T>): MyResult<T> {
} catch (exception: TimeoutException) {
MyResult.Failure(exception)
}
}
fun unwrapException(exception: Exception): Throwable {
if (exception is ExecutionException && exception.cause != null)
return exception.cause!!
return exception
}

View File

@@ -26,7 +26,7 @@ open class CachedRequest(
private val listener: Response.Listener<String>,
errorListener: Response.ErrorListener?,
) : AuthorizedRequest(context, method, url, {
runBlocking {
runBlocking(Dispatchers.IO) {
(context as PolytechnicApplication)
.container.networkCacheRepository.put(url, it)
}

View File

@@ -7,7 +7,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.network.RequestBase
class AuthLogin(
class AuthSignIn(
private val data: RequestDto,
context: Context,
listener: Response.Listener<ResponseDto>,
@@ -23,9 +23,16 @@ class AuthLogin(
data class RequestDto(val username: String, val password: String)
@Serializable
data class ResponseDto(val id: String, val accessToken: String)
data class ResponseDto(val id: String, val accessToken: String, val group: String)
override fun getBody(): ByteArray {
return Json.encodeToString(data).toByteArray()
}
override fun getHeaders(): MutableMap<String, String> {
val headers = super.getHeaders()
headers["version"] = "2"
return headers
}
}

View File

@@ -8,7 +8,7 @@ import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.network.RequestBase
class AuthRegister(
class AuthSignUp(
private val data: RequestDto,
context: Context,
listener: Response.Listener<ResponseDto>,

View File

@@ -3,13 +3,11 @@ package ru.n08i40k.polytechnic.next.network.request.schedule
import android.content.Context
import com.android.volley.Response
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.model.Group
import ru.n08i40k.polytechnic.next.network.request.CachedRequest
class ScheduleGet(
private val data: RequestDto,
context: Context,
listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null
@@ -29,8 +27,4 @@ class ScheduleGet(
val group: Group,
val lastChangedDays: ArrayList<Int>,
)
override fun getBody(): ByteArray {
return Json.encodeToString(data).toByteArray()
}
}

View File

@@ -24,4 +24,11 @@ class ScheduleGetCacheStatus(
val lastCacheUpdate: Long,
val lastScheduleUpdate: Long,
)
override fun getHeaders(): MutableMap<String, String> {
val headers = super.getHeaders()
headers["version"] = "1"
return headers
}
}

View File

@@ -4,13 +4,13 @@ import android.content.Context
import com.android.volley.Response
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import ru.n08i40k.polytechnic.next.network.request.CachedRequest
import ru.n08i40k.polytechnic.next.network.RequestBase
class ScheduleGetGroupNames(
context: Context,
listener: Response.Listener<ResponseDto>,
errorListener: Response.ErrorListener? = null
) : CachedRequest(
) : RequestBase(
context,
Method.GET,
"schedule/get-group-names",

View File

@@ -25,4 +25,11 @@ class ScheduleUpdate(
override fun getBody(): ByteArray {
return Json.encodeToString(data).toByteArray()
}
override fun getHeaders(): MutableMap<String, String> {
val headers = super.getHeaders()
headers["version"] = "1"
return headers
}
}

View File

@@ -1,42 +0,0 @@
package ru.n08i40k.polytechnic.next.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import ru.n08i40k.polytechnic.next.service.CurrentLessonViewService
import ru.n08i40k.polytechnic.next.work.ScheduleClvAlarm
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
println("Hi from AlarmReceiver")
if (intent == null) {
println("No intend provided!")
return
}
if (context == null) {
println("No context provided!")
return
}
println(intent.action)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val rescheduleRequest = OneTimeWorkRequestBuilder<ScheduleClvAlarm>()
.setConstraints(constraints)
.build()
WorkManager
.getInstance(context)
.enqueue(rescheduleRequest)
CurrentLessonViewService.startService(context.applicationContext)
}
}

View File

@@ -1,45 +0,0 @@
package ru.n08i40k.polytechnic.next.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import ru.n08i40k.polytechnic.next.work.ScheduleClvAlarm
import java.util.logging.Logger
class BootCompletedBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val logger = Logger.getLogger("BootCompletedBroadcastReceiver")
if (context == null) {
logger.warning("No context provided!")
return
}
if (intent == null) {
logger.warning("No intend provided!")
return
}
if (intent.action != "android.intent.action.BOOT_COMPLETED") {
logger.warning("Strange intent action passed!")
logger.warning(intent.action)
return
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<ScheduleClvAlarm>()
.setConstraints(constraints)
.build()
WorkManager
.getInstance(context)
.enqueue(request)
}
}

View File

@@ -3,25 +3,24 @@ package ru.n08i40k.polytechnic.next.service
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.core.content.ContextCompat.startForegroundService
import ru.n08i40k.polytechnic.next.NotificationChannels
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.model.Day
import ru.n08i40k.polytechnic.next.model.Group
import ru.n08i40k.polytechnic.next.model.Lesson
import ru.n08i40k.polytechnic.next.utils.fmtAsClock
import ru.n08i40k.polytechnic.next.utils.getDayMinutes
import ru.n08i40k.polytechnic.next.work.StartClvService
import java.util.Calendar
import java.util.logging.Logger
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
class CurrentLessonViewService : Service() {
@@ -30,19 +29,31 @@ class CurrentLessonViewService : Service() {
private const val NOTIFICATION_END_ID = NOTIFICATION_STATUS_ID + 1
private const val UPDATE_INTERVAL = 60_000L
fun startService(appContext: Context) {
if (!(appContext as PolytechnicApplication).hasNotificationPermission())
suspend fun startService(application: PolytechnicApplication) {
if (!application.hasNotificationPermission())
return
if (Calendar.getInstance()
.get(Calendar.HOUR_OF_DAY) * 60 + Calendar.getInstance()
.get(Calendar.MINUTE) < 420)
val schedule =
application
.container
.scheduleRepository
.getGroup()
if (schedule is MyResult.Failure)
return
val request = OneTimeWorkRequestBuilder<StartClvService>()
.build()
val intent = Intent(application, CurrentLessonViewService::class.java)
.apply {
putExtra("group", (schedule as MyResult.Success).data)
}
WorkManager.getInstance(appContext).enqueue(request)
application.stopService(
Intent(
application,
CurrentLessonViewService::class.java
)
)
startForegroundService(application, intent)
}
}
@@ -51,7 +62,10 @@ class CurrentLessonViewService : Service() {
private val handler = Handler(Looper.getMainLooper())
private val updateRunnable = object : Runnable {
override fun run() {
val logger = Logger.getLogger("CLV.updateRunnable")
if (day == null || day!!.nonNullIndices.isEmpty()) {
logger.warning("Stopping, because day is null or empty!")
stopSelf()
return
}
@@ -74,7 +88,7 @@ class CurrentLessonViewService : Service() {
if (currentLesson == null && nextLesson == null) {
val notification = NotificationCompat
.Builder(applicationContext, NotificationChannels.LESSON_VIEW)
.setSmallIcon(R.drawable.logo)
.setSmallIcon(R.drawable.schedule)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentTitle(getString(R.string.lessons_end_notification_title))
.setContentText(getString(R.string.lessons_end_notification_description))
@@ -146,7 +160,7 @@ class CurrentLessonViewService : Service() {
): Notification {
return NotificationCompat
.Builder(applicationContext, NotificationChannels.LESSON_VIEW)
.setSmallIcon(R.drawable.logo)
.setSmallIcon(R.drawable.schedule)
.setContentTitle(title ?: getString(R.string.lesson_notification_title))
.setContentText(description ?: getString(R.string.lesson_notification_description))
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -159,26 +173,29 @@ class CurrentLessonViewService : Service() {
return getSystemService(NOTIFICATION_SERVICE) as NotificationManager
}
private fun updateSchedule(group: Group?): Boolean {
private fun updateSchedule(group: Group?) {
val logger = Logger.getLogger("CLV")
if (group == null) {
logger.warning("Stopping, because group is null")
stopSelf()
return false
return
}
val currentDay = group.current
if (currentDay == null || currentDay.nonNullIndices.isEmpty()) {
logger.warning("Stopping, because current day is null or empty")
stopSelf()
return false
return
}
if (this.day == null) {
val nowMinutes = Calendar.getInstance().getDayMinutes()
if (currentDay.first!!.time.start - nowMinutes > 30
|| currentDay.last!!.time.end < nowMinutes) {
stopSelf()
return false
}
val nowMinutes = Calendar.getInstance().getDayMinutes()
if (nowMinutes < ((5 * 60) + 30)
|| currentDay.last!!.time.end < nowMinutes
) {
logger.warning("Stopping, because service started outside of acceptable time range!")
stopSelf()
return
}
this.day = currentDay
@@ -186,7 +203,7 @@ class CurrentLessonViewService : Service() {
this.handler.removeCallbacks(updateRunnable)
updateRunnable.run()
return true
logger.info("Running...")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -201,16 +218,14 @@ class CurrentLessonViewService : Service() {
val notification = createNotification()
startForeground(NOTIFICATION_STATUS_ID, notification)
if (!updateSchedule(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("group", Group::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("group")
}
)
updateSchedule(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("group", Group::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("group")
}
)
updateRunnable.run()
return START_STICKY
}

View File

@@ -17,13 +17,19 @@ import androidx.work.WorkManager
import androidx.work.workDataOf
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.NotificationChannels
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.work.FcmSetTokenWorker
import ru.n08i40k.polytechnic.next.work.ScheduleClvAlarm
import java.time.Duration
class MyFirebaseMessagingService : FirebaseMessagingService() {
val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onNewToken(token: String) {
super.onNewToken(token)
@@ -79,7 +85,6 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
}
notify(id.hashCode(), notification)
CurrentLessonViewService.startService(applicationContext)
}
}
@@ -90,7 +95,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
"schedule-update" -> {
sendNotification(
NotificationChannels.SCHEDULE_UPDATE,
R.drawable.logo,
R.drawable.schedule,
getString(R.string.schedule_update_title),
getString(
if (message.data["replaced"] == "true")
@@ -101,24 +106,19 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
NotificationCompat.PRIORITY_DEFAULT,
message.data["etag"]
)
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<ScheduleClvAlarm>()
.setConstraints(constraints)
.build()
WorkManager
.getInstance(applicationContext)
.enqueue(request)
"lessons-start" -> {
scope.launch {
CurrentLessonViewService
.startService(applicationContext as PolytechnicApplication)
}
}
"app-update" -> {
sendNotification(
NotificationChannels.APP_UPDATE,
R.drawable.logo,
R.drawable.download,
getString(R.string.app_update_title, message.data["version"]),
getString(R.string.app_update_description),
NotificationCompat.PRIORITY_DEFAULT,

View File

@@ -36,7 +36,6 @@ import kotlinx.coroutines.launch
import ru.n08i40k.polytechnic.next.NotificationChannels
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import ru.n08i40k.polytechnic.next.work.FcmUpdateCallbackWorker
import ru.n08i40k.polytechnic.next.work.LinkUpdateWorker
@@ -101,21 +100,6 @@ class MainActivity : ComponentActivity() {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
private fun scheduleAlarm() {
lifecycleScope.launch {
val schedule = (applicationContext as PolytechnicApplication)
.container
.scheduleRepository
.getGroup()
if (schedule is MyResult.Failure)
return@launch
(applicationContext as PolytechnicApplication)
.scheduleClvService((schedule as MyResult.Success).data)
}
}
fun scheduleLinkUpdate(intervalInMinutes: Long) {
val tag = "schedule-update"
@@ -182,7 +166,6 @@ class MainActivity : ComponentActivity() {
setupFirebaseConfig()
handleUpdate()
scheduleAlarm()
setContent {
Box(Modifier.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))) {

View File

@@ -1,504 +1,113 @@
package ru.n08i40k.polytechnic.next.ui.auth
import android.content.Context
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.android.volley.AuthFailureError
import com.android.volley.ClientError
import com.android.volley.TimeoutError
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.model.UserRole.Companion.AcceptableUserRoles
import ru.n08i40k.polytechnic.next.network.request.auth.AuthLogin
import ru.n08i40k.polytechnic.next.network.request.auth.AuthRegister
import ru.n08i40k.polytechnic.next.network.request.profile.ProfileMe
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
@Preview(showBackground = true)
@Composable
private fun LoginForm(
mutableVisible: MutableState<Boolean> = mutableStateOf(true),
navController: NavHostController = rememberNavController(),
scope: CoroutineScope = rememberCoroutineScope(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val mutableIsLoading = remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var visible by mutableVisible
Text(
text = stringResource(R.string.login_title),
modifier = Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.size(10.dp))
val mutableUsernameError = remember { mutableStateOf(false) }
val mutablePasswordError = remember { mutableStateOf(false) }
var usernameError by mutableUsernameError
var passwordError by mutablePasswordError
OutlinedTextField(
value = username,
singleLine = true,
onValueChange = {
username = it
usernameError = false
},
label = { Text(stringResource(R.string.username)) },
isError = usernameError
)
OutlinedTextField(
value = password,
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
onValueChange = {
passwordError = false
password = it
},
label = { Text(stringResource(R.string.password)) },
isError = passwordError
)
TextButton(onClick = { visible = false }) {
Text(text = stringResource(R.string.not_registered))
}
Button(
enabled = !mutableIsLoading.value,
onClick = {
if (username.length < 4) usernameError = true
if (password.isEmpty()) passwordError = true
if (usernameError || passwordError) return@Button
tryLogin(
username,
password,
mutableUsernameError,
mutablePasswordError,
mutableIsLoading,
context,
snackbarHostState,
scope,
navController
)
focusManager.clearFocus()
}
) {
Text(
text = stringResource(R.string.login),
style = MaterialTheme.typography.bodyLarge
)
}
}
@Preview(showBackground = true)
@Composable
private fun RegisterForm(
mutableVisible: MutableState<Boolean> = mutableStateOf(true),
navController: NavHostController = rememberNavController(),
scope: CoroutineScope = rememberCoroutineScope(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val mutableIsLoading = remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var group by remember { mutableStateOf("") }
val mutableRole = remember { mutableStateOf(UserRole.STUDENT) }
var visible by mutableVisible
Text(
text = stringResource(R.string.register_title),
modifier = Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.size(10.dp))
val mutableUsernameError = remember { mutableStateOf(false) }
var usernameError by mutableUsernameError
var passwordError by remember { mutableStateOf(false) }
val mutableGroupError = remember { mutableStateOf(false) }
var groupError by mutableGroupError
OutlinedTextField(
value = username,
singleLine = true,
onValueChange = {
username = it
usernameError = false
},
label = { Text(stringResource(R.string.username)) },
isError = usernameError
)
OutlinedTextField(
value = password,
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
onValueChange = {
passwordError = false
password = it
},
label = { Text(stringResource(R.string.password)) },
isError = passwordError
)
OutlinedTextField(
value = group,
singleLine = true,
onValueChange = {
groupError = false
group = it
},
label = { Text(stringResource(R.string.group)) },
isError = groupError
)
RoleSelector(mutableRole)
TextButton(onClick = { visible = false }) {
Text(text = stringResource(R.string.already_registered))
}
Button(
enabled = !mutableIsLoading.value,
onClick = {
if (username.length < 4) usernameError = true
if (password.isEmpty()) passwordError = true
if (group.isEmpty()) groupError = true
if (usernameError || passwordError || groupError) return@Button
tryRegister(
username,
password,
group,
mutableRole.value,
mutableUsernameError,
mutableGroupError,
mutableIsLoading,
context,
snackbarHostState,
scope,
navController
)
focusManager.clearFocus()
}
) {
Text(
text = stringResource(R.string.register),
style = MaterialTheme.typography.bodyLarge
)
}
}
@Preview(showBackground = true)
@Composable
fun AuthForm(
mutableIsLogin: MutableState<Boolean> = mutableStateOf(true),
navController: NavHostController = rememberNavController(),
scope: CoroutineScope = rememberCoroutineScope(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
appNavController: NavHostController = rememberNavController(),
onPendingSnackbar: (String) -> Unit = {},
) {
var isLogin by mutableIsLogin
val navController = rememberNavController()
val mutableVisible = remember { mutableStateOf(true) }
var visible by mutableVisible
val modifier = Modifier.fillMaxSize()
val animatedAlpha by animateFloatAsState(
targetValue = if (visible) 1.0f else 0f, label = "alpha"
)
Column(
modifier = Modifier
.padding(10.dp)
.graphicsLayer {
alpha = animatedAlpha
if (alpha == 0F) {
if (!visible) isLogin = isLogin.not()
visible = true
}
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (isLogin)
LoginForm(mutableVisible, navController, scope, snackbarHostState)
else
RegisterForm(mutableVisible, navController, scope, snackbarHostState)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
fun RoleSelector(mutableRole: MutableState<UserRole> = mutableStateOf(UserRole.STUDENT)) {
var expanded by remember { mutableStateOf(false) }
var role by mutableRole
Box(
modifier = Modifier.wrapContentSize()
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
TextField(
label = { Text(stringResource(R.string.role)) },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable),
value = stringResource(role.stringId),
leadingIcon = {
Icon(
imageVector = role.icon,
contentDescription = "role icon"
)
},
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }) {
AcceptableUserRoles.forEach {
DropdownMenuItem(
text = { Text(stringResource(it.stringId)) },
onClick = {
role = it
expanded = false
}
)
}
}
}
}
}
fun tryLogin(
// data
username: String,
password: String,
// errors
mutableUsernameError: MutableState<Boolean>,
mutablePasswordError: MutableState<Boolean>,
// additional
mutableIsLoading: MutableState<Boolean>,
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
navController: NavHostController
) {
var isLoading by mutableIsLoading
isLoading = true
AuthLogin(AuthLogin.RequestDto(username, password), context, {
runBlocking {
context.settingsDataStore.updateData { currentSettings ->
currentSettings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.build()
}
}
ProfileMe(context, {
scope.launch { snackbarHostState.showSnackbar("Cool!") }
runBlocking {
context.settingsDataStore.updateData { currentSettings ->
currentSettings
.toBuilder()
.setGroup(it.group)
.build()
}
}
navController.navigate("main")
}, null).send()
}, {
isLoading = false
if (it is TimeoutError) {
scope.launch { snackbarHostState.showSnackbar("Request timed out!") }
}
if (it is ClientError && it.networkResponse.statusCode == 400) scope.launch {
snackbarHostState.showSnackbar("Request schema not identical!")
}
if (it is AuthFailureError) scope.launch {
mutableUsernameError.value = true
mutablePasswordError.value = true
snackbarHostState.showSnackbar("Invalid credentials!")
}
it.printStackTrace()
}).send()
}
fun tryRegister(
// data
username: String,
password: String,
group: String,
role: UserRole,
// errors
mutableUsernameError: MutableState<Boolean>,
mutableGroupError: MutableState<Boolean>,
// additional
mutableIsLoading: MutableState<Boolean>,
context: Context,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
navController: NavHostController
) {
var isLoading by mutableIsLoading
isLoading = true
AuthRegister(
AuthRegister.RequestDto(
username,
password,
group,
role
), context, {
scope.launch { snackbarHostState.showSnackbar("Cool!") }
runBlocking {
context.settingsDataStore.updateData { currentSettings ->
currentSettings.toBuilder().setUserId(it.id)
.setAccessToken(it.accessToken).build()
}
}
runBlocking {
context.settingsDataStore.updateData { currentSettings ->
currentSettings
.toBuilder()
.setGroup(group)
.build()
}
}
navController.navigate("main")
}, {
isLoading = false
if (it is TimeoutError) {
scope.launch { snackbarHostState.showSnackbar("Request timed out!") }
}
if (it is ClientError) scope.launch {
val statusCode = it.networkResponse.statusCode
when (statusCode) {
400 -> snackbarHostState.showSnackbar("Request schema not identical!")
409 -> {
mutableUsernameError.value = true
snackbarHostState.showSnackbar("User already exists!")
}
404 -> {
mutableGroupError.value = true
snackbarHostState.showSnackbar("Group doesn't exists!")
}
}
}
if (it is AuthFailureError) scope.launch {
snackbarHostState.showSnackbar(
"Invalid credentials!"
NavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
startDestination = "sign-in",
enterTransition = {
slideIn(
animationSpec = tween(
400,
delayMillis = 250,
easing = LinearOutSlowInEasing
)
) { fullSize -> IntOffset(0, fullSize.height / 16) } + fadeIn(
animationSpec = tween(
400,
delayMillis = 250,
easing = LinearOutSlowInEasing
)
)
},
exitTransition = {
fadeOut(
animationSpec = tween(
250,
easing = FastOutSlowInEasing
)
)
},
) {
composable("sign-in") {
Row(
modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Card(border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)) {
LoginForm(appNavController, navController, onPendingSnackbar)
}
}
}
composable("sign-up") {
Row(
modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Card(border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)) {
RegisterForm(appNavController, navController, onPendingSnackbar)
}
}
}
}
it.printStackTrace()
}).send()
}
@Preview(showBackground = true)
@Composable
fun AuthScreen(navController: NavHostController = rememberNavController()) {
fun AuthScreen(appNavController: NavHostController = rememberNavController()) {
val context = LocalContext.current
LaunchedEffect(Unit) {
@@ -506,14 +115,20 @@ fun AuthScreen(navController: NavHostController = rememberNavController()) {
context.settingsDataStore.data.map { settings -> settings.accessToken }.first()
}
if (accessToken.isNotEmpty()) navController.navigate("main")
if (accessToken.isNotEmpty()) {
appNavController.navigate("main") {
popUpTo("auth") { inclusive = true }
}
}
}
val mutableIsLogin = remember { mutableStateOf(true) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val onPendingSnackbar: (String) -> Unit = {
scope.launch { snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long) }
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
@@ -525,14 +140,10 @@ fun AuthScreen(navController: NavHostController = rememberNavController()) {
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
Card {
AuthForm(
mutableIsLogin,
navController,
scope,
snackbarHostState
)
}
AuthForm(
appNavController,
onPendingSnackbar
)
}
})
}

View File

@@ -0,0 +1,143 @@
package ru.n08i40k.polytechnic.next.ui.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import ru.n08i40k.polytechnic.next.R
@Preview(showBackground = true)
@Composable
internal fun LoginForm(
appNavController: NavHostController = rememberNavController(),
navController: NavHostController = rememberNavController(),
onPendingSnackbar: (String) -> Unit = {}
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
var loading by remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var usernameError by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf(false) }
val onClick = fun() {
focusManager.clearFocus()
if (username.length < 4) usernameError = true
if (password.isEmpty()) passwordError = true
if (usernameError || passwordError) return
loading = true
trySignIn(
context,
username,
password,
{
loading = false
val stringRes = when (it) {
SignInError.INVALID_CREDENTIALS -> {
usernameError = true
passwordError = true
R.string.invalid_credentials
}
SignInError.TIMED_OUT -> R.string.timed_out
SignInError.NO_CONNECTION -> R.string.no_connection
SignInError.APPLICATION_TOO_OLD -> R.string.app_too_old
SignInError.UNKNOWN -> R.string.unknown_error
}
onPendingSnackbar(context.getString(stringRes))
},
{
loading = false
appNavController.navigate("main") {
popUpTo("auth") { inclusive = true }
}
}
)
}
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.sign_in_title),
modifier = Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.size(10.dp))
OutlinedTextField(
value = username,
singleLine = true,
onValueChange = {
username = it
usernameError = false
},
label = { Text(stringResource(R.string.username)) },
isError = usernameError
)
OutlinedTextField(
value = password,
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
onValueChange = {
passwordError = false
password = it
},
label = { Text(stringResource(R.string.password)) },
isError = passwordError
)
TextButton(onClick = { navController.navigate("sign-up") }) {
Text(text = stringResource(R.string.not_registered))
}
Button(
enabled = !loading && !(usernameError || passwordError),
onClick = onClick
) {
Text(
text = stringResource(R.string.proceed),
style = MaterialTheme.typography.bodyLarge
)
}
}
}

View File

@@ -0,0 +1,166 @@
package ru.n08i40k.polytechnic.next.ui.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.ui.widgets.GroupSelector
import ru.n08i40k.polytechnic.next.ui.widgets.RoleSelector
@Preview(showBackground = true)
@Composable
internal fun RegisterForm(
appNavController: NavHostController = rememberNavController(),
navController: NavHostController = rememberNavController(),
onPendingSnackbar: (String) -> Unit = {}
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
var loading by remember { mutableStateOf(false) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var group by remember { mutableStateOf<String?>(null) }
var role by remember { mutableStateOf(UserRole.STUDENT) }
var usernameError by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf(false) }
var groupError by remember { mutableStateOf(false) }
val onClick = fun() {
focusManager.clearFocus()
if (username.length < 4) usernameError = true
if (password.isEmpty()) passwordError = true
if (usernameError || passwordError || groupError) return
loading = true
trySignUp(
context,
username,
password,
group!!,
role,
{
loading = false
val stringRes = when (it) {
SignUpError.UNKNOWN -> R.string.unknown_error
SignUpError.ALREADY_EXISTS -> R.string.already_exists
SignUpError.APPLICATION_TOO_OLD -> R.string.app_too_old
SignUpError.TIMED_OUT -> R.string.timed_out
SignUpError.NO_CONNECTION -> R.string.no_connection
SignUpError.GROUP_DOES_NOT_EXISTS -> R.string.group_does_not_exists
}
onPendingSnackbar(context.getString(stringRes))
},
{
loading = false
appNavController.navigate("main") {
popUpTo("auth") { inclusive = true }
}
}
)
}
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.sign_up_title),
modifier = Modifier.padding(10.dp),
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.ExtraBold
)
Spacer(modifier = Modifier.size(10.dp))
OutlinedTextField(
value = username,
singleLine = true,
onValueChange = {
username = it
usernameError = false
},
label = { Text(stringResource(R.string.username)) },
isError = usernameError,
readOnly = loading
)
OutlinedTextField(
value = password,
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
onValueChange = {
passwordError = false
password = it
},
label = { Text(stringResource(R.string.password)) },
isError = passwordError,
readOnly = loading
)
Spacer(modifier = Modifier.size(10.dp))
GroupSelector(
value = group,
isError = groupError,
readOnly = loading
) {
groupError = false
group = it
}
Spacer(modifier = Modifier.size(10.dp))
RoleSelector(
value = role,
isError = false,
readOnly = loading
) { role = it }
TextButton(onClick = { navController.navigate("sign-in") }) {
Text(text = stringResource(R.string.already_registered))
}
Button(
enabled = !loading && group != null && !(usernameError || passwordError || groupError),
onClick = onClick
) {
Text(
text = stringResource(R.string.proceed),
style = MaterialTheme.typography.bodyLarge
)
}
}
}

View File

@@ -0,0 +1,68 @@
package ru.n08i40k.polytechnic.next.ui.auth
import android.content.Context
import com.android.volley.AuthFailureError
import com.android.volley.ClientError
import com.android.volley.NoConnectionError
import com.android.volley.TimeoutError
import com.google.firebase.logger.Logger
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignIn
import ru.n08i40k.polytechnic.next.network.unwrapException
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import java.util.concurrent.TimeoutException
internal enum class SignInError {
INVALID_CREDENTIALS,
TIMED_OUT,
NO_CONNECTION,
APPLICATION_TOO_OLD,
UNKNOWN
}
internal fun trySignIn(
context: Context,
username: String,
password: String,
onError: (SignInError) -> Unit,
onSuccess: () -> Unit,
) {
AuthSignIn(AuthSignIn.RequestDto(username, password), context, {
runBlocking {
context.settingsDataStore.updateData { currentSettings ->
currentSettings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.setGroup(it.group)
.build()
}
}
onSuccess()
}, {
val error = when (val exception = unwrapException(it)) {
is TimeoutException -> SignInError.TIMED_OUT
is TimeoutError -> SignInError.TIMED_OUT
is NoConnectionError -> SignInError.NO_CONNECTION
is AuthFailureError -> SignInError.INVALID_CREDENTIALS
is ClientError -> {
if (exception.networkResponse.statusCode == 400)
SignInError.APPLICATION_TOO_OLD
else
SignInError.UNKNOWN
}
else -> SignInError.UNKNOWN
}
if (error == SignInError.UNKNOWN) {
Logger.getLogger("tryLogin")
.error("Unknown exception while trying to login!", it)
}
onError(error)
}).send()
}

View File

@@ -0,0 +1,78 @@
package ru.n08i40k.polytechnic.next.ui.auth
import android.content.Context
import com.android.volley.ClientError
import com.android.volley.NoConnectionError
import com.android.volley.TimeoutError
import com.google.firebase.logger.Logger
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.network.request.auth.AuthSignUp
import ru.n08i40k.polytechnic.next.network.unwrapException
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import java.util.concurrent.TimeoutException
internal enum class SignUpError {
ALREADY_EXISTS,
GROUP_DOES_NOT_EXISTS,
TIMED_OUT,
NO_CONNECTION,
APPLICATION_TOO_OLD,
UNKNOWN
}
internal fun trySignUp(
context: Context,
username: String,
password: String,
group: String,
role: UserRole,
onError: (SignUpError) -> Unit,
onSuccess: () -> Unit,
) {
AuthSignUp(
AuthSignUp.RequestDto(
username,
password,
group,
role
), context, {
runBlocking {
context.settingsDataStore.updateData { currentSettings ->
currentSettings
.toBuilder()
.setUserId(it.id)
.setAccessToken(it.accessToken)
.setGroup(group)
.build()
}
}
onSuccess()
}, {
val error = when (val exception = unwrapException(it)) {
is TimeoutException -> SignUpError.TIMED_OUT
is NoConnectionError -> SignUpError.NO_CONNECTION
is TimeoutError -> SignUpError.UNKNOWN
is ClientError -> {
when (exception.networkResponse.statusCode) {
400 -> SignUpError.APPLICATION_TOO_OLD
404 -> SignUpError.GROUP_DOES_NOT_EXISTS
409 -> SignUpError.ALREADY_EXISTS
else -> SignUpError.UNKNOWN
}
}
else -> SignUpError.UNKNOWN
}
if (error == SignUpError.UNKNOWN) {
Logger.getLogger("tryRegister")
.error("Unknown exception while trying to register!", it)
}
onError(error)
}).send()
}

View File

@@ -1,3 +1,3 @@
package ru.n08i40k.polytechnic.next.ui.icons
object AppIcons
object AppIcons

View File

@@ -6,4 +6,4 @@ object FilledGroup
@Suppress("UnusedReceiverParameter")
val AppIcons.Filled: FilledGroup
get() = FilledGroup
get() = FilledGroup

View File

@@ -1,3 +1,5 @@
@file:Suppress("ObjectPropertyName", "UnusedReceiverParameter")
package ru.n08i40k.polytechnic.next.ui.icons.appicons.filled
import androidx.compose.ui.graphics.Color
@@ -11,14 +13,13 @@ import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.appicons.FilledGroup
@Suppress("UnusedReceiverParameter")
val FilledGroup.Download: ImageVector
get() {
if (_download != null) {
return _download!!
}
_download = Builder(
name = "Download", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
name = "Download", defaultWidth = 24.dp, defaultHeight = 24.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f
).apply {
path(
@@ -55,5 +56,4 @@ val FilledGroup.Download: ImageVector
return _download!!
}
@Suppress("ObjectPropertyName")
private var _download: ImageVector? = null

View File

@@ -0,0 +1,64 @@
@file:Suppress("ObjectPropertyName", "UnusedReceiverParameter")
package ru.n08i40k.polytechnic.next.ui.icons.appicons.filled
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.EvenOdd
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.appicons.FilledGroup
val FilledGroup.Error: ImageVector
get() {
if (_error != null) {
return _error!!
}
_error = Builder(
name = "Error", defaultWidth = 24.dp, defaultHeight = 24.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = EvenOdd
) {
moveTo(1.25f, 8.0f)
curveTo(1.25f, 4.2721f, 4.2721f, 1.25f, 8.0f, 1.25f)
lineTo(16.0f, 1.25f)
curveTo(19.7279f, 1.25f, 22.75f, 4.2721f, 22.75f, 8.0f)
lineTo(22.75f, 16.0f)
curveTo(22.75f, 19.7279f, 19.7279f, 22.75f, 16.0f, 22.75f)
lineTo(8.0f, 22.75f)
curveTo(4.2721f, 22.75f, 1.25f, 19.7279f, 1.25f, 16.0f)
lineTo(1.25f, 8.0f)
close()
moveTo(8.4697f, 8.4697f)
curveTo(8.7626f, 8.1768f, 9.2374f, 8.1768f, 9.5303f, 8.4697f)
lineTo(12.0f, 10.9393f)
lineTo(14.4697f, 8.4697f)
curveTo(14.7626f, 8.1768f, 15.2374f, 8.1768f, 15.5303f, 8.4697f)
curveTo(15.8232f, 8.7626f, 15.8232f, 9.2374f, 15.5303f, 9.5303f)
lineTo(13.0606f, 12.0f)
lineTo(15.5303f, 14.4697f)
curveTo(15.8232f, 14.7626f, 15.8232f, 15.2374f, 15.5303f, 15.5303f)
curveTo(15.2374f, 15.8232f, 14.7625f, 15.8232f, 14.4696f, 15.5303f)
lineTo(12.0f, 13.0606f)
lineTo(9.5303f, 15.5303f)
curveTo(9.2374f, 15.8232f, 8.7626f, 15.8232f, 8.4697f, 15.5303f)
curveTo(8.1768f, 15.2374f, 8.1768f, 14.7625f, 8.4697f, 14.4696f)
lineTo(10.9393f, 12.0f)
lineTo(8.4697f, 9.5303f)
curveTo(8.1768f, 9.2374f, 8.1768f, 8.7626f, 8.4697f, 8.4697f)
close()
}
}
.build()
return _error!!
}
private var _error: ImageVector? = null

View File

@@ -0,0 +1,49 @@
@file:Suppress("ObjectPropertyName", "UnusedReceiverParameter")
package ru.n08i40k.polytechnic.next.ui.icons.appicons.filled
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.appicons.FilledGroup
val FilledGroup.Info: ImageVector
get() {
if (_info != null) {
return _info!!
}
_info = Builder(
name = "Info", defaultWidth = 24.dp, defaultHeight = 24.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero
) {
moveTo(12.0f, 2.0f)
arcTo(10.0f, 10.0f, 0.0f, true, false, 22.0f, 12.0f)
arcTo(10.0f, 10.0f, 0.0f, false, false, 12.0f, 2.0f)
close()
moveTo(13.0f, 17.0f)
arcToRelative(1.0f, 1.0f, 0.0f, false, true, -2.0f, 0.0f)
lineTo(11.0f, 11.0f)
arcToRelative(1.0f, 1.0f, 0.0f, false, true, 2.0f, 0.0f)
close()
moveTo(12.0f, 8.0f)
arcToRelative(1.5f, 1.5f, 0.0f, true, true, 1.5f, -1.5f)
arcTo(1.5f, 1.5f, 0.0f, false, true, 12.0f, 8.0f)
close()
}
}
.build()
return _info!!
}
private var _info: ImageVector? = null

View File

@@ -1,3 +1,5 @@
@file:Suppress("ObjectPropertyName", "UnusedReceiverParameter")
package ru.n08i40k.polytechnic.next.ui.icons.appicons.filled
import androidx.compose.ui.graphics.Color
@@ -11,14 +13,13 @@ import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.appicons.FilledGroup
@Suppress("UnusedReceiverParameter")
val FilledGroup.Telegram: ImageVector
get() {
if (_telegram != null) {
return _telegram!!
}
_telegram = Builder(
name = "Telegram", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
name = "Telegram", defaultWidth = 24.dp, defaultHeight = 24.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f
).apply {
path(
@@ -49,5 +50,4 @@ val FilledGroup.Telegram: ImageVector
return _telegram!!
}
@Suppress("ObjectPropertyName")
private var _telegram: ImageVector? = null

View File

@@ -0,0 +1,59 @@
@file:Suppress("ObjectPropertyName", "UnusedReceiverParameter")
package ru.n08i40k.polytechnic.next.ui.icons.appicons.filled
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.EvenOdd
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.appicons.FilledGroup
val FilledGroup.Warning: ImageVector
get() {
if (_warning != null) {
return _warning!!
}
_warning = Builder(
name = "Warning", defaultWidth = 24.dp, defaultHeight = 24.dp,
viewportWidth = 512.0f, viewportHeight = 512.0f
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = SolidColor(Color(0x00000000)),
strokeLineWidth = 1.0f, strokeLineCap = Butt, strokeLineJoin = Miter,
strokeLineMiter = 4.0f, pathFillType = EvenOdd
) {
moveTo(278.313f, 48.296f)
curveTo(284.928f, 52.075f, 290.41f, 57.557f, 294.189f, 64.172f)
lineTo(476.667f, 383.508f)
curveTo(488.358f, 403.967f, 481.25f, 430.03f, 460.791f, 441.722f)
curveTo(454.344f, 445.405f, 447.047f, 447.343f, 439.622f, 447.343f)
lineTo(74.667f, 447.343f)
curveTo(51.103f, 447.343f, 32.0f, 428.241f, 32.0f, 404.677f)
curveTo(32.0f, 397.251f, 33.938f, 389.955f, 37.622f, 383.508f)
lineTo(220.099f, 64.172f)
curveTo(231.79f, 43.713f, 257.854f, 36.605f, 278.313f, 48.296f)
close()
moveTo(256.0f, 314.667f)
curveTo(240.762f, 314.667f, 229.333f, 325.931f, 229.333f, 340.949f)
curveTo(229.333f, 356.651f, 240.416f, 367.915f, 256.0f, 367.915f)
curveTo(271.238f, 367.915f, 282.667f, 356.651f, 282.667f, 341.291f)
curveTo(282.667f, 325.931f, 271.238f, 314.667f, 256.0f, 314.667f)
close()
moveTo(277.333f, 149.333f)
lineTo(234.667f, 149.333f)
lineTo(234.667f, 277.333f)
lineTo(277.333f, 277.333f)
lineTo(277.333f, 149.333f)
close()
}
}
.build()
return _warning!!
}
private var _warning: ImageVector? = null

View File

@@ -116,21 +116,21 @@ private fun NavHostContainer(
)
)
},
builder = {
composable("profile") {
ProfileScreen(LocalContext.current.profileViewModel!!) { context.profileViewModel!!.refreshProfile() }
}
) {
composable("profile") {
ProfileScreen(LocalContext.current.profileViewModel!!) { context.profileViewModel!!.refreshProfile() }
}
composable("schedule") {
ScheduleScreen(scheduleViewModel) { scheduleViewModel.refreshGroup() }
}
composable("schedule") {
ScheduleScreen(scheduleViewModel) { scheduleViewModel.refreshGroup() }
}
if (scheduleReplacerViewModel != null) {
composable("replacer") {
ReplacerScreen(scheduleReplacerViewModel) { scheduleReplacerViewModel.refresh() }
}
if (scheduleReplacerViewModel != null) {
composable("replacer") {
ReplacerScreen(scheduleReplacerViewModel) { scheduleReplacerViewModel.refresh() }
}
})
}
}
}
private fun openLink(context: Context, link: String) {
@@ -260,7 +260,11 @@ fun MainScreen(
viewModel(
factory = ProfileViewModel.provideFactory(
profileRepository = mainViewModel.appContainer.profileRepository,
onUnauthorized = { appNavController.navigate("auth") })
onUnauthorized = {
appNavController.navigate("auth") {
popUpTo("main") { inclusive = true }
}
})
)
LocalContext.current.profileViewModel = profileViewModel

View File

@@ -1,26 +1,14 @@
package ru.n08i40k.polytechnic.next.ui.main.profile
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -36,7 +24,7 @@ import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
import ru.n08i40k.polytechnic.next.model.Profile
import ru.n08i40k.polytechnic.next.network.request.profile.ProfileChangeGroup
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetGroupNames
import ru.n08i40k.polytechnic.next.ui.widgets.GroupSelector
private enum class ChangeGroupError {
NOT_EXISTS
@@ -57,80 +45,6 @@ private fun tryChangeGroup(
}).send()
}
@Composable
private fun getGroups(context: Context): ArrayList<String> {
val groupPlaceholder = stringResource(R.string.loading)
val groups = remember { arrayListOf(groupPlaceholder) }
LaunchedEffect(groups) {
ScheduleGetGroupNames(context, {
groups.clear()
groups.addAll(it.names)
}, {
throw it
}).send()
}
return groups
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
private fun GroupSelector(
value: String = "ИС-214/24",
onValueChange: (String) -> Unit = {},
isError: Boolean = false,
readOnly: Boolean = false,
) {
var expanded by remember { mutableStateOf(false) }
Box(
modifier = Modifier.wrapContentSize()
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !readOnly && !expanded
}
) {
TextField(
label = { Text(stringResource(R.string.group)) },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable),
value = value,
leadingIcon = {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "group"
)
},
onValueChange = {},
isError = isError,
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }
)
val context = LocalContext.current
val groups = getGroups(context)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }) {
groups.forEach {
DropdownMenuItem(
text = { Text(it) },
onClick = {
onValueChange(it)
expanded = false
}
)
}
}
}
}
}
@Preview(showBackground = true)
@Composable
internal fun ChangeGroupDialog(
@@ -141,7 +55,7 @@ internal fun ChangeGroupDialog(
) {
Dialog(onDismissRequest = onDismiss) {
Card {
var group by remember { mutableStateOf("ИС-214/23") }
var group by remember { mutableStateOf<String?>(profile.group) }
var groupError by remember { mutableStateOf(false) }
var processing by remember { mutableStateOf(false) }
@@ -165,7 +79,7 @@ internal fun ChangeGroupDialog(
tryChangeGroup(
context = context,
group = group,
group = group!!,
onError = {
when (it) {
ChangeGroupError.NOT_EXISTS -> {
@@ -178,7 +92,7 @@ internal fun ChangeGroupDialog(
onSuccess = onChange
)
},
enabled = !(groupError || processing)
enabled = !(groupError || processing) && group != null
) {
Text(stringResource(R.string.change_group))
}

View File

@@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
import ru.n08i40k.polytechnic.next.model.Profile
@@ -187,6 +188,10 @@ internal fun ProfileCard(profile: Profile = FakeProfileRepository.exampleProfile
context.settingsDataStore.updateData {
it.toBuilder().setGroup(group).build()
}
(context.applicationContext as PolytechnicApplication)
.container
.networkCacheRepository
.clear()
}
context.profileViewModel!!.refreshProfile {
scheduleViewModel.refreshGroup()

View File

@@ -14,7 +14,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MockAppContainer
import ru.n08i40k.polytechnic.next.ui.LoadingContent
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState
import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel

View File

@@ -43,7 +43,7 @@ import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MockAppContainer
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.impl.FakeScheduleReplacerRepository
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
import ru.n08i40k.polytechnic.next.ui.LoadingContent
import ru.n08i40k.polytechnic.next.ui.widgets.LoadingContent
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerUiState
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerViewModel

View File

@@ -33,23 +33,15 @@ import kotlinx.coroutines.flow.flow
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository
import ru.n08i40k.polytechnic.next.model.Day
import ru.n08i40k.polytechnic.next.model.Lesson
import ru.n08i40k.polytechnic.next.model.LessonType
import java.util.Calendar
private fun getCurrentMinutes(): Int {
return Calendar.getInstance()
.get(Calendar.HOUR_OF_DAY) * 60 + Calendar.getInstance()
.get(Calendar.MINUTE)
}
@Composable
private fun getMinutes(): Flow<Int> {
private fun getCurrentLessonIdx(day: Day?): Flow<Int> {
val value by remember {
derivedStateOf {
flow {
while (true) {
emit(getCurrentMinutes())
emit(day?.currentIdx ?: -1)
delay(5_000)
}
}
@@ -59,22 +51,6 @@ private fun getMinutes(): Flow<Int> {
return value
}
@Composable
fun calculateCurrentLessonIdx(lessons: ArrayList<Lesson?>): Int {
val currentMinutes by getMinutes().collectAsStateWithLifecycle(0)
val filteredLessons = lessons
.filterNotNull()
.filter {
it.time.start <= currentMinutes && it.time.end >= currentMinutes
}
if (filteredLessons.isEmpty())
return -1
return lessons.indexOf(filteredLessons[0])
}
@Preview(showBackground = true)
@Composable
fun DayCard(
@@ -99,8 +75,8 @@ fun DayCard(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor =
if (current) MaterialTheme.colorScheme.inverseSurface
else MaterialTheme.colorScheme.surface
if (current) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.secondaryContainer
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.inverseSurface)
) {
@@ -109,14 +85,10 @@ fun DayCard(
modifier = Modifier.fillMaxWidth(),
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
text = stringResource(R.string.day_null),
color =
if (current) MaterialTheme.colorScheme.inverseOnSurface
else MaterialTheme.colorScheme.onSurface
text = stringResource(R.string.day_null)
)
return@Card
}
Text(
modifier = Modifier.fillMaxWidth(),
fontWeight = FontWeight.Bold,
@@ -124,7 +96,8 @@ fun DayCard(
text = day.name,
)
val currentLessonIdx = calculateCurrentLessonIdx(day.lessons)
val currentLessonIdx by getCurrentLessonIdx(if (current) day else null)
.collectAsStateWithLifecycle(0)
Column(
modifier = Modifier.fillMaxWidth(),

View File

@@ -1,21 +1,50 @@
package ru.n08i40k.polytechnic.next.ui.main.schedule
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository
import ru.n08i40k.polytechnic.next.model.Group
import ru.n08i40k.polytechnic.next.ui.widgets.NotificationCard
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.temporal.WeekFields
import java.util.Calendar
import java.util.Locale
import java.util.logging.Level
import kotlin.math.absoluteValue
private fun isCurrentWeek(group: Group): Boolean {
if (group.days.size == 0 || group.days[0] == null)
return true
val dateString = group.days[0]!!.name
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale("ru"))
val datePart = dateString.split(" ").getOrNull(1) ?: return true
val date = LocalDate.parse(datePart, formatter)
val currentDate = LocalDate.now()
val weekField = WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear()
val currentWeek = currentDate.get(weekField)
val dateWeek = date.get(weekField)
return dateWeek >= currentWeek
}
@Preview
@Composable
fun DayPager(group: Group = FakeScheduleRepository.exampleGroup) {
@@ -23,31 +52,38 @@ fun DayPager(group: Group = FakeScheduleRepository.exampleGroup) {
val calendarDay = currentDay
.coerceAtLeast(0)
.coerceAtMost(group.days.size - 1)
val pagerState = rememberPagerState(initialPage = calendarDay, pageCount = { group.days.size })
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(horizontal = 20.dp),
verticalAlignment = Alignment.Top,
modifier = Modifier.height(600.dp)
) { page ->
DayCard(
modifier = Modifier.graphicsLayer {
val offset = pagerState.getOffsetDistanceInPages(page).absoluteValue
Column {
if (!isCurrentWeek(group)) {
NotificationCard(
level = Level.WARNING,
title = stringResource(R.string.outdated_schedule)
)
}
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(horizontal = 20.dp),
verticalAlignment = Alignment.Top,
modifier = Modifier.height(600.dp).padding(top = 5.dp)
) { page ->
DayCard(
modifier = Modifier.graphicsLayer {
val offset = pagerState.getOffsetDistanceInPages(page).absoluteValue
lerp(
start = 1f, stop = 0.95f, fraction = 1f - offset.coerceIn(0f, 1f)
).also { scale ->
scaleX = scale
scaleY = scale
}
alpha = lerp(
start = 0.5f, stop = 1f, fraction = 1f - offset.coerceIn(0f, 1f)
)
},
day = group.days[page],
current = currentDay == page
)
lerp(
start = 1f, stop = 0.95f, fraction = 1f - offset.coerceIn(0f, 1f)
).also { scale ->
scaleX = scale
scaleY = scale
}
alpha = lerp(
start = 0.5f, stop = 1f, fraction = 1f - offset.coerceIn(0f, 1f)
)
},
day = group.days[page],
current = currentDay == page
)
}
}
}

View File

@@ -26,7 +26,7 @@ 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.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

View File

@@ -19,7 +19,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.UpdateDates
import ru.n08i40k.polytechnic.next.ui.ExpandableCard
import ru.n08i40k.polytechnic.next.ui.widgets.ExpandableCard
import ru.n08i40k.polytechnic.next.ui.widgets.ExpandableCardTitle
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -49,7 +50,7 @@ fun UpdateInfo(
ExpandableCard(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
title = stringResource(R.string.update_info_header)
title = { ExpandableCardTitle(stringResource(R.string.update_info_header)) }
) {
Column(
modifier = Modifier

View File

@@ -1,5 +1,4 @@
package ru.n08i40k.polytechnic.next.ui.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF4C662B)
@@ -218,9 +217,33 @@ val surfaceContainerDarkHighContrast = Color(0xFF1E201A)
val surfaceContainerHighDarkHighContrast = Color(0xFF282B24)
val surfaceContainerHighestDarkHighContrast = Color(0xFF33362E)
val warningLight = Color(0xFF7B580C)
val onWarningLight = Color(0xFFFFFFFF)
val warningContainerLight = Color(0xFFFFDEA8)
val onWarningContainerLight = Color(0xFF271900)
val warningLightMediumContrast = Color(0xFF593E00)
val onWarningLightMediumContrast = Color(0xFFFFFFFF)
val warningContainerLightMediumContrast = Color(0xFF946E24)
val onWarningContainerLightMediumContrast = Color(0xFFFFFFFF)
val warningLightHighContrast = Color(0xFF2F1F00)
val onWarningLightHighContrast = Color(0xFFFFFFFF)
val warningContainerLightHighContrast = Color(0xFF593E00)
val onWarningContainerLightHighContrast = Color(0xFFFFFFFF)
val warningDark = Color(0xFFEEBF6D)
val onWarningDark = Color(0xFF422D00)
val warningContainerDark = Color(0xFF5E4200)
val onWarningContainerDark = Color(0xFFFFDEA8)
val warningDarkMediumContrast = Color(0xFFF2C470)
val onWarningDarkMediumContrast = Color(0xFF201400)
val warningContainerDarkMediumContrast = Color(0xFFB38A3D)
val onWarningContainerDarkMediumContrast = Color(0xFF000000)
val warningDarkHighContrast = Color(0xFFFFFAF7)
val onWarningDarkHighContrast = Color(0xFF000000)
val warningContainerDarkHighContrast = Color(0xFFF2C470)
val onWarningContainerDarkHighContrast = Color(0xFF000000)

View File

@@ -14,6 +14,11 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@Immutable
data class ExtendedColorScheme(
val warning: ColorFamily,
)
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
@@ -242,6 +247,60 @@ private val highContrastDarkColorScheme = darkColorScheme(
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
)
val extendedLight = ExtendedColorScheme(
warning = ColorFamily(
warningLight,
onWarningLight,
warningContainerLight,
onWarningContainerLight,
),
)
val extendedDark = ExtendedColorScheme(
warning = ColorFamily(
warningDark,
onWarningDark,
warningContainerDark,
onWarningContainerDark,
),
)
val extendedLightMediumContrast = ExtendedColorScheme(
warning = ColorFamily(
warningLightMediumContrast,
onWarningLightMediumContrast,
warningContainerLightMediumContrast,
onWarningContainerLightMediumContrast,
),
)
val extendedLightHighContrast = ExtendedColorScheme(
warning = ColorFamily(
warningLightHighContrast,
onWarningLightHighContrast,
warningContainerLightHighContrast,
onWarningContainerLightHighContrast,
),
)
val extendedDarkMediumContrast = ExtendedColorScheme(
warning = ColorFamily(
warningDarkMediumContrast,
onWarningDarkMediumContrast,
warningContainerDarkMediumContrast,
onWarningContainerDarkMediumContrast,
),
)
val extendedDarkHighContrast = ExtendedColorScheme(
warning = ColorFamily(
warningDarkHighContrast,
onWarningDarkHighContrast,
warningContainerDarkHighContrast,
onWarningContainerDarkHighContrast,
),
)
@Immutable
data class ColorFamily(
val color: Color,
@@ -254,12 +313,17 @@ val unspecified_scheme = ColorFamily(
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
)
@Composable
fun extendedColorScheme(): ExtendedColorScheme {
return if (isSystemInDarkTheme()) extendedDark else extendedLight
}
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
content: @Composable() () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {

View File

@@ -1,4 +1,4 @@
package ru.n08i40k.polytechnic.next.ui
package ru.n08i40k.polytechnic.next.ui.widgets
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
@@ -31,7 +31,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -39,9 +38,14 @@ import androidx.compose.ui.unit.dp
@Composable
fun ExpandableCard(
modifier: Modifier = Modifier,
expanded: Boolean,
colors: CardColors = CardDefaults.cardColors(),
border: BorderStroke = BorderStroke(
Dp.Hairline,
MaterialTheme.colorScheme.inverseSurface
),
expanded: Boolean = false,
onExpandedChange: () -> Unit,
title: String,
title: @Composable () -> Unit,
content: @Composable () -> Unit
) {
val transitionState = remember {
@@ -57,8 +61,8 @@ fun ExpandableCard(
onExpandedChange()
transitionState.targetState = expanded
},
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.inverseSurface)
colors = colors,
border = border
) {
Column {
ExpandableCardHeader(title, transition)
@@ -67,6 +71,25 @@ fun ExpandableCard(
}
}
@Composable
fun ExpandableCard(
modifier: Modifier = Modifier,
title: @Composable () -> Unit,
colors: CardColors = CardDefaults.cardColors(),
border: BorderStroke = BorderStroke(
Dp.Hairline,
MaterialTheme.colorScheme.inverseSurface
),
) {
Card(
modifier = modifier,
colors = colors,
border = border
) {
ExpandableCardHeader(title, null)
}
}
@Composable
private fun ExpandableCardContent(
visible: Boolean = true,
@@ -99,7 +122,7 @@ private fun ExpandableCardContent(
}
@Composable
private fun ExpandableCardTitle(text: String) {
fun ExpandableCardTitle(text: String) {
Text(
text = text,
modifier = Modifier
@@ -130,8 +153,8 @@ private fun ExpandableCardArrow(
@Composable
private fun ExpandableCardHeader(
title: String = "TODO",
transition: Transition<Boolean>
title: @Composable () -> Unit,
transition: Transition<Boolean>?
) {
Box(
modifier = Modifier
@@ -139,7 +162,8 @@ private fun ExpandableCardHeader(
.padding(10.dp, 0.dp),
contentAlignment = Alignment.CenterEnd,
) {
ExpandableCardArrow(transition)
ExpandableCardTitle(title)
if (transition != null)
ExpandableCardArrow(transition)
title()
}
}

View File

@@ -0,0 +1,109 @@
package ru.n08i40k.polytechnic.next.ui.widgets
import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
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.tooling.preview.Preview
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.network.request.schedule.ScheduleGetGroupNames
@Composable
private fun getGroups(context: Context, onUpdated: (String?) -> Unit): ArrayList<String?> {
val groupPlaceholder = stringResource(R.string.loading)
val groups = remember { arrayListOf(null, groupPlaceholder) }
LaunchedEffect(groups) {
ScheduleGetGroupNames(context, {
groups.clear()
groups.addAll(it.names)
onUpdated(groups.getOrElse(0) { "TODO" }!!)
}, {
groups.clear()
groups.add(null)
groups.add(context.getString(R.string.failed_to_fetch_group_names))
onUpdated(groups[1]!!)
}).send()
}
return groups
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
fun GroupSelector(
value: String? = "ИС-214/24",
isError: Boolean = false,
readOnly: Boolean = false,
onValueChange: (String?) -> Unit = {},
) {
var expanded by remember { mutableStateOf(false) }
Box(
modifier = Modifier.wrapContentSize()
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !readOnly && !expanded
}
) {
val groups = getGroups(LocalContext.current, onValueChange)
TextField(
label = { Text(stringResource(R.string.group)) },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable),
value = value ?: groups.getOrElse(1) { "TODO" }!!,
leadingIcon = {
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "group"
)
},
onValueChange = {},
isError = isError,
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
groups.forEach {
if (it == null)
return@forEach
DropdownMenuItem(
text = { Text(it) },
onClick = {
if (groups.size > 0 && groups[0] != null)
onValueChange(it)
expanded = false
}
)
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
package ru.n08i40k.polytechnic.next.ui
package ru.n08i40k.polytechnic.next.ui.widgets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View File

@@ -0,0 +1,101 @@
package ru.n08i40k.polytechnic.next.ui.widgets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.AppIcons
import ru.n08i40k.polytechnic.next.ui.icons.appicons.Filled
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Error
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Info
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Warning
import ru.n08i40k.polytechnic.next.ui.theme.extendedColorScheme
import java.util.logging.Level
@Preview(showBackground = true)
@Composable
fun NotificationCard(
level: Level = Level.SEVERE,
title: String = "Test",
content: (@Composable () -> Unit)? = null
) {
val titleComposable = @Composable {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
val icon = when (level) {
Level.SEVERE -> AppIcons.Filled.Error
Level.WARNING -> AppIcons.Filled.Warning
else -> AppIcons.Filled.Info
}
Icon(imageVector = icon, contentDescription = "Level")
Icon(imageVector = icon, contentDescription = "Level")
}
Text(
text = title,
modifier = Modifier
.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
}
}
val colors = when (level) {
Level.WARNING -> {
val colorFamily = extendedColorScheme().warning
CardDefaults.cardColors(
containerColor = colorFamily.colorContainer,
contentColor = colorFamily.onColorContainer
)
}
Level.SEVERE -> CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
else -> CardDefaults.cardColors()
}
if (content != null) {
var expanded by remember { mutableStateOf(false) }
ExpandableCard(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
content = content,
title = titleComposable,
colors = colors
)
} else {
ExpandableCard(
title = titleComposable,
colors = colors
)
}
}

View File

@@ -0,0 +1,76 @@
package ru.n08i40k.polytechnic.next.ui.widgets
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.model.UserRole.Companion.AcceptableUserRoles
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
fun RoleSelector(
value: UserRole = UserRole.STUDENT,
isError: Boolean = false,
readOnly: Boolean = false,
onValueChange: (UserRole) -> Unit = {},
) {
var expanded by remember { mutableStateOf(false) }
Box(
modifier = Modifier.wrapContentSize()
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !readOnly && !expanded }
) {
TextField(
label = { Text(stringResource(R.string.role)) },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable),
value = stringResource(value.stringId),
leadingIcon = {
Icon(
imageVector = value.icon,
contentDescription = "role icon"
)
},
onValueChange = {},
isError = isError,
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }) {
AcceptableUserRoles.forEach {
DropdownMenuItem(
leadingIcon = { Icon(it.icon, contentDescription = "Role icon") },
text = { Text(stringResource(it.stringId)) },
onClick = {
expanded = false
onValueChange(it)
}
)
}
}
}
}
}

View File

@@ -1,29 +0,0 @@
package ru.n08i40k.polytechnic.next.work
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.data.MyResult
class ScheduleClvAlarm(context: Context, workerParams: WorkerParameters) :
Worker(context, workerParams) {
override fun doWork(): Result {
val application = applicationContext as PolytechnicApplication
val result = runBlocking {
application
.container
.scheduleRepository
.getGroup()
}
if (result is MyResult.Failure)
return Result.failure()
application.scheduleClvService((result as MyResult.Success).data)
return Result.success()
}
}

View File

@@ -1,41 +0,0 @@
package ru.n08i40k.polytechnic.next.work
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat.startForegroundService
import androidx.work.Worker
import androidx.work.WorkerParameters
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.service.CurrentLessonViewService
class StartClvService(context: Context, workerParams: WorkerParameters) :
Worker(context, workerParams) {
override fun doWork(): Result {
val schedule = runBlocking {
(applicationContext as PolytechnicApplication)
.container
.scheduleRepository
.getGroup()
}
if (schedule is MyResult.Failure)
return Result.success()
val intent = Intent(applicationContext, CurrentLessonViewService::class.java)
.apply {
putExtra("group", (schedule as MyResult.Success).data)
}
applicationContext.stopService(
Intent(
applicationContext,
CurrentLessonViewService::class.java
)
)
startForegroundService(applicationContext, intent)
return Result.success()
}
}

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground>
<inset
android:drawable="@drawable/logo"
android:inset="20dp" />
</foreground>
<background android:drawable="@color/white" />
<monochrome>
<inset
android:drawable="@drawable/logo"
android:inset="20dp" />
</monochrome>
</adaptive-icon>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="108dp"
android:height="108dp"
android:viewportWidth="81.5"
android:viewportHeight="81.5">
<group android:scaleX="0.5265958"
android:scaleY="0.5265958"
android:translateX="19.291224"
android:translateY="19.291224">
<path
android:fillColor="#FF000000"
android:pathData="M51.89,64.09c0,1.22 -0.06,2.8 2.48,4.03 2.55,1.23 5.58,-2.19 7.04,-3.69 1.46,-1.5 5.46,-6.51 5.46,-7.91 0,-0.77 -1.56,-2.28 -2.77,-0.36l-2,3.14c-1.45,2.13 -8.07,9.83 -7.43,4.3 0.74,-6.42 6.07,-28.1 6.07,-28.76 0,-1.45 -2.03,-2.42 -2.88,-0.1l-4.98,24.31c-0.31,1.21 -0.49,2.44 -0.77,3.62 -0.14,0.61 -0.22,0.69 -0.22,1.43h0Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M5.33,39.39c0,1.71 2.35,1.81 3.01,-0.31 0.17,-0.53 0.17,-0.77 0.36,-1.28l1.6,-3.35c0.43,-0.76 0.78,-1.48 1.27,-2.22l4.21,-5.89c0.59,-0.79 1.09,-1.08 1.67,-1.82l2.21,-2.2c2.95,-2.54 5.98,-4.96 9.83,-6.18 11.36,-3.5 19.74,2.13 19.28,14.3 0,0.7 -1.94,1.18 -2.58,1.47 -4.35,1.9 -8.65,3.99 -13.11,5.69 -2.01,0.76 -3.26,1.1 -3.63,2.01 -0.37,0.91 0.13,1.85 1.01,1.98 0.88,0.12 2.75,-0.86 3.87,-1.33 1.08,-0.45 12.96,-6.07 14.07,-6.16 -0.24,1.97 -0.92,4.08 -1.58,5.93l-0.54,1.29c-2.27,5.24 -5.46,9.65 -9.08,14.04 -2.46,2.83 -5.74,6.02 -8.98,7.93l-5.24,2.5c-4.75,1.77 -10.16,0.07 -13.25,-3.7 -0.41,-0.5 -0.69,-1.31 -0.84,-1.91 -0.22,-0.93 0.07,-2.48 -0.6,-3.24 -0.85,-0.96 -2.41,-0.43 -2.41,0.92 0,5.07 2.79,7.94 7.15,10.11 3.8,1.89 8.59,1.37 12.37,-0.14l1.78,-0.79c5.11,-2.78 9.5,-6.62 13.14,-11.13 3.48,-4.27 6.64,-8.87 8.74,-14.02l1.11,-3.11c0.29,-0.82 0.43,-1.38 0.57,-2.17 0.98,-3.89 -0.32,-3.35 2.5,-4.66 7.13,-3.13 14.12,-6.43 21.3,-9.47 0.77,-0.33 1.43,-0.41 1.6,-1.34 0.1,-0.55 -0.21,-1.32 -0.97,-1.54 -0.75,-0.22 -1.36,0.26 -2.07,0.55 -7.55,3.07 -15.9,7.61 -19.81,8.78 -2.85,1.53 -1.9,-0.01 -2.42,-4.66 -1.17,-6.1 -5.82,-10.61 -12,-11.61 -6.59,-1.2 -12.17,1.22 -17.45,4.66l-0.4,0.34s-0.07,0.06 -0.1,0.08l-2.52,2.08c-3.35,2.69 -6,6.24 -8.39,9.8 -1.41,2.13 -4.7,7.55 -4.7,9.79Z"
tools:ignore="VectorPath" />
</group>
</vector>

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground>
<inset
android:drawable="@drawable/logo"
android:inset="20dp" />
</foreground>
<background android:drawable="@color/white" />
<monochrome>
<inset
android:drawable="@drawable/logo"
android:inset="20dp" />
</monochrome>
</adaptive-icon>

View File

@@ -1,12 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="81.5dp"
android:height="81.5dp"
android:viewportWidth="81.5"
android:viewportHeight="81.5">
<path
android:fillColor="#FF000000"
android:pathData="M51.89,64.09c0,1.22 -0.06,2.8 2.48,4.03 2.55,1.23 5.58,-2.19 7.04,-3.69 1.46,-1.5 5.46,-6.51 5.46,-7.91 0,-0.77 -1.56,-2.28 -2.77,-0.36l-2,3.14c-1.45,2.13 -8.07,9.83 -7.43,4.3 0.74,-6.42 6.07,-28.1 6.07,-28.76 0,-1.45 -2.03,-2.42 -2.88,-0.1l-4.98,24.31c-0.31,1.21 -0.49,2.44 -0.77,3.62 -0.14,0.61 -0.22,0.69 -0.22,1.43h0Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M5.33,39.39c0,1.71 2.35,1.81 3.01,-0.31 0.17,-0.53 0.17,-0.77 0.36,-1.28l1.6,-3.35c0.43,-0.76 0.78,-1.48 1.27,-2.22l4.21,-5.89c0.59,-0.79 1.09,-1.08 1.67,-1.82l2.21,-2.2c2.95,-2.54 5.98,-4.96 9.83,-6.18 11.36,-3.5 19.74,2.13 19.28,14.3 0,0.7 -1.94,1.18 -2.58,1.47 -4.35,1.9 -8.65,3.99 -13.11,5.69 -2.01,0.76 -3.26,1.1 -3.63,2.01 -0.37,0.91 0.13,1.85 1.01,1.98 0.88,0.12 2.75,-0.86 3.87,-1.33 1.08,-0.45 12.96,-6.07 14.07,-6.16 -0.24,1.97 -0.92,4.08 -1.58,5.93l-0.54,1.29c-2.27,5.24 -5.46,9.65 -9.08,14.04 -2.46,2.83 -5.74,6.02 -8.98,7.93l-5.24,2.5c-4.75,1.77 -10.16,0.07 -13.25,-3.7 -0.41,-0.5 -0.69,-1.31 -0.84,-1.91 -0.22,-0.93 0.07,-2.48 -0.6,-3.24 -0.85,-0.96 -2.41,-0.43 -2.41,0.92 0,5.07 2.79,7.94 7.15,10.11 3.8,1.89 8.59,1.37 12.37,-0.14l1.78,-0.79c5.11,-2.78 9.5,-6.62 13.14,-11.13 3.48,-4.27 6.64,-8.87 8.74,-14.02l1.11,-3.11c0.29,-0.82 0.43,-1.38 0.57,-2.17 0.98,-3.89 -0.32,-3.35 2.5,-4.66 7.13,-3.13 14.12,-6.43 21.3,-9.47 0.77,-0.33 1.43,-0.41 1.6,-1.34 0.1,-0.55 -0.21,-1.32 -0.97,-1.54 -0.75,-0.22 -1.36,0.26 -2.07,0.55 -7.55,3.07 -15.9,7.61 -19.81,8.78 -2.85,1.53 -1.9,-0.01 -2.42,-4.66 -1.17,-6.1 -5.82,-10.61 -12,-11.61 -6.59,-1.2 -12.17,1.22 -17.45,4.66l-0.4,0.34s-0.07,0.06 -0.1,0.08l-2.52,2.08c-3.35,2.69 -6,6.24 -8.39,9.8 -1.41,2.13 -4.7,7.55 -4.7,9.79Z"/>
</vector>

View File

@@ -0,0 +1,69 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="18.80dp"
android:viewportWidth="100"
android:viewportHeight="85.47">
<path
android:pathData="M14.99,12.42L85.01,12.42A13.04,13.04 0,0 1,98.05 25.46L98.05,70.48A13.04,13.04 0,0 1,85.01 83.52L14.99,83.52A13.04,13.04 0,0 1,1.95 70.48L1.95,25.46A13.04,13.04 0,0 1,14.99 12.42z"
android:strokeLineJoin="miter"
android:strokeWidth="3.9016"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M100,30.35L0,30.35"
android:strokeWidth="4"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="m17,10.81v-4.11c0,-2.61 1.79,-4.7 4.01,-4.7h7.3c2.22,0 4.01,2.1 4.01,4.7v4.11"
android:strokeWidth="4"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="m67.68,10.81v-4.11c0,-2.61 1.79,-4.7 4.01,-4.7h7.3c2.22,0 4.01,2.1 4.01,4.7v4.11"
android:strokeWidth="4"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M19.51,45.64m-4.26,0a4.26,4.26 89.5,1 1,8.52 0a4.26,4.26 89.5,1 1,-8.52 0"
android:strokeLineJoin="miter"
android:strokeWidth="4"
android:fillColor="#000000"
android:strokeColor="#00000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M34.4,41.38L80.52,41.38A4.23,4.23 90.46,0 1,84.75 45.61L84.75,45.67A4.23,4.23 90.46,0 1,80.52 49.9L34.4,49.9A4.23,4.23 90.46,0 1,30.17 45.67L30.17,45.61A4.23,4.23 90.46,0 1,34.4 41.38z"
android:strokeLineJoin="miter"
android:strokeWidth="4.83"
android:fillColor="#000000"
android:strokeColor="#00000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M19.51,56.98m-4.26,0a4.26,4.26 89.5,1 1,8.52 0a4.26,4.26 89.5,1 1,-8.52 0"
android:strokeLineJoin="miter"
android:strokeWidth="4"
android:fillColor="#000000"
android:strokeColor="#00000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M34.4,52.72L80.52,52.72A4.23,4.23 90.46,0 1,84.75 56.95L84.75,57.01A4.23,4.23 90.46,0 1,80.52 61.24L34.4,61.24A4.23,4.23 90.46,0 1,30.17 57.01L30.17,56.95A4.23,4.23 90.46,0 1,34.4 52.72z"
android:strokeLineJoin="miter"
android:strokeWidth="4.83"
android:fillColor="#000000"
android:strokeColor="#00000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M19.51,68.32m-4.26,0a4.26,4.26 89.5,1 1,8.52 0a4.26,4.26 89.5,1 1,-8.52 0"
android:strokeLineJoin="miter"
android:strokeWidth="4"
android:fillColor="#000000"
android:strokeColor="#00000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M34.4,64.06L80.52,64.06A4.23,4.23 90.46,0 1,84.75 68.29L84.75,68.35A4.23,4.23 90.46,0 1,80.52 72.58L34.4,72.58A4.23,4.23 90.46,0 1,30.17 68.35L30.17,68.29A4.23,4.23 90.46,0 1,34.4 64.06z"
android:strokeLineJoin="miter"
android:strokeWidth="4.83"
android:fillColor="#000000"
android:strokeColor="#00000000"
android:strokeLineCap="butt"/>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -3,10 +3,9 @@
<string name="app_name">Политехникум</string>
<string name="username">Имя пользователя</string>
<string name="password">Пароль</string>
<string name="register">Зарегистрироваться</string>
<string name="login">Авторизоваться</string>
<string name="login_title">Авторизация</string>
<string name="register_title">Регистрация</string>
<string name="proceed">Продолжить</string>
<string name="sign_in_title">Авторизация</string>
<string name="sign_up_title">Регистрация</string>
<string name="not_registered">Не зарегистрированы?</string>
<string name="already_registered">Уже зарегистрированы?</string>
<string name="reload">Перезагрузить</string>
@@ -23,7 +22,7 @@
<string name="role_admin">Администратор</string>
<string name="group">Группа</string>
<string name="role">Роль</string>
<string name="day_null">Расписание ещё не обновилось.</string>
<string name="day_null">На этот день расписания ещё нет!</string>
<string name="old_password">Старый пароль</string>
<string name="new_password">Новый пароль</string>
<string name="loading">Загрузка…</string>
@@ -65,4 +64,13 @@
<string name="in_gym_lc">в спорт-зале</string>
<string name="lessons_not_started">Пары ещё не начались</string>
<string name="waiting_for_day_start_notification_title">До начала пар %1$d ч. %2$d мин.</string>
<string name="outdated_schedule">Вы просматриваете устаревшее расписание!</string>
<string name="invalid_credentials">Некорректное имя пользователя или пароль!</string>
<string name="timed_out">Неудалось отправить запрос, попробуйте позже!</string>
<string name="app_too_old">Пожалуйста обновите приложение!</string>
<string name="unknown_error">Произошла неизвестная ошибка! Попробуйте позже.</string>
<string name="failed_to_fetch_group_names">Не удалось получить список названий групп!</string>
<string name="already_exists">Пользователь с таким именем уже зарегистрирован!</string>
<string name="group_does_not_exists">Группа с таким названием не существует!</string>
<string name="no_connection">Нет подключения к интернету!</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -3,10 +3,9 @@
<string name="username">Username</string>
<string name="password">Password</string>
<string name="register">Register</string>
<string name="login">Login</string>
<string name="login_title">Login</string>
<string name="register_title">Registration</string>
<string name="proceed">Proceed</string>
<string name="sign_in_title">Sign In</string>
<string name="sign_up_title">Sign Up</string>
<string name="not_registered">Not registered?</string>
<string name="already_registered">Already registered?</string>
<string name="reload">Reload</string>
@@ -23,7 +22,7 @@
<string name="role_admin">Administrator</string>
<string name="group">Group</string>
<string name="role">Role</string>
<string name="day_null">Schedule not updated yet.</string>
<string name="day_null">There is no schedule for this day yet!</string>
<string name="old_password">Old password</string>
<string name="new_password">New password</string>
<string name="change_password">Change password</string>
@@ -65,4 +64,13 @@
<string name="in_gym_lc">in gym</string>
<string name="lessons_not_started">Lessons haven\'t started yet</string>
<string name="waiting_for_day_start_notification_title">%1$d h. %2$d min. before lessons start</string>
<string name="outdated_schedule">You are viewing an outdated schedule!</string>
<string name="invalid_credentials">Invalid credentials!</string>
<string name="timed_out">Failed to send request, try again later!</string>
<string name="app_too_old">Please update the application!</string>
<string name="unknown_error">An unknown error has occurred! Please try again later.</string>
<string name="failed_to_fetch_group_names">Failed to get list of group names!</string>
<string name="already_exists">A user with this name is already registered!</string>
<string name="group_does_not_exists">A group with this name does not exist!</string>
<string name="no_connection">No internet connection!</string>
</resources>