09-09 Auth page complete

This commit is contained in:
2024-09-09 23:18:53 +04:00
parent fcc3f992db
commit ec43b774de
18 changed files with 830 additions and 204 deletions

View File

@@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-09-08T10:52:57.628174800Z">
<DropdownSelection timestamp="2024-09-09T18:25:06.130054Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\StepanovPlaton\.android\avd\Medium_Phone_API_30.avd" />

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -3,6 +3,7 @@ import com.android.build.api.variant.BuildConfigField
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
}
@@ -35,8 +36,21 @@ android {
androidComponents {
onVariants {
it.buildConfigFields.putAll(mapOf(
Pair("BASE_URL", BuildConfigField("String", "\"https://lk.ssau.ru/\"", null)),
Pair("SIGN_IN_URL", BuildConfigField("String", "\"account/login\"", null))
Pair("BASE_URL",
BuildConfigField("String",
"\"https://lk.ssau.ru/\"", null)),
Pair("SIGN_IN_URL",
BuildConfigField("String",
"\"account/login\"", null)),
Pair("USER_DETAILS_URL",
BuildConfigField("String",
"\"api/proxy/current-user-details\"", null)),
Pair("USER_GROUPS_URL",
BuildConfigField("String",
"\"api/proxy/personal/groups\"", null)),
Pair("YEARS_URL",
BuildConfigField("String",
"\"api/proxy/dictionaries?slug=unified_years\"", null))
))
}
}
@@ -62,7 +76,6 @@ android {
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -82,4 +95,5 @@ dependencies {
implementation(libs.squareup.okhttp)
implementation(libs.androidx.datastore)
implementation(libs.kotlinx.serialization.json)
}

View File

@@ -25,5 +25,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.SSAU_Schedule"
android:screenOrientation="portrait">
</activity>
</application>
</manifest>

View File

@@ -1,5 +1,6 @@
package com.example.ssau_schedule
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -8,10 +9,9 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -32,40 +32,58 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import com.example.ssau_schedule.ui.theme.SSAU_ScheduleTheme
import kotlinx.coroutines.delay
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.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import com.example.ssau_schedule.api.ApiErrorMessage
import com.example.ssau_schedule.api.AuthErrorMessage
import com.example.ssau_schedule.api.AuthorizationAPI
import com.example.ssau_schedule.api.Http
import com.example.ssau_schedule.api.GeneralApi
import com.example.ssau_schedule.data.store.AuthStore
import com.example.ssau_schedule.data.store.GeneralStore
import com.example.ssau_schedule.data.store.Group
import com.example.ssau_schedule.data.store.User
import com.example.ssau_schedule.data.store.Year
import com.example.ssau_schedule.ui.theme.ApplicationColors
import com.example.ssau_schedule.ui.theme.SSAU_ScheduleTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import kotlin.math.min
class AuthActivity : ComponentActivity() {
private val http = Http()
private var auth: AuthorizationAPI? = null
private var authApi: AuthorizationAPI? = null
private var userApi: GeneralApi? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
auth = AuthorizationAPI.getInstance(http, applicationContext)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
@@ -77,129 +95,256 @@ class AuthActivity : ComponentActivity() {
@Composable
fun AuthPage() {
var loginOpen by remember { mutableStateOf(false) }
val authScope = rememberCoroutineScope()
authApi = remember { AuthorizationAPI(http, applicationContext, authScope) }
userApi = remember { GeneralApi(http, applicationContext, authScope) }
var user by remember { mutableStateOf<User?>(null) }
var group by remember { mutableStateOf<Group?>(null) }
var year by remember { mutableStateOf<Year?>(null) }
var needAuth by remember { mutableStateOf(false) }
var entered by remember { mutableStateOf(false) }
val keyboardOpen by Utils.keyboardState()
val snackbarHostState = remember { SnackbarHostState() }
val logoHeight by animateFloatAsState(
if (keyboardOpen) 0f else min(LocalConfiguration.current.screenWidthDp, 500) / 101f * 48f,
if (keyboardOpen) 0f else min(
LocalConfiguration.current.screenWidthDp,
500
) / 101f * 48f,
label = "alpha",
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
))
)
)
LaunchedEffect(false) {
delay(1000)
loginOpen = true
LaunchedEffect(user, group, year) {
if(user != null && group != null && year != null) {
delay(1500)
startActivity(Intent(applicationContext, MainActivity::class.java))
}
}
Box(Modifier.background(MaterialTheme.colorScheme.primary)
.fillMaxSize().statusBarsPadding().navigationBarsPadding().imePadding(),
LaunchedEffect(entered) {
delay(1000)
AuthStore.getAuthToken(applicationContext, authScope) { authToken ->
if(authToken != null) {
userApi?.getUserDetails(authToken, { u -> user = u }, { needAuth = true })
userApi?.getUserGroups(authToken,
{ groups ->
GeneralStore.getCurrentGroup(applicationContext, authScope) { g ->
if(g != null && groups.contains(g)) group = g
else {
GeneralStore.setCurrentGroup(groups[0],
applicationContext, authScope)
group = groups[0]
}
}
}, { error ->
if(error != ApiErrorMessage.USER_NOT_AUTHORIZED) {
authScope.launch {
val message = error.getMessage(applicationContext)
if(message != null) snackbarHostState.showSnackbar(message)
}
} else needAuth = true
})
GeneralStore.getCurrentYear(applicationContext, authScope) { y ->
if(y != null && y.hasDate(Date())) year = y
else {
userApi?.getYears(authToken,
{ rawYears ->
val currentRawYear = rawYears.find { y -> y.isCurrent }
if(currentRawYear != null) {
year = currentRawYear.toYear()
GeneralStore.setCurrentYear(year!!,
applicationContext, authScope)
} else {
authScope.launch {
val message = ApiErrorMessage.FAILED_GET_YEARS
.getMessage(applicationContext)
if(message != null)
snackbarHostState.showSnackbar(message)
}
}
}, { error ->
if(error != ApiErrorMessage.USER_NOT_AUTHORIZED) {
authScope.launch {
val message = error.getMessage(applicationContext)
if(message != null)
snackbarHostState.showSnackbar(message)
}
} else needAuth = true
})
}
}
}
else needAuth = true
}
}
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState) {
Snackbar(
snackbarData = it,
containerColor = MaterialTheme.colorScheme.errorContainer,
)
}
}
) { padding ->
Box(
Modifier
.background(MaterialTheme.colorScheme.primary)
.fillMaxSize()
.padding(padding)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding(),
contentAlignment = BiasAlignment(0f, -0.25f),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.widthIn(0.dp, 500.dp)
.height(logoHeight.dp).padding(20.dp, 0.dp)
) {
Box(
Modifier
.widthIn(0.dp, 500.dp)
.height(logoHeight.dp)
.padding(20.dp, 0.dp)) {
Image(painterResource(R.drawable.ssau_logo_01),
contentDescription = stringResource(R.string.samara_university),
modifier = Modifier.fillMaxSize().padding(10.dp),
modifier = Modifier
.fillMaxSize()
.padding(10.dp),
contentScale = ContentScale.FillWidth,
alignment = Alignment.TopCenter)
}
Box(Modifier.padding(20.dp, 0.dp).widthIn(0.dp, 400.dp)) {
Card(Modifier.fillMaxWidth().animateContentSize(animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = 50f
))
.height(if(loginOpen) 280.dp else 0.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background,
),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
) {
AuthForm()
Box(
Modifier
.padding(20.dp, 0.dp)
.widthIn(0.dp, 400.dp)) {
Column {
WelcomeMessage(user, group, year)
AuthForm(open = needAuth) {
needAuth = false
entered = true
}
}
}
}
}
}
}
@Composable
fun AuthForm() {
val authScope = rememberCoroutineScope()
fun AuthForm(open: Boolean, callback: () -> Unit) {
var login by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<AuthErrorMessage?>(null) }
Column(Modifier.fillMaxWidth().padding(30.dp, 20.dp),
val height by animateDpAsState(if (open) 290.dp else 0.dp, label = "Auth form height",
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
Card(
Modifier
.fillMaxWidth()
.height(height)
.padding(0.dp, 10.dp)
.shadow(10.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background,
),
) {
Column(
Modifier
.fillMaxWidth()
.padding(30.dp, 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(R.string.sign_in),
Text(stringResource(R.string.sign_in),
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.displaySmall
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.displaySmall)
OutlinedTextField(modifier = Modifier.fillMaxWidth(),
value = login,
onValueChange = { login = it; error = null },
label = { Text(stringResource(R.string.login)) },
placeholder =
{ Text(stringResource(R.string.enter_your_login)) },
)
placeholder = { Text(stringResource(R.string.enter_your_login)) })
Spacer(Modifier.height(2.dp))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
OutlinedTextField(modifier = Modifier.fillMaxWidth(),
value = password,
onValueChange = { password = it; error = null },
label = { Text(stringResource(R.string.password)) },
placeholder =
{ Text(stringResource(R.string.enter_your_password)) },
)
placeholder = { Text(stringResource(R.string.enter_your_password)) })
Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(14.dp)) {
Box(
Modifier
.fillMaxWidth()
.height(14.dp)) {
this@Column.AnimatedVisibility(
modifier = Modifier.align(Alignment.Center),
visible = error !== null,
enter = fadeIn(animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessVeryLow
)),
exit = fadeOut(animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessVeryLow
))
visible = error !== null
) {
Text(error?.getMessage(applicationContext) ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall
)
style = MaterialTheme.typography.labelSmall)
}
}
Spacer(Modifier.height(4.dp))
FilledTonalButton(onClick = {
if(login.length < 5) error = AuthErrorMessage.LOGIN_IS_TOO_SHORT
else if(password.length < 5) error = AuthErrorMessage.PASSWORD_IS_TOO_SHORT
FilledTonalButton(
onClick = {
if (login.length < 5)
error = AuthErrorMessage.LOGIN_IS_TOO_SHORT
else if (password.length < 5)
error = AuthErrorMessage.PASSWORD_IS_TOO_SHORT
else {
auth?.signIn(login, password, authScope,
{ startActivity(Intent(applicationContext, AuthActivity::class.java)) },
{ _, _ ->
error = AuthErrorMessage.INCORRECT_LOGIN_OR_PASSWORD
}
authApi?.signIn(login, password,
{ callback() },
{ error = AuthErrorMessage.INCORRECT_LOGIN_OR_PASSWORD }
)
}
},
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary)
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text(stringResource(R.string.sign_in))
}
}
}
}
@SuppressLint("SimpleDateFormat")
@Composable
fun WelcomeMessage(user: User?, group: Group?, year: Year?) {
val currentDate = remember { SimpleDateFormat("d MMMM").format(Date()) }
val currentYear = remember { Calendar.getInstance().get(Calendar.YEAR); }
Column(Modifier.fillMaxWidth().animateContentSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
if(user !== null && group != null && year != null) {
Text("Здравствуйте ${user.name}!",
color = ApplicationColors.White,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center)
Text("Расписание для группы ${group.name}",
color = ApplicationColors.White,
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center)
Text("$currentDate, ${year.getWeekOfDate(Date())} "+
"учебная неделя, ${currentYear}-${currentYear+1} учебный год",
color = ApplicationColors.White,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center)
}
}
}
}

View File

@@ -0,0 +1,50 @@
package com.example.ssau_schedule
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import com.example.ssau_schedule.api.Http
import com.example.ssau_schedule.ui.theme.SSAU_ScheduleTheme
class MainActivity : ComponentActivity() {
private val http = Http()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
SSAU_ScheduleTheme {
MainPage()
}
}
}
@Composable
fun MainPage() {
Box(
Modifier
.background(MaterialTheme.colorScheme.background)
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.imePadding(),
) {
Text("main page")
}
}
}

View File

@@ -1,19 +1,14 @@
package com.example.ssau_schedule.api
import android.annotation.SuppressLint
import android.content.Context
import androidx.datastore.preferences.core.edit
import com.example.ssau_schedule.BuildConfig
import com.example.ssau_schedule.R
import com.example.ssau_schedule.data.source.AuthStoreKeys
import com.example.ssau_schedule.data.source.authStore
import com.example.ssau_schedule.data.store.AuthStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.Headers.Companion.toHeaders
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.IOException
import org.json.JSONArray
enum class AuthErrorMessage(private val resource: Int) {
@@ -25,63 +20,48 @@ enum class AuthErrorMessage(private val resource: Int) {
context.getString(resource)
}
class AuthorizationAPI(private var http: Http, private var context: Context) {
companion object {
@SuppressLint("StaticFieldLeak")
@Volatile
private var INSTANCE: AuthorizationAPI? = null
fun getInstance(http: Http, context: Context) =
INSTANCE ?: synchronized(this) {
INSTANCE ?: AuthorizationAPI(http, context).also {
INSTANCE = it
}
}
}
class AuthorizationAPI(
private var http: Http,
private var context: Context,
private var scope: CoroutineScope
) {
private val responseHasAuthToken =
{ response: Response? -> response?.headers?.toMap()?.containsKey("set-cookie") == true }
private fun safeAuthToken(response: Response,
authScope: CoroutineScope,
callback: HttpResponseCallback) {
authScope.launch {
context.authStore.edit { authStore ->
authStore[AuthStoreKeys.AUTH_TOKEN] =
response.headers("set-cookie").joinToString(", ")
}.run {
callback(response)
}
}
}
fun signIn(login: String, password: String, authScope: CoroutineScope,
callback: HttpResponseCallback,
exceptionCallback: HttpExceptionCallback? = null) {
fun signIn(
login: String, password: String,
callback: (token: String) -> Unit,
exceptionCallback: ((error: HttpRequestException) -> Unit)? = null
) {
http.request(
Method.POST,
BuildConfig.SIGN_IN_URL,
JSONArray(arrayOf(mapOf(
JSONArray(
arrayOf(
mapOf(
Pair("login", login),
Pair("password", password)
))).toString().toRequestBody("application/json".toMediaType()),
)
)
).toString().toRequestBody("application/json".toMediaType()),
mapOf(
Pair("Next-Action", "b395d17834d8b7df06372cbf1f241170a272d540")
).toHeaders(),
fun(response) {
if(responseHasAuthToken(response))
safeAuthToken(response, authScope, callback)
else if(exceptionCallback != null)
exceptionCallback(IOException("Authorization token not found"), response)
if (responseHasAuthToken(response)) {
val token = response.headers("set-cookie").joinToString(", ")
AuthStore.setAuthToken(token, context, scope) { callback(token) }
} else
exceptionCallback?.invoke(
HttpRequestException("Authorization token not found"))
},
fun(error, response): Boolean {
if (responseHasAuthToken(response)) return true
if(exceptionCallback !== null) exceptionCallback(error, response)
exceptionCallback?.invoke(error)
return false
}
)
}
}

View File

@@ -0,0 +1,189 @@
package com.example.ssau_schedule.api
import android.content.Context
import android.util.Log
import com.example.ssau_schedule.BuildConfig
import com.example.ssau_schedule.R
import com.example.ssau_schedule.data.store.AuthStore
import com.example.ssau_schedule.data.store.Group
import com.example.ssau_schedule.data.store.RawYear
import com.example.ssau_schedule.data.store.User
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import okhttp3.Headers.Companion.toHeaders
enum class ApiErrorMessage(private val resource: Int?) {
FAILED_GET_USER_DETAILS(R.string.failder_get_user_details),
NOT_MEMBER_OF_ANY_GROUP(R.string.not_member_of_any_group),
FAILED_GET_USER_GROUPS(R.string.failed_get_user_groups),
FAILED_GET_YEARS(R.string.failed_get_years),
USER_NOT_AUTHORIZED(null);
fun getMessage(context: Context) =
if(resource != null) context.getString(resource) else null
}
class GeneralApi(
private var http: Http,
private var context: Context,
private var scope: CoroutineScope
) {
fun getUserDetails(
token: String,
callback: (user: User) -> Unit,
exceptionCallback: ((error: ApiErrorMessage) -> Unit)
) {
http.request(
Method.GET,
BuildConfig.USER_DETAILS_URL,
mapOf(
Pair("Cookie", token)
).toHeaders(),
{ response ->
try {
if(response.body != null) {
val serializer = Json {
isLenient = true
ignoreUnknownKeys = true
}
callback(serializer
.decodeFromString<User>(response.body!!.string()))
}
else exceptionCallback(ApiErrorMessage.FAILED_GET_USER_DETAILS)
}
catch(e: SerializationException) {
Log.e("Groups Deserialization exception", e.message ?: "")
exceptionCallback(ApiErrorMessage.FAILED_GET_USER_DETAILS)
}
catch (e: IllegalArgumentException) {
Log.e("Groups argument exception", e.message ?: "")
exceptionCallback(ApiErrorMessage.FAILED_GET_USER_DETAILS)
}
},
fun(_, r): Boolean {
if(r?.code == 401) exceptionCallback(ApiErrorMessage.USER_NOT_AUTHORIZED)
else exceptionCallback(ApiErrorMessage.FAILED_GET_USER_DETAILS)
return false
}
)
}
fun getUserDetails(
callback: (user: User) -> Unit,
exceptionCallback: ((error: ApiErrorMessage) -> Unit)
) {
AuthStore.getAuthToken(context, scope) { authToken ->
if(authToken != null)
getUserDetails(authToken, callback, exceptionCallback)
else exceptionCallback(ApiErrorMessage.USER_NOT_AUTHORIZED)
}
}
fun getUserGroups(
token: String,
callback: (groups: List<Group>) -> Unit,
exceptionCallback: (error: ApiErrorMessage) -> Unit
) {
http.request(
Method.GET,
BuildConfig.USER_GROUPS_URL,
mapOf(
Pair("Cookie", token)
).toHeaders(),
{ response ->
try {
if(response.body != null) {
val serializer = Json {
isLenient = true
ignoreUnknownKeys = true
}
val groups = serializer
.decodeFromString<List<Group>>(response.body!!.string())
if(groups.isNotEmpty()) callback(groups)
else { exceptionCallback(ApiErrorMessage.NOT_MEMBER_OF_ANY_GROUP) }
}
else exceptionCallback(ApiErrorMessage.FAILED_GET_USER_GROUPS)
}
catch(e: SerializationException) {
Log.e("Groups Deserialization exception", e.message ?: "")
exceptionCallback(ApiErrorMessage.FAILED_GET_USER_GROUPS)
}
catch (e: IllegalArgumentException) {
Log.e("Groups argument exception", e.message ?: "")
exceptionCallback(ApiErrorMessage.FAILED_GET_USER_GROUPS)
}
},
fun(_, r): Boolean {
if(r?.code == 401) exceptionCallback(ApiErrorMessage.USER_NOT_AUTHORIZED)
else exceptionCallback(ApiErrorMessage.FAILED_GET_USER_GROUPS)
return false
}
)
}
fun getUserGroups(
callback: (groups: List<Group>) -> Unit,
exceptionCallback: (error: ApiErrorMessage) -> Unit
) {
AuthStore.getAuthToken(context, scope) { authToken ->
if(authToken != null)
getUserGroups(authToken, callback, exceptionCallback)
else exceptionCallback(ApiErrorMessage.USER_NOT_AUTHORIZED)
}
}
fun getYears(
token: String,
callback: (rawYears: List<RawYear>) -> Unit,
exceptionCallback: (error: ApiErrorMessage) -> Unit
) {
http.request(
Method.GET,
BuildConfig.YEARS_URL,
mapOf(
Pair("Cookie", token)
).toHeaders(),
{ response ->
try {
if(response.body != null) {
val serializer = Json {
isLenient = true
ignoreUnknownKeys = true
}
val rawYears = serializer
.decodeFromString<List<RawYear>>(response.body!!.string())
if(rawYears.isNotEmpty()) callback(rawYears)
else { exceptionCallback(ApiErrorMessage.FAILED_GET_YEARS) }
}
else exceptionCallback(ApiErrorMessage.FAILED_GET_YEARS)
}
catch(e: SerializationException) {
Log.e("Groups Deserialization exception", e.message ?: "")
exceptionCallback(ApiErrorMessage.FAILED_GET_YEARS)
}
catch (e: IllegalArgumentException) {
Log.e("Groups argument exception", e.message ?: "")
exceptionCallback(ApiErrorMessage.FAILED_GET_YEARS)
}
},
fun(_, r): Boolean {
if(r?.code == 401) exceptionCallback(ApiErrorMessage.USER_NOT_AUTHORIZED)
else exceptionCallback(ApiErrorMessage.FAILED_GET_YEARS)
return false
}
)
}
fun getYears(
callback: (rawYears: List<RawYear>) -> Unit,
exceptionCallback: (error: ApiErrorMessage) -> Unit
) {
AuthStore.getAuthToken(context, scope) { authToken ->
if(authToken != null)
getYears(authToken, callback, exceptionCallback)
else exceptionCallback(ApiErrorMessage.USER_NOT_AUTHORIZED)
}
}
}

View File

@@ -9,7 +9,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import java.io.IOException
import okio.IOException
enum class Method {
GET,
@@ -18,9 +18,14 @@ enum class Method {
DELETE
}
typealias HttpResponseCallback = (response: Response) -> Unit
typealias HttpExceptionVerifyCallback = (exception: IOException, response: Response?) -> Boolean
typealias HttpExceptionCallback = (exception: IOException, response: Response?) -> Unit
typealias HttpRequestException = IOException
typealias HttpResponseCallback =
(response: Response) -> Unit
typealias HttpExceptionVerifyCallback =
(exception: HttpRequestException, response: Response?) -> Boolean
typealias HttpExceptionCallback =
(exception: HttpRequestException, response: Response?) -> Unit
class Http {
val http = OkHttpClient()
@@ -33,22 +38,38 @@ class Http {
callback: HttpResponseCallback,
exceptionCallback: HttpExceptionVerifyCallback? = null
) {
val request = Request.Builder().url(BuildConfig.BASE_URL+url).
method(method.toString(), body)
val request =
Request.Builder().url(BuildConfig.BASE_URL + url).method(method.toString(), body)
if (headers !== null) request.headers(headers)
http.newCall(request.build()).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
override fun onFailure(call: Call, e: HttpRequestException) {
Log.e("Http request failed", e.toString())
if(exceptionCallback !== null) exceptionCallback(e, null)
exceptionCallback?.invoke(e, null)
}
override fun onResponse(call: Call, response: Response) {
var runCallback = false
if (!response.isSuccessful && exceptionCallback !== null)
runCallback = exceptionCallback(
IOException("Http response is not successful"), response)
HttpRequestException("Http response is not successful"), response
)
if (runCallback || response.isSuccessful) callback(response)
}
})
}
fun request(
method: Method,
url: String,
headers: Headers? = null,
callback: HttpResponseCallback,
exceptionCallback: HttpExceptionVerifyCallback? = null
) = request(method, url, null, headers, callback, exceptionCallback)
fun request(
method: Method,
url: String,
callback: HttpResponseCallback,
exceptionCallback: HttpExceptionVerifyCallback? = null
) = request(method, url, null, null, callback, exceptionCallback)
}

View File

@@ -1,11 +0,0 @@
package com.example.ssau_schedule.data.source
import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
val Context.authStore by preferencesDataStore(name = "auth")
object AuthStoreKeys {
val AUTH_TOKEN = stringPreferencesKey("auth_token")
}

View File

@@ -0,0 +1,49 @@
package com.example.ssau_schedule.data.store
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
val Context.authStore by preferencesDataStore(name = "auth")
class AuthStore {
class Keys {
companion object {
val AUTH_TOKEN = stringPreferencesKey("auth_token")
}
}
companion object {
fun setAuthToken(
token: String,
context: Context,
scope: CoroutineScope,
callback: (() -> Unit)? = null
) {
scope.launch {
context.authStore.edit { authStore ->
authStore[Keys.AUTH_TOKEN] = token
}.run { callback?.invoke() }
}
}
fun getAuthToken(
context: Context,
scope: CoroutineScope,
callback: (token: String?) -> Unit
) {
scope.launch {
val authTokenFlow = context.authStore.data
.map { authStore -> authStore[Keys.AUTH_TOKEN] }
callback(authTokenFlow.first())
}
}
}
}

View File

@@ -0,0 +1,156 @@
package com.example.ssau_schedule.data.store
import android.annotation.SuppressLint
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
val Context.generalStore by preferencesDataStore(name = "user")
@Serializable
data class User(val name: String)
@Serializable
data class Group(val id: Int, val name: String)
data class Year(
val id: Int,
val startDate: Date,
val endDate: Date,
) {
companion object {
@SuppressLint("SimpleDateFormat")
val DateFormat = SimpleDateFormat("yyyy-MM-dd")
fun parseDate(dateString: String): Date = DateFormat.parse(dateString)!!
fun dateFormat(date: Date): String = DateFormat.format(date)
}
fun hasDate(date: Date) = date.after(startDate) && endDate.after(date)
fun getWeekOfDate(date: Date): Int {
val calendar = Calendar.getInstance()
calendar.minimalDaysInFirstWeek = 6
calendar.firstDayOfWeek = Calendar.MONDAY
calendar.time = startDate
val firstWeek = calendar.get(Calendar.WEEK_OF_YEAR)
calendar.time = date
return (calendar.get(Calendar.WEEK_OF_YEAR) - firstWeek)+1
}
}
@Serializable
data class RawYear(
val id: Int,
val startDate: String,
val endDate: String,
val isCurrent: Boolean,
) {
@SuppressLint("SimpleDateFormat")
fun toYear() = Year(
id = id,
startDate = Year.parseDate(startDate),
endDate = Year.parseDate(endDate),
)
}
class GeneralStore {
class Keys {
companion object {
val CURRENT_GROUP_ID = intPreferencesKey("group_id")
val CURRENT_GROUP_NAME = stringPreferencesKey("group_name")
val CURRENT_YEAR_ID = intPreferencesKey("year_id")
val CURRENT_YEAR_START = stringPreferencesKey("year_start")
val CURRENT_YEAR_END = stringPreferencesKey("year_end")
}
}
companion object {
fun setCurrentGroup(
group: Group,
context: Context,
scope: CoroutineScope,
callback: (() -> Unit)? = null
) {
scope.launch {
context.generalStore.edit { generalStore ->
generalStore[Keys.CURRENT_GROUP_ID] = group.id
generalStore[Keys.CURRENT_GROUP_NAME] = group.name
}.run { callback?.invoke() }
}
}
fun getCurrentGroup(
context: Context,
scope: CoroutineScope,
callback: (group: Group?) -> Unit
) {
scope.launch {
val currentGroupId = context.generalStore.data
.map { generalStore -> generalStore[Keys.CURRENT_GROUP_ID] }.first()
val currentGroupName = context.generalStore.data
.map { generalStore -> generalStore[Keys.CURRENT_GROUP_NAME] }.first()
callback(
if(currentGroupId != null && currentGroupName != null)
Group(id = currentGroupId,
name = currentGroupName)
else null
)
}
}
fun setCurrentYear(
year: Year,
context: Context,
scope: CoroutineScope,
callback: (() -> Unit)? = null
) {
scope.launch {
context.generalStore.edit { generalStore ->
generalStore[Keys.CURRENT_YEAR_ID] = year.id
generalStore[Keys.CURRENT_YEAR_START] = Year.dateFormat(year.startDate)
generalStore[Keys.CURRENT_YEAR_END] = Year.dateFormat(year.endDate)
}.run { callback?.invoke() }
}
}
fun getCurrentYear(
context: Context,
scope: CoroutineScope,
callback: (year: Year?) -> Unit
) {
scope.launch {
val currentYearId = context.generalStore.data
.map { generalStore -> generalStore[Keys.CURRENT_YEAR_ID] }.first()
val currentYearStartDate = context.generalStore.data
.map { generalStore -> generalStore[Keys.CURRENT_YEAR_START]
}.first()
val currentYearEndDate = context.generalStore.data
.map { generalStore -> generalStore[Keys.CURRENT_YEAR_END]
}.first()
callback(
if(currentYearId != null &&
currentYearStartDate != null &&
currentYearEndDate != null)
Year(id = currentYearId,
startDate = Year.parseDate(currentYearStartDate),
endDate = Year.parseDate(currentYearEndDate))
else null
)
}
}
}
}

View File

@@ -2,6 +2,8 @@ package com.example.ssau_schedule.ui.theme
import androidx.compose.ui.graphics.Color
class ApplicationColors {
companion object {
val Primary01 = Color(0xFF0D47A1)
val Primary02 = Color(0xFF134BC5)
val Primary03 = Color(0xFF1D5DEB)
@@ -15,4 +17,9 @@ val Gray01 = Color(0xFF2C2C2C)
val Gray02 = Color(0xFF383838)
val Gray03 = Color(0xFF66727F)
val Red = Color(0xFFEE3F58)
val Red01 = Color(0xFFEE3F58)
val Red02 = Color(0xFF7E212E)
}
}

View File

@@ -12,21 +12,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Primary01,
secondary = Primary03,
tertiary = Primary04,
background = Gray01,
surface = Gray02,
error = Red,
primary = ApplicationColors.Primary01,
secondary = ApplicationColors.Primary03,
tertiary = ApplicationColors.Primary04,
background = ApplicationColors.Gray01,
surface = ApplicationColors.Gray02,
error = ApplicationColors.Red01,
errorContainer = ApplicationColors.Red02
)
private val LightColorScheme = lightColorScheme(
primary = Primary01,
secondary = Primary03,
tertiary = Primary04,
background = White,
surface = Primary06,
error = Red,
primary = ApplicationColors.Primary01,
secondary = ApplicationColors.Primary03,
tertiary = ApplicationColors.Primary04,
background = ApplicationColors.White,
surface = ApplicationColors.Primary06,
error = ApplicationColors.Red01,
errorContainer = ApplicationColors.Red02
/* Other default colors to override
background = Color(0xFFFFFBFE),

View File

@@ -10,4 +10,8 @@
<string name="incorrect_login_or_password">Incorrect login or password</string>
<string name="login_is_too_short">Login is too short</string>
<string name="password_is_too_short">Password is too short</string>
<string name="not_member_of_any_group">The user is not a member of any study group. You can\'t get a schedule for him</string>
<string name="failed_get_user_groups">Не получилось получить список групп, в которых состоит пользователь. Расписание нельзя получить без учебной группы</string>
<string name="failed_get_years">It was not possible to obtain a list of academic years. The timetable cannot be obtained without the academic year</string>
<string name="failder_get_user_details">Failed to retrieve user information. The schedule cannot be obtained without user data</string>
</resources>

View File

@@ -9,4 +9,8 @@
<string name="incorrect_login_or_password">Неверный логин или пароль</string>
<string name="login_is_too_short">Логин слишком короткий</string>
<string name="password_is_too_short">Пароль слишком короткий</string>
<string name="not_member_of_any_group">Пользователь не состоит ни в одной учебной группе. Для него нельзя получить расписание</string>
<string name="failed_get_user_groups">Не получилось получить список групп, в которых состоит пользователь. Расписание нельзя получить без учебной группы</string>
<string name="failed_get_years">Не получилось получить список учебных годов. Расписание нельзя получить без учебного года</string>
<string name="failder_get_user_details">Не получилось получить информацию о пользователе. Расписание нельзя получить без пользовательских данных</string>
</resources>

View File

@@ -2,5 +2,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.compose.compiler) apply false
}

View File

@@ -11,6 +11,7 @@ composeBom = "2024.09.00"
okhttp = "4.12.0"
datastore = "1.1.1"
media3Common = "1.4.1"
kotlinSerializationJson = "1.7.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -30,8 +31,10 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
squareup-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
androidx-datastore = { group = "androidx.datastore", name="datastore-preferences", version.ref = "datastore" }
androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3Common" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerializationJson" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }