mirror of
https://github.com/n08i40k/polytechnic-android.git
synced 2025-12-06 17:57:46 +03:00
1.4.0
Администраторам добавлена возможность заменять расписание на текущую неделю. Исправлены недочёты в меню авторизации и регистрации. Добавлен класс AuthorizedMultipartRequest для отправки multipart запросов. Файлы дата классов разбиты на ещё большее количество файлов. Переименовано большинство классов с сетевыми запросами и их файлов для подгонки под однородный вид. Убрано много мусора в коде. Я наконец-то плюс минус разобрался в ViewModel'ах. Очень важная информация.
This commit is contained in:
@@ -10,8 +10,11 @@ import ru.n08i40k.polytechnic.next.data.cache.NetworkCacheRepository
|
||||
import ru.n08i40k.polytechnic.next.data.cache.impl.FakeNetworkCacheRepository
|
||||
import ru.n08i40k.polytechnic.next.data.cache.impl.LocalNetworkCacheRepository
|
||||
import ru.n08i40k.polytechnic.next.data.schedule.ScheduleRepository
|
||||
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleReplacerRepository
|
||||
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleRepository
|
||||
import ru.n08i40k.polytechnic.next.data.schedule.impl.RemoteScheduleReplacerRepository
|
||||
import ru.n08i40k.polytechnic.next.data.schedule.impl.RemoteScheduleRepository
|
||||
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.ScheduleReplacerRepository
|
||||
import ru.n08i40k.polytechnic.next.data.users.ProfileRepository
|
||||
import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
|
||||
import ru.n08i40k.polytechnic.next.data.users.impl.RemoteProfileRepository
|
||||
@@ -19,33 +22,42 @@ import javax.inject.Singleton
|
||||
|
||||
interface AppContainer {
|
||||
val applicationContext: Context
|
||||
|
||||
val networkCacheRepository: NetworkCacheRepository
|
||||
|
||||
val scheduleRepository: ScheduleRepository
|
||||
|
||||
val scheduleReplacerRepository: ScheduleReplacerRepository
|
||||
|
||||
val profileRepository: ProfileRepository
|
||||
}
|
||||
|
||||
class MockAppContainer(override val applicationContext: Context) : AppContainer {
|
||||
override val networkCacheRepository: NetworkCacheRepository by lazy { FakeNetworkCacheRepository() }
|
||||
override val scheduleRepository: ScheduleRepository by lazy { FakeScheduleRepository() }
|
||||
override val profileRepository: ProfileRepository by lazy { FakeProfileRepository() }
|
||||
override val networkCacheRepository: NetworkCacheRepository
|
||||
by lazy { FakeNetworkCacheRepository() }
|
||||
|
||||
override val scheduleRepository: ScheduleRepository
|
||||
by lazy { FakeScheduleRepository() }
|
||||
|
||||
override val scheduleReplacerRepository: ScheduleReplacerRepository
|
||||
by lazy { FakeScheduleReplacerRepository() }
|
||||
|
||||
override val profileRepository: ProfileRepository
|
||||
by lazy { FakeProfileRepository() }
|
||||
}
|
||||
|
||||
class RemoteAppContainer(override val applicationContext: Context) : AppContainer {
|
||||
override val networkCacheRepository: NetworkCacheRepository by lazy {
|
||||
LocalNetworkCacheRepository(
|
||||
applicationContext
|
||||
)
|
||||
}
|
||||
override val scheduleRepository: ScheduleRepository by lazy {
|
||||
RemoteScheduleRepository(
|
||||
applicationContext
|
||||
)
|
||||
}
|
||||
override val profileRepository: ProfileRepository by lazy {
|
||||
RemoteProfileRepository(
|
||||
applicationContext
|
||||
)
|
||||
}
|
||||
override val networkCacheRepository: NetworkCacheRepository
|
||||
by lazy { LocalNetworkCacheRepository(applicationContext) }
|
||||
|
||||
override val scheduleRepository: ScheduleRepository
|
||||
by lazy { RemoteScheduleRepository(applicationContext) }
|
||||
|
||||
override val scheduleReplacerRepository: ScheduleReplacerRepository
|
||||
by lazy { RemoteScheduleReplacerRepository(applicationContext) }
|
||||
|
||||
override val profileRepository: ProfileRepository
|
||||
by lazy { RemoteProfileRepository(applicationContext) }
|
||||
}
|
||||
|
||||
@Module
|
||||
|
||||
@@ -4,4 +4,3 @@ sealed interface MyResult<out R> {
|
||||
data class Success<out T>(val data: T) : MyResult<T>
|
||||
data class Failure(val exception: Exception) : MyResult<Nothing>
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ 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.data.schedule.ScheduleGetRequest
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetRequestData
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetReq
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetReqData
|
||||
import ru.n08i40k.polytechnic.next.network.tryFuture
|
||||
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
|
||||
|
||||
@@ -27,8 +27,8 @@ class RemoteScheduleRepository(private val context: Context) : ScheduleRepositor
|
||||
return@withContext MyResult.Failure(IllegalArgumentException("No group name provided!"))
|
||||
|
||||
val response = tryFuture {
|
||||
ScheduleGetRequest(
|
||||
ScheduleGetRequestData(groupName),
|
||||
ScheduleGetReq(
|
||||
ScheduleGetReqData(groupName),
|
||||
context,
|
||||
it,
|
||||
it
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.n08i40k.polytechnic.next.data.scheduleReplacer
|
||||
|
||||
import ru.n08i40k.polytechnic.next.data.MyResult
|
||||
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
|
||||
|
||||
interface ScheduleReplacerRepository {
|
||||
suspend fun getAll(): MyResult<List<ScheduleReplacer>>
|
||||
|
||||
suspend fun setCurrent(
|
||||
fileName: String,
|
||||
fileData: ByteArray,
|
||||
fileType: String
|
||||
): MyResult<Unit>
|
||||
|
||||
suspend fun clear(): MyResult<Int>
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package ru.n08i40k.polytechnic.next.data.schedule.impl
|
||||
|
||||
import ru.n08i40k.polytechnic.next.data.MyResult
|
||||
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.ScheduleReplacerRepository
|
||||
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
|
||||
|
||||
class FakeScheduleReplacerRepository : ScheduleReplacerRepository {
|
||||
companion object {
|
||||
@Suppress("SpellCheckingInspection")
|
||||
val exampleReplacers: List<ScheduleReplacer> = listOf(
|
||||
ScheduleReplacer("test-etag", 236 * 1024),
|
||||
ScheduleReplacer("frgsjkfhg", 623 * 1024),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getAll(): MyResult<List<ScheduleReplacer>> {
|
||||
return MyResult.Success(exampleReplacers)
|
||||
}
|
||||
|
||||
override suspend fun setCurrent(
|
||||
fileName: String,
|
||||
fileData: ByteArray,
|
||||
fileType: String
|
||||
): MyResult<Unit> {
|
||||
return MyResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun clear(): MyResult<Int> {
|
||||
return MyResult.Success(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.n08i40k.polytechnic.next.data.schedule.impl
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.n08i40k.polytechnic.next.data.MyResult
|
||||
import ru.n08i40k.polytechnic.next.data.scheduleReplacer.ScheduleReplacerRepository
|
||||
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
|
||||
import ru.n08i40k.polytechnic.next.network.data.scheduleReplacer.ScheduleReplacerClearReq
|
||||
import ru.n08i40k.polytechnic.next.network.data.scheduleReplacer.ScheduleReplacerGetReq
|
||||
import ru.n08i40k.polytechnic.next.network.data.scheduleReplacer.ScheduleReplacerSetReq
|
||||
import ru.n08i40k.polytechnic.next.network.tryFuture
|
||||
|
||||
class RemoteScheduleReplacerRepository(private val context: Context) : ScheduleReplacerRepository {
|
||||
override suspend fun getAll(): MyResult<List<ScheduleReplacer>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
tryFuture { ScheduleReplacerGetReq(context, it, it) }
|
||||
}
|
||||
|
||||
|
||||
override suspend fun setCurrent(
|
||||
fileName: String,
|
||||
fileData: ByteArray,
|
||||
fileType: String
|
||||
): MyResult<Nothing> =
|
||||
withContext(Dispatchers.IO) {
|
||||
tryFuture { ScheduleReplacerSetReq(context, fileName, fileData, fileType, it, it) }
|
||||
}
|
||||
|
||||
override suspend fun clear(): MyResult<Int> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
tryFuture { ScheduleReplacerClearReq(context, it, it) }
|
||||
}
|
||||
|
||||
return when (response) {
|
||||
is MyResult.Failure -> response
|
||||
is MyResult.Success -> MyResult.Success(response.data.count)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import ru.n08i40k.polytechnic.next.network.data.profile.UsersMeRequest
|
||||
import ru.n08i40k.polytechnic.next.network.tryFuture
|
||||
|
||||
class RemoteProfileRepository(private val context: Context) : ProfileRepository {
|
||||
override suspend fun getProfile(): MyResult<Profile> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
override suspend fun getProfile(): MyResult<Profile> =
|
||||
withContext(Dispatchers.IO) {
|
||||
tryFuture {
|
||||
UsersMeRequest(
|
||||
context,
|
||||
@@ -20,5 +20,4 @@ class RemoteProfileRepository(private val context: Context) : ProfileRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/src/main/java/ru/n08i40k/polytechnic/next/model/Day.kt
Normal file
13
app/src/main/java/ru/n08i40k/polytechnic/next/model/Day.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package ru.n08i40k.polytechnic.next.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Suppress("unused")
|
||||
@Serializable
|
||||
class Day(
|
||||
val name: String,
|
||||
val nonNullIndices: ArrayList<Int>,
|
||||
val defaultIndices: ArrayList<Int>,
|
||||
val customIndices: ArrayList<Int>,
|
||||
val lessons: ArrayList<Lesson?>
|
||||
)
|
||||
@@ -1,42 +1,6 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package ru.n08i40k.polytechnic.next.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import ru.n08i40k.polytechnic.next.utils.EnumAsIntSerializer
|
||||
|
||||
@Serializable
|
||||
data class LessonTime(val start: Int, val end: Int)
|
||||
|
||||
private class LessonTypeIntSerializer : EnumAsIntSerializer<LessonType>(
|
||||
"LessonType",
|
||||
{ it.value },
|
||||
{ v -> LessonType.entries.first { it.value == v } }
|
||||
)
|
||||
|
||||
@Serializable(with = LessonTypeIntSerializer::class)
|
||||
enum class LessonType(val value: Int) {
|
||||
DEFAULT(0), CUSTOM(1)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Lesson(
|
||||
val type: LessonType,
|
||||
val defaultIndex: Int,
|
||||
val name: String,
|
||||
val time: LessonTime?,
|
||||
val cabinets: ArrayList<String>,
|
||||
val teacherNames: ArrayList<String>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Day(
|
||||
val name: String,
|
||||
val nonNullIndices: ArrayList<Int>,
|
||||
val defaultIndices: ArrayList<Int>,
|
||||
val customIndices: ArrayList<Int>,
|
||||
val lessons: ArrayList<Lesson?>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Group(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.n08i40k.polytechnic.next.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Lesson(
|
||||
val type: LessonType,
|
||||
val defaultIndex: Int,
|
||||
val name: String,
|
||||
val time: LessonTime?,
|
||||
val cabinets: ArrayList<String>,
|
||||
val teacherNames: ArrayList<String>
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.n08i40k.polytechnic.next.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LessonTime(val start: Int, val end: Int)
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.n08i40k.polytechnic.next.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import ru.n08i40k.polytechnic.next.utils.EnumAsIntSerializer
|
||||
|
||||
private class LessonTypeIntSerializer : EnumAsIntSerializer<LessonType>(
|
||||
"LessonType",
|
||||
{ it.value },
|
||||
{ v -> LessonType.entries.first { it.value == v } }
|
||||
)
|
||||
|
||||
@Serializable(with = LessonTypeIntSerializer::class)
|
||||
enum class LessonType(val value: Int) {
|
||||
DEFAULT(0), CUSTOM(1)
|
||||
}
|
||||
@@ -1,29 +1,6 @@
|
||||
package ru.n08i40k.polytechnic.next.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Face
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import kotlinx.serialization.Serializable
|
||||
import ru.n08i40k.polytechnic.next.R
|
||||
import ru.n08i40k.polytechnic.next.utils.EnumAsStringSerializer
|
||||
|
||||
private class UserRoleStringSerializer : EnumAsStringSerializer<UserRole>(
|
||||
"UserRole",
|
||||
{ it.value },
|
||||
{ v -> UserRole.entries.first { it.value == v } }
|
||||
)
|
||||
|
||||
@Serializable(with = UserRoleStringSerializer::class)
|
||||
enum class UserRole(val value: String, val icon: ImageVector, @StringRes val stringId: Int) {
|
||||
STUDENT("STUDENT", Icons.Filled.Face, R.string.role_student),
|
||||
TEACHER("TEACHER", Icons.Filled.Person, R.string.role_teacher),
|
||||
ADMIN("ADMIN", Icons.Filled.Settings, R.string.role_admin)
|
||||
}
|
||||
|
||||
val AcceptableUserRoles = listOf(UserRole.STUDENT, UserRole.TEACHER)
|
||||
|
||||
@Serializable
|
||||
data class Profile(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.n08i40k.polytechnic.next.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ScheduleReplacer(
|
||||
val etag: String,
|
||||
val size: Int
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.n08i40k.polytechnic.next.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Face
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import kotlinx.serialization.Serializable
|
||||
import ru.n08i40k.polytechnic.next.R
|
||||
import ru.n08i40k.polytechnic.next.utils.EnumAsStringSerializer
|
||||
|
||||
private class UserRoleStringSerializer : EnumAsStringSerializer<UserRole>(
|
||||
"UserRole",
|
||||
{ it.value },
|
||||
{ v -> UserRole.entries.first { it.value == v } }
|
||||
)
|
||||
|
||||
|
||||
@Serializable(with = UserRoleStringSerializer::class)
|
||||
enum class UserRole(val value: String, val icon: ImageVector, @StringRes val stringId: Int) {
|
||||
STUDENT("STUDENT", Icons.Filled.Face, R.string.role_student),
|
||||
TEACHER("TEACHER", Icons.Filled.Person, R.string.role_teacher),
|
||||
ADMIN("ADMIN", Icons.Filled.Settings, R.string.role_admin);
|
||||
|
||||
companion object {
|
||||
val AcceptableUserRoles = listOf(STUDENT, TEACHER)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ru.n08i40k.polytechnic.next.network
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.Response
|
||||
import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.UnsupportedEncodingException
|
||||
import kotlin.math.min
|
||||
|
||||
open class AuthorizedMultipartRequest(
|
||||
context: Context,
|
||||
method: Int,
|
||||
url: String,
|
||||
listener: Response.Listener<String>,
|
||||
errorListener: Response.ErrorListener?,
|
||||
canBeUnauthorized: Boolean = false
|
||||
) : AuthorizedRequest(context, method, url, listener, errorListener, canBeUnauthorized) {
|
||||
private val twoHyphens = "--"
|
||||
private val lineEnd = "\r\n"
|
||||
private val boundary = "apiclient-" + System.currentTimeMillis()
|
||||
|
||||
protected open val byteData: Map<String, DataPart>? get() = null
|
||||
|
||||
override fun getBodyContentType(): String {
|
||||
return "multipart/form-data;boundary=$boundary"
|
||||
}
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
val headers = super.getHeaders()
|
||||
headers["Content-Type"] = bodyContentType
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
override fun getBody(): ByteArray {
|
||||
val bos = ByteArrayOutputStream()
|
||||
val dos = DataOutputStream(bos)
|
||||
|
||||
try {
|
||||
val params = params
|
||||
if (!params.isNullOrEmpty()) {
|
||||
textParse(dos, params, paramsEncoding)
|
||||
}
|
||||
|
||||
val data = byteData
|
||||
if (!data.isNullOrEmpty()) {
|
||||
dataParse(dos, data)
|
||||
}
|
||||
|
||||
dos.writeBytes(twoHyphens + boundary + twoHyphens + lineEnd)
|
||||
|
||||
return bos.toByteArray()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return ByteArray(0)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun textParse(
|
||||
dataOutputStream: DataOutputStream, params: Map<String, String>, encoding: String
|
||||
) {
|
||||
try {
|
||||
for ((key, value) in params) {
|
||||
buildTextPart(dataOutputStream, key, value)
|
||||
}
|
||||
} catch (uee: UnsupportedEncodingException) {
|
||||
throw RuntimeException("Encoding not supported: $encoding", uee)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun dataParse(dataOutputStream: DataOutputStream, data: Map<String, DataPart>) {
|
||||
for ((key, value) in data) {
|
||||
buildDataPart(dataOutputStream, value, key)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun buildTextPart(
|
||||
dataOutputStream: DataOutputStream, parameterName: String, parameterValue: String
|
||||
) {
|
||||
dataOutputStream.writeBytes(twoHyphens + boundary + lineEnd)
|
||||
dataOutputStream.writeBytes("Content-Disposition: form-data; name=\"$parameterName\"$lineEnd")
|
||||
dataOutputStream.writeBytes(lineEnd)
|
||||
dataOutputStream.writeBytes(parameterValue + lineEnd)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun buildDataPart(
|
||||
dataOutputStream: DataOutputStream, dataFile: DataPart, inputName: String
|
||||
) {
|
||||
dataOutputStream.writeBytes(twoHyphens + boundary + lineEnd)
|
||||
dataOutputStream.writeBytes(
|
||||
"Content-Disposition: form-data; name=\"" + inputName + "\"; filename=\"" + dataFile.fileName + "\"" + lineEnd
|
||||
)
|
||||
|
||||
if (dataFile.type != null && dataFile.type!!.trim { it <= ' ' }
|
||||
.isNotEmpty()) dataOutputStream.writeBytes("Content-Type: " + dataFile.type + lineEnd)
|
||||
|
||||
dataOutputStream.writeBytes(lineEnd)
|
||||
|
||||
val fileInputStream = ByteArrayInputStream(dataFile.content)
|
||||
var bytesAvailable = fileInputStream.available()
|
||||
|
||||
val maxBufferSize = 1024 * 1024
|
||||
var bufferSize = min(bytesAvailable.toDouble(), maxBufferSize.toDouble()).toInt()
|
||||
val buffer = ByteArray(bufferSize)
|
||||
|
||||
var bytesRead = fileInputStream.read(buffer, 0, bufferSize)
|
||||
|
||||
while (bytesRead > 0) {
|
||||
dataOutputStream.write(buffer, 0, bufferSize)
|
||||
bytesAvailable = fileInputStream.available()
|
||||
bufferSize = min(bytesAvailable.toDouble(), maxBufferSize.toDouble()).toInt()
|
||||
bytesRead = fileInputStream.read(buffer, 0, bufferSize)
|
||||
}
|
||||
|
||||
dataOutputStream.writeBytes(lineEnd)
|
||||
}
|
||||
|
||||
inner class DataPart(name: String?, data: ByteArray, mimeType: String? = null) {
|
||||
var fileName: String? = name
|
||||
var content: ByteArray = data
|
||||
var type: String? = mimeType
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,14 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import com.android.volley.Request
|
||||
import com.android.volley.RequestQueue
|
||||
import com.android.volley.Response
|
||||
import com.android.volley.VolleyError
|
||||
import com.android.volley.toolbox.HurlStack
|
||||
import com.android.volley.toolbox.RequestFuture
|
||||
import com.android.volley.toolbox.StringRequest
|
||||
import com.android.volley.toolbox.Volley
|
||||
import ru.n08i40k.polytechnic.next.data.MyResult
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.TimeoutException
|
||||
import java.util.logging.Logger
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
|
||||
class NetworkConnection(ctx: Context) {
|
||||
companion object {
|
||||
@Volatile
|
||||
@@ -65,45 +56,4 @@ class NetworkConnection(ctx: Context) {
|
||||
fun <T> addToRequestQueue(req: Request<T>) {
|
||||
requestQueue.add(req)
|
||||
}
|
||||
}
|
||||
|
||||
open class RequestBase(
|
||||
protected val context: Context,
|
||||
method: Int,
|
||||
url: String?,
|
||||
listener: Response.Listener<String>,
|
||||
errorListener: Response.ErrorListener?
|
||||
) : StringRequest(method, NetworkValues.API_HOST + url, listener, errorListener) {
|
||||
open fun send() {
|
||||
Logger.getLogger("RequestBase").info("Sending request to $url")
|
||||
NetworkConnection.getInstance(context).addToRequestQueue(this)
|
||||
}
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
headers["version"] = "1"
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
|
||||
fun <ResultT, RequestT : RequestBase> tryFuture(
|
||||
buildRequest: (RequestFuture<ResultT>) -> RequestT
|
||||
): MyResult<ResultT> {
|
||||
val future = RequestFuture.newFuture<ResultT>()
|
||||
buildRequest(future).send()
|
||||
return tryGet(future)
|
||||
}
|
||||
|
||||
fun <T> tryGet(future: RequestFuture<T>): MyResult<T> {
|
||||
return try {
|
||||
MyResult.Success(future.get())
|
||||
} catch (exception: VolleyError) {
|
||||
MyResult.Failure(exception)
|
||||
} catch (exception: ExecutionException) {
|
||||
MyResult.Failure(exception.cause as VolleyError)
|
||||
} catch (exception: TimeoutException) {
|
||||
MyResult.Failure(exception)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.n08i40k.polytechnic.next.network
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.Response
|
||||
import com.android.volley.toolbox.StringRequest
|
||||
import java.util.logging.Logger
|
||||
|
||||
open class RequestBase(
|
||||
protected val context: Context,
|
||||
method: Int,
|
||||
url: String?,
|
||||
listener: Response.Listener<String>,
|
||||
errorListener: Response.ErrorListener?
|
||||
) : StringRequest(method, NetworkValues.API_HOST + url, listener, errorListener) {
|
||||
open fun send() {
|
||||
Logger.getLogger("RequestBase").info("Sending request to $url")
|
||||
NetworkConnection.getInstance(context).addToRequestQueue(this)
|
||||
}
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
headers["version"] = "1"
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.n08i40k.polytechnic.next.network
|
||||
|
||||
import com.android.volley.VolleyError
|
||||
import com.android.volley.toolbox.RequestFuture
|
||||
import ru.n08i40k.polytechnic.next.data.MyResult
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
fun <ResultT, RequestT : RequestBase> tryFuture(
|
||||
buildRequest: (RequestFuture<ResultT>) -> RequestT
|
||||
): MyResult<ResultT> {
|
||||
val future = RequestFuture.newFuture<ResultT>()
|
||||
buildRequest(future).send()
|
||||
return tryGet(future)
|
||||
}
|
||||
|
||||
fun <T> tryGet(future: RequestFuture<T>): MyResult<T> {
|
||||
return try {
|
||||
MyResult.Success(future.get())
|
||||
} catch (exception: VolleyError) {
|
||||
MyResult.Failure(exception)
|
||||
} catch (exception: ExecutionException) {
|
||||
MyResult.Failure(exception.cause as VolleyError)
|
||||
} catch (exception: TimeoutException) {
|
||||
MyResult.Failure(exception)
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import ru.n08i40k.polytechnic.next.PolytechnicApplication
|
||||
import ru.n08i40k.polytechnic.next.data.AppContainer
|
||||
import ru.n08i40k.polytechnic.next.data.MyResult
|
||||
import ru.n08i40k.polytechnic.next.network.NetworkConnection
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetCacheStatusRequest
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetCacheStatusResponse
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleUpdateRequest
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleUpdateRequestData
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetCacheStatusReq
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetCacheStatusResData
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleUpdateReq
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleUpdateReqData
|
||||
import ru.n08i40k.polytechnic.next.network.tryFuture
|
||||
import ru.n08i40k.polytechnic.next.network.tryGet
|
||||
import java.util.logging.Logger
|
||||
@@ -58,14 +58,14 @@ open class CachedRequest(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateMainPage(): MyResult<ScheduleGetCacheStatusResponse> {
|
||||
private suspend fun updateMainPage(): MyResult<ScheduleGetCacheStatusResData> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
when (val mainPage = getMainPage()) {
|
||||
is MyResult.Failure -> mainPage
|
||||
is MyResult.Success -> {
|
||||
tryFuture {
|
||||
ScheduleUpdateRequest(
|
||||
ScheduleUpdateRequestData(mainPage.data),
|
||||
ScheduleUpdateReq(
|
||||
ScheduleUpdateReqData(mainPage.data),
|
||||
context,
|
||||
it,
|
||||
it
|
||||
@@ -83,7 +83,7 @@ open class CachedRequest(
|
||||
logger.info("Getting cache status...")
|
||||
|
||||
val cacheStatusResult = tryFuture {
|
||||
ScheduleGetCacheStatusRequest(context, it, it)
|
||||
ScheduleGetCacheStatusReq(context, it, it)
|
||||
}
|
||||
|
||||
if (cacheStatusResult is MyResult.Success) {
|
||||
|
||||
@@ -15,7 +15,7 @@ class LoginRequest(
|
||||
context,
|
||||
Method.POST,
|
||||
"auth/sign-in",
|
||||
Response.Listener<String> { response -> listener.onResponse(Json.decodeFromString(response)) },
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
) {
|
||||
override fun getBody(): ByteArray {
|
||||
|
||||
@@ -15,7 +15,7 @@ class RegisterRequest(
|
||||
context,
|
||||
Method.POST,
|
||||
"auth/sign-up",
|
||||
Response.Listener<String> { response -> listener.onResponse(Json.decodeFromString(response)) },
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
) {
|
||||
override fun getBody(): ByteArray {
|
||||
|
||||
@@ -11,9 +11,9 @@ class UsersMeRequest(
|
||||
listener: Response.Listener<Profile>,
|
||||
errorListener: Response.ErrorListener?
|
||||
) : AuthorizedRequest(
|
||||
context, Method.GET, "users/me", Response.Listener<String> { response ->
|
||||
listener.onResponse(
|
||||
Json.decodeFromString<Profile>(response)
|
||||
)
|
||||
}, errorListener
|
||||
context,
|
||||
Method.GET,
|
||||
"users/me",
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
)
|
||||
@@ -5,12 +5,14 @@ import com.android.volley.Response
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest
|
||||
|
||||
class ScheduleGetCacheStatusRequest(
|
||||
class ScheduleGetCacheStatusReq(
|
||||
context: Context,
|
||||
listener: Response.Listener<ScheduleGetCacheStatusResponse>,
|
||||
listener: Response.Listener<ScheduleGetCacheStatusResData>,
|
||||
errorListener: Response.ErrorListener? = null
|
||||
) : AuthorizedRequest(
|
||||
context, Method.GET, "schedule/cache-status", Response.Listener<String> { response ->
|
||||
listener.onResponse(Json.decodeFromString<ScheduleGetCacheStatusResponse>(response))
|
||||
}, errorListener
|
||||
context,
|
||||
Method.GET,
|
||||
"schedule/cache-status",
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
)
|
||||
@@ -3,7 +3,7 @@ package ru.n08i40k.polytechnic.next.network.data.schedule
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ScheduleGetCacheStatusResponse(
|
||||
data class ScheduleGetCacheStatusResData(
|
||||
val cacheUpdateRequired: Boolean,
|
||||
val cacheHash: String,
|
||||
val lastCacheUpdate: Long,
|
||||
@@ -5,12 +5,14 @@ import com.android.volley.Response
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.n08i40k.polytechnic.next.network.data.CachedRequest
|
||||
|
||||
class ScheduleGetGroupNamesRequest(
|
||||
class ScheduleGetGroupNamesReq(
|
||||
context: Context,
|
||||
listener: Response.Listener<ScheduleGetGroupNamesResponseData>,
|
||||
listener: Response.Listener<ScheduleGetGroupNamesResData>,
|
||||
errorListener: Response.ErrorListener? = null
|
||||
) : CachedRequest(
|
||||
context, Method.GET, "schedule/get-group-names", Response.Listener<String> { response ->
|
||||
listener.onResponse(Json.decodeFromString<ScheduleGetGroupNamesResponseData>(response))
|
||||
}, errorListener
|
||||
context,
|
||||
Method.GET,
|
||||
"schedule/get-group-names",
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
)
|
||||
@@ -3,6 +3,6 @@ package ru.n08i40k.polytechnic.next.network.data.schedule
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ScheduleGetGroupNamesResponseData(
|
||||
data class ScheduleGetGroupNamesResData(
|
||||
val names: ArrayList<String>,
|
||||
)
|
||||
@@ -6,15 +6,17 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.n08i40k.polytechnic.next.network.data.CachedRequest
|
||||
|
||||
class ScheduleGetRequest(
|
||||
private val data: ScheduleGetRequestData,
|
||||
class ScheduleGetReq(
|
||||
private val data: ScheduleGetReqData,
|
||||
context: Context,
|
||||
listener: Response.Listener<ScheduleGetResponse>,
|
||||
listener: Response.Listener<ScheduleGetResData>,
|
||||
errorListener: Response.ErrorListener? = null
|
||||
) : CachedRequest(
|
||||
context, Method.POST, "schedule/get-group", Response.Listener<String> { response ->
|
||||
listener.onResponse(Json.decodeFromString<ScheduleGetResponse>(response))
|
||||
}, errorListener
|
||||
context,
|
||||
Method.POST,
|
||||
"schedule/get-group",
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
) {
|
||||
override fun getBody(): ByteArray {
|
||||
return Json.encodeToString(data).toByteArray()
|
||||
@@ -3,4 +3,4 @@ package ru.n08i40k.polytechnic.next.network.data.schedule
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ScheduleGetRequestData(val name: String)
|
||||
data class ScheduleGetReqData(val name: String)
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
|
||||
import ru.n08i40k.polytechnic.next.model.Group
|
||||
|
||||
@Serializable
|
||||
data class ScheduleGetResponse(
|
||||
data class ScheduleGetResData(
|
||||
val updatedAt: String,
|
||||
val group: Group,
|
||||
val lastChangedDays: ArrayList<Int>,
|
||||
@@ -6,15 +6,17 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest
|
||||
|
||||
class ScheduleUpdateRequest(
|
||||
private val data: ScheduleUpdateRequestData,
|
||||
class ScheduleUpdateReq(
|
||||
private val data: ScheduleUpdateReqData,
|
||||
context: Context,
|
||||
listener: Response.Listener<ScheduleGetCacheStatusResponse>,
|
||||
listener: Response.Listener<ScheduleGetCacheStatusResData>,
|
||||
errorListener: Response.ErrorListener? = null
|
||||
) : AuthorizedRequest(
|
||||
context, Method.POST, "schedule/update-site-main-page", Response.Listener<String> {
|
||||
listener.onResponse(Json.decodeFromString<ScheduleGetCacheStatusResponse>(it))
|
||||
}, errorListener
|
||||
context,
|
||||
Method.POST,
|
||||
"schedule/update-site-main-page",
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
) {
|
||||
override fun getBody(): ByteArray {
|
||||
return Json.encodeToString(data).toByteArray()
|
||||
@@ -3,4 +3,4 @@ package ru.n08i40k.polytechnic.next.network.data.schedule
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ScheduleUpdateRequestData(val mainPage: String)
|
||||
data class ScheduleUpdateReqData(val mainPage: String)
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.n08i40k.polytechnic.next.network.data.scheduleReplacer
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.Response
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest
|
||||
|
||||
class ScheduleReplacerClearReq(
|
||||
context: Context,
|
||||
listener: Response.Listener<ScheduleReplacerClearResData>,
|
||||
errorListener: Response.ErrorListener?
|
||||
) : AuthorizedRequest(
|
||||
context,
|
||||
Method.POST,
|
||||
"schedule-replacer/clear",
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.n08i40k.polytechnic.next.network.data.scheduleReplacer
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ScheduleReplacerClearResData(
|
||||
val count: Int
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.n08i40k.polytechnic.next.network.data.scheduleReplacer
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.Response
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.n08i40k.polytechnic.next.network.data.AuthorizedRequest
|
||||
|
||||
class ScheduleReplacerGetReq(
|
||||
context: Context,
|
||||
listener: Response.Listener<ScheduleReplacerGetResData>,
|
||||
errorListener: Response.ErrorListener?
|
||||
) : AuthorizedRequest(
|
||||
context,
|
||||
Method.GET,
|
||||
"schedule-replacer/get",
|
||||
{ listener.onResponse(Json.decodeFromString(it)) },
|
||||
errorListener
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.n08i40k.polytechnic.next.network.data.scheduleReplacer
|
||||
|
||||
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
|
||||
|
||||
typealias ScheduleReplacerGetResData = List<ScheduleReplacer>
|
||||
@@ -0,0 +1,28 @@
|
||||
package ru.n08i40k.polytechnic.next.network.data.scheduleReplacer
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.Response
|
||||
import ru.n08i40k.polytechnic.next.network.AuthorizedMultipartRequest
|
||||
|
||||
class ScheduleReplacerSetReq(
|
||||
context: Context,
|
||||
private val fileName: String,
|
||||
private val fileData: ByteArray,
|
||||
private val fileType: String,
|
||||
private val listener: Response.Listener<Nothing>,
|
||||
errorListener: Response.ErrorListener?
|
||||
) : AuthorizedMultipartRequest(
|
||||
context,
|
||||
Method.POST,
|
||||
"schedule-replacer/set",
|
||||
{ listener.onResponse(null) },
|
||||
errorListener
|
||||
) {
|
||||
override val byteData: Map<String, DataPart>
|
||||
get() = mapOf(
|
||||
Pair(
|
||||
"file",
|
||||
DataPart(fileName, fileData, fileType)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -23,15 +23,15 @@ fun LoadingContent(
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Center,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
if (empty) emptyContent()
|
||||
else {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = loading, onRefresh = onRefresh
|
||||
) {
|
||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = verticalArrangement) {
|
||||
item {
|
||||
content()
|
||||
}
|
||||
if (empty) {
|
||||
emptyContent()
|
||||
return
|
||||
}
|
||||
|
||||
PullToRefreshBox(isRefreshing = loading, onRefresh = onRefresh) {
|
||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = verticalArrangement) {
|
||||
item {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,5 @@ fun FullScreenLoading() {
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.wrapContentSize(Alignment.Center)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
@@ -57,8 +57,8 @@ 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.AcceptableUserRoles
|
||||
import ru.n08i40k.polytechnic.next.model.UserRole
|
||||
import ru.n08i40k.polytechnic.next.model.UserRole.Companion.AcceptableUserRoles
|
||||
import ru.n08i40k.polytechnic.next.network.data.auth.LoginRequest
|
||||
import ru.n08i40k.polytechnic.next.network.data.auth.LoginRequestData
|
||||
import ru.n08i40k.polytechnic.next.network.data.auth.RegisterRequest
|
||||
@@ -125,27 +125,29 @@ private fun LoginForm(
|
||||
Text(text = stringResource(R.string.not_registered))
|
||||
}
|
||||
|
||||
Button(onClick = {
|
||||
if (username.length < 4) usernameError = true
|
||||
if (password.isEmpty()) passwordError = true
|
||||
Button(
|
||||
enabled = !mutableIsLoading.value,
|
||||
onClick = {
|
||||
if (username.length < 4) usernameError = true
|
||||
if (password.isEmpty()) passwordError = true
|
||||
|
||||
if (usernameError || passwordError) return@Button
|
||||
if (usernameError || passwordError) return@Button
|
||||
|
||||
tryLogin(
|
||||
username,
|
||||
password,
|
||||
mutableUsernameError,
|
||||
mutablePasswordError,
|
||||
mutableIsLoading,
|
||||
context,
|
||||
snackbarHostState,
|
||||
scope,
|
||||
navController
|
||||
)
|
||||
tryLogin(
|
||||
username,
|
||||
password,
|
||||
mutableUsernameError,
|
||||
mutablePasswordError,
|
||||
mutableIsLoading,
|
||||
context,
|
||||
snackbarHostState,
|
||||
scope,
|
||||
navController
|
||||
)
|
||||
|
||||
mutableIsLoading.value = true
|
||||
focusManager.clearFocus()
|
||||
}) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.login),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
@@ -253,9 +255,9 @@ private fun RegisterForm(
|
||||
navController
|
||||
)
|
||||
|
||||
mutableIsLoading.value = true
|
||||
focusManager.clearFocus()
|
||||
}) {
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.register),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
@@ -364,9 +366,9 @@ fun tryLogin(
|
||||
) {
|
||||
var isLoading by mutableIsLoading
|
||||
|
||||
LoginRequest(LoginRequestData(username, password), context, {
|
||||
scope.launch { snackbarHostState.showSnackbar("Cool!") }
|
||||
isLoading = true
|
||||
|
||||
LoginRequest(LoginRequestData(username, password), context, {
|
||||
runBlocking {
|
||||
context.settingsDataStore.updateData { currentSettings ->
|
||||
currentSettings
|
||||
@@ -378,6 +380,8 @@ fun tryLogin(
|
||||
}
|
||||
|
||||
UsersMeRequest(context, {
|
||||
scope.launch { snackbarHostState.showSnackbar("Cool!") }
|
||||
|
||||
runBlocking {
|
||||
context.settingsDataStore.updateData { currentSettings ->
|
||||
currentSettings
|
||||
@@ -388,7 +392,7 @@ fun tryLogin(
|
||||
}
|
||||
|
||||
navController.navigate("main")
|
||||
}, {}).send()
|
||||
}, null).send()
|
||||
}, {
|
||||
isLoading = false
|
||||
|
||||
@@ -431,6 +435,8 @@ fun tryRegister(
|
||||
) {
|
||||
var isLoading by mutableIsLoading
|
||||
|
||||
isLoading = true
|
||||
|
||||
RegisterRequest(
|
||||
RegisterRequestData(
|
||||
username,
|
||||
|
||||
@@ -3,17 +3,22 @@ package ru.n08i40k.polytechnic.next.ui.main
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import ru.n08i40k.polytechnic.next.R
|
||||
|
||||
data class BottomNavItem(
|
||||
@StringRes val label: Int, val icon: ImageVector, val route: String
|
||||
@StringRes val label: Int,
|
||||
val icon: ImageVector,
|
||||
val route: String,
|
||||
val isAdmin: Boolean = false
|
||||
)
|
||||
|
||||
object Constants {
|
||||
val bottomNavItem = listOf(
|
||||
BottomNavItem(R.string.profile, Icons.Filled.AccountCircle, "profile"),
|
||||
BottomNavItem(R.string.replacer, Icons.Filled.Create, "replacer", true),
|
||||
BottomNavItem(R.string.schedule, Icons.Filled.DateRange, "schedule")
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -31,10 +32,14 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import ru.n08i40k.polytechnic.next.MainViewModel
|
||||
import ru.n08i40k.polytechnic.next.model.UserRole
|
||||
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
|
||||
import ru.n08i40k.polytechnic.next.ui.main.profile.ProfileScreen
|
||||
import ru.n08i40k.polytechnic.next.ui.main.replacer.ReplacerScreen
|
||||
import ru.n08i40k.polytechnic.next.ui.main.schedule.ScheduleScreen
|
||||
import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState
|
||||
import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel
|
||||
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerViewModel
|
||||
import ru.n08i40k.polytechnic.next.ui.model.ScheduleViewModel
|
||||
import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
|
||||
|
||||
@@ -43,13 +48,14 @@ import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
|
||||
private fun NavHostContainer(
|
||||
navController: NavHostController,
|
||||
padding: PaddingValues,
|
||||
scheduleViewModel: ScheduleViewModel
|
||||
scheduleViewModel: ScheduleViewModel,
|
||||
scheduleReplacerViewModel: ScheduleReplacerViewModel?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Constants.bottomNavItem[1].route,
|
||||
startDestination = "schedule",
|
||||
modifier = Modifier.padding(paddingValues = padding),
|
||||
enterTransition = {
|
||||
slideIn(
|
||||
@@ -76,27 +82,36 @@ private fun NavHostContainer(
|
||||
composable("schedule") {
|
||||
ScheduleScreen(scheduleViewModel) { scheduleViewModel.refreshGroup() }
|
||||
}
|
||||
|
||||
if (scheduleReplacerViewModel != null) {
|
||||
composable("replacer") {
|
||||
ReplacerScreen(scheduleReplacerViewModel) { scheduleReplacerViewModel.refresh() }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomNavBar(navController: NavHostController) {
|
||||
private fun BottomNavBar(navController: NavHostController, isAdmin: Boolean) {
|
||||
NavigationBar {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
Constants.bottomNavItem.forEach { navItem ->
|
||||
Constants.bottomNavItem.forEach {
|
||||
if (it.isAdmin && !isAdmin)
|
||||
return@forEach
|
||||
|
||||
NavigationBarItem(
|
||||
selected = navItem.route == currentRoute,
|
||||
onClick = { if (navItem.route != currentRoute) navController.navigate(navItem.route) },
|
||||
selected = it.route == currentRoute,
|
||||
onClick = { if (it.route != currentRoute) navController.navigate(it.route) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = navItem.icon,
|
||||
contentDescription = stringResource(navItem.label)
|
||||
imageVector = it.icon,
|
||||
contentDescription = stringResource(it.label)
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(navItem.label)) })
|
||||
label = { Text(stringResource(it.label)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,14 +141,23 @@ fun MainScreen(
|
||||
onUnauthorized = { appNavController.navigate("auth") })
|
||||
)
|
||||
|
||||
val profileUiState by LocalContext.current.profileViewModel!!.uiState.collectAsStateWithLifecycle()
|
||||
val isAdmin = (profileUiState is ProfileUiState.HasProfile) &&
|
||||
(profileUiState as ProfileUiState.HasProfile).profile.role == UserRole.ADMIN
|
||||
|
||||
val scheduleReplacerViewModel: ScheduleReplacerViewModel? =
|
||||
if (isAdmin) hiltViewModel(LocalContext.current as ComponentActivity)
|
||||
else null
|
||||
|
||||
val navController = rememberNavController()
|
||||
Scaffold(
|
||||
bottomBar = { BottomNavBar(navController = navController) }
|
||||
bottomBar = { BottomNavBar(navController, isAdmin) }
|
||||
) { paddingValues ->
|
||||
NavHostContainer(
|
||||
navController,
|
||||
paddingValues,
|
||||
scheduleViewModel
|
||||
scheduleViewModel,
|
||||
scheduleReplacerViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import ru.n08i40k.polytechnic.next.data.users.impl.FakeProfileRepository
|
||||
import ru.n08i40k.polytechnic.next.model.Profile
|
||||
import ru.n08i40k.polytechnic.next.network.data.profile.ChangeGroupRequest
|
||||
import ru.n08i40k.polytechnic.next.network.data.profile.ChangeGroupRequestData
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetGroupNamesRequest
|
||||
import ru.n08i40k.polytechnic.next.network.data.schedule.ScheduleGetGroupNamesReq
|
||||
|
||||
private enum class ChangeGroupError {
|
||||
NOT_EXISTS
|
||||
@@ -65,7 +65,7 @@ private fun getGroups(context: Context): ArrayList<String> {
|
||||
val groups = remember { arrayListOf(groupPlaceholder) }
|
||||
|
||||
LaunchedEffect(groups) {
|
||||
ScheduleGetGroupNamesRequest(context, {
|
||||
ScheduleGetGroupNamesReq(context, {
|
||||
groups.clear()
|
||||
groups.addAll(it.names)
|
||||
}, {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package ru.n08i40k.polytechnic.next.ui.main.replacer
|
||||
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.n08i40k.polytechnic.next.R
|
||||
import ru.n08i40k.polytechnic.next.data.MockAppContainer
|
||||
import ru.n08i40k.polytechnic.next.data.schedule.impl.FakeScheduleReplacerRepository
|
||||
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
|
||||
import ru.n08i40k.polytechnic.next.ui.LoadingContent
|
||||
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerUiState
|
||||
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerViewModel
|
||||
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
@Composable
|
||||
fun ReplacerScreen(
|
||||
scheduleReplacerViewModel: ScheduleReplacerViewModel = ScheduleReplacerViewModel(
|
||||
MockAppContainer(
|
||||
LocalContext.current
|
||||
)
|
||||
),
|
||||
refresh: () -> Unit = {}
|
||||
) {
|
||||
val uiState by scheduleReplacerViewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
var uri by remember { mutableStateOf<Uri?>(null) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
|
||||
uri = it
|
||||
}
|
||||
|
||||
UploadFile(scheduleReplacerViewModel, uri) { uri = null }
|
||||
|
||||
LoadingContent(
|
||||
empty = when (uiState) {
|
||||
is ScheduleReplacerUiState.NoData -> uiState.isLoading
|
||||
is ScheduleReplacerUiState.HasData -> false
|
||||
},
|
||||
loading = uiState.isLoading,
|
||||
onRefresh = refresh,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
content = {
|
||||
when (uiState) {
|
||||
is ScheduleReplacerUiState.NoData -> {
|
||||
if (!uiState.isLoading) {
|
||||
TextButton(onClick = refresh, modifier = Modifier.fillMaxSize()) {
|
||||
Text(stringResource(R.string.reload), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ScheduleReplacerUiState.HasData -> {
|
||||
Column {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
ClearButton(Modifier.fillMaxWidth(0.5F)) {
|
||||
scheduleReplacerViewModel.clear()
|
||||
}
|
||||
|
||||
SetNewButton(Modifier.fillMaxWidth()) {
|
||||
launcher.launch(arrayOf("application/vnd.ms-excel"))
|
||||
}
|
||||
}
|
||||
|
||||
ReplacerList((uiState as ScheduleReplacerUiState.HasData).replacers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UploadFile(
|
||||
scheduleReplacerViewModel: ScheduleReplacerViewModel,
|
||||
uri: Uri?,
|
||||
onFinish: () -> Unit
|
||||
) {
|
||||
if (uri == null)
|
||||
return
|
||||
|
||||
val context = LocalContext.current
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
// get file name
|
||||
val query = contentResolver.query(uri, null, null, null, null)
|
||||
if (query == null) {
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
|
||||
val fileName = query.use { cursor ->
|
||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
|
||||
cursor.getString(nameIdx)
|
||||
}
|
||||
|
||||
// get file type
|
||||
val fileType: String? = contentResolver.getType(uri)
|
||||
if (fileType == null) {
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
|
||||
// get file data
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
onFinish()
|
||||
return
|
||||
}
|
||||
|
||||
val fileData = inputStream.readBytes()
|
||||
|
||||
inputStream.close()
|
||||
|
||||
scheduleReplacerViewModel.set(fileName, fileData, fileType)
|
||||
onFinish()
|
||||
}
|
||||
|
||||
//@Preview(showBackground = true)
|
||||
//@Composable
|
||||
//private fun UploadFileDialog(
|
||||
// opened: Boolean = true,
|
||||
// onClose: () -> Unit = {}
|
||||
//) {
|
||||
// Dialog(onDismissRequest = onClose) {
|
||||
// Card {
|
||||
// Button
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SetNewButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
|
||||
Button(modifier = modifier, onClick = onClick) {
|
||||
val setReplacerText = stringResource(R.string.set_replacer)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(0.dp, 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Add, contentDescription = setReplacerText)
|
||||
Text(text = setReplacerText)
|
||||
Icon(imageVector = Icons.Filled.Add, contentDescription = setReplacerText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ClearButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
|
||||
Button(modifier = modifier, onClick = onClick) {
|
||||
val clearReplacersText = stringResource(R.string.clear_replacers)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(0.dp, 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Delete, contentDescription = clearReplacersText)
|
||||
Text(text = clearReplacersText)
|
||||
Icon(imageVector = Icons.Filled.Delete, contentDescription = clearReplacersText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ReplacerElement(replacer: ScheduleReplacer = FakeScheduleReplacerRepository.exampleReplacers[0]) {
|
||||
Column(
|
||||
modifier = Modifier.border(
|
||||
BorderStroke(
|
||||
Dp.Hairline,
|
||||
MaterialTheme.colorScheme.inverseSurface
|
||||
)
|
||||
)
|
||||
) {
|
||||
val modifier = Modifier.fillMaxWidth()
|
||||
|
||||
Text(modifier = modifier, textAlign = TextAlign.Center, text = replacer.etag)
|
||||
Text(modifier = modifier, textAlign = TextAlign.Center, text = buildString {
|
||||
append(replacer.size)
|
||||
append(" ")
|
||||
append(stringResource(R.string.bytes))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ReplacerList(replacers: List<ScheduleReplacer> = FakeScheduleReplacerRepository.exampleReplacers) {
|
||||
Surface {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(0.dp, 5.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(500.dp)
|
||||
) {
|
||||
items(replacers) {
|
||||
ReplacerElement(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import ru.n08i40k.polytechnic.next.model.Group
|
||||
import java.util.Calendar
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@Preview(showBackground = true, showSystemUi = true)
|
||||
@Preview
|
||||
@Composable
|
||||
fun DayPager(group: Group = FakeScheduleRepository.exampleGroup) {
|
||||
val currentDay = (Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 2)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package ru.n08i40k.polytechnic.next.ui.model
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.n08i40k.polytechnic.next.data.AppContainer
|
||||
import ru.n08i40k.polytechnic.next.data.MyResult
|
||||
import ru.n08i40k.polytechnic.next.model.ScheduleReplacer
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface ScheduleReplacerUiState {
|
||||
val isLoading: Boolean
|
||||
|
||||
data class NoData(
|
||||
override val isLoading: Boolean,
|
||||
) : ScheduleReplacerUiState
|
||||
|
||||
data class HasData(
|
||||
override val isLoading: Boolean,
|
||||
val replacers: List<ScheduleReplacer>,
|
||||
) : ScheduleReplacerUiState
|
||||
}
|
||||
|
||||
private data class ScheduleReplacerViewModelState(
|
||||
val isLoading: Boolean = false,
|
||||
val replacers: List<ScheduleReplacer>? = null,
|
||||
) {
|
||||
fun toUiState(): ScheduleReplacerUiState =
|
||||
if (replacers == null)
|
||||
ScheduleReplacerUiState.NoData(isLoading)
|
||||
else
|
||||
ScheduleReplacerUiState.HasData(isLoading, replacers)
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ScheduleReplacerViewModel @Inject constructor(
|
||||
appContainer: AppContainer
|
||||
) : ViewModel() {
|
||||
private val scheduleReplacerRepository = appContainer.scheduleReplacerRepository
|
||||
private val viewModelState = MutableStateFlow(ScheduleReplacerViewModelState(isLoading = true))
|
||||
|
||||
val uiState = viewModelState
|
||||
.map(ScheduleReplacerViewModelState::toUiState)
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUiState())
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
setLoading()
|
||||
|
||||
viewModelScope.launch { update() }
|
||||
}
|
||||
|
||||
fun set(
|
||||
fileName: String,
|
||||
fileData: ByteArray,
|
||||
fileType: String
|
||||
) {
|
||||
setLoading()
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = scheduleReplacerRepository.setCurrent(fileName, fileData, fileType)
|
||||
|
||||
if (result is MyResult.Success) update()
|
||||
else setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
setLoading()
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = scheduleReplacerRepository.clear()
|
||||
|
||||
viewModelState.update {
|
||||
when (result) {
|
||||
is MyResult.Failure -> it.copy(isLoading = false)
|
||||
is MyResult.Success -> it.copy(isLoading = false, replacers = emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLoading(loading: Boolean = true) {
|
||||
viewModelState.update { it.copy(isLoading = loading) }
|
||||
}
|
||||
|
||||
private suspend fun update() {
|
||||
val result = scheduleReplacerRepository.getAll()
|
||||
|
||||
viewModelState.update {
|
||||
when (result) {
|
||||
is MyResult.Success -> {
|
||||
it.copy(
|
||||
replacers = result.data,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
|
||||
is MyResult.Failure -> it.copy(
|
||||
replacers = null,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package ru.n08i40k.polytechnic.next.ui.model
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
кimport androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -14,7 +14,6 @@ import ru.n08i40k.polytechnic.next.data.AppContainer
|
||||
import ru.n08i40k.polytechnic.next.data.MyResult
|
||||
import ru.n08i40k.polytechnic.next.model.Group
|
||||
import java.util.Date
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface ScheduleUiState {
|
||||
@@ -72,8 +71,6 @@ class ScheduleViewModel @Inject constructor(
|
||||
is MyResult.Success -> {
|
||||
val updateDates = networkCacheRepository.getUpdateDates()
|
||||
|
||||
Logger.getLogger("ScheduleViewModel").info("Updating...")
|
||||
|
||||
it.copy(
|
||||
group = result.data,
|
||||
updateDates = updateDates,
|
||||
|
||||
@@ -36,4 +36,8 @@
|
||||
<string name="last_server_cache_update">Последнее обновление кеша</string>
|
||||
<string name="last_server_schedule_update">Последнее обновление расписания</string>
|
||||
<string name="update_info_header">Дополнительная информация</string>
|
||||
<string name="replacer">Заменитель</string>
|
||||
<string name="bytes">байт</string>
|
||||
<string name="clear_replacers">Удалить всё</string>
|
||||
<string name="set_replacer">Загрузить новое расписание</string>
|
||||
</resources>
|
||||
@@ -36,4 +36,8 @@
|
||||
<string name="last_server_cache_update">Last server cache update</string>
|
||||
<string name="last_server_schedule_update">Last server schedule update</string>
|
||||
<string name="update_info_header">Additional information</string>
|
||||
<string name="replacer">Replacer</string>
|
||||
<string name="bytes">bytes</string>
|
||||
<string name="clear_replacers">Clear</string>
|
||||
<string name="set_replacer">Set new</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user