diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index 3cd1f66..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,10 +42,39 @@ android { } buildFeatures { compose = true + buildConfig = true } } 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("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) @@ -55,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 c13dade..6b86389 100644 --- a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt @@ -5,43 +5,57 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge 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 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?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + + val tokenStorage = TokenStorage(context = this) + + 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 { - SmartJamTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + val navController = rememberNavController() + + Surface( + modifier = Modifier.fillMaxSize(), + color = Color(0xFF05050A) + ) { + + SmartJamNavGraph( + navController = navController, + authRepository = authRepository, + connectionRepository = connectionRepository, + tokenStorage = tokenStorage + ) } } } -} - -@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..8e507ab --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthApi.kt @@ -0,0 +1,20 @@ +package com.smartjam.app.data.api + +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 + +interface AuthApi { + + @POST("/api/auth/register") + suspend fun register(@Body request: RegisterRequest): LoginResponse + + @POST("/api/auth/login") + suspend fun login(@Body request: LoginRequest): LoginResponse + + @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 new file mode 100644 index 0000000..aa5b687 --- /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 com.smartjam.app.BuildConfig +import com.smartjam.app.data.local.TokenStorage +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + + +object NetworkModule { + + fun createRetrofit(tokenStorage: TokenStorage): Retrofit { + val authInterceptor = AuthInterceptor(tokenStorage) + + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(loggingInterceptor) + .build() + + return Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + 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/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 new file mode 100644 index 0000000..3568d56 --- /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.first +import kotlinx.coroutines.flow.map + +private val Context.dataStore : DataStore by preferencesDataStore( //TODO: make encrypted storage + 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() + } + +} \ 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/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/LoginResponse.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponse.kt new file mode 100644 index 0000000..b9dbcdc --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponse.kt @@ -0,0 +1,8 @@ +package com.smartjam.app.data.model + +data class LoginResponse ( + 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/data/model/RegisterRequest.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt new file mode 100644 index 0000000..737258a --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt @@ -0,0 +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: UserRole +) \ 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/model/UserRole.kt b/mobile/app/src/main/java/com/smartjam/app/domain/model/UserRole.kt new file mode 100644 index 0000000..260fef8 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/domain/model/UserRole.kt @@ -0,0 +1,6 @@ +package com.smartjam.app.domain.model + +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 new file mode 100644 index 0000000..cd0174c --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt @@ -0,0 +1,125 @@ +package com.smartjam.app.domain.repository + +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.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 +) { + + suspend fun register(email: String, password: String, username: String, role: UserRole): 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) { + if (e is CancellationException) throw e; + Result.failure(e) + } + } + suspend fun login(email: String, password: String): Result { + return try { + val response = authApi.login(LoginRequest(email, password)) + + tokenStorage.saveToken( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + accessExpiredAt = response.accessExpiresAt, + refreshExpiredAt = response.refreshExpiredAt + ) + Result.success(Unit) + } catch (e: Exception){ + if (e is CancellationException) throw e; + 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){ + if (e is CancellationException) { + throw e + } + else{ + 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/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 new file mode 100644 index 0000000..e69de29 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..c40435b --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt @@ -0,0 +1,98 @@ +package com.smartjam.app.ui.navigation + +import androidx.compose.runtime.Composable +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.local.TokenStorage +import com.smartjam.app.domain.repository.AuthRepository +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 +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") + object Room : Screen("room_screen") +} + +@Composable +fun SmartJamNavGraph( + navController: NavHostController, + authRepository: AuthRepository, + connectionRepository: ConnectionRepository, + tokenStorage: TokenStorage +) { + NavHost( + navController = navController, + startDestination = Screen.Home.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) { + val viewModel: HomeViewModel = viewModel( + factory = HomeViewModelFactory(connectionRepository, authRepository) + ) + + HomeScreen( + viewModel = viewModel, + 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 new file mode 100644 index 0000000..32dc9d1 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt @@ -0,0 +1,321 @@ +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.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 +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.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 + +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + onNavigateToRoom: (String) -> Unit, + onNavigateToLogin: () -> 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.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))) { + AppleLiquidBackground() + + Column(modifier = Modifier.fillMaxSize()) { + HomeHeader( + role = state.currentRole, + isLoading = state.isLoading, + onLogout = viewModel::onLogoutClicked, + onSync = viewModel::syncNetworkData, + onToggleDebugRole = viewModel::toggleDebugRole + ) + + if (state.errorMessage != null) { + Text( + text = state.errorMessage!!, + color = Color(0xFFFF5252), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + fontSize = 14.sp + ) + } + + 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 = 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 = inputValue, + onValueChange = onInputChange, + hint = "Введите код (напр. A1B2C)", + icon = Icons.Default.Person, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { onJoin() }), + enabled = !isLoading + ) + Spacer(modifier = Modifier.height(16.dp)) + + GoldenStringsButton( + text = "Отправить заявку", + onClick = onJoin, + enabled = !isLoading && inputValue.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(50.dp) + ) + } + } +} + +@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)) + } + 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 new file mode 100644 index 0000000..0c01fd0 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,184 @@ +package com.smartjam.app.ui.screens.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +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 +import kotlinx.coroutines.flow.receiveAsFlow +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 errorMessage: String? = null +) + +sealed class HomeEvent { + object NavigateToLogin : HomeEvent() + data class NavigateToRoom(val connectionId: String) : HomeEvent() + data class ShowToast(val message: String) : HomeEvent() +} + +class HomeViewModel( + private val connectionRepository: ConnectionRepository, + private val authRepository: AuthRepository +) : ViewModel() { + + private val _state = MutableStateFlow(HomeState()) + val state = _state.asStateFlow() + + private val eventChannel = Channel(Channel.BUFFERED) + val events = eventChannel.receiveAsFlow() + + 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()) 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) } + + val result = connectionRepository.generateInviteCode() + + _state.update { it.copy(isLoading = false) } + + if (result.isSuccess) { + _state.update { it.copy(teacherGeneratedCode = result.getOrNull()) } + } else { + _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 connectionRepository: ConnectionRepository, + private val authRepository: AuthRepository +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): 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 new file mode 100644 index 0000000..49abb47 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt @@ -0,0 +1,462 @@ +package com.smartjam.app.ui.screens.login + +import android.widget.Toast +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.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 +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.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.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.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, + 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, + enabled: Boolean = true +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + enabled = enabled, + textStyle = TextStyle( + color = if (enabled) Color.White else Color.White.copy(alpha = 0.5f), + 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 = if (enabled) 0.05f else 0.02f)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = if (enabled) 0.15f else 0.05f), + shape = RoundedCornerShape(24.dp) + ) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White.copy(alpha = if (enabled) 0.5f else 0.2f), + 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 = if (enabled) 0.3f else 0.15f), + fontSize = 16.sp + ) + } + innerTextField() + } + } + } + ) +} + +@Composable +fun GoldenStringsButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + 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 = if (enabled) 0.1f else 0.05f)) + .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( + enabled = enabled, + 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 = if (enabled) Color.White else Color.White.copy(alpha = 0.5f), + 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 + ) + } +} 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..263784f --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt @@ -0,0 +1,100 @@ +package com.smartjam.app.ui.screens.login + + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +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.flow.update +import kotlinx.coroutines.launch + +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(Channel.BUFFERED) + 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() { + 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 { + 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" + ) + } 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 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..4b0fb78 --- /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.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 + +@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..14c5e95 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt @@ -0,0 +1,126 @@ +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 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 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(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() + + 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() { + if (_state.value.isLoading){ + return; + } + 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 + ) + + _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 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