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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\StepanovPlaton\.android\avd\Medium_Phone_API_30.avd" /> <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 { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
} }
@@ -35,8 +36,21 @@ android {
androidComponents { androidComponents {
onVariants { onVariants {
it.buildConfigFields.putAll(mapOf( it.buildConfigFields.putAll(mapOf(
Pair("BASE_URL", BuildConfigField("String", "\"https://lk.ssau.ru/\"", null)), Pair("BASE_URL",
Pair("SIGN_IN_URL", BuildConfigField("String", "\"account/login\"", null)) 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 { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@@ -82,4 +95,5 @@ dependencies {
implementation(libs.squareup.okhttp) implementation(libs.squareup.okhttp)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.kotlinx.serialization.json)
} }

View File

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

View File

@@ -1,5 +1,6 @@
package com.example.ssau_schedule package com.example.ssau_schedule
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -8,10 +9,9 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -32,40 +32,58 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment 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.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat 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.AuthErrorMessage
import com.example.ssau_schedule.api.AuthorizationAPI import com.example.ssau_schedule.api.AuthorizationAPI
import com.example.ssau_schedule.api.Http 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 import kotlin.math.min
class AuthActivity : ComponentActivity() { class AuthActivity : ComponentActivity() {
private val http = Http() private val http = Http()
private var auth: AuthorizationAPI? = null private var authApi: AuthorizationAPI? = null
private var userApi: GeneralApi? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
auth = AuthorizationAPI.getInstance(http, applicationContext)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
@@ -77,129 +95,256 @@ class AuthActivity : ComponentActivity() {
@Composable @Composable
fun AuthPage() { 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 keyboardOpen by Utils.keyboardState()
val snackbarHostState = remember { SnackbarHostState() }
val logoHeight by animateFloatAsState( 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", label = "alpha",
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
)) )
)
LaunchedEffect(user, group, year) {
LaunchedEffect(false) { if(user != null && group != null && year != null) {
delay(1000) delay(1500)
loginOpen = true startActivity(Intent(applicationContext, MainActivity::class.java))
}
} }
Box(Modifier.background(MaterialTheme.colorScheme.primary) LaunchedEffect(entered) {
.fillMaxSize().statusBarsPadding().navigationBarsPadding().imePadding(), 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), contentAlignment = BiasAlignment(0f, -0.25f),
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.widthIn(0.dp, 500.dp) Box(
.height(logoHeight.dp).padding(20.dp, 0.dp) Modifier
) { .widthIn(0.dp, 500.dp)
.height(logoHeight.dp)
.padding(20.dp, 0.dp)) {
Image(painterResource(R.drawable.ssau_logo_01), Image(painterResource(R.drawable.ssau_logo_01),
contentDescription = stringResource(R.string.samara_university), contentDescription = stringResource(R.string.samara_university),
modifier = Modifier.fillMaxSize().padding(10.dp), modifier = Modifier
.fillMaxSize()
.padding(10.dp),
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
alignment = Alignment.TopCenter) alignment = Alignment.TopCenter)
} }
Box(Modifier.padding(20.dp, 0.dp).widthIn(0.dp, 400.dp)) { Box(
Card(Modifier.fillMaxWidth().animateContentSize(animationSpec = spring( Modifier
dampingRatio = Spring.DampingRatioLowBouncy, .padding(20.dp, 0.dp)
stiffness = 50f .widthIn(0.dp, 400.dp)) {
)) Column {
.height(if(loginOpen) 280.dp else 0.dp), WelcomeMessage(user, group, year)
colors = CardDefaults.cardColors( AuthForm(open = needAuth) {
containerColor = MaterialTheme.colorScheme.background, needAuth = false
), entered = true
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
) {
AuthForm()
} }
} }
}
} }
} }
} }
}
@Composable @Composable
fun AuthForm() { fun AuthForm(open: Boolean, callback: () -> Unit) {
val authScope = rememberCoroutineScope()
var login by remember { mutableStateOf("") } var login by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<AuthErrorMessage?>(null) } 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 horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(stringResource(R.string.sign_in),
stringResource(R.string.sign_in),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.displaySmall style = MaterialTheme.typography.displaySmall)
) OutlinedTextField(modifier = Modifier.fillMaxWidth(),
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = login, value = login,
onValueChange = { login = it; error = null }, onValueChange = { login = it; error = null },
label = { Text(stringResource(R.string.login)) }, label = { Text(stringResource(R.string.login)) },
placeholder = placeholder = { Text(stringResource(R.string.enter_your_login)) })
{ Text(stringResource(R.string.enter_your_login)) },
)
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
OutlinedTextField( OutlinedTextField(modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
value = password, value = password,
onValueChange = { password = it; error = null }, onValueChange = { password = it; error = null },
label = { Text(stringResource(R.string.password)) }, label = { Text(stringResource(R.string.password)) },
placeholder = placeholder = { Text(stringResource(R.string.enter_your_password)) })
{ Text(stringResource(R.string.enter_your_password)) },
)
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(14.dp)) { Box(
Modifier
.fillMaxWidth()
.height(14.dp)) {
this@Column.AnimatedVisibility( this@Column.AnimatedVisibility(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
visible = error !== null, visible = error !== null
enter = fadeIn(animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessVeryLow
)),
exit = fadeOut(animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessVeryLow
))
) { ) {
Text(error?.getMessage(applicationContext) ?: "", Text(error?.getMessage(applicationContext) ?: "",
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelSmall)
)
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
FilledTonalButton(onClick = { FilledTonalButton(
if(login.length < 5) error = AuthErrorMessage.LOGIN_IS_TOO_SHORT onClick = {
else if(password.length < 5) error = AuthErrorMessage.PASSWORD_IS_TOO_SHORT if (login.length < 5)
error = AuthErrorMessage.LOGIN_IS_TOO_SHORT
else if (password.length < 5)
error = AuthErrorMessage.PASSWORD_IS_TOO_SHORT
else { else {
auth?.signIn(login, password, authScope, authApi?.signIn(login, password,
{ startActivity(Intent(applicationContext, AuthActivity::class.java)) }, { callback() },
{ _, _ -> { error = AuthErrorMessage.INCORRECT_LOGIN_OR_PASSWORD }
error = AuthErrorMessage.INCORRECT_LOGIN_OR_PASSWORD
}
) )
} }
}, },
shape = RoundedCornerShape(50), shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary) containerColor = MaterialTheme.colorScheme.primary
)
) { ) {
Text(stringResource(R.string.sign_in)) 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 package com.example.ssau_schedule.api
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.edit
import com.example.ssau_schedule.BuildConfig import com.example.ssau_schedule.BuildConfig
import com.example.ssau_schedule.R import com.example.ssau_schedule.R
import com.example.ssau_schedule.data.source.AuthStoreKeys import com.example.ssau_schedule.data.store.AuthStore
import com.example.ssau_schedule.data.source.authStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.Headers.Companion.toHeaders import okhttp3.Headers.Companion.toHeaders
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okio.IOException
import org.json.JSONArray import org.json.JSONArray
enum class AuthErrorMessage(private val resource: Int) { enum class AuthErrorMessage(private val resource: Int) {
@@ -25,63 +20,48 @@ enum class AuthErrorMessage(private val resource: Int) {
context.getString(resource) context.getString(resource)
} }
class AuthorizationAPI(private var http: Http, private var context: Context) { class AuthorizationAPI(
companion object { private var http: Http,
@SuppressLint("StaticFieldLeak") private var context: Context,
@Volatile private var scope: CoroutineScope
private var INSTANCE: AuthorizationAPI? = null ) {
fun getInstance(http: Http, context: Context) =
INSTANCE ?: synchronized(this) {
INSTANCE ?: AuthorizationAPI(http, context).also {
INSTANCE = it
}
}
}
private val responseHasAuthToken = private val responseHasAuthToken =
{response: Response? -> response?.headers?.toMap()?.containsKey("set-cookie") == true} { response: Response? -> response?.headers?.toMap()?.containsKey("set-cookie") == true }
private fun safeAuthToken(response: Response, fun signIn(
authScope: CoroutineScope, login: String, password: String,
callback: HttpResponseCallback) { callback: (token: String) -> Unit,
authScope.launch { exceptionCallback: ((error: HttpRequestException) -> Unit)? = null
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) {
http.request( http.request(
Method.POST, Method.POST,
BuildConfig.SIGN_IN_URL, BuildConfig.SIGN_IN_URL,
JSONArray(arrayOf(mapOf( JSONArray(
arrayOf(
mapOf(
Pair("login", login), Pair("login", login),
Pair("password", password) Pair("password", password)
))).toString().toRequestBody("application/json".toMediaType()), )
)
).toString().toRequestBody("application/json".toMediaType()),
mapOf( mapOf(
Pair("Next-Action", "b395d17834d8b7df06372cbf1f241170a272d540") Pair("Next-Action", "b395d17834d8b7df06372cbf1f241170a272d540")
).toHeaders(), ).toHeaders(),
fun(response) { fun(response) {
if(responseHasAuthToken(response)) if (responseHasAuthToken(response)) {
safeAuthToken(response, authScope, callback) val token = response.headers("set-cookie").joinToString(", ")
else if(exceptionCallback != null) AuthStore.setAuthToken(token, context, scope) { callback(token) }
exceptionCallback(IOException("Authorization token not found"), response) } else
exceptionCallback?.invoke(
HttpRequestException("Authorization token not found"))
}, },
fun (error, response): Boolean { fun(error, response): Boolean {
if(responseHasAuthToken(response)) return true if (responseHasAuthToken(response)) return true
if(exceptionCallback !== null) exceptionCallback(error, response) exceptionCallback?.invoke(error)
return false 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.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.Response import okhttp3.Response
import java.io.IOException import okio.IOException
enum class Method { enum class Method {
GET, GET,
@@ -18,9 +18,14 @@ enum class Method {
DELETE DELETE
} }
typealias HttpResponseCallback = (response: Response) -> Unit typealias HttpRequestException = IOException
typealias HttpExceptionVerifyCallback = (exception: IOException, response: Response?) -> Boolean
typealias HttpExceptionCallback = (exception: IOException, response: Response?) -> Unit typealias HttpResponseCallback =
(response: Response) -> Unit
typealias HttpExceptionVerifyCallback =
(exception: HttpRequestException, response: Response?) -> Boolean
typealias HttpExceptionCallback =
(exception: HttpRequestException, response: Response?) -> Unit
class Http { class Http {
val http = OkHttpClient() val http = OkHttpClient()
@@ -33,22 +38,38 @@ class Http {
callback: HttpResponseCallback, callback: HttpResponseCallback,
exceptionCallback: HttpExceptionVerifyCallback? = null exceptionCallback: HttpExceptionVerifyCallback? = null
) { ) {
val request = Request.Builder().url(BuildConfig.BASE_URL+url). val request =
method(method.toString(), body) Request.Builder().url(BuildConfig.BASE_URL + url).method(method.toString(), body)
if(headers !== null) request.headers(headers) if (headers !== null) request.headers(headers)
http.newCall(request.build()).enqueue(object : Callback { 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()) Log.e("Http request failed", e.toString())
if(exceptionCallback !== null) exceptionCallback(e, null) exceptionCallback?.invoke(e, null)
} }
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
var runCallback = false var runCallback = false
if (!response.isSuccessful && exceptionCallback !== null) if (!response.isSuccessful && exceptionCallback !== null)
runCallback = exceptionCallback( runCallback = exceptionCallback(
IOException("Http response is not successful"), response) HttpRequestException("Http response is not successful"), response
if(runCallback || response.isSuccessful) callback(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,17 +2,24 @@ package com.example.ssau_schedule.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Primary01 = Color(0xFF0D47A1) class ApplicationColors {
val Primary02 = Color(0xFF134BC5) companion object {
val Primary03 = Color(0xFF1D5DEB) val Primary01 = Color(0xFF0D47A1)
val Primary04 = Color(0xFF2780E3) val Primary02 = Color(0xFF134BC5)
val Primary05 = Color(0xFF6B92E5) val Primary03 = Color(0xFF1D5DEB)
val Primary06 = Color(0xFFA8C4EC) val Primary04 = Color(0xFF2780E3)
val Primary05 = Color(0xFF6B92E5)
val Primary06 = Color(0xFFA8C4EC)
val White = Color(0xFFFFFFFF) val White = Color(0xFFFFFFFF)
val Gray01 = Color(0xFF2C2C2C)
val Gray02 = Color(0xFF383838)
val Gray03 = Color(0xFF66727F)
val Red01 = Color(0xFFEE3F58)
val Red02 = Color(0xFF7E212E)
}
}
val Gray01 = Color(0xFF2C2C2C)
val Gray02 = Color(0xFF383838)
val Gray03 = Color(0xFF66727F)
val Red = Color(0xFFEE3F58)

View File

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

View File

@@ -10,4 +10,8 @@
<string name="incorrect_login_or_password">Incorrect login or password</string> <string name="incorrect_login_or_password">Incorrect login or password</string>
<string name="login_is_too_short">Login is too short</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="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> </resources>

View File

@@ -9,4 +9,8 @@
<string name="incorrect_login_or_password">Неверный логин или пароль</string> <string name="incorrect_login_or_password">Неверный логин или пароль</string>
<string name="login_is_too_short">Логин слишком короткий</string> <string name="login_is_too_short">Логин слишком короткий</string>
<string name="password_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> </resources>

View File

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

View File

@@ -11,6 +11,7 @@ composeBom = "2024.09.00"
okhttp = "4.12.0" okhttp = "4.12.0"
datastore = "1.1.1" datastore = "1.1.1"
media3Common = "1.4.1" media3Common = "1.4.1"
kotlinSerializationJson = "1.7.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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" } squareup-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
androidx-datastore = { group = "androidx.datastore", name="datastore-preferences", version.ref = "datastore" } androidx-datastore = { group = "androidx.datastore", name="datastore-preferences", version.ref = "datastore" }
androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3Common" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", 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" }