From f41b600ad94569d0ab7fcd7d1ab9f78394051054 Mon Sep 17 00:00:00 2001 From: ParovozikThomas Date: Thu, 5 Mar 2026 19:19:08 +0300 Subject: [PATCH 1/4] feat: add login screen logic, JWT token login, login screen ui --- mobile/app/build.gradle.kts | 24 + .../java/com/smartjam/app/MainActivity.kt | 62 +-- .../java/com/smartjam/app/data/api/AuthApi.kt | 17 + .../smartjam/app/data/api/NetworkModule.kt | 39 ++ .../smartjam/app/data/local/TokenStorage.kt | 79 ++++ .../smartjam/app/data/model/LoginRequest.kt | 6 + .../smartjam/app/data/model/LoginResponce.kt | 8 + .../com/smartjam/app/data/model/LoginState.kt | 8 + .../smartjam/app/data/model/RefreshRequest.kt | 5 + .../app/domain/repository/AuthRepository.kt | 101 ++++ .../smartjam/app/ui/navigation/NavGraph.kt | 0 .../app/ui/screens/login/LoginScreen.kt | 445 ++++++++++++++++++ .../app/ui/screens/login/LoginViewModel.kt | 91 ++++ 13 files changed, 855 insertions(+), 30 deletions(-) create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/LoginRequest.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponce.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/LoginState.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/RefreshRequest.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index 3cd1f66..d908e78 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -40,6 +40,30 @@ android { } dependencies { + + val nav_version = "2.9.7" + // Jetpack Compose integration + implementation("androidx.navigation:navigation-compose:$nav_version+") + + //network + implementation("com.squareup.retrofit2:retrofit:2.11.+") + implementation("com.squareup.okhttp3:okhttp:4.12.+") + + //serialization + implementation("com.squareup.retrofit2:converter-gson:2.11.+") + + //logging + implementation("com.squareup.okhttp3:logging-interceptor:4.12.+") + + //database + implementation("androidx.datastore:datastore-preferences:1.1.+") + + //coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.+") + + //ne pon + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.+") + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt index c13dade..498567e 100644 --- a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt @@ -1,47 +1,49 @@ package com.smartjam.app +import androidx.activity.compose.setContent +import androidx.lifecycle.viewmodel.compose.viewModel +import com.smartjam.app.ui.screens.login.LoginScreen 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.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.smartjam.app.ui.theme.SmartJamTheme +import androidx.compose.ui.graphics.Color +import com.smartjam.app.data.api.NetworkModule +import com.smartjam.app.data.local.TokenStorage +import com.smartjam.app.domain.repository.AuthRepository +import com.smartjam.app.ui.screens.login.LoginScreen +import com.smartjam.app.ui.screens.login.LoginViewModel +import com.smartjam.app.ui.screens.login.LoginViewModelFactory class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - enableEdgeToEdge() + val tokenStorage = TokenStorage(context = this) + + val authApi = NetworkModule.authApi + + val authRepository = AuthRepository(tokenStorage, authApi) + + val factory = LoginViewModelFactory(authRepository) + setContent { - SmartJamTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) + val viewModel: LoginViewModel = viewModel(factory = factory) + + LoginScreen( + viewModel = viewModel, + onNavigateToHome = { + + println("SUCCESS: Успешный вход, переходим на Home!") + }, + onNavigateToRegister = { + println("CLICK: Переход на экран регистрации") } - } + ) } } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - SmartJamTheme { - Greeting("Android") - } } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt new file mode 100644 index 0000000..45d9d12 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt @@ -0,0 +1,17 @@ +package com.smartjam.app.data.api + +import com.smartjam.app.data.model.LoginResponce +import com.smartjam.app.data.model.LoginRequest +import com.smartjam.app.data.model.RefreshRequest + +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthApi { + + @POST("/api/auth/login") + suspend fun login(@Body request: LoginRequest): LoginResponce + + @POST("api/auth/refresh") + suspend fun refresh(@Body request: RefreshRequest): LoginResponce +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt new file mode 100644 index 0000000..322e8cb --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt @@ -0,0 +1,39 @@ +package com.smartjam.app.data.api + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + + + +object NetworkModule { + private const val BASE_URL = "http:/10.0.2.2:8000/" + + val authApi : AuthApi by lazy{ + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(AuthApi::class.java) + } + + private val okHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + private val loggingInterceptor by lazy { + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + } + + +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt new file mode 100644 index 0000000..2565dbf --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt @@ -0,0 +1,79 @@ +package com.smartjam.app.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.first + +private val Context.dataStore : DataStore by preferencesDataStore( + name = "auth_preferences" + ) +class TokenStorage(private val context: Context) { + private companion object Keys{ + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + + val ACCESS_EXPIRED_AT = longPreferencesKey("access_expires_at") + + val REFRESH_EXPIRED_AT = longPreferencesKey("refresh_expired_at") + } + + suspend fun saveToken(accessToken: String, refreshToken: String, accessExpiredAt: Long, refreshExpiredAt: Long){ + context.dataStore.edit { preferences -> + preferences[ACCESS_TOKEN] = accessToken + preferences[REFRESH_TOKEN] = refreshToken + preferences[ACCESS_EXPIRED_AT] = accessExpiredAt + preferences[REFRESH_EXPIRED_AT] = refreshExpiredAt + } + } + + val accessToken : Flow = context.dataStore.data + .map { preferences -> preferences[ACCESS_TOKEN] } + + val refreshToken : Flow = context.dataStore.data + .map { preferences -> preferences[REFRESH_TOKEN] } + + val accessExpiredAt : Flow = context.dataStore.data + .map {preferences -> preferences[ACCESS_EXPIRED_AT]} + + val refreshExpiredAt : Flow = context.dataStore.data + .map {preferences -> preferences[REFRESH_EXPIRED_AT]} + + suspend fun clearTokens(){ + context.dataStore.edit { preferences -> + preferences.remove(ACCESS_TOKEN) + preferences.remove(REFRESH_TOKEN) + preferences.remove(ACCESS_EXPIRED_AT) + preferences.remove(REFRESH_EXPIRED_AT) + } + } + + suspend fun isAccessTokenExpired(): Boolean { + val expires = context.dataStore.data + .map { preferences -> preferences[ACCESS_EXPIRED_AT] } + .first() + val currentTime = System.currentTimeMillis() / 1000 + return (expires == null) || (currentTime > expires) + + } + + suspend fun isRefreshTokenExpired(): Boolean { + val expires = context.dataStore.data + .map { preferences -> preferences[REFRESH_EXPIRED_AT] } + .first() + val currentTime = System.currentTimeMillis() / 1000 + return (expires == null) || (currentTime > expires) + + } + + suspend fun isAuthenticated(): Boolean { + return !isRefreshTokenExpired() && !isAccessTokenExpired() + } + +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/LoginRequest.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginRequest.kt new file mode 100644 index 0000000..32fe422 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginRequest.kt @@ -0,0 +1,6 @@ +package com.smartjam.app.data.model + +data class LoginRequest ( + val email: String, + val password: String +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponce.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponce.kt new file mode 100644 index 0000000..02a53a0 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponce.kt @@ -0,0 +1,8 @@ +package com.smartjam.app.data.model + +data class LoginResponce ( + val accessToken: String, + val refreshToken: String, + val accessExpiresAt: Long, + val refreshExpiredAt: Long +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/LoginState.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginState.kt new file mode 100644 index 0000000..06cdc5e --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginState.kt @@ -0,0 +1,8 @@ +package com.smartjam.app.data.model + +data class LoginState ( + val email: String = "", + val password: String = "", + val isLoading: Boolean = false, + val error: String? = null +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/RefreshRequest.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/RefreshRequest.kt new file mode 100644 index 0000000..f1300c1 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/RefreshRequest.kt @@ -0,0 +1,5 @@ +package com.smartjam.app.data.model + +data class RefreshRequest ( + val refreshToken: String +) diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt b/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..03ec18f --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt @@ -0,0 +1,101 @@ +package com.smartjam.app.domain.repository + +import androidx.compose.animation.core.animateDpAsState +import com.smartjam.app.data.api.AuthApi +import com.smartjam.app.data.api.NetworkModule +import com.smartjam.app.data.local.TokenStorage +import com.smartjam.app.data.model.LoginResponce +import com.smartjam.app.data.model.LoginRequest +import com.smartjam.app.data.model.RefreshRequest +import kotlinx.coroutines.flow.first + +class AuthRepository ( + private val tokenStorage: TokenStorage, + private val authApi: AuthApi = NetworkModule.authApi +) { + + + suspend fun login(email: String, password: String): Result { + return try { + val responce = authApi.login(LoginRequest(email, password)) + + tokenStorage.saveToken( + accessToken = responce.accessToken, + refreshToken = responce.refreshToken, + accessExpiredAt = responce.accessExpiresAt, + refreshExpiredAt = responce.refreshExpiredAt + ) + Result.success(Unit) + } catch (e: Exception){ + Result.failure(e) + } + } + + suspend fun refreshToken(): Boolean { + return try{ + if (tokenStorage.isRefreshTokenExpired()){ + tokenStorage.clearTokens() + return false + } + + val refreshToken = tokenStorage.refreshToken.first() + + if (refreshToken == null){ + return false + } + val responce = authApi.refresh(RefreshRequest(refreshToken)) + + tokenStorage.saveToken( + accessToken = responce.accessToken, + refreshToken = responce.refreshToken, + accessExpiredAt = responce.accessExpiresAt, + refreshExpiredAt = responce.refreshExpiredAt + ) + + return true + + } catch (e: Exception){ + tokenStorage.clearTokens() + return false + } + } + + suspend fun logout() { + tokenStorage.clearTokens() + } + + suspend fun isAuthenticated(): Boolean{ + return tokenStorage.isAuthenticated() + } + + suspend fun getAccessToken(): String?{ + return tokenStorage.accessToken.first() + } + + suspend fun getRefreshToken(): String?{ + return tokenStorage.refreshToken.first() + } + + suspend fun getAccessTokenExpiredIn(): Long?{ + val accessExpires = tokenStorage.accessExpiredAt.first() + + if (accessExpires == null){ + return null + } + + val currTime = System.currentTimeMillis() + return accessExpires - currTime + } + + suspend fun getRefreshTokenExpiredIn(): Long?{ + val refreshExpires = tokenStorage.refreshExpiredAt.first() + + if (refreshExpires == null){ + return null + } + + val currTime = System.currentTimeMillis() + return refreshExpires - currTime + } + +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..e69de29 diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt new file mode 100644 index 0000000..628b049 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt @@ -0,0 +1,445 @@ +package com.smartjam.app.ui.screens.login + +import android.widget.Toast +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +@Composable +fun LoginScreen( + + viewModel: LoginViewModel = viewModel(), + onNavigateToHome: () -> Unit = {}, + onNavigateToRegister: () -> Unit = {} +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is LoginEvent.NavigateToHome -> onNavigateToHome() + is LoginEvent.ShowToast -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF05050A)) + ) { + AppleLiquidBackground() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "SmartJam", + fontSize = 42.sp, + fontWeight = FontWeight.ExtraBold, + style = TextStyle( + brush = Brush.linearGradient( + colors = listOf(Color.White, Color(0xFFE0E0E0)) + ) + ) + ) + + Text( + text = "Почувствуй музыку", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 4.dp, bottom = 56.dp) + ) + + AppleGlassTextField( + value = state.emailInput, + onValueChange = { viewModel.onEmailChanged(it) }, + hint = "Email", + icon = Icons.Default.Email, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + AppleGlassTextField( + value = state.passwordInput, + onValueChange = { viewModel.onPasswordChanged(it) }, + hint = "Пароль", + icon = Icons.Default.Lock, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { viewModel.onLoginClicked() }) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + contentAlignment = Alignment.Center + ) { + if (state.errorMessage != null) { + Text( + text = state.errorMessage!!, + color = Color(0xFFFF5252), + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + } + + GoldenStringsButton( + text = if (state.isLoading) "Загрузка..." else "Войти", + onClick = { viewModel.onLoginClicked() }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Создать аккаунт", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF00E5FF), + modifier = Modifier + .clickable { onNavigateToRegister() } + .padding(8.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "или продолжить через", + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.4f), + modifier = Modifier.padding(vertical = 16.dp) + ) + + AppleGlassButton( + onClick = { /* TODO: Google Auth */ }, + text = "Google", + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +fun AppleLiquidBackground() { + val infiniteTransition = rememberInfiniteTransition(label = "bg") + val phase1 by infiniteTransition.animateFloat( + initialValue = 0f, targetValue = 360f, + animationSpec = infiniteRepeatable(tween(15000, easing = LinearEasing)), label = "p1" + ) + + Box(modifier = Modifier.fillMaxSize()) { + Canvas(modifier = Modifier.fillMaxSize().blur(120.dp)) { + val width = size.width + val height = size.height + + drawCircle( + color = Color(0xFF4A00E0).copy(alpha = 0.4f), + radius = width * 0.7f, + center = Offset( + x = width * 0.5f + sin(Math.toRadians(phase1.toDouble())).toFloat() * 200f, + y = height * 0.2f + ) + ) + + drawCircle( + color = Color(0xFF8E2DE2).copy(alpha = 0.3f), + radius = width * 0.6f, + center = Offset( + x = width * 0.8f, + y = height * 0.6f + sin(Math.toRadians(phase1.toDouble() + 90)).toFloat() * 300f + ) + ) + + drawCircle( + color = Color(0xFF00C9FF).copy(alpha = 0.2f), + radius = width * 0.5f, + center = Offset( + x = width * 0.2f + sin(Math.toRadians(phase1.toDouble() + 180)).toFloat() * 150f, + y = height * 0.8f + ) + ) + } + } +} + +@Composable +fun AppleGlassTextField( + value: String, + onValueChange: (String) -> Unit, + hint: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = TextStyle( + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + cursorBrush = SolidColor(Color.White), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.15f), + shape = RoundedCornerShape(24.dp) + ) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White.copy(alpha = 0.5f), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Box(modifier = Modifier.weight(1f)) { + if (value.isEmpty()) { + Text( + text = hint, + color = Color.White.copy(alpha = 0.3f), + fontSize = 16.sp + ) + } + innerTextField() + } + } + } + ) +} + +@Composable +fun GoldenStringsButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val infiniteTransition = rememberInfiniteTransition(label = "strings") + val masterProgress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(2500, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), label = "master_progress" + ) + + Box( + modifier = modifier + .height(64.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White.copy(alpha = 0.1f)) + .border( + width = 1.dp, + brush = Brush.linearGradient( + colors = listOf( + Color(0xFFFFD700).copy(alpha = 0.5f), + Color(0xFFFF007F).copy(alpha = 0.3f), + Color(0xFF00E5FF).copy(alpha = 0.3f) + ) + ), + shape = RoundedCornerShape(24.dp) + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + onClick = onClick + ), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(24.dp))) { + val width = size.width + val height = size.height + val numStrings = 4 + val spacing = height * 0.18f + + val startY = (height - (spacing * (numStrings - 1))) / 2f + + val stringColors = listOf( + Color(0xFFFFD700), + Color(0xFFFF8C00), + Color(0xFFFF007F), + Color(0xFF00E5FF) + ) + + for (i in 0 until numStrings) { + val baseY = startY + (i * spacing) + val path = Path() + path.moveTo(0f, baseY) + + var localTime = masterProgress - (i * 0.15f) + if (localTime < 0f) localTime += 1f + + val pullDuration = 0.15f + val maxAmplitude = spacing * 0.7f + val frequencies = 6f + val direction = 1f + + var currentYOffset = 0f + var energyAlpha = 0f + + if (localTime < pullDuration) { + val pullProgress = localTime / pullDuration + currentYOffset = maxAmplitude * (pullProgress * pullProgress) + energyAlpha = pullProgress + } else { + val vibProgress = (localTime - pullDuration) / (1f - pullDuration) + val decay = (1f - vibProgress) * (1f - vibProgress) * (1f - vibProgress) + val oscillation = cos(vibProgress * PI * 2 * frequencies).toFloat() + + currentYOffset = maxAmplitude * decay * oscillation + energyAlpha = decay + } + + currentYOffset *= direction + + for (x in 0..width.toInt() step 4) { + val normalizedX = x / width + val spatialEnvelope = sin(normalizedX * PI).toFloat() + val y = baseY + currentYOffset * spatialEnvelope + path.lineTo(x.toFloat(), y) + } + + val glowAlpha = 0.1f + (0.3f * energyAlpha) + val coreAlpha = 0.3f + (0.7f * energyAlpha) + + val baseThickness = 4f - (i * 0.8f) + + drawPath( + path = path, + color = stringColors[i].copy(alpha = glowAlpha), + style = Stroke(width = baseThickness * 3f * (1f + energyAlpha)) + ) + + drawPath( + path = path, + brush = Brush.horizontalGradient( + colors = listOf( + stringColors[i].copy(alpha = coreAlpha * 0.1f), + stringColors[i].copy(alpha = coreAlpha), + stringColors[i].copy(alpha = coreAlpha * 0.1f) + ) + ), + style = Stroke(width = baseThickness) + ) + } + } + + Text( + text = text, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + style = TextStyle( + shadow = Shadow( + color = Color(0xFF000000).copy(alpha = 0.7f), + offset = Offset(0f, 2f), + blurRadius = 8f + ) + ) + ) + } +} + +@Composable +fun AppleGlassButton( + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(60.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.1f), + shape = RoundedCornerShape(24.dp) + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + } +} + +@Preview(showBackground = true) +@Composable +fun LoginScreenModernPreview() { + LoginScreen() +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt new file mode 100644 index 0000000..871305e --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt @@ -0,0 +1,91 @@ +package com.smartjam.app.ui.screens.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewModelScope +import com.smartjam.app.domain.repository.AuthRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + + +import androidx.lifecycle.ViewModelProvider + +data class LoginState( + val emailInput: String = "", + val passwordInput: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +sealed class LoginEvent{ + object NavigateToHome : LoginEvent() + data class ShowToast(val message: String) : LoginEvent() +} + +class LoginViewModel ( + private val authRepository: AuthRepository +) : ViewModel(){ + + private val _state = MutableStateFlow(LoginState()) + val state : StateFlow = _state.asStateFlow() + + private val eventChannel = Channel() + val events = eventChannel.receiveAsFlow() + + fun onPasswordChanged(newPassword: String){ + _state.value = _state.value.copy( + passwordInput = newPassword, + errorMessage = null + ) + } + + fun onEmailChanged(newEmail: String) { + _state.value = _state.value.copy( + emailInput = newEmail, + errorMessage = null + ) + } + + fun onLoginClicked() { + val currentEmail = _state.value.emailInput + val currentPassword = _state.value.passwordInput + + if (currentPassword.isBlank() || currentEmail.isBlank()){ + _state.value = _state.value.copy(errorMessage = "Fill in all fields") + } + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, errorMessage = null) + + try { + authRepository.login(currentEmail, currentPassword) + + eventChannel.send(LoginEvent.NavigateToHome) + } catch (e: Exception){ + _state.value = _state.value.copy( + errorMessage = e.message?: "Unknown error" + ) + } finally { + _state.value = _state.value.copy(isLoading = false) + } + } + } + +} + + +class LoginViewModelFactory( + private val authRepository: AuthRepository +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(LoginViewModel::class.java)) { + return LoginViewModel(authRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file From e9661f2c38c49633c2a38ed09b68b73f385cc27d Mon Sep 17 00:00:00 2001 From: ParovozikThomas Date: Tue, 10 Mar 2026 19:21:59 +0300 Subject: [PATCH 2/4] feat: add registration page --- .../java/com/smartjam/app/MainActivity.kt | 39 ++- .../java/com/smartjam/app/data/api/AuthApi.kt | 10 +- .../{LoginResponce.kt => LoginResponse.kt} | 2 +- .../app/data/model/RegisterRequest.kt | 8 + .../com/smartjam/app/domain/data/UserRole.kt | 6 + .../app/domain/repository/AuthRepository.kt | 28 +- .../smartjam/app/ui/navigation/NavGraph.kt | 79 ++++++ .../app/ui/screens/register/RegisterScreen.kt | 246 ++++++++++++++++++ .../ui/screens/register/RegisterViewModel.kt | 123 +++++++++ 9 files changed, 506 insertions(+), 35 deletions(-) rename mobile/app/src/main/java/com/smartjam/app/data/model/{LoginResponce.kt => LoginResponse.kt} (85%) create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/domain/data/UserRole.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt diff --git a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt index 498567e..a81fec4 100644 --- a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt @@ -1,49 +1,40 @@ package com.smartjam.app -import androidx.activity.compose.setContent -import androidx.lifecycle.viewmodel.compose.viewModel -import com.smartjam.app.ui.screens.login.LoginScreen 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.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.navigation.compose.rememberNavController import com.smartjam.app.data.api.NetworkModule import com.smartjam.app.data.local.TokenStorage import com.smartjam.app.domain.repository.AuthRepository -import com.smartjam.app.ui.screens.login.LoginScreen -import com.smartjam.app.ui.screens.login.LoginViewModel -import com.smartjam.app.ui.screens.login.LoginViewModelFactory +import com.smartjam.app.ui.navigation.SmartJamNavGraph class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val tokenStorage = TokenStorage(context = this) + enableEdgeToEdge() + val tokenStorage = TokenStorage(context = this) val authApi = NetworkModule.authApi - val authRepository = AuthRepository(tokenStorage, authApi) - val factory = LoginViewModelFactory(authRepository) - setContent { - val viewModel: LoginViewModel = viewModel(factory = factory) - - LoginScreen( - viewModel = viewModel, - onNavigateToHome = { - - println("SUCCESS: Успешный вход, переходим на Home!") - }, - onNavigateToRegister = { - println("CLICK: Переход на экран регистрации") - } - ) + val navController = rememberNavController() + + Surface( + modifier = Modifier.fillMaxSize(), + color = Color(0xFF05050A) + ) { + SmartJamNavGraph( + navController = navController, + authRepository = authRepository + ) + } } } } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt index 45d9d12..cfc0f07 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt @@ -1,17 +1,21 @@ package com.smartjam.app.data.api -import com.smartjam.app.data.model.LoginResponce +import com.smartjam.app.data.model.LoginResponse import com.smartjam.app.data.model.LoginRequest import com.smartjam.app.data.model.RefreshRequest +import com.smartjam.app.data.model.RegisterRequest import retrofit2.http.Body import retrofit2.http.POST interface AuthApi { + @POST("/api/auth/register") + suspend fun register(@Body request: RegisterRequest): LoginResponse + @POST("/api/auth/login") - suspend fun login(@Body request: LoginRequest): LoginResponce + suspend fun login(@Body request: LoginRequest): LoginResponse @POST("api/auth/refresh") - suspend fun refresh(@Body request: RefreshRequest): LoginResponce + suspend fun refresh(@Body request: RefreshRequest): LoginResponse } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponce.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponse.kt similarity index 85% rename from mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponce.kt rename to mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponse.kt index 02a53a0..b9dbcdc 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponce.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponse.kt @@ -1,6 +1,6 @@ package com.smartjam.app.data.model -data class LoginResponce ( +data class LoginResponse ( val accessToken: String, val refreshToken: String, val accessExpiresAt: Long, diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt new file mode 100644 index 0000000..0a59794 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt @@ -0,0 +1,8 @@ +package com.smartjam.app.data.model + +data class RegisterRequest( + val email: String, + val password: String, + val username: String, + val role: String +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/data/UserRole.kt b/mobile/app/src/main/java/com/smartjam/app/domain/data/UserRole.kt new file mode 100644 index 0000000..6525a1a --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/domain/data/UserRole.kt @@ -0,0 +1,6 @@ +package com.smartjam.app.domain.data + +enum class UserRole { + STUDENT, + TEACHER +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt b/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt index 03ec18f..e2e0a9b 100644 --- a/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt +++ b/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt @@ -1,12 +1,11 @@ package com.smartjam.app.domain.repository -import androidx.compose.animation.core.animateDpAsState import com.smartjam.app.data.api.AuthApi import com.smartjam.app.data.api.NetworkModule import com.smartjam.app.data.local.TokenStorage -import com.smartjam.app.data.model.LoginResponce import com.smartjam.app.data.model.LoginRequest import com.smartjam.app.data.model.RefreshRequest +import com.smartjam.app.data.model.RegisterRequest import kotlinx.coroutines.flow.first class AuthRepository ( @@ -14,16 +13,31 @@ class AuthRepository ( private val authApi: AuthApi = NetworkModule.authApi ) { + suspend fun register(email: String, password: String, username: String, role: String): Result { + return try { + val response = authApi.register(RegisterRequest(email, password, username, role)) + + tokenStorage.saveToken( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + accessExpiredAt = response.accessExpiresAt, + refreshExpiredAt = response.refreshExpiredAt + ) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } suspend fun login(email: String, password: String): Result { return try { - val responce = authApi.login(LoginRequest(email, password)) + val response = authApi.login(LoginRequest(email, password)) tokenStorage.saveToken( - accessToken = responce.accessToken, - refreshToken = responce.refreshToken, - accessExpiredAt = responce.accessExpiresAt, - refreshExpiredAt = responce.refreshExpiredAt + accessToken = response.accessToken, + refreshToken = response.refreshToken, + accessExpiredAt = response.accessExpiresAt, + refreshExpiredAt = response.refreshExpiredAt ) Result.success(Unit) } catch (e: Exception){ diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt index e69de29..fdda346 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt @@ -0,0 +1,79 @@ +package com.smartjam.app.ui.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.smartjam.app.domain.repository.AuthRepository +import com.smartjam.app.ui.screens.login.LoginScreen +import com.smartjam.app.ui.screens.login.LoginViewModel +import com.smartjam.app.ui.screens.login.LoginViewModelFactory +import com.smartjam.app.ui.screens.register.RegisterScreen +import com.smartjam.app.ui.screens.register.RegisterViewModel +import com.smartjam.app.ui.screens.register.RegisterViewModelFactory + +sealed class Screen(val route: String) { + object Login : Screen("login_screen") + object Register : Screen("register_screen") + object Home : Screen("home_screen") +} + +@Composable +fun SmartJamNavGraph( + navController: NavHostController, + authRepository: AuthRepository +) { + NavHost( + navController = navController, + startDestination = Screen.Login.route + ) { + + composable(route = Screen.Login.route) { + val viewModel: LoginViewModel = viewModel( + factory = LoginViewModelFactory(authRepository) + ) + + LoginScreen( + viewModel = viewModel, + onNavigateToHome = { + navController.navigate(Screen.Home.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onNavigateToRegister = { + navController.navigate(Screen.Register.route) + } + ) + } + + composable(route = Screen.Register.route) { + val viewModel: RegisterViewModel = viewModel( + factory = RegisterViewModelFactory(authRepository) + ) + + RegisterScreen( + viewModel = viewModel, + onNavigateToHome = { + navController.navigate(Screen.Home.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable(route = Screen.Home.route) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = "Добро пожаловать в SmartJam!") + } + } + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt new file mode 100644 index 0000000..3f6bd4a --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt @@ -0,0 +1,246 @@ +package com.smartjam.app.ui.screens.register + +import android.widget.Toast +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.smartjam.app.domain.data.UserRole +import com.smartjam.app.ui.screens.login.AppleGlassTextField +import com.smartjam.app.ui.screens.login.AppleLiquidBackground +import com.smartjam.app.ui.screens.login.GoldenStringsButton + +@Composable +fun RegisterScreen( + viewModel: RegisterViewModel, + onNavigateToHome: () -> Unit, + onNavigateBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is RegisterEvent.NavigateToHome -> onNavigateToHome() + is RegisterEvent.NavigateBack -> onNavigateBack() + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF05050A)) + ) { + AppleLiquidBackground() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Создать аккаунт", + fontSize = 32.sp, + fontWeight = FontWeight.ExtraBold, + color = Color.White + ) + + Spacer(modifier = Modifier.height(24.dp)) + + + GlassRoleSelector( + selectedRole = state.selectedRole, + onRoleSelected = { viewModel.onRoleSelected(it) } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + + AppleGlassTextField( + value = state.usernameInput, + onValueChange = { viewModel.onUsernameChanged(it) }, + hint = "Имя пользователя", + icon = Icons.Default.Person, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + AppleGlassTextField( + value = state.emailInput, + onValueChange = { viewModel.onEmailChanged(it) }, + hint = "Email", + icon = Icons.Default.Email, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + AppleGlassTextField( + value = state.passwordInput, + onValueChange = { viewModel.onPasswordChanged(it) }, + hint = "Пароль", + icon = Icons.Default.Lock, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + AppleGlassTextField( + value = state.repeatPasswordInput, + onValueChange = { viewModel.onRepeatPasswordChanged(it) }, + hint = "Повторите пароль", + icon = Icons.Default.Lock, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { viewModel.onRegisterClicked() }) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + contentAlignment = Alignment.Center + ) { + if (state.errorMessage != null) { + Text( + text = state.errorMessage!!, + color = Color(0xFFFF5252), + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + } + } + + GoldenStringsButton( + text = if (state.isLoading) "Создание..." else "Зарегистрироваться", + onClick = { viewModel.onRegisterClicked() }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Уже есть аккаунт? Войти", + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.6f), + modifier = Modifier + .clickable { viewModel.onBackClicked() } + .padding(16.dp) + ) + + Spacer(modifier = Modifier.height(48.dp)) + } + } +} + +@Composable +fun GlassRoleSelector( + selectedRole: UserRole, + onRoleSelected: (UserRole) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.15f), + shape = RoundedCornerShape(24.dp) + ), + verticalAlignment = Alignment.CenterVertically + ) { + RoleButton( + text = "Я ученик", + isSelected = selectedRole == UserRole.STUDENT, + onClick = { onRoleSelected(UserRole.STUDENT) }, + modifier = Modifier.weight(1f) + ) + + RoleButton( + text = "Я преподаватель", + isSelected = selectedRole == UserRole.TEACHER, + onClick = { onRoleSelected(UserRole.TEACHER) }, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +fun RoleButton( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val backgroundColor by animateColorAsState( + targetValue = if (isSelected) Color(0xFFFFD700).copy(alpha = 0.2f) else Color.Transparent, + label = "RoleColorAnimation" + ) + + val textColor = if (isSelected) Color(0xFFFFD700) else Color.White.copy(alpha = 0.5f) + + Box( + modifier = modifier + .fillMaxHeight() + .clip(RoundedCornerShape(24.dp)) + .background(backgroundColor) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = textColor, + fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt new file mode 100644 index 0000000..585acda --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt @@ -0,0 +1,123 @@ +package com.smartjam.app.ui.screens.register + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smartjam.app.domain.repository.AuthRepository +import com.smartjam.app.domain.data.UserRole +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import androidx.lifecycle.ViewModelProvider + +data class RegisterState( + val usernameInput: String = "", + val emailInput: String = "", + val passwordInput: String = "", + val repeatPasswordInput: String = "", + val selectedRole: UserRole = UserRole.STUDENT, + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +sealed class RegisterEvent { + object NavigateToHome : RegisterEvent() + object NavigateBack : RegisterEvent() +} + +class RegisterViewModel( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _state = MutableStateFlow(RegisterState()) + val state = _state.asStateFlow() + + private val eventChannel = Channel() + val events = eventChannel.receiveAsFlow() + private val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\$".toRegex() + private val passwordRegex = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}\$".toRegex() + + fun onUsernameChanged(username: String) { + _state.update { it.copy(usernameInput = username, errorMessage = null) } + } + + fun onEmailChanged(email: String) { + _state.update { it.copy(emailInput = email, errorMessage = null) } + } + + fun onPasswordChanged(password: String) { + _state.update { it.copy(passwordInput = password, errorMessage = null) } + } + + fun onRepeatPasswordChanged(password: String) { + _state.update { it.copy(repeatPasswordInput = password, errorMessage = null) } + } + + fun onRoleSelected(role: UserRole) { + _state.update { it.copy(selectedRole = role) } + } + + fun onBackClicked() { + viewModelScope.launch { + eventChannel.send(RegisterEvent.NavigateBack) + } + } + + fun onRegisterClicked() { + val currentState = _state.value + + if (currentState.usernameInput.isBlank()) { + _state.update { it.copy(errorMessage = "Введите имя пользователя") } + return + } + + if (!emailRegex.matches(currentState.emailInput)) { + _state.update { it.copy(errorMessage = "Некорректный формат email") } + return + } + + if (!passwordRegex.matches(currentState.passwordInput)) { + _state.update { it.copy(errorMessage = "Пароль: мин. 8 символов, латинские буквы и цифры") } + return + } + + if (currentState.passwordInput != currentState.repeatPasswordInput) { + _state.update { it.copy(errorMessage = "Пароли не совпадают") } + return + } + + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + + val result = authRepository.register( + email = currentState.emailInput, + password = currentState.passwordInput, + username = currentState.usernameInput, + role = currentState.selectedRole.name + ) + + _state.update { it.copy(isLoading = false) } + + if (result.isSuccess) { + eventChannel.send(RegisterEvent.NavigateToHome) + } else { + val error = result.exceptionOrNull()?.message ?: "Ошибка регистрации" + _state.update { it.copy(errorMessage = error) } + } + } + } +} + +class RegisterViewModelFactory( + private val authRepository: AuthRepository +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(RegisterViewModel::class.java)) { + return RegisterViewModel(authRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file From 831aa8d13d8962d9919fe07a06a6b9b71f1b1c0f Mon Sep 17 00:00:00 2001 From: ParovozikThomas Date: Tue, 10 Mar 2026 19:53:42 +0300 Subject: [PATCH 3/4] feat: add base home screen and room request --- .../smartjam/app/data/api/NetworkModule.kt | 9 ++ .../java/com/smartjam/app/data/api/RoomApi.kt | 12 ++ .../com/smartjam/app/data/model/RoomModels.kt | 11 ++ .../app/domain/repository/RoomRepository.kt | 23 +++ .../smartjam/app/ui/navigation/NavGraph.kt | 24 +++- .../app/ui/screens/home/HomeScreen.kt | 136 ++++++++++++++++++ .../app/ui/screens/home/HomeViewModel.kt | 76 ++++++++++ .../app/ui/screens/login/LoginScreen.kt | 8 +- 8 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/api/RoomApi.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/RoomModels.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt index 322e8cb..cdf8a0f 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt @@ -35,5 +35,14 @@ object NetworkModule { } } + val roomApi: RoomApi by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(RoomApi::class.java) + } + } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/RoomApi.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/RoomApi.kt new file mode 100644 index 0000000..c778565 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/RoomApi.kt @@ -0,0 +1,12 @@ +package com.smartjam.app.data.api + +import com.smartjam.app.data.model.JoinRoomRequest +import com.smartjam.app.data.model.RoomResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface RoomApi { + @POST("/api/rooms/join") + suspend fun joinRoom(@Body request: JoinRoomRequest): Response +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/RoomModels.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/RoomModels.kt new file mode 100644 index 0000000..a965905 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/RoomModels.kt @@ -0,0 +1,11 @@ +package com.smartjam.app.data.model + +data class JoinRoomRequest( + val inviteCode: String +) + +data class RoomResponse( + val id: String, + val teacherName: String, + val title: String +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt b/mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt new file mode 100644 index 0000000..6900a2f --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt @@ -0,0 +1,23 @@ +package com.smartjam.app.domain.repository + +import com.smartjam.app.data.api.RoomApi +import com.smartjam.app.data.model.JoinRoomRequest +import com.smartjam.app.data.model.RoomResponse + +class RoomRepository( + private val roomApi: RoomApi +) { + suspend fun joinRoomByCode(code: String): Result { + return try { + val response = roomApi.joinRoom(JoinRoomRequest(inviteCode = code)) + + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("Неверный код или комната не найдена")) + } + } catch (e: Exception) { + Result.failure(Exception("Ошибка сети: проверьте подключение")) + } + } +} diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt index fdda346..94dd245 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt @@ -10,7 +10,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import com.smartjam.app.data.api.NetworkModule import com.smartjam.app.domain.repository.AuthRepository +import com.smartjam.app.domain.repository.RoomRepository +import com.smartjam.app.ui.screens.home.HomeScreen +import com.smartjam.app.ui.screens.home.HomeViewModel +import com.smartjam.app.ui.screens.home.HomeViewModelFactory import com.smartjam.app.ui.screens.login.LoginScreen import com.smartjam.app.ui.screens.login.LoginViewModel import com.smartjam.app.ui.screens.login.LoginViewModelFactory @@ -31,7 +36,7 @@ fun SmartJamNavGraph( ) { NavHost( navController = navController, - startDestination = Screen.Login.route + startDestination = Screen.Home.route ) { composable(route = Screen.Login.route) { @@ -71,9 +76,20 @@ fun SmartJamNavGraph( } composable(route = Screen.Home.route) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = "Добро пожаловать в SmartJam!") - } + val roomRepo = RoomRepository(NetworkModule.roomApi) + + val viewModel: HomeViewModel = viewModel( + factory = HomeViewModelFactory(roomRepo) + ) + + HomeScreen( + viewModel = viewModel, + onLogoutClicked = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.Home.route) { inclusive = true } + } + } + ) } } } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..eaf7c96 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt @@ -0,0 +1,136 @@ +package com.smartjam.app.ui.screens.home + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.smartjam.app.ui.screens.login.AppleGlassTextField +import com.smartjam.app.ui.screens.login.AppleLiquidBackground +import com.smartjam.app.ui.screens.login.GoldenStringsButton + +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + onLogoutClicked: () -> Unit +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is HomeEvent.RoomJoined -> { + Toast.makeText(context, "Успешно добавлено!", Toast.LENGTH_SHORT).show() + } + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF05050A)) + ) { + AppleLiquidBackground() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Мои классы", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + + GoldenStringsButton( + text = "Выход", + onClick = onLogoutClicked, + modifier = Modifier.width(100.dp).height(40.dp) + ) + } + + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Присоединиться к учителю", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.Start) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + AppleGlassTextField( + value = state.inviteCodeInput, + onValueChange = { viewModel.onInviteCodeChanged(it) }, + hint = "Введите инвайт-код (напр. A1B2C)", + icon = Icons.Default.Add, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { + keyboard?.hide() + viewModel.onJoinRoomClicked() + }) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + GoldenStringsButton( + text = if (state.isLoading) "Поиск..." else "Присоединиться", + onClick = { + keyboard?.hide() + viewModel.onJoinRoomClicked() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + contentAlignment = Alignment.Center + ) { + if (state.errorMessage != null) { + Text(text = state.errorMessage!!, color = Color(0xFFFF5252), fontSize = 14.sp) + } + if (state.successMessage != null) { + Text(text = state.successMessage!!, color = Color(0xFF00E5FF), fontSize = 14.sp) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt new file mode 100644 index 0000000..f11a7e6 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,76 @@ +package com.smartjam.app.ui.screens.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.smartjam.app.domain.repository.RoomRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class HomeState( + val inviteCodeInput: String = "", + val isLoading: Boolean = false, + val successMessage: String? = null, + val errorMessage: String? = null +) + +sealed class HomeEvent { + object RoomJoined : HomeEvent() +} + +class HomeViewModel( + private val roomRepository: RoomRepository +) : ViewModel() { + + private val _state = MutableStateFlow(HomeState()) + val state = _state.asStateFlow() + + private val eventChannel = Channel() + val events = eventChannel.receiveAsFlow() + + fun onInviteCodeChanged(code: String) { + _state.update { it.copy(inviteCodeInput = code.trim(), errorMessage = null, successMessage = null) } + } + + fun onJoinRoomClicked() { + val code = _state.value.inviteCodeInput + if (code.isBlank()) { + _state.update { it.copy(errorMessage = "Введите код") } + return + } + + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) } + + val result = roomRepository.joinRoomByCode(code) + + _state.update { it.copy(isLoading = false) } + + if (result.isSuccess) { + val room = result.getOrNull() + _state.update { + it.copy( + successMessage = "Вы присоединились к классу: ${room?.teacherName}", + inviteCodeInput = "" + ) + } + eventChannel.send(HomeEvent.RoomJoined) + } else { + _state.update { it.copy(errorMessage = result.exceptionOrNull()?.message) } + } + } + } +} + +class HomeViewModelFactory( + private val roomRepository: RoomRepository +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return HomeViewModel(roomRepository) as T + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt index 628b049..a5e5ac2 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt @@ -281,7 +281,8 @@ fun AppleGlassTextField( fun GoldenStringsButton( text: String, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { val infiniteTransition = rememberInfiniteTransition(label = "strings") val masterProgress by infiniteTransition.animateFloat( @@ -297,7 +298,7 @@ fun GoldenStringsButton( modifier = modifier .height(64.dp) .clip(RoundedCornerShape(24.dp)) - .background(Color.White.copy(alpha = 0.1f)) + .background(Color.White.copy(alpha = if (enabled) 0.1f else 0.05f)) .border( width = 1.dp, brush = Brush.linearGradient( @@ -310,6 +311,7 @@ fun GoldenStringsButton( shape = RoundedCornerShape(24.dp) ) .clickable( + enabled = enabled, interactionSource = remember { MutableInteractionSource() }, indication = LocalIndication.current, onClick = onClick @@ -398,7 +400,7 @@ fun GoldenStringsButton( text = text, fontSize = 18.sp, fontWeight = FontWeight.Bold, - color = Color.White, + color = if (enabled) Color.White else Color.White.copy(alpha = 0.5f), style = TextStyle( shadow = Shadow( color = Color(0xFF000000).copy(alpha = 0.7f), From 057230947f72100711832159cae7a1d5f7726342 Mon Sep 17 00:00:00 2001 From: ParovozikThomas Date: Sun, 22 Mar 2026 06:15:39 +0300 Subject: [PATCH 4/4] feat(home): implement role-based Home Screen with Room local caching and interceptor for wrapping requests in JWT --- mobile/app/build.gradle.kts | 12 + .../java/com/smartjam/app/MainActivity.kt | 25 +- .../java/com/smartjam/app/data/api/AuthApi.kt | 5 +- .../smartjam/app/data/api/AuthInterceptor.kt | 26 ++ .../smartjam/app/data/api/NetworkModule.kt | 43 +-- .../java/com/smartjam/app/data/api/RoomApi.kt | 12 - .../com/smartjam/app/data/api/SmartJamApi.kt | 44 +++ .../app/data/local/SmartJamDatabase.kt | 11 + .../smartjam/app/data/local/TokenStorage.kt | 6 +- .../app/data/local/dao/ConnectionDao.kt | 21 ++ .../data/local/entity/ConnectionEntity.kt.kt | 14 + .../smartjam/app/data/model/CommentModels.kt | 11 + .../app/data/model/ConnectionModels.kt | 21 ++ .../app/data/model/RegisterRequest.kt | 4 +- .../com/smartjam/app/data/model/RoomModels.kt | 11 - .../com/smartjam/app/data/model/TaskModels.kt | 24 ++ .../smartjam/app/domain/model/Connection.kt | 9 + .../app/domain/{data => model}/UserRole.kt | 2 +- .../app/domain/repository/AuthRepository.kt | 18 +- .../domain/repository/ConnectionRepository.kt | 105 ++++++ .../app/domain/repository/RoomRepository.kt | 23 -- .../smartjam/app/ui/navigation/NavGraph.kt | 27 +- .../app/ui/screens/home/HomeScreen.kt | 311 ++++++++++++++---- .../app/ui/screens/home/HomeViewModel.kt | 154 +++++++-- .../app/ui/screens/login/LoginScreen.kt | 63 ++-- .../app/ui/screens/login/LoginViewModel.kt | 25 +- .../app/ui/screens/register/RegisterScreen.kt | 2 +- .../ui/screens/register/RegisterViewModel.kt | 11 +- .../app/ui/screens/room/RoomScreen.kt | 0 .../app/ui/screens/room/RoomViewModel.kt | 0 mobile/build.gradle.kts | 1 + 31 files changed, 820 insertions(+), 221 deletions(-) create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/api/AuthInterceptor.kt delete mode 100644 mobile/app/src/main/java/com/smartjam/app/data/api/RoomApi.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/api/SmartJamApi.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/SmartJamDatabase.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/dao/ConnectionDao.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/CommentModels.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/ConnectionModels.kt delete mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/RoomModels.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/TaskModels.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/domain/model/Connection.kt rename mobile/app/src/main/java/com/smartjam/app/domain/{data => model}/UserRole.kt (56%) create mode 100644 mobile/app/src/main/java/com/smartjam/app/domain/repository/ConnectionRepository.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomScreen.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomViewModel.kt diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index d908e78..650e064 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) + id("com.google.devtools.ksp") } android { @@ -28,6 +29,11 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + buildConfigField("String", "BASE_URL", "\"https://api.smartjam.com/\"") + } + + getByName("debug") { + buildConfigField("String", "BASE_URL", "\"http://10.0.2.2:8000/\"") } } compileOptions { @@ -36,6 +42,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -64,6 +71,10 @@ dependencies { //ne pon implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.+") + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.5.0") + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -79,4 +90,5 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt index a81fec4..6b86389 100644 --- a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt @@ -9,10 +9,16 @@ import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.navigation.compose.rememberNavController +import androidx.room.Room +import com.smartjam.app.data.api.AuthApi import com.smartjam.app.data.api.NetworkModule +import com.smartjam.app.data.api.SmartJamApi +import com.smartjam.app.data.local.SmartJamDatabase import com.smartjam.app.data.local.TokenStorage import com.smartjam.app.domain.repository.AuthRepository +import com.smartjam.app.domain.repository.ConnectionRepository import com.smartjam.app.ui.navigation.SmartJamNavGraph +import kotlin.jvm.java class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -20,8 +26,20 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() val tokenStorage = TokenStorage(context = this) - val authApi = NetworkModule.authApi + + val appDatabase = Room.databaseBuilder( + applicationContext, + SmartJamDatabase::class.java, + "smartjam_database" + ).build() + + + val retrofit = NetworkModule.createRetrofit(tokenStorage) + val smartJamApi = retrofit.create(SmartJamApi::class.java) + val authApi = retrofit.create(AuthApi::class.java) + val authRepository = AuthRepository(tokenStorage, authApi) + val connectionRepository = ConnectionRepository(smartJamApi, appDatabase.connectionDao()) setContent { val navController = rememberNavController() @@ -30,9 +48,12 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = Color(0xFF05050A) ) { + SmartJamNavGraph( navController = navController, - authRepository = authRepository + authRepository = authRepository, + connectionRepository = connectionRepository, + tokenStorage = tokenStorage ) } } diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt index cfc0f07..8e507ab 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt @@ -1,10 +1,9 @@ package com.smartjam.app.data.api -import com.smartjam.app.data.model.LoginResponse import com.smartjam.app.data.model.LoginRequest +import com.smartjam.app.data.model.LoginResponse import com.smartjam.app.data.model.RefreshRequest import com.smartjam.app.data.model.RegisterRequest - import retrofit2.http.Body import retrofit2.http.POST @@ -16,6 +15,6 @@ interface AuthApi { @POST("/api/auth/login") suspend fun login(@Body request: LoginRequest): LoginResponse - @POST("api/auth/refresh") + @POST("/api/auth/refresh") suspend fun refresh(@Body request: RefreshRequest): LoginResponse } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthInterceptor.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthInterceptor.kt new file mode 100644 index 0000000..c761ef2 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthInterceptor.kt @@ -0,0 +1,26 @@ +package com.smartjam.app.data.api + +import com.smartjam.app.data.local.TokenStorage +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response + +class AuthInterceptor ( + private val tokenStorage: TokenStorage +): Interceptor{ + override fun intercept(chain: Interceptor.Chain): Response { + val token = runBlocking { + tokenStorage.accessToken.first() + } + val originalRequest = chain.request() + + val requestBuilder = originalRequest.newBuilder() + if (token != null){ + requestBuilder.addHeader("Authorization",token) + } + + val newRequest = requestBuilder.build() + return chain.proceed(newRequest) + } +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt index cdf8a0f..aa5b687 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/NetworkModule.kt @@ -1,5 +1,7 @@ package com.smartjam.app.data.api +import com.smartjam.app.BuildConfig +import com.smartjam.app.data.local.TokenStorage import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -7,42 +9,31 @@ import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit - object NetworkModule { - private const val BASE_URL = "http:/10.0.2.2:8000/" - val authApi : AuthApi by lazy{ - Retrofit.Builder() - .baseUrl(BASE_URL) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) - .build() - .create(AuthApi::class.java) - } + fun createRetrofit(tokenStorage: TokenStorage): Retrofit { + val authInterceptor = AuthInterceptor(tokenStorage) - private val okHttpClient by lazy { - OkHttpClient.Builder() + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(authInterceptor) .addInterceptor(loggingInterceptor) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) .build() - } - private val loggingInterceptor by lazy { - HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - } - } - - val roomApi: RoomApi by lazy { - Retrofit.Builder() - .baseUrl(BASE_URL) + return Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() - .create(RoomApi::class.java) } + private val loggingInterceptor by lazy { + HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else{ + HttpLoggingInterceptor.Level.NONE + } + } + } } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/RoomApi.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/RoomApi.kt deleted file mode 100644 index c778565..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/api/RoomApi.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.smartjam.app.data.api - -import com.smartjam.app.data.model.JoinRoomRequest -import com.smartjam.app.data.model.RoomResponse -import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.POST - -interface RoomApi { - @POST("/api/rooms/join") - suspend fun joinRoom(@Body request: JoinRoomRequest): Response -} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/SmartJamApi.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/SmartJamApi.kt new file mode 100644 index 0000000..7a3e92b --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/SmartJamApi.kt @@ -0,0 +1,44 @@ +package com.smartjam.app.data.api + +import com.smartjam.app.data.model.* +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface SmartJamApi { + @POST("/api/connections/invite-code") + suspend fun generateInviteCode(): Response + + @POST("/api/connections/join") + suspend fun joinByCode(@Body request: JoinRequest): Response + + @GET("/api/connections/pending") + suspend fun getPendingConnections(): Response> + + @GET("/api/connections/active") + suspend fun getActiveConnections(): Response> + + @POST("/api/connections/{connectionId}/respond") + suspend fun respondToConnection( + @Path("connectionId") connectionId: String, + @Body request: RespondConnectionRequest + ): Response + + @POST("/api/assignments") + suspend fun createAssignment( + @Body request: CreateAssignmentRequest + ): Response + + @POST("/api/submissions") + suspend fun createSubmission( + @Body request: CreateSubmissionRequest + ): Response + + @GET("/api/submissions/{submissionId}/status") + suspend fun getSubmissionStatus( + @Path("submissionId") submissionId: String + ): Response + +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/SmartJamDatabase.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/SmartJamDatabase.kt new file mode 100644 index 0000000..55507c7 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/SmartJamDatabase.kt @@ -0,0 +1,11 @@ +package com.smartjam.app.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.smartjam.app.data.local.dao.ConnectionDao +import com.smartjam.app.data.local.entity.ConnectionEntity + +@Database(entities = [ConnectionEntity::class], version = 1, exportSchema = false) +abstract class SmartJamDatabase : RoomDatabase() { + abstract fun connectionDao(): ConnectionDao +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt index 2565dbf..3568d56 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt @@ -8,10 +8,10 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map -private val Context.dataStore : DataStore by preferencesDataStore( +private val Context.dataStore : DataStore by preferencesDataStore( //TODO: make encrypted storage name = "auth_preferences" ) class TokenStorage(private val context: Context) { @@ -73,7 +73,7 @@ class TokenStorage(private val context: Context) { } suspend fun isAuthenticated(): Boolean { - return !isRefreshTokenExpired() && !isAccessTokenExpired() + return !isRefreshTokenExpired() } } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/dao/ConnectionDao.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/ConnectionDao.kt new file mode 100644 index 0000000..0fd4312 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/ConnectionDao.kt @@ -0,0 +1,21 @@ +package com.smartjam.app.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.smartjam.app.data.local.entity.ConnectionEntity +import com.smartjam.app.domain.model.UserRole +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConnectionDao { + @Query("SELECT * FROM connections WHERE myRole = :role") + fun getConnectionsFlow(role: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertConnections(connections: List): List + + @Query("DELETE FROM connections WHERE myRole = :role") + suspend fun clearConnections(role: String): Int +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt.kt new file mode 100644 index 0000000..9f94640 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt.kt @@ -0,0 +1,14 @@ +package com.smartjam.app.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.smartjam.app.domain.model.UserRole + +@Entity(tableName = "connections") +data class ConnectionEntity( + @PrimaryKey val connectionId: String, + val peerId: String, + val peerName: String, + val status: String, + val myRole: String +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/CommentModels.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/CommentModels.kt new file mode 100644 index 0000000..78a65a2 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/CommentModels.kt @@ -0,0 +1,11 @@ +package com.smartjam.app.data.model + +data class SendCommentRequest( + val commentText: String +) + +data class CommentResponse( + val attemptId: String, + val commentText: String, + val timestamp: Long +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/ConnectionModels.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/ConnectionModels.kt new file mode 100644 index 0000000..3843b92 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/ConnectionModels.kt @@ -0,0 +1,21 @@ +package com.smartjam.app.data.model + +data class InviteCodeResponse( + val code: String +) + +data class JoinRequest( + val inviteCode: String, +) + +data class ConnectionDto( + val connectionId: String, + val peerId: String, + val peerName: String, + val status: String +) + +data class RespondConnectionRequest( + val accept: Boolean +) + diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt index 0a59794..737258a 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt @@ -1,8 +1,10 @@ package com.smartjam.app.data.model +import com.smartjam.app.domain.model.UserRole + data class RegisterRequest( val email: String, val password: String, val username: String, - val role: String + val role: UserRole ) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/RoomModels.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/RoomModels.kt deleted file mode 100644 index a965905..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/RoomModels.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.smartjam.app.data.model - -data class JoinRoomRequest( - val inviteCode: String -) - -data class RoomResponse( - val id: String, - val teacherName: String, - val title: String -) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/model/TaskModels.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/TaskModels.kt new file mode 100644 index 0000000..7d54c7d --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/TaskModels.kt @@ -0,0 +1,24 @@ +package com.smartjam.app.data.model + +data class CreateAssignmentRequest( + val connectionId: String, + val title: String, + val description: String? +) + +data class CreateSubmissionRequest( + val assignmentId: String +) + +data class PresignedUrlResponse( + val uploadUrl: String, + val entityId: String +) + +data class SubmissionStatusResponse( + val id: String, + val status: String, + val pitchScore: Int?, + val rhythmScore: Int?, + val errorMessage: String? +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/model/Connection.kt b/mobile/app/src/main/java/com/smartjam/app/domain/model/Connection.kt new file mode 100644 index 0000000..af4e8e0 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/domain/model/Connection.kt @@ -0,0 +1,9 @@ +package com.smartjam.app.domain.model + + +data class Connection( + val id: String, + val peerId: String, + val peerName: String, + val status: String +) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/data/UserRole.kt b/mobile/app/src/main/java/com/smartjam/app/domain/model/UserRole.kt similarity index 56% rename from mobile/app/src/main/java/com/smartjam/app/domain/data/UserRole.kt rename to mobile/app/src/main/java/com/smartjam/app/domain/model/UserRole.kt index 6525a1a..260fef8 100644 --- a/mobile/app/src/main/java/com/smartjam/app/domain/data/UserRole.kt +++ b/mobile/app/src/main/java/com/smartjam/app/domain/model/UserRole.kt @@ -1,4 +1,4 @@ -package com.smartjam.app.domain.data +package com.smartjam.app.domain.model enum class UserRole { STUDENT, diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt b/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt index e2e0a9b..cd0174c 100644 --- a/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt +++ b/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt @@ -6,14 +6,16 @@ import com.smartjam.app.data.local.TokenStorage import com.smartjam.app.data.model.LoginRequest import com.smartjam.app.data.model.RefreshRequest import com.smartjam.app.data.model.RegisterRequest +import com.smartjam.app.domain.model.UserRole +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first class AuthRepository ( private val tokenStorage: TokenStorage, - private val authApi: AuthApi = NetworkModule.authApi + private val authApi: AuthApi ) { - suspend fun register(email: String, password: String, username: String, role: String): Result { + suspend fun register(email: String, password: String, username: String, role: UserRole): Result { return try { val response = authApi.register(RegisterRequest(email, password, username, role)) @@ -26,6 +28,7 @@ class AuthRepository ( Result.success(Unit) } catch (e: Exception) { + if (e is CancellationException) throw e; Result.failure(e) } } @@ -41,6 +44,7 @@ class AuthRepository ( ) Result.success(Unit) } catch (e: Exception){ + if (e is CancellationException) throw e; Result.failure(e) } } @@ -69,8 +73,14 @@ class AuthRepository ( return true } catch (e: Exception){ - tokenStorage.clearTokens() - return false + if (e is CancellationException) { + throw e + } + else{ + tokenStorage.clearTokens() + return false + } + } } diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/repository/ConnectionRepository.kt b/mobile/app/src/main/java/com/smartjam/app/domain/repository/ConnectionRepository.kt new file mode 100644 index 0000000..2a21bd0 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/domain/repository/ConnectionRepository.kt @@ -0,0 +1,105 @@ +package com.smartjam.app.domain.repository + +import com.smartjam.app.data.api.SmartJamApi +import com.smartjam.app.data.local.dao.ConnectionDao +import com.smartjam.app.data.local.entity.ConnectionEntity +import com.smartjam.app.data.model.JoinRequest +import com.smartjam.app.data.model.RespondConnectionRequest +import com.smartjam.app.domain.model.Connection +import com.smartjam.app.domain.model.UserRole +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ConnectionRepository ( + private val api: SmartJamApi, + private val dao: ConnectionDao +){ + fun getConnectionsFlow(role: UserRole): Flow> { + return dao.getConnectionsFlow(role.name).map { entities -> + entities.map { entity -> + Connection( + id = entity.connectionId, + peerId = entity.peerId, + peerName = entity.peerName, + status = entity.status + ) + } + } + } + + suspend fun syncConnections(role: UserRole): Result { + return try { + val activeResponse = api.getActiveConnections() + val pendingResponse = api.getPendingConnections() + + if (activeResponse.isSuccessful && pendingResponse.isSuccessful) { + val active = activeResponse.body() ?: emptyList() + val pending = pendingResponse.body() ?: emptyList() + + val allEntities = (active + pending).map { dto -> + ConnectionEntity( + connectionId = dto.connectionId, + peerId = dto.peerId, + peerName = dto.peerName, + status = dto.status, + myRole = role.name + ) + } + + dao.clearConnections(role.name) + dao.insertConnections(allEntities) + + Result.success(Unit) + } else { + Result.failure(Exception("Failed to fetch connections")) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Result.failure(e) + } + } + + suspend fun generateInviteCode(): Result { + return try { + val response = api.generateInviteCode() + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!.code) + } else { + Result.failure(Exception("Failed to generate code")) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Result.failure(e) + } + } + + suspend fun joinByCode(code: String): Result { + return try { + val response = api.joinByCode(JoinRequest(code)) + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("Invalid invite code")) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Result.failure(e) + } + } + + suspend fun respondToRequest(connectionId: String, accept: Boolean): Result { + return try { + val response = api.respondToConnection(connectionId, RespondConnectionRequest(accept)) + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("Failed to respond")) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Result.failure(e) + } + } + +} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt b/mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt index 6900a2f..e69de29 100644 --- a/mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt +++ b/mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt @@ -1,23 +0,0 @@ -package com.smartjam.app.domain.repository - -import com.smartjam.app.data.api.RoomApi -import com.smartjam.app.data.model.JoinRoomRequest -import com.smartjam.app.data.model.RoomResponse - -class RoomRepository( - private val roomApi: RoomApi -) { - suspend fun joinRoomByCode(code: String): Result { - return try { - val response = roomApi.joinRoom(JoinRoomRequest(inviteCode = code)) - - if (response.isSuccessful && response.body() != null) { - Result.success(response.body()!!) - } else { - Result.failure(Exception("Неверный код или комната не найдена")) - } - } catch (e: Exception) { - Result.failure(Exception("Ошибка сети: проверьте подключение")) - } - } -} diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt index 94dd245..c40435b 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt @@ -1,18 +1,14 @@ package com.smartjam.app.ui.navigation -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.runtime.remember import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import com.smartjam.app.data.api.NetworkModule +import com.smartjam.app.data.local.TokenStorage import com.smartjam.app.domain.repository.AuthRepository -import com.smartjam.app.domain.repository.RoomRepository +import com.smartjam.app.domain.repository.ConnectionRepository import com.smartjam.app.ui.screens.home.HomeScreen import com.smartjam.app.ui.screens.home.HomeViewModel import com.smartjam.app.ui.screens.home.HomeViewModelFactory @@ -23,16 +19,20 @@ import com.smartjam.app.ui.screens.register.RegisterScreen import com.smartjam.app.ui.screens.register.RegisterViewModel import com.smartjam.app.ui.screens.register.RegisterViewModelFactory + sealed class Screen(val route: String) { object Login : Screen("login_screen") object Register : Screen("register_screen") object Home : Screen("home_screen") + object Room : Screen("room_screen") } @Composable fun SmartJamNavGraph( navController: NavHostController, - authRepository: AuthRepository + authRepository: AuthRepository, + connectionRepository: ConnectionRepository, + tokenStorage: TokenStorage ) { NavHost( navController = navController, @@ -75,21 +75,24 @@ fun SmartJamNavGraph( ) } - composable(route = Screen.Home.route) { - val roomRepo = RoomRepository(NetworkModule.roomApi) + composable(route = Screen.Home.route) { val viewModel: HomeViewModel = viewModel( - factory = HomeViewModelFactory(roomRepo) + factory = HomeViewModelFactory(connectionRepository, authRepository) ) HomeScreen( viewModel = viewModel, - onLogoutClicked = { + onNavigateToRoom = { connectionId -> + navController.navigate(Screen.Room.route) + }, + onNavigateToLogin = { navController.navigate(Screen.Login.route) { popUpTo(Screen.Home.route) { inclusive = true } } } ) } + } } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt index eaf7c96..32dc9d1 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt @@ -2,19 +2,29 @@ package com.smartjam.app.ui.screens.home import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -23,6 +33,8 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.smartjam.app.domain.model.Connection +import com.smartjam.app.domain.model.UserRole import com.smartjam.app.ui.screens.login.AppleGlassTextField import com.smartjam.app.ui.screens.login.AppleLiquidBackground import com.smartjam.app.ui.screens.login.GoldenStringsButton @@ -30,7 +42,8 @@ import com.smartjam.app.ui.screens.login.GoldenStringsButton @Composable fun HomeScreen( viewModel: HomeViewModel, - onLogoutClicked: () -> Unit + onNavigateToRoom: (String) -> Unit, + onNavigateToLogin: () -> Unit ) { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -39,98 +52,270 @@ fun HomeScreen( LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { - is HomeEvent.RoomJoined -> { - Toast.makeText(context, "Успешно добавлено!", Toast.LENGTH_SHORT).show() + is HomeEvent.NavigateToLogin -> onNavigateToLogin() + is HomeEvent.NavigateToRoom -> onNavigateToRoom(event.connectionId) + is HomeEvent.ShowToast -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } } } } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFF05050A)) - ) { + Box(modifier = Modifier.fillMaxSize().background(Color(0xFF05050A))) { AppleLiquidBackground() - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 32.dp, vertical = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(modifier = Modifier.fillMaxSize()) { + HomeHeader( + role = state.currentRole, + isLoading = state.isLoading, + onLogout = viewModel::onLogoutClicked, + onSync = viewModel::syncNetworkData, + onToggleDebugRole = viewModel::toggleDebugRole + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { + if (state.errorMessage != null) { Text( - text = "Мои классы", - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) - - GoldenStringsButton( - text = "Выход", - onClick = onLogoutClicked, - modifier = Modifier.width(100.dp).height(40.dp) + text = state.errorMessage!!, + color = Color(0xFFFF5252), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + fontSize = 14.sp ) } - Spacer(modifier = Modifier.height(48.dp)) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (state.currentRole == UserRole.TEACHER) { + item { + TeacherInviteSection( + code = state.teacherGeneratedCode, + isLoading = state.isLoading, + onGenerate = viewModel::onGenerateCodeClicked + ) + } + + if (state.pendingConnections.isNotEmpty()) { + item { SectionTitle("Новые заявки (${state.pendingConnections.size})") } + items(state.pendingConnections) { connection -> + PendingRequestCard( + connection = connection, + onAccept = { viewModel.onRespondToRequest(it, true) }, + onReject = { viewModel.onRespondToRequest(it, false) } + ) + } + } + + item { Spacer(modifier = Modifier.height(8.dp)) } + item { SectionTitle("Мои ученики") } + + } else { + item { + StudentJoinSection( + inputValue = state.inviteCodeInput, + isLoading = state.isLoading, + onInputChange = viewModel::onInviteCodeInputChanged, + onJoin = { + keyboard?.hide() + viewModel.onJoinRoomClicked() + } + ) + } + + item { Spacer(modifier = Modifier.height(8.dp)) } + item { SectionTitle("Мои преподаватели") } + } + + if (state.activeConnections.isEmpty()) { + item { + Text( + text = "Список пуст", + color = Color.White.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 16.dp) + ) + } + } else { + items(state.activeConnections) { connection -> + ActiveConnectionCard( + connection = connection, + onClick = { viewModel.onConnectionClicked(connection.id) } + ) + } + } + } + } + } +} +@Composable +private fun HomeHeader( + role: UserRole, + isLoading: Boolean, + onLogout: () -> Unit, + onSync: () -> Unit, + onToggleDebugRole: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.clickable { onToggleDebugRole() }) { + Text( + text = "SmartJam", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) Text( - text = "Присоединиться к учителю", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = Color.White.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.Start) + text = if (role == UserRole.TEACHER) "Режим преподавателя" else "Режим ученика", + fontSize = 12.sp, + color = Color(0xFF00E5FF) + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(16.dp)) + } else { + IconButton(onClick = onSync) { + Icon(Icons.Default.Refresh, contentDescription = "Обновить", tint = Color.White) + } + } + + IconButton(onClick = onLogout) { + Icon(Icons.Default.ExitToApp, contentDescription = "Выйти", tint = Color.White.copy(alpha = 0.7f)) + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.8f) + ) +} + +@Composable +private fun TeacherInviteSection(code: String?, isLoading: Boolean, onGenerate: () -> Unit) { + GlassContainer { + Column(modifier = Modifier.fillMaxWidth()) { + Text("Код приглашения", color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp) + Spacer(modifier = Modifier.height(12.dp)) + + if (code != null) { + Text(text = code, fontSize = 36.sp, fontWeight = FontWeight.ExtraBold, color = Color(0xFFFFD700), letterSpacing = 4.sp) + Spacer(modifier = Modifier.height(16.dp)) + } + + GoldenStringsButton( + text = if (code == null) "Сгенерировать код" else "Обновить код", + onClick = onGenerate, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth().height(50.dp) ) + } + } +} +@Composable +private fun StudentJoinSection(inputValue: String, isLoading: Boolean, onInputChange: (String) -> Unit, onJoin: () -> Unit) { + GlassContainer { + Column { + Text("Присоединиться к классу", color = Color.White.copy(alpha = 0.6f), fontSize = 14.sp) Spacer(modifier = Modifier.height(12.dp)) AppleGlassTextField( - value = state.inviteCodeInput, - onValueChange = { viewModel.onInviteCodeChanged(it) }, - hint = "Введите инвайт-код (напр. A1B2C)", - icon = Icons.Default.Add, + value = inputValue, + onValueChange = onInputChange, + hint = "Введите код (напр. A1B2C)", + icon = Icons.Default.Person, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Done ), - keyboardActions = KeyboardActions(onDone = { - keyboard?.hide() - viewModel.onJoinRoomClicked() - }) + keyboardActions = KeyboardActions(onDone = { onJoin() }), + enabled = !isLoading ) - Spacer(modifier = Modifier.height(16.dp)) GoldenStringsButton( - text = if (state.isLoading) "Поиск..." else "Присоединиться", - onClick = { - keyboard?.hide() - viewModel.onJoinRoomClicked() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !state.isLoading + text = "Отправить заявку", + onClick = onJoin, + enabled = !isLoading && inputValue.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(50.dp) ) + } + } +} - Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - contentAlignment = Alignment.Center - ) { - if (state.errorMessage != null) { - Text(text = state.errorMessage!!, color = Color(0xFFFF5252), fontSize = 14.sp) +@Composable +private fun PendingRequestCard(connection: Connection, onAccept: (String) -> Unit, onReject: (String) -> Unit) { + GlassContainer { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text("Новая заявка", color = Color(0xFFFFD700), fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text(connection.peerName, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) + } + Row { + IconButton(onClick = { onReject(connection.id) }, modifier = Modifier.background(Color(0xFFFF5252).copy(0.2f), RoundedCornerShape(12.dp))) { + Icon(Icons.Default.Close, contentDescription = "Отклонить", tint = Color(0xFFFF5252)) } - if (state.successMessage != null) { - Text(text = state.successMessage!!, color = Color(0xFF00E5FF), fontSize = 14.sp) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = { onAccept(connection.id) }, modifier = Modifier.background(Color(0xFF00E5FF).copy(0.2f), RoundedCornerShape(12.dp))) { + Icon(Icons.Default.Check, contentDescription = "Принять", tint = Color(0xFF00E5FF)) } } } } +} + +@Composable +private fun ActiveConnectionCard(connection: Connection, onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(20.dp)) + .clickable { onClick() } + .padding(20.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(48.dp).clip(RoundedCornerShape(24.dp)).background(Color.White.copy(0.1f)), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Person, contentDescription = null, tint = Color.White) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(connection.peerName, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + Text("Нажмите, чтобы открыть", color = Color.White.copy(0.5f), fontSize = 13.sp) + } + } + } +} + +@Composable +private fun GlassContainer(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(24.dp)) + .padding(24.dp) + ) { + content() + } } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt index f11a7e6..0c01fd0 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt @@ -3,7 +3,11 @@ package com.smartjam.app.ui.screens.home import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.smartjam.app.domain.repository.RoomRepository +import com.smartjam.app.domain.model.Connection +import com.smartjam.app.domain.model.UserRole +import com.smartjam.app.domain.repository.AuthRepository +import com.smartjam.app.domain.repository.ConnectionRepository +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,65 +16,169 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class HomeState( + val currentRole: UserRole = UserRole.STUDENT, + val activeConnections: List = emptyList(), + val pendingConnections: List = emptyList(), val inviteCodeInput: String = "", + val teacherGeneratedCode: String? = null, val isLoading: Boolean = false, - val successMessage: String? = null, val errorMessage: String? = null ) sealed class HomeEvent { - object RoomJoined : HomeEvent() + object NavigateToLogin : HomeEvent() + data class NavigateToRoom(val connectionId: String) : HomeEvent() + data class ShowToast(val message: String) : HomeEvent() } class HomeViewModel( - private val roomRepository: RoomRepository + private val connectionRepository: ConnectionRepository, + private val authRepository: AuthRepository ) : ViewModel() { private val _state = MutableStateFlow(HomeState()) val state = _state.asStateFlow() - private val eventChannel = Channel() + private val eventChannel = Channel(Channel.BUFFERED) val events = eventChannel.receiveAsFlow() - fun onInviteCodeChanged(code: String) { - _state.update { it.copy(inviteCodeInput = code.trim(), errorMessage = null, successMessage = null) } + private var connectionJob: Job? = null + + init { + startObservingConnections() + } + + fun toggleDebugRole() { + val newRole = if (_state.value.currentRole == UserRole.STUDENT) { + UserRole.TEACHER + } else { + UserRole.STUDENT + } + + _state.update { it.copy( + currentRole = newRole, + activeConnections = emptyList(), + pendingConnections = emptyList(), + errorMessage = null + ) } + + startObservingConnections() + } + + private fun startObservingConnections() { + connectionJob?.cancel() + + connectionJob = viewModelScope.launch { + val role = _state.value.currentRole + + launch { + connectionRepository.getConnectionsFlow(role).collect { connections -> + _state.update { currentState -> + currentState.copy( + activeConnections = connections.filter { it.status == "ACTIVE" }, + pendingConnections = connections.filter { it.status == "PENDING" } + ) + } + } + } + + syncNetworkData() + } + } + + fun syncNetworkData() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + + val result = connectionRepository.syncConnections(_state.value.currentRole) + + if (result.isFailure) { + _state.update { it.copy(errorMessage = "Не удалось обновить данные с сервера") } + } + + _state.update { it.copy(isLoading = false) } + } + } + + fun onInviteCodeInputChanged(code: String) { + _state.update { it.copy(inviteCodeInput = code, errorMessage = null) } } fun onJoinRoomClicked() { val code = _state.value.inviteCodeInput - if (code.isBlank()) { - _state.update { it.copy(errorMessage = "Введите код") } - return + if (code.isBlank()) return + + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + + val result = connectionRepository.joinByCode(code) + + _state.update { it.copy(isLoading = false) } + + if (result.isSuccess) { + _state.update { it.copy(inviteCodeInput = "") } + eventChannel.send(HomeEvent.ShowToast("Заявка успешно отправлена!")) + syncNetworkData() + } else { + _state.update { it.copy(errorMessage = "Неверный код или ошибка сервера") } + } } + } + fun onGenerateCodeClicked() { viewModelScope.launch { - _state.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) } + _state.update { it.copy(isLoading = true, errorMessage = null) } - val result = roomRepository.joinRoomByCode(code) + val result = connectionRepository.generateInviteCode() _state.update { it.copy(isLoading = false) } if (result.isSuccess) { - val room = result.getOrNull() - _state.update { - it.copy( - successMessage = "Вы присоединились к классу: ${room?.teacherName}", - inviteCodeInput = "" - ) - } - eventChannel.send(HomeEvent.RoomJoined) + _state.update { it.copy(teacherGeneratedCode = result.getOrNull()) } } else { - _state.update { it.copy(errorMessage = result.exceptionOrNull()?.message) } + _state.update { it.copy(errorMessage = "Не удалось сгенерировать код") } } } } + + fun onRespondToRequest(connectionId: String, accept: Boolean) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + + val result = connectionRepository.respondToRequest(connectionId, accept) + + _state.update { it.copy(isLoading = false) } + + if (result.isSuccess) { + val msg = if (accept) "Ученик добавлен" else "Заявка отклонена" + eventChannel.send(HomeEvent.ShowToast(msg)) + syncNetworkData() + } else { + _state.update { it.copy(errorMessage = "Ошибка при обработке заявки") } + } + } + } + + fun onConnectionClicked(connectionId: String) { + viewModelScope.launch { + eventChannel.send(HomeEvent.NavigateToRoom(connectionId)) + } + } + + fun onLogoutClicked() { + viewModelScope.launch { + authRepository.logout() + eventChannel.send(HomeEvent.NavigateToLogin) + } + } } class HomeViewModelFactory( - private val roomRepository: RoomRepository + private val connectionRepository: ConnectionRepository, + private val authRepository: AuthRepository ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return HomeViewModel(roomRepository) as T + return HomeViewModel(connectionRepository, authRepository) as T } } \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt index a5e5ac2..49abb47 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt @@ -1,19 +1,29 @@ package com.smartjam.app.ui.screens.login import android.widget.Toast -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.compose.animation.core.* +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -21,22 +31,31 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Lock -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlin.math.PI @@ -46,7 +65,7 @@ import kotlin.math.sin @Composable fun LoginScreen( - viewModel: LoginViewModel = viewModel(), + viewModel: LoginViewModel, onNavigateToHome: () -> Unit = {}, onNavigateToRegister: () -> Unit = {} ) { @@ -225,14 +244,16 @@ fun AppleGlassTextField( icon: androidx.compose.ui.graphics.vector.ImageVector, visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default + keyboardActions: KeyboardActions = KeyboardActions.Default, + enabled: Boolean = true ) { BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, + enabled = enabled, textStyle = TextStyle( - color = Color.White, + color = if (enabled) Color.White else Color.White.copy(alpha = 0.5f), fontSize = 16.sp, fontWeight = FontWeight.Medium ), @@ -246,10 +267,10 @@ fun AppleGlassTextField( .fillMaxWidth() .height(60.dp) .clip(RoundedCornerShape(24.dp)) - .background(Color.White.copy(alpha = 0.05f)) + .background(Color.White.copy(alpha = if (enabled) 0.05f else 0.02f)) .border( width = 1.dp, - color = Color.White.copy(alpha = 0.15f), + color = Color.White.copy(alpha = if (enabled) 0.15f else 0.05f), shape = RoundedCornerShape(24.dp) ) .padding(horizontal = 20.dp), @@ -258,7 +279,7 @@ fun AppleGlassTextField( Icon( imageVector = icon, contentDescription = null, - tint = Color.White.copy(alpha = 0.5f), + tint = Color.White.copy(alpha = if (enabled) 0.5f else 0.2f), modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(16.dp)) @@ -266,7 +287,7 @@ fun AppleGlassTextField( if (value.isEmpty()) { Text( text = hint, - color = Color.White.copy(alpha = 0.3f), + color = Color.White.copy(alpha = if (enabled) 0.3f else 0.15f), fontSize = 16.sp ) } @@ -439,9 +460,3 @@ fun AppleGlassButton( ) } } - -@Preview(showBackground = true) -@Composable -fun LoginScreenModernPreview() { - LoginScreen() -} \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt index 871305e..263784f 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt @@ -1,7 +1,8 @@ package com.smartjam.app.ui.screens.login + import androidx.lifecycle.ViewModel -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.smartjam.app.domain.repository.AuthRepository import kotlinx.coroutines.channels.Channel @@ -9,11 +10,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch - -import androidx.lifecycle.ViewModelProvider - data class LoginState( val emailInput: String = "", val passwordInput: String = "", @@ -33,7 +32,7 @@ class LoginViewModel ( private val _state = MutableStateFlow(LoginState()) val state : StateFlow = _state.asStateFlow() - private val eventChannel = Channel() + private val eventChannel = Channel(Channel.BUFFERED) val events = eventChannel.receiveAsFlow() fun onPasswordChanged(newPassword: String){ @@ -51,19 +50,29 @@ class LoginViewModel ( } fun onLoginClicked() { + if (_state.value.isLoading){ + return; + } val currentEmail = _state.value.emailInput val currentPassword = _state.value.passwordInput if (currentPassword.isBlank() || currentEmail.isBlank()){ _state.value = _state.value.copy(errorMessage = "Fill in all fields") + return; } viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, errorMessage = null) try { - authRepository.login(currentEmail, currentPassword) - - eventChannel.send(LoginEvent.NavigateToHome) + val result = authRepository.login(currentEmail, currentPassword) + + if (result.isSuccess){ + eventChannel.send(LoginEvent.NavigateToHome) + } + else{ + val error = result.exceptionOrNull()?.message ?: "Error" + _state.update { it.copy(errorMessage = error) } + } } catch (e: Exception){ _state.value = _state.value.copy( errorMessage = e.message?: "Unknown error" diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt index 3f6bd4a..4b0fb78 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.smartjam.app.domain.data.UserRole +import com.smartjam.app.domain.model.UserRole import com.smartjam.app.ui.screens.login.AppleGlassTextField import com.smartjam.app.ui.screens.login.AppleLiquidBackground import com.smartjam.app.ui.screens.login.GoldenStringsButton diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt index 585acda..14c5e95 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt @@ -1,16 +1,16 @@ package com.smartjam.app.ui.screens.register import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.smartjam.app.domain.model.UserRole import com.smartjam.app.domain.repository.AuthRepository -import com.smartjam.app.domain.data.UserRole import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import androidx.lifecycle.ViewModelProvider data class RegisterState( val usernameInput: String = "", @@ -34,7 +34,7 @@ class RegisterViewModel( private val _state = MutableStateFlow(RegisterState()) val state = _state.asStateFlow() - private val eventChannel = Channel() + private val eventChannel = Channel(Channel.BUFFERED) val events = eventChannel.receiveAsFlow() private val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\$".toRegex() private val passwordRegex = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}\$".toRegex() @@ -66,6 +66,9 @@ class RegisterViewModel( } fun onRegisterClicked() { + if (_state.value.isLoading){ + return; + } val currentState = _state.value if (currentState.usernameInput.isBlank()) { @@ -95,7 +98,7 @@ class RegisterViewModel( email = currentState.emailInput, password = currentState.passwordInput, username = currentState.usernameInput, - role = currentState.selectedRole.name + role = currentState.selectedRole ) _state.update { it.copy(isLoading = false) } diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomScreen.kt new file mode 100644 index 0000000..e69de29 diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomViewModel.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomViewModel.kt new file mode 100644 index 0000000..e69de29 diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index 18318be..d3b18ca 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false + id("com.google.devtools.ksp") version "2.3.6" apply false } \ No newline at end of file