From df34cf0588a9ecc0447aa6f3793cf2bf3c648caa Mon Sep 17 00:00:00 2001 From: Anton Podrezov Date: Thu, 21 May 2026 15:27:44 +0300 Subject: [PATCH 01/17] implement room screen logic --- mobile/app/build.gradle.kts | 7 +- .../app/ConnectionSeedInstrumentedTest.kt | 108 +++ mobile/app/src/main/AndroidManifest.xml | 4 + .../java/com/smartjam/app/MainActivity.kt | 73 +- .../src/main/java/com/smartjam/app/api.yaml | 830 ++++++++++++++++++ .../java/com/smartjam/app/common-models.yaml | 74 ++ .../app/data/api/AuthAuthenticator.kt | 16 +- .../smartjam/app/data/api/InstantAdapter.kt | 31 + .../smartjam/app/data/local/AudioFileStore.kt | 22 + .../app/data/local/SmartJamDatabase.kt | 16 +- .../smartjam/app/data/local/TokenStorage.kt | 15 +- .../app/data/local/dao/AssignmentDao.kt | 27 + .../app/data/local/dao/ConnectionDao.kt | 6 +- .../app/data/local/dao/SubmissionResultDao.kt | 24 + .../app/data/local/entity/AssignmentEntity.kt | 19 + .../app/data/local/entity/ConnectionEntity.kt | 4 +- .../local/entity/SubmissionResultEntity.kt | 21 + .../smartjam/app/data/model/CommentModels.kt | 11 - .../app/data/model/ConnectionModels.kt | 21 - .../smartjam/app/data/model/LoginRequest.kt | 6 - .../smartjam/app/data/model/LoginResponse.kt | 8 - .../smartjam/app/data/model/RefreshRequest.kt | 5 - .../app/data/model/RegisterRequest.kt | 10 - .../com/smartjam/app/data/model/TaskModels.kt | 24 - .../smartjam/app/domain/model/Connection.kt | 4 +- .../app/domain/repository/AuthRepository.kt | 67 +- .../domain/repository/ConnectionRepository.kt | 71 +- .../app/domain/repository/RoomRepository.kt | 350 ++++++++ .../smartjam/app/ui/navigation/NavGraph.kt | 387 ++++++-- .../app/ui/screens/home/HomeScreen.kt | 41 +- .../app/ui/screens/home/HomeViewModel.kt | 95 +- .../app/ui/screens/login/LoginScreen.kt | 75 ++ .../app/ui/screens/login/LoginViewModel.kt | 34 +- .../ui/screens/register/RegisterViewModel.kt | 3 +- .../app/ui/screens/room/RoomScreen.kt | 409 +++++++++ .../app/ui/screens/room/RoomViewModel.kt | 229 +++++ mobile/gradlew | 0 37 files changed, 2937 insertions(+), 210 deletions(-) create mode 100644 mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/api.yaml create mode 100644 mobile/app/src/main/java/com/smartjam/app/common-models.yaml create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/AudioFileStore.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/dao/AssignmentDao.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/entity/AssignmentEntity.kt create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt delete mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/CommentModels.kt delete 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/LoginRequest.kt delete mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponse.kt delete mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/RefreshRequest.kt delete mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt delete mode 100644 mobile/app/src/main/java/com/smartjam/app/data/model/TaskModels.kt mode change 100644 => 100755 mobile/gradlew diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index f5d0d02..44023e8 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -76,9 +76,9 @@ dependencies { //serialization implementation("com.squareup.retrofit2:converter-gson:2.11.+") - 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("androidx.room:room-runtime:2.7.0-alpha11") + implementation("androidx.room:room-ktx:2.7.0-alpha11") + ksp("androidx.room:room-compiler:2.7.0-alpha11") //logging implementation("com.squareup.okhttp3:logging-interceptor:4.12.+") @@ -108,6 +108,7 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + implementation("io.coil-kt:coil-compose:2.6.0") } diff --git a/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt b/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt new file mode 100644 index 0000000..51f73a5 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt @@ -0,0 +1,108 @@ +package com.smartjam.app + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.smartjam.app.data.local.SmartJamDatabase +import com.smartjam.app.data.local.TokenStorage +import com.smartjam.app.data.local.entity.ConnectionEntity +import com.smartjam.app.domain.model.UserRole +import com.smartjam.app.api.AuthApi +import com.smartjam.app.domain.repository.AuthRepository +import com.smartjam.app.model.AuthResponse +import com.smartjam.app.model.LoginRequest +import com.smartjam.app.model.RefreshRequest +import com.smartjam.app.model.RegisterRequest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.openapitools.client.infrastructure.ApiClient +import retrofit2.Response +import java.time.Instant +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class ConnectionSeedInstrumentedTest { + + @Test + fun createUserAndSeedConnections() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val tokenStorage = TokenStorage(context) + val apiClient = ApiClient(baseUrl = "http://localhost") + + val fakeAuthApi = object : AuthApi { + override suspend fun loginUser(loginRequest: LoginRequest): Response { + return Response.success( + AuthResponse( + accessToken = "mock_access_token", + refreshToken = "mock_refresh_token" + ) + ) + } + + override suspend fun refreshToken(refreshRequest: RefreshRequest): Response { + return Response.success( + AuthResponse( + accessToken = "mock_access_token", + refreshToken = "mock_refresh_token" + ) + ) + } + + override suspend fun registerUser(registerRequest: RegisterRequest): Response { + return Response.success( + AuthResponse( + accessToken = "mock_access_token", + refreshToken = "mock_refresh_token" + ) + ) + } + } + + val authRepository = AuthRepository(tokenStorage, fakeAuthApi, apiClient) + authRepository.register( + email = "mmm", + password = "Qwerty1!", + username = "mmm", + role = UserRole.STUDENT + ) + + val db = Room.inMemoryDatabaseBuilder(context, SmartJamDatabase::class.java) + .allowMainThreadQueries() + .build() + + try { + val dao = db.connectionDao() + val now = Instant.now() + + val connections = (1..50).map { index -> + ConnectionEntity( + connectionId = UUID.randomUUID(), + peerId = UUID.randomUUID(), + peerUsername = "User$index", + createdAt = now, + peerFirstName = null, + peerLastName = null, + peerAvatarUrl = null, + peerAvatarBytes = null, + myRole = UserRole.STUDENT.name + ) + } + + dao.insertConnections(connections) + + val stored = dao.getConnectionsFlow(UserRole.STUDENT.name).first() + assertEquals(50, stored.size) + + val refreshToken = tokenStorage.refreshToken.first() + assertNotNull(refreshToken) + } finally { + tokenStorage.clearTokens() + db.close() + } + } +} + diff --git a/mobile/app/src/main/AndroidManifest.xml b/mobile/app/src/main/AndroidManifest.xml index a75e66e..98da7c8 100644 --- a/mobile/app/src/main/AndroidManifest.xml +++ b/mobile/app/src/main/AndroidManifest.xml @@ -2,7 +2,11 @@ + + + + val original = chain.request() + val token = runBlocking { tokenStorage.accessToken.first() } + if (token != null && original.header("Authorization") == null) { + val request = original.newBuilder() + .header("Authorization", "Bearer $token") + .build() + chain.proceed(request) + } else { + chain.proceed(original) + } + } val apiClient = ApiClient( baseUrl = baseUrl, okHttpClientBuilder = okHttpClientBuilder, + serializerBuilder = serializerBuilder, authNames = arrayOf("bearerAuth") ) authenticator.apiClient = apiClient @@ -65,9 +87,26 @@ class MainActivity : ComponentActivity() { val authApi = apiClient.createService(AuthApi::class.java) val connectionsApi = apiClient.createService(ConnectionsApi::class.java) + val assignmentsApi = apiClient.createService(AssignmentsApi::class.java) + val submissionsApi = apiClient.createService(SubmissionsApi::class.java) val authRepository = AuthRepository(tokenStorage, authApi, apiClient) val connectionRepository = ConnectionRepository(connectionsApi, appDatabase.connectionDao()) + val roomRepository = RoomRepository( + assignmentsApi = assignmentsApi, + submissionsApi = submissionsApi, + assignmentDao = appDatabase.assignmentDao(), + submissionResultDao = appDatabase.submissionResultDao(), + audioFileStore = AudioFileStore(applicationContext) + ) + + lifecycleScope.launch { + tokenStorage.accessToken.collect { newToken -> + if (newToken != null) { + apiClient.setBearerToken(newToken) + } + } + } setContent { val navController = rememberNavController() @@ -87,6 +126,19 @@ class MainActivity : ComponentActivity() { } } + LaunchedEffect(startDestination) { + if (startDestination != null) { + tokenStorage.refreshToken.collect { token -> + if (token.isNullOrEmpty()) { + navController.navigate(Screen.Login.route) { + popUpTo(navController.graph.id) { inclusive = true } + launchSingleTop = true + } + } + } + } + } + Surface( modifier = Modifier.fillMaxSize(), color = Color(0xFF05050A) @@ -96,6 +148,7 @@ class MainActivity : ComponentActivity() { navController = navController, authRepository = authRepository, connectionRepository = connectionRepository, + roomRepository = roomRepository, tokenStorage = tokenStorage, startDestination = startDestination!! ) diff --git a/mobile/app/src/main/java/com/smartjam/app/api.yaml b/mobile/app/src/main/java/com/smartjam/app/api.yaml new file mode 100644 index 0000000..42aa031 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/api.yaml @@ -0,0 +1,830 @@ +openapi: 3.0.3 +info: + title: SmartJam API + version: 1.0.0 + contact: + name: SmartJam Team + email: satlykovs@gmail.com + url: https://github.com/Satlykovs/SmartJam + description: | + Interactive Music Learning and Performance Analysis System. + + **Development Team:** + - Sanjar Satlykov ([satlykovs@gmail.com](mailto:satlykovs@gmail.com)) + - Anton Podrezov ([toni.podrezov@gmail.com](mailto:toni.podrezov@gmail.com)) + - Serj Baskov ([baskovs450@gmail.com](mailto:baskovs450@gmail.com)) + + **Supervised by:** + - Andrey Sheremeev ([sheremeev.andrey@gmail.com](mailto:sheremeev.andrey@gmail.com)) + + +servers: + - url: 'http://localhost:8081' + description: Local Development Server + - url: '/' + description: Current Environment + + +security: + - bearerAuth: [ ] + +paths: + /api/v1/auth/register: + post: + tags: + - Auth + security: [ ] + summary: Register a new user + description: Creates a new user account and returns a pair of JWT tokens (Access & Refresh). + operationId: registerUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '201': + description: User successfully registered and authenticated. + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '400': + description: Validation error or user already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/auth/login: + post: + tags: + - Auth + security: [ ] + summary: Authenticate user + description: Authenticates a user using email and password, returning a new pair of JWT tokens. + operationId: loginUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Successful authentication. + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '401': + description: Invalid credentials + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/auth/refresh: + post: + tags: + - Auth + security: [ ] + summary: Refresh tokens + description: Accepts a valid refresh token and issues a new pair of tokens. The old refresh token will be invalidated. + operationId: refreshToken + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshRequest' + responses: + '200': + description: Tokens successfully refreshed. + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '401': + description: Invalid or expired refresh token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + + /api/v1/users/me: + get: + tags: + - Profile + summary: Get current user profile + description: Returns info about the logged-in user based on the provided JWT. + operationId: getCurrentUserProfile + responses: + '200': + description: User profile data retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + + /api/v1/connections/invite: + post: + tags: + - Connections + summary: Create invite code + description: (Teacher only) Generates a new code for students. + operationId: createInvite + responses: + '201': + description: Code created + content: + application/json: + schema: + $ref: '#/components/schemas/InviteResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + + /api/v1/connections/join: + post: + tags: + - Connections + summary: Join a teacher + description: (Student only) Connects student to a teacher via code. + operationId: joinTeacher + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JoinRequest' + responses: + '200': + description: Joined successfully. + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + description: Invalid code. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/connections: + get: + tags: + - Connections + summary: Get my active connections + description: Returns a paginated list of active student-teacher connections for the authenticated user. + operationId: getMyConnections + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/SizeParam' + - $ref: '#/components/parameters/SortParam' + responses: + '200': + description: List of active connections retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/ConnectionPageResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + + /api/v1/connections/{connectionId}/assignments: + parameters: + - name: connectionId + in: path + required: true + schema: + type: string + format: uuid + get: + tags: + - Assignments + summary: List assignments in a connection + description: Returns paginated list of exercises created for this teacher-student connection. + operationId: getAssignmentsByConnection + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/SizeParam' + - $ref: '#/components/parameters/SortParam' + responses: + '200': + description: List of assignments retrieved. + content: + application/json: + schema: + $ref: '#/components/schemas/AssignmentPageResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + + /api/v1/assignments: + post: + tags: + - Assignments + summary: Create a new assignment + description: (Teacher only) Creates a new task and returns the S3 presigned URL to upload the reference audio file. + operationId: createAssignment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAssignmentRequest' + responses: + '201': + description: Assignment created. Use the uploadUrl to send the audio file via HTTP PUT. + content: + application/json: + schema: + $ref: '#/components/schemas/AssignmentUploadResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + + /api/v1/assignments/{assignmentId}: + parameters: + - name: assignmentId + in: path + required: true + schema: + type: string + format: uuid + get: + tags: + - Assignments + summary: Get assignment details + operationId: getAssignment + responses: + '200': + description: Details of the assignment. + content: + application/json: + schema: + $ref: '#/components/schemas/AssignmentResponseDetailed' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + description: Assignment not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/assignments/{assignmentId}/submissions: + parameters: + - name: assignmentId + in: path + required: true + schema: + type: string + format: uuid + post: + tags: + - Submissions + summary: Submit an exercise attempt + description: (Student only) Registers a new attempt and returns an S3 presigned URL to upload the attempt audio file. + operationId: createSubmission + responses: + '201': + description: Submission registered. Use the uploadUrl to send the audio file via HTTP PUT. + content: + application/json: + schema: + $ref: '#/components/schemas/SubmissionUploadResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '409': + description: Conflict - Teacher file is not ready + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + get: + tags: + - Submissions + summary: List submissions for an assignment + description: Returns a paginated list of all attempts made by the student for this specific exercise. + operationId: getSubmissionsByAssignment + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/SizeParam' + - $ref: '#/components/parameters/SortParam' + responses: + '200': + description: List of submissions retrieved. + content: + application/json: + schema: + $ref: '#/components/schemas/SubmissionPageResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + + /api/v1/submissions/{submissionId}: + parameters: + - name: submissionId + in: path + required: true + schema: + type: string + format: uuid + get: + tags: + - Submissions + summary: Get the submission analysis status and result + description: Returns the analysis status and computed scores with feedback events. + operationId: getSubmissionResult + responses: + '200': + description: Analysis result. + content: + application/json: + schema: + $ref: '#/components/schemas/SubmissionResultResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Provide your JWT token obtained from the login endpoint. + + parameters: + PageParam: + name: page + in: query + schema: + type: integer + minimum: 0 + default: 0 + SizeParam: + name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + SortParam: + name: sort + in: query + schema: + type: string + default: "createdAt,desc" + + schemas: + RegisterRequest: + type: object + description: Payload required to register a new user. + required: + - email + - username + - password + properties: + email: + type: string + format: email + example: "rickroll@gmail.com" + username: + type: string + example: "Doomslayer" + minLength: 3 + maxLength: 20 + pattern: '^\w+$' + password: + type: string + format: password + example: "VeryStrongPassword123!" + pattern: '^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d)(?=[^#?!@$%^&*-]*[#?!@$%^&*-]).{8,20}$' + + LoginRequest: + type: object + description: Payload required for user authentication. + required: + - email + - password + properties: + email: + type: string + format: email + example: "rickroll@gmail.com" + password: + type: string + format: password + example: "VeryStrongPassword123!" + + RefreshRequest: + type: object + description: Payload required to refresh JWT tokens. + required: + - refreshToken + properties: + refreshToken: + type: string + description: The valid refresh token previously issued to the user. + asRole: + $ref: '#/components/schemas/UserRole' + + UserRole: + type: string + description: User roles in the SmartJam system. + enum: + - STUDENT + - TEACHER + + JoinRequest: + type: object + description: Payload required to join a room using an invitation code. + required: + - inviteCode + properties: + inviteCode: + type: string + example: "ABC-123-XYZ" + description: Invitation code provided by the teacher. + + CreateAssignmentRequest: + type: object + description: Payload to create a new assignment. + required: + - connectionId + - title + properties: + connectionId: + type: string + format: uuid + description: ID of the teacher-student connection. + title: + type: string + example: "RHCP Californication solo." + description: + type: string + example: "Try playing it slow with a metronome." + + AuthResponse: + type: object + description: Contains the JWT access token and refresh token. + required: + - accessToken + - refreshToken + properties: + accessToken: + type: string + description: Short-lived JWT token for API authorization. + refreshToken: + type: string + description: Long-lived token used to obtain a new pair of tokens. + + UserResponse: + type: object + description: Detailed user profile data. + required: + - id + - username + - email + - roles + properties: + id: + type: string + format: uuid + email: + type: string + format: email + example: "rickroll@gmail.com" + username: + type: string + example: "Doomslayer" + firstName: + type: string + example: "John" + lastName: + type: string + example: "Frusciante" + avatarUrl: + type: string + format: uri + description: "S3 url to the user's avatar image." + roles: + type: array + items: + type: string + example: [ "STUDENT" ] + + InviteResponse: + type: object + description: Response containing the generated invitation code. + required: + - inviteCode + properties: + inviteCode: + type: string + example: "ABC-123-XYZ" + + PageInfo: + type: object + description: Pagination metadata. + required: + - totalElements + - totalPages + - number + - size + properties: + totalElements: + type: integer + format: int64 + example: 10 + description: Total count of items across all pages. + totalPages: + type: integer + example: 1 + number: + type: integer + example: 0 + description: Current page index. + size: + type: integer + example: 20 + description: Items per page. + + ConnectionResponse: + type: object + description: Brief information about a teacher-student connection. + required: + - id + - peerId + - peerUsername + - createdAt + properties: + id: + type: string + format: uuid + description: Connection ID. + peerId: + type: string + format: uuid + description: ID of the student (if you are a teacher) or teacher (if you are a student). + peerUsername: + type: string + example: "Doomslayer" + peerFirstName: + type: string + example: "John" + peerLastName: + type: string + example: "Frusciante" + peerAvatarUrl: + type: string + format: uri + createdAt: + type: string + format: date-time + + AssignmentResponse: + type: object + description: Brief exercise information for list display. + required: + - id + - title + - status + - createdAt + properties: + id: + type: string + format: uuid + title: + type: string + example: "RHCP Californication Solo" + status: + $ref: './common-models.yaml#/components/schemas/AudioProcessingStatus' + createdAt: + type: string + format: date-time + + SubmissionResponse: + type: object + description: Brief submission information for list display. + required: + - id + - status + - createdAt + properties: + id: + type: string + format: uuid + status: + $ref: './common-models.yaml#/components/schemas/AudioProcessingStatus' + totalScore: + type: number + format: double + createdAt: + type: string + format: date-time + + ConnectionPageResponse: + type: object + required: + - content + - page + properties: + content: + type: array + items: + $ref: '#/components/schemas/ConnectionResponse' + page: + $ref: '#/components/schemas/PageInfo' + + AssignmentPageResponse: + type: object + required: + - content + - page + properties: + content: + type: array + items: + $ref: '#/components/schemas/AssignmentResponse' + page: + $ref: '#/components/schemas/PageInfo' + + SubmissionPageResponse: + type: object + required: + - content + - page + properties: + content: + type: array + items: + $ref: '#/components/schemas/SubmissionResponse' + page: + $ref: '#/components/schemas/PageInfo' + + AssignmentResponseDetailed: + type: object + required: + - id + - title + - status + - referenceAudioUrl + properties: + id: + type: string + format: uuid + title: + type: string + example: "RHCP Californication solo." + description: + type: string + example: "Try playing it slow with a metronome." + status: + $ref: './common-models.yaml#/components/schemas/AudioProcessingStatus' + referenceAudioUrl: + type: string + format: uri + description: "URL to the teacher audio file." + + AssignmentUploadResponse: + type: object + description: Returns the assignment ID and the URL for S3 upload. + required: + - assignmentId + - uploadUrl + properties: + assignmentId: + type: string + format: uuid + uploadUrl: + type: string + format: uri + description: Temporary S3 URL. Use HTTP PUT to upload audio file here. + + SubmissionUploadResponse: + type: object + description: Returns the submission ID and the URL for for S3 upload. + required: + - submissionId + - uploadUrl + properties: + submissionId: + type: string + format: uuid + uploadUrl: + type: string + format: uri + + SubmissionResultResponse: + type: object + description: Information about student's attempt on an assignment. + required: + - id + - status + properties: + id: + type: string + format: uuid + status: + $ref: './common-models.yaml#/components/schemas/AudioProcessingStatus' + totalScore: + type: number + format: double + pitchScore: + type: number + format: double + rhythmScore: + type: number + format: double + errorMessage: + type: string + description: Error message if the analysis failed. + referenceAudioUrl: + type: string + format: uri + description: Presigned S3 GET URL for the teacher's reference audio. + submissionAudioUrl: + type: string + format: uri + description: Presigned S3 GET URL for the student's submission audio. + feedback: + type: array + description: Array of errors detected during performance. + items: + $ref: './common-models.yaml#/components/schemas/FeedbackEvent' + + ErrorResponse: + type: object + description: Standardized error response returned by the API. + required: + - timestamp + - status + - error + - message + properties: + timestamp: + type: string + format: date-time + description: + The time the error occurred. + status: + type: integer + format: int32 + description: HTTP status code. + example: 400 + error: + type: string + description: Short description of the error. + example: "Bad Request" + message: + type: string + description: Detailed error message. + example: "Email already in use." + + responses: + UnauthorizedError: + description: Access token is missing or invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + timestamp: "2026-04-05T12:00:00Z" + status: 401 + error: "Unauthorized" + message: "Auth is required to access this resource" + + + ForbiddenError: + description: You do not have the required role to access this resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + timestamp: "2026-04-05T12:00:00Z" + status: 403 + error: "Forbidden" + message: "Access denied: Teacher role required" + + diff --git a/mobile/app/src/main/java/com/smartjam/app/common-models.yaml b/mobile/app/src/main/java/com/smartjam/app/common-models.yaml new file mode 100644 index 0000000..4f2283a --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/common-models.yaml @@ -0,0 +1,74 @@ +openapi: 3.0.3 +info: + title: SmartJam API Contract + description: SmartJam API Contract + version: 1.0.0 + +components: + schemas: + AudioProcessingStatus: + type: string + description: | + Represents the lifecycle stages of an audio processing task. + Used to track the state from the moment a database record is created + until the final analysis result is stored. + enum: + - AWAITING_UPLOAD + - UPLOADED + - ANALYZING + - COMPLETED + - FAILED + + FeedbackType: + type: string + description: | + Categories of musical discrepancies identified during the comparison + of a student's performance against the teacher's reference. + enum: + - WRONG_NOTE + - WRONG_RHYTHM + + FeedbackEvent: + type: object + description: | + A shared contract for a single feedback occurrence. + Used by Analyzer to produce results and by API/Mobile to display them. + required: + - teacherStartTime + - teacherEndTime + - studentStartTime + - studentEndTime + - type + - severity + properties: + teacherStartTime: + type: number + format: double + description: Start time of the event in the teacher's reference track (seconds). + teacherEndTime: + type: number + format: double + description: End time of the event in the teacher's reference track (seconds). + studentStartTime: + type: number + format: double + description: Start time of the event in the student's submission (seconds). + studentEndTime: + type: number + format: double + description: End time of the event in the student's submission (seconds). + type: + $ref: '#/components/schemas/FeedbackType' + severity: + type: number + format: double + minimum: 0 + maximum: 1 + description: Error severity score (0.0 to 1.0). + +paths: {} + + + + + diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt index b2a6535..f211c71 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt @@ -35,18 +35,22 @@ class AuthAuthenticator( } val refreshToken = tokenStorage.refreshToken.first() ?: return@runBlocking null + val storedRole = tokenStorage.userRole.first() val authApiClient = ApiClient(baseUrl = baseUrl) val authApi = authApiClient.createService(AuthApi::class.java) try { - val refreshResponse = authApi.refreshToken(RefreshRequest(refreshToken)) + val refreshResponse = authApi.refreshToken( + RefreshRequest(refreshToken, storedRole?.let { toApiRole(it) }) + ) if (refreshResponse.isSuccessful && refreshResponse.body() != null) { val newAuthResponse = refreshResponse.body()!! tokenStorage.saveToken( accessToken = newAuthResponse.accessToken, - refreshToken = newAuthResponse.refreshToken + refreshToken = newAuthResponse.refreshToken, + role = storedRole ) apiClient?.setBearerToken(newAuthResponse.accessToken) @@ -78,4 +82,12 @@ class AuthAuthenticator( } return result } + + private fun toApiRole(role: String): com.smartjam.app.model.UserRole? { + return try { + com.smartjam.app.model.UserRole.valueOf(role) + } catch (e: Exception) { + null + } + } } diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt new file mode 100644 index 0000000..f4744e0 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt @@ -0,0 +1,31 @@ +package com.smartjam.app.data.api + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.time.Instant + +class InstantAdapter : TypeAdapter() { + override fun write(out: JsonWriter, value: Instant?) { + if (value == null) { + out.nullValue() + } else { + out.value(value.toString()) + } + } + + override fun read(`in`: JsonReader): Instant? { + if (`in`.peek() == JsonToken.NULL) { + `in`.nextNull() + return null + } + val s = `in`.nextString() + return try { + Instant.parse(s) + } catch (e: Exception) { + null + } + } +} + diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/AudioFileStore.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/AudioFileStore.kt new file mode 100644 index 0000000..0f34407 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/AudioFileStore.kt @@ -0,0 +1,22 @@ +package com.smartjam.app.data.local + +import android.content.Context +import java.io.File +import java.util.UUID + +class AudioFileStore(context: Context) { + private val baseDir: File = File(context.filesDir, "assignment_audio").apply { + if (!exists()) { + mkdirs() + } + } + + fun getAssignmentAudioFile(assignmentId: UUID): File { + return File(baseDir, "$assignmentId.wav") + } + + fun getSubmissionAudioFile(submissionId: UUID): File { + return File(baseDir, "submission_$submissionId.wav") + } +} + 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 index d1d6db3..c90a7ce 100644 --- 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 @@ -1,11 +1,25 @@ package com.smartjam.app.data.local import androidx.room.* +import com.smartjam.app.data.local.dao.AssignmentDao import com.smartjam.app.data.local.dao.ConnectionDao +import com.smartjam.app.data.local.dao.SubmissionResultDao +import com.smartjam.app.data.local.entity.AssignmentEntity import com.smartjam.app.data.local.entity.ConnectionEntity +import com.smartjam.app.data.local.entity.SubmissionResultEntity -@Database(entities = [ConnectionEntity::class], version = 1, exportSchema = false) +@Database( + entities = [ + ConnectionEntity::class, + AssignmentEntity::class, + SubmissionResultEntity::class + ], + version = 6, + exportSchema = false +) @TypeConverters(Converters::class) abstract class SmartJamDatabase : RoomDatabase() { abstract fun connectionDao(): ConnectionDao + abstract fun assignmentDao(): AssignmentDao + abstract fun submissionResultDao(): SubmissionResultDao } \ 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 634537e..181b3b8 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 @@ -17,12 +17,16 @@ class TokenStorage(private val context: Context) { private companion object Keys{ val ACCESS_TOKEN = stringPreferencesKey("access_token") val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + val USER_ROLE = stringPreferencesKey("user_role") } - suspend fun saveToken(accessToken: String, refreshToken: String){ + suspend fun saveToken(accessToken: String, refreshToken: String, role: String? = null){ context.dataStore.edit { preferences -> preferences[ACCESS_TOKEN] = accessToken preferences[REFRESH_TOKEN] = refreshToken + if (role != null) { + preferences[USER_ROLE] = role + } } } @@ -32,6 +36,15 @@ class TokenStorage(private val context: Context) { val refreshToken : Flow = context.dataStore.data .map { preferences -> preferences[REFRESH_TOKEN] } + val userRole : Flow = context.dataStore.data + .map { preferences -> preferences[USER_ROLE] } + + suspend fun saveRole(role: String){ + context.dataStore.edit { preferences -> + preferences[USER_ROLE] = role + } + } + suspend fun clearTokens(){ context.dataStore.edit { preferences -> preferences.remove(ACCESS_TOKEN) diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/dao/AssignmentDao.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/AssignmentDao.kt new file mode 100644 index 0000000..152829e --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/AssignmentDao.kt @@ -0,0 +1,27 @@ +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.AssignmentEntity +import java.util.UUID +import kotlinx.coroutines.flow.Flow + +@Dao +interface AssignmentDao { + @Query("SELECT * FROM assignments WHERE connectionId = :connectionId ORDER BY createdAt DESC") + fun getAssignmentsForConnection(connectionId: UUID): Flow> + + @Query("SELECT * FROM assignments WHERE id = :assignmentId") + suspend fun getAssignmentById(assignmentId: UUID): AssignmentEntity? + + @Query("SELECT * FROM assignments WHERE id IN (:ids)") + suspend fun getAssignmentsByIds(ids: List): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(assignments: List) + + @Query("DELETE FROM assignments WHERE connectionId = :connectionId") + suspend fun clearForConnection(connectionId: UUID) +} 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 index 866e4d0..2861e38 100644 --- 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 @@ -5,15 +5,17 @@ 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") + @Query("SELECT * FROM connections WHERE myRole = :role ORDER BY createdAt DESC") fun getConnectionsFlow(role: String): Flow> + @Query("SELECT * FROM connections WHERE connectionId IN (:ids)") + suspend fun getConnectionsByIds(ids: List): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertConnections(connections: List): List diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt new file mode 100644 index 0000000..f126707 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt @@ -0,0 +1,24 @@ +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.SubmissionResultEntity +import java.util.UUID +import kotlinx.coroutines.flow.Flow + +@Dao +interface SubmissionResultDao { + @Query("SELECT * FROM submission_results WHERE assignmentId = :assignmentId ORDER BY createdAt DESC") + fun getResultsForAssignment(assignmentId: UUID): Flow> + + @Query("SELECT * FROM submission_results WHERE assignmentId = :assignmentId") + suspend fun getResultsForAssignmentOnce(assignmentId: UUID): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(results: List) + + @Query("DELETE FROM submission_results WHERE assignmentId = :assignmentId") + suspend fun clearForAssignment(assignmentId: UUID) +} diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/AssignmentEntity.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/AssignmentEntity.kt new file mode 100644 index 0000000..35b118b --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/AssignmentEntity.kt @@ -0,0 +1,19 @@ +package com.smartjam.app.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.net.URI +import java.time.Instant +import java.util.UUID + +@Entity(tableName = "assignments") +data class AssignmentEntity( + @PrimaryKey val id: UUID, + val connectionId: UUID, + val title: String, + val description: String?, + val referenceAudioUrl: URI?, + val referenceAudioLocalPath: String?, + val status: String, + val createdAt: Instant +) diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt index c0178dd..c5306d6 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt @@ -2,7 +2,6 @@ package com.smartjam.app.data.local.entity import androidx.room.Entity import androidx.room.PrimaryKey -import java.net.URI import java.time.Instant import java.util.UUID @@ -14,6 +13,7 @@ data class ConnectionEntity( val createdAt: Instant, val peerFirstName: String? = null, val peerLastName: String? = null, - val peerAvatarUrl: URI? = null, + val peerAvatarUrl: String? = null, + val peerAvatarBytes: ByteArray? = null, val myRole: String ) \ No newline at end of file diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt new file mode 100644 index 0000000..aa35dae --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt @@ -0,0 +1,21 @@ +package com.smartjam.app.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.Instant +import java.util.UUID + +@Entity(tableName = "submission_results") +data class SubmissionResultEntity( + @PrimaryKey val id: UUID, + val assignmentId: UUID, + val status: String, + val totalScore: Float?, + val pitchScore: Float?, + val rhythmScore: Float?, + val errorMessage: String?, + val fileUrl: String?, + val submissionAudioLocalPath: String?, + val createdAt: Instant +) + 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 deleted file mode 100644 index 78a65a2..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/CommentModels.kt +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 3843b92..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/ConnectionModels.kt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 32fe422..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/LoginRequest.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index b9dbcdc..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -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/RefreshRequest.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/RefreshRequest.kt deleted file mode 100644 index f1300c1..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/RefreshRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 737258a..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 7d54c7d..0000000 --- a/mobile/app/src/main/java/com/smartjam/app/data/model/TaskModels.kt +++ /dev/null @@ -1,24 +0,0 @@ -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 index 00392aa..cd50815 100644 --- 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 @@ -4,5 +4,7 @@ package com.smartjam.app.domain.model data class Connection( val id: String, val peerId: String, - val peerName: String + val peerName: String, + val peerAvatarUrl: String? = null, + val peerAvatarBytes: ByteArray? = null ) \ 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 d0e1b82..4244168 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 @@ -8,6 +8,7 @@ import com.smartjam.app.data.local.TokenStorage import com.smartjam.app.domain.model.UserRole import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.Flow import org.openapitools.client.infrastructure.ApiClient import com.smartjam.app.BuildConfig @@ -16,6 +17,11 @@ class AuthRepository ( private val authApi: AuthApi, private val apiClient: ApiClient ) { + val userRole: Flow = tokenStorage.userRole + + suspend fun saveRole(role: String) { + tokenStorage.saveRole(role) + } suspend fun register(email: String, password: String, username: String, role: UserRole): Result { return try { @@ -25,7 +31,8 @@ class AuthRepository ( val authResponse = response.body()!! tokenStorage.saveToken( accessToken = authResponse.accessToken, - refreshToken = authResponse.refreshToken + refreshToken = authResponse.refreshToken, + role = role.name ) apiClient.setBearerToken(authResponse.accessToken) Result.success(Unit) @@ -38,12 +45,13 @@ class AuthRepository ( } } - suspend fun login(email: String, password: String): Result { + suspend fun login(email: String, password: String, role: UserRole): Result { return try { if (BuildConfig.DEBUG && email == "admin" && password == "admin") { tokenStorage.saveToken( accessToken = "mock_admin_access_token", - refreshToken = "mock_admin_refresh_token" + refreshToken = "mock_admin_refresh_token", + role = role.name ) apiClient.setBearerToken("mock_admin_access_token") return Result.success(Unit) @@ -55,9 +63,13 @@ class AuthRepository ( val authResponse = response.body()!! tokenStorage.saveToken( accessToken = authResponse.accessToken, - refreshToken = authResponse.refreshToken + refreshToken = authResponse.refreshToken, + role = role.name ) apiClient.setBearerToken(authResponse.accessToken) + + refreshWithRole(role) + Result.success(Unit) } else { Result.failure(Exception("Login failed: ${response.code()}")) @@ -75,20 +87,24 @@ class AuthRepository ( if (refreshTokenStr == null){ return false } - val response = authApi.refreshToken(RefreshRequest(refreshTokenStr)) + + val storedRole = tokenStorage.userRole.first() ?: UserRole.STUDENT.name + val apiRole = toApiRole(storedRole) + val response = authApi.refreshToken(RefreshRequest(refreshTokenStr, apiRole)) if (response.isSuccessful && response.body() != null) { val authResponse = response.body()!! tokenStorage.saveToken( accessToken = authResponse.accessToken, - refreshToken = authResponse.refreshToken + refreshToken = authResponse.refreshToken, + role = storedRole ) apiClient.setBearerToken(authResponse.accessToken) - return true + true } else { tokenStorage.clearTokens() apiClient.setBearerToken("") - return false + false } } catch (e: Exception){ @@ -104,6 +120,33 @@ class AuthRepository ( } } + suspend fun refreshWithRole(role: UserRole): Boolean { + return try { + val refreshTokenStr = tokenStorage.refreshToken.first() ?: return false + + val response = authApi.refreshToken(RefreshRequest(refreshTokenStr, toApiRole(role.name))) + + if (response.isSuccessful && response.body() != null) { + val authResponse = response.body()!! + tokenStorage.saveToken( + accessToken = authResponse.accessToken, + refreshToken = authResponse.refreshToken, + role = role.name + ) + apiClient.setBearerToken(authResponse.accessToken) + true + } else { + tokenStorage.clearTokens() + apiClient.setBearerToken("") + false + } + } catch (e: Exception) { + tokenStorage.clearTokens() + apiClient.setBearerToken("") + false + } + } + suspend fun logout() { tokenStorage.clearTokens() apiClient.setBearerToken("") @@ -134,4 +177,12 @@ class AuthRepository ( } return refreshToken() } + + private fun toApiRole(role: String): com.smartjam.app.model.UserRole? { + return try { + com.smartjam.app.model.UserRole.valueOf(role) + } catch (e: Exception) { + null + } + } } \ 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 index 76c87aa..9e21002 100644 --- 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 @@ -6,38 +6,61 @@ import com.smartjam.app.data.local.entity.ConnectionEntity import com.smartjam.app.domain.model.Connection import com.smartjam.app.domain.model.UserRole import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlin.collections.emptyList - import com.smartjam.app.api.ConnectionsApi import com.smartjam.app.model.JoinRequest +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request class ConnectionRepository ( private val api: ConnectionsApi, private val dao: ConnectionDao ){ + data class ConnectionPageInfo( + val pageNumber: Int, + val totalPages: Int, + val pageSize: Int, + val totalElements: Long + ) + + private val avatarClient = OkHttpClient.Builder().build() + fun getConnectionsFlow(role: UserRole): Flow> { return dao.getConnectionsFlow(role.name).map { entities -> entities.map { entity -> Connection( id = entity.connectionId.toString(), peerId = entity.peerId.toString(), - peerName = entity.peerUsername + peerName = entity.peerUsername, + peerAvatarUrl = entity.peerAvatarUrl, + peerAvatarBytes = entity.peerAvatarBytes ) } } } - suspend fun syncConnections(role: UserRole): Result { + suspend fun syncConnectionsPage(role: UserRole, page: Int, size: Int): Result { return try { - val activeResponse = api.getMyConnections() - - if (activeResponse.isSuccessful) { + val activeResponse = api.getMyConnections(page = page, size = size) - val activeItems = activeResponse.body()?.content ?: emptyList() + if (activeResponse.isSuccessful && activeResponse.body() != null) { + val body = activeResponse.body()!! + val activeItems = body.content + val ids = activeItems.map { it.id } + val existing = dao.getConnectionsByIds(ids).associateBy { it.connectionId } val allEntities = activeItems.map { dto -> + val avatarUrl = dto.peerAvatarUrl?.toString() + val cached = existing[dto.id] + val avatarBytes = when { + avatarUrl.isNullOrBlank() -> null + cached != null && cached.peerAvatarUrl == avatarUrl && cached.peerAvatarBytes != null -> cached.peerAvatarBytes + else -> downloadAvatar(avatarUrl) + } + ConnectionEntity( connectionId = dto.id, peerId = dto.peerId, @@ -45,15 +68,22 @@ class ConnectionRepository ( createdAt = dto.createdAt, peerFirstName = dto.peerFirstName, peerLastName = dto.peerLastName, - peerAvatarUrl = dto.peerAvatarUrl, + peerAvatarUrl = avatarUrl, + peerAvatarBytes = avatarBytes, myRole = role.name ) } - dao.clearConnections(role.name) dao.insertConnections(allEntities) - Result.success(Unit) + Result.success( + ConnectionPageInfo( + pageNumber = body.page.number, + totalPages = body.page.totalPages, + pageSize = body.page.propertySize, + totalElements = body.page.totalElements + ) + ) } else { Result.failure(Exception("Failed to fetch connections: ${activeResponse.code()}")) } @@ -63,6 +93,11 @@ class ConnectionRepository ( } } + @Deprecated("Use syncConnectionsPage for paged loading") + suspend fun syncConnections(role: UserRole): Result { + return syncConnectionsPage(role, page = 0, size = 20).map { } + } + suspend fun generateInviteCode(): Result { return try { val response = api.createInvite() @@ -95,5 +130,17 @@ class ConnectionRepository ( return Result.success(Unit) } - + private suspend fun downloadAvatar(url: String): ByteArray? = withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + val response = avatarClient.newCall(request).execute() + if (response.isSuccessful) { + response.body?.bytes() + } else { + null + } + } catch (e: Exception) { + null + } + } } \ 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 e69de29..a3f13bb 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 @@ -0,0 +1,350 @@ +package com.smartjam.app.domain.repository + +import android.util.Log +import com.smartjam.app.api.AssignmentsApi +import com.smartjam.app.api.SubmissionsApi +import com.smartjam.app.data.local.AudioFileStore +import com.smartjam.app.data.local.dao.AssignmentDao +import com.smartjam.app.data.local.dao.SubmissionResultDao +import com.smartjam.app.data.local.entity.AssignmentEntity +import com.smartjam.app.data.local.entity.SubmissionResultEntity +import com.smartjam.app.model.CreateAssignmentRequest +import com.smartjam.app.model.AssignmentResponseDetailed +import com.smartjam.app.model.AssignmentUploadResponse +import com.smartjam.app.model.SubmissionUploadResponse +import com.smartjam.app.model.SubmissionResultResponse +import java.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File + +class RoomRepository( + private val assignmentsApi: AssignmentsApi, + private val submissionsApi: SubmissionsApi, + private val assignmentDao: AssignmentDao, + private val submissionResultDao: SubmissionResultDao, + private val audioFileStore: AudioFileStore +) { + + data class AssignmentPageInfo( + val pageNumber: Int, + val totalPages: Int, + val pageSize: Int, + val totalElements: Long + ) + + private val httpClient = OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .build() + + fun getAssignmentsFlow(connectionId: UUID): Flow> { + return assignmentDao.getAssignmentsForConnection(connectionId) + } + + fun getSubmissionsFlow(assignmentId: UUID): Flow> { + return submissionResultDao.getResultsForAssignment(assignmentId) + } + + suspend fun syncAssignmentsPage(connectionId: UUID, page: Int, size: Int): Result { + return try { + val response = assignmentsApi.getAssignmentsByConnection(connectionId, page = page, size = size) + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + val existing = assignmentDao.getAssignmentsByIds(body.content.map { it.id }) + .associateBy { it.id } + + val entities = body.content.map { dto -> + val cached = existing[dto.id] + AssignmentEntity( + id = dto.id, + connectionId = connectionId, + title = dto.title, + description = cached?.description, + referenceAudioUrl = cached?.referenceAudioUrl, + referenceAudioLocalPath = cached?.referenceAudioLocalPath, + status = dto.status.name, + createdAt = dto.createdAt + ) + } + assignmentDao.insertAll(entities) + + Result.success( + AssignmentPageInfo( + pageNumber = body.page.number, + totalPages = body.page.totalPages, + pageSize = body.page.propertySize, + totalElements = body.page.totalElements + ) + ) + } else { + Result.failure(Exception("Failed to fetch assignments: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun syncAssignments(connectionId: UUID): Result { + return syncAssignmentsPage(connectionId, page = 0, size = 20).map { } + } + + suspend fun ensureAssignmentDetailsCached(assignmentId: UUID): Result { + return try { + val existing = assignmentDao.getAssignmentById(assignmentId) + ?: return Result.failure(Exception("Assignment not found in cache")) + + val response = assignmentsApi.getAssignment(assignmentId) + if (response.isSuccessful && response.body() != null) { + val dto = response.body()!! + Log.d("RoomRepository", "Fetched assignment details: id=${dto.id} description=${dto.description?.take(100)} referenceAudioUrl=${dto.referenceAudioUrl}") + val localPath = cacheReferenceAudioIfNeeded(existing, dto) + Log.d("RoomRepository", "Reference audio localPath for assignment ${existing.id}: $localPath") + val updated = existing.copy( + title = dto.title, + description = dto.description, + referenceAudioUrl = dto.referenceAudioUrl, + referenceAudioLocalPath = localPath, + status = dto.status.name + ) + assignmentDao.insertAll(listOf(updated)) + Result.success(updated) + } else { + Result.failure(Exception("Failed to fetch detailed assignment")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun createAssignment(request: CreateAssignmentRequest): Result { + return try { + val response = assignmentsApi.createAssignment(request) + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + val errorBody = response.errorBody()?.string() + Log.w("RoomRepository", "createAssignment failed: code=${response.code()} body=$errorBody") + Result.failure(Exception("Failed to create assignment: ${response.code()}")) + } + } catch (e: Exception) { + Log.w("RoomRepository", "createAssignment exception", e) + Result.failure(e) + } + } + + suspend fun syncSubmissions(assignmentId: UUID): Result { + return try { + val response = submissionsApi.getSubmissionsByAssignment(assignmentId) + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + val existing = submissionResultDao.getResultsForAssignmentOnce(assignmentId).associateBy { it.id } + + val entities = body.content.map { dto -> + val cached = existing[dto.id] + SubmissionResultEntity( + id = dto.id, + assignmentId = assignmentId, + status = dto.status.name, + totalScore = dto.totalScore?.toFloat(), + pitchScore = cached?.pitchScore, + rhythmScore = cached?.rhythmScore, + errorMessage = cached?.errorMessage, + fileUrl = cached?.fileUrl, + submissionAudioLocalPath = cached?.submissionAudioLocalPath, + createdAt = dto.createdAt + ) + } + submissionResultDao.insertAll(entities) + Result.success(Unit) + } else { + Result.failure(Exception("Failed to fetch submissions")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun createSubmission(assignmentId: UUID): Result { + return try { + val response = submissionsApi.createSubmission(assignmentId) + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + val errorBody = response.errorBody()?.string() + Log.w("RoomRepository", "createSubmission failed: code=${response.code()} body=$errorBody") + Result.failure(Exception("Failed to create submission")) + } + } catch (e: Exception) { + Log.w("RoomRepository", "createSubmission exception", e) + Result.failure(e) + } + } + + suspend fun getSubmissionResult(submissionId: UUID, assignmentId: UUID): Result { + return try { + val response = submissionsApi.getSubmissionResult(submissionId) + if (response.isSuccessful && response.body() != null) { + val dto = response.body()!! + val created = java.time.Instant.now() + val existing = submissionResultDao.getResultsForAssignmentOnce(assignmentId).associateBy { it.id }[dto.id] + submissionResultDao.insertAll(listOf( + SubmissionResultEntity( + id = dto.id, + assignmentId = assignmentId, + status = dto.status.name, + totalScore = dto.totalScore?.toFloat(), + pitchScore = dto.pitchScore?.toFloat(), + rhythmScore = dto.rhythmScore?.toFloat(), + errorMessage = dto.errorMessage, + fileUrl = dto.submissionAudioUrl?.toString(), + submissionAudioLocalPath = existing?.submissionAudioLocalPath, + createdAt = created + ) + )) + Result.success(dto) + } else { + Result.failure(Exception("Failed to fetch submission result")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun cacheSubmissionAudioIfNeeded(submissionId: UUID, assignmentId: UUID, urlString: String?): Result = withContext(Dispatchers.IO) { + try { + if (urlString.isNullOrBlank()) { + return@withContext Result.success(null) + } + + val existing = submissionResultDao.getResultsForAssignmentOnce(assignmentId).associateBy { it.id }[submissionId] + val existingPath = existing?.submissionAudioLocalPath + if (!existingPath.isNullOrBlank()) { + val f = java.io.File(existingPath) + if (f.exists()) return@withContext Result.success(existingPath) + } + val uri = java.net.URI(urlString) + val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" + + val fixedUrl = urlString + .replace("localhost", "10.0.2.2") + .replace("127.0.0.1", "10.0.2.2") + + val target = audioFileStore.getSubmissionAudioFile(submissionId) + val request = Request.Builder() + .url(fixedUrl) + .header("Host", originalHost) + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext Result.success(null) + } + + response.body?.byteStream()?.use { input -> + target.outputStream().use { output -> + input.copyTo(output) + } + } + } + + val updated = SubmissionResultEntity( + id = submissionId, + assignmentId = assignmentId, + status = existing?.status ?: "", + totalScore = existing?.totalScore, + pitchScore = existing?.pitchScore, + rhythmScore = existing?.rhythmScore, + errorMessage = existing?.errorMessage, + fileUrl = existing?.fileUrl, + submissionAudioLocalPath = target.absolutePath, + createdAt = existing?.createdAt ?: java.time.Instant.now() + ) + submissionResultDao.insertAll(listOf(updated)) + Result.success(target.absolutePath) + } catch (e: Exception) { + Log.w("RoomRepository", "cacheSubmissionAudioIfNeeded exception", e) + Result.failure(e) + } + } + + suspend fun uploadFileToS3(uploadUrl: String, file: File): Result = withContext(Dispatchers.IO) { + try { + val uri = java.net.URI(uploadUrl) + val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" + + val fixedUrl = uploadUrl + .replace("localhost", "10.0.2.2") + .replace("127.0.0.1", "10.0.2.2") + .replace("references.localhost", "10.0.2.2") + + val requestBody = file.asRequestBody(null) + val request = Request.Builder() + .url(fixedUrl) + .header("Host", originalHost) + .put(requestBody) + .build() + + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + Result.success(Unit) + } else { + val errorBody = response.body?.string() + Log.w("RoomRepository", "uploadFileToS3 failed: code=${response.code} body=$errorBody") + Result.failure(Exception("S3 upload failed: ${response.code}")) + } + } + } catch (e: Exception) { + Log.w("RoomRepository", "uploadFileToS3 exception", e) + Result.failure(e) + } + } + + private suspend fun cacheReferenceAudioIfNeeded( + existing: AssignmentEntity, + dto: AssignmentResponseDetailed + ): String? = withContext(Dispatchers.IO) { + val url = dto.referenceAudioUrl.toString().takeIf { it.isNotBlank() } + ?: return@withContext existing.referenceAudioLocalPath + + val existingPath = existing.referenceAudioLocalPath + if (!existingPath.isNullOrBlank()) { + val file = File(existingPath) + if (file.exists()) { + return@withContext existingPath + } + } + + val target = audioFileStore.getAssignmentAudioFile(existing.id) + + val uri = java.net.URI(url) + val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" + val fixedUrl = url + .replace("localhost", "10.0.2.2") + .replace("127.0.0.1", "10.0.2.2") + + val request = Request.Builder() + .url(fixedUrl) + .header("Host", originalHost) + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext existing.referenceAudioLocalPath + } + + response.body?.byteStream()?.use { input -> + target.outputStream().use { output -> + input.copyTo(output) + } + } + } + + target.absolutePath + } +} 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 c9c80fe..d7c5c6c 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,14 +1,51 @@ package com.smartjam.app.ui.navigation +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.animation.core.LinearEasing +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.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +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.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState 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.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 @@ -18,13 +55,24 @@ 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 +import com.smartjam.app.ui.screens.room.RoomScreen +import com.smartjam.app.ui.screens.room.RoomViewModel +import com.smartjam.app.ui.screens.room.RoomViewModelFactory +import com.smartjam.app.ui.theme.BlurCyan +import com.smartjam.app.ui.theme.BlurPurpleDark +import com.smartjam.app.ui.theme.CoreBackground +import java.util.* 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") + object Profile : Screen("profile_screen") + object Comments : Screen("comments_screen") + object Room : Screen("room_screen/{connectionId}/{role}") { + fun createRoute(connectionId: String, role: String) = "room_screen/$connectionId/$role" + } } @Composable @@ -32,68 +80,309 @@ fun SmartJamNavGraph( navController: NavHostController, authRepository: AuthRepository, connectionRepository: ConnectionRepository, + roomRepository: RoomRepository, tokenStorage: TokenStorage, startDestination: String = Screen.Login.route ) { - NavHost( - navController = navController, - startDestination = startDestination + + val backStack by navController.currentBackStackEntryAsState() + val currentRoute = backStack?.destination?.route + val appBackground = Brush.verticalGradient( + colors = listOf( + CoreBackground, + Color(0xFF0A0A14), + BlurPurpleDark.copy(alpha = 0.28f), + CoreBackground + ) + ) + val glassShape = RoundedCornerShape(38.dp) + val glassBarBrush = Brush.verticalGradient( + colors = listOf( + CoreBackground.copy(alpha = 0.56f), + Color(0xFF0A0A14).copy(alpha = 0.36f), + BlurPurpleDark.copy(alpha = 0.14f), + Color(0xFF0A0A14).copy(alpha = 0.44f) + ) + ) + val navBarTransition = rememberInfiniteTransition(label = "nav_bar_bg") + val navBarPhase by navBarTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(14000, easing = LinearEasing)), + label = "nav_bar_phase" + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(appBackground) ) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize() + ) { - composable(route = Screen.Login.route) { - val viewModel: LoginViewModel = viewModel( - factory = LoginViewModelFactory(authRepository) - ) + composable(route = Screen.Login.route) { + val loginViewModel: LoginViewModel = viewModel( + factory = LoginViewModelFactory(authRepository, tokenStorage) + ) - LoginScreen( - viewModel = viewModel, - onNavigateToHome = { - navController.navigate(Screen.Home.route) { - popUpTo(Screen.Login.route) { inclusive = true } + LoginScreen( + viewModel = loginViewModel, + onNavigateToHome = { + navController.navigate(Screen.Home.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onNavigateToRegister = { + navController.navigate(Screen.Register.route) } - }, - onNavigateToRegister = { - navController.navigate(Screen.Register.route) - } - ) - } + ) + } - composable(route = Screen.Register.route) { - val viewModel: RegisterViewModel = viewModel( - factory = RegisterViewModelFactory(authRepository) - ) + 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 } + RegisterScreen( + viewModel = viewModel, + onNavigateToHome = { + navController.navigate(Screen.Home.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onNavigateBack = { + navController.popBackStack() } - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } + ) + } + + + composable(route = Screen.Home.route) { + val viewModel: HomeViewModel = viewModel( + factory = HomeViewModelFactory(connectionRepository, authRepository) + ) + + HomeScreen( + viewModel = viewModel, + onNavigateToRoom = { connectionId -> + val role = viewModel.state.value.currentRole.name + navController.navigate(Screen.Room.createRoute(connectionId, role)) + }, + onNavigateToLogin = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.Home.route) { inclusive = true } + } + } + ) + } + + composable(route = Screen.Profile.route) { + PlaceholderScreen( + title = "Профиль", + subtitle = "Здесь появится аватар, настройки и данные аккаунта", + icon = Icons.Filled.Person + ) + } + + composable(route = Screen.Comments.route) { + PlaceholderScreen( + title = "Комментарии", + subtitle = "Здесь будут сообщения, обсуждения и обратная связь", + icon = Icons.Filled.Email + ) + } + + composable(route = Screen.Room.route) { backStackEntry -> + val connectionIdStr = + backStackEntry.arguments?.getString("connectionId") ?: return@composable + val roleStr = backStackEntry.arguments?.getString("role") ?: return@composable + val connectionId = UUID.fromString(connectionIdStr) + val role = com.smartjam.app.domain.model.UserRole.valueOf(roleStr) + val viewModel: RoomViewModel = viewModel( + factory = RoomViewModelFactory(connectionId, roomRepository) + ) - composable(route = Screen.Home.route) { - val viewModel: HomeViewModel = viewModel( - factory = HomeViewModelFactory(connectionRepository, authRepository) - ) + RoomScreen( + connectionId = connectionId, + role = role, + viewModel = viewModel, + onBack = { navController.popBackStack() } + ) + } + } + + if (currentRoute != Screen.Login.route && currentRoute != Screen.Register.route) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp, vertical = 14.dp) + .fillMaxWidth() + .height(88.dp) + .clip(glassShape) + .shadow( + elevation = 28.dp, + shape = glassShape, + ambientColor = Color.Black.copy(alpha = 0.12f), + spotColor = Color.Black.copy(alpha = 0.22f) + ) + .background(glassBarBrush) + ) { + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.08f), + Color.Transparent, + Color.White.copy(alpha = 0.03f) + ) + ) + ) + ) - HomeScreen( - viewModel = viewModel, - onNavigateToRoom = { connectionId -> - navController.navigate(Screen.Room.route) - }, - onNavigateToLogin = { - navController.navigate(Screen.Login.route) { - popUpTo(Screen.Home.route) { inclusive = true } + NavigationBar( + modifier = Modifier + .fillMaxSize() + .background(Color.Transparent), + containerColor = Color.Transparent, + tonalElevation = 0.dp, + windowInsets = androidx.compose.foundation.layout.WindowInsets(0) + ) { + val items = listOf(Screen.Home, Screen.Profile, Screen.Comments) + items.forEach { screen -> + NavigationBarItem( + selected = currentRoute == screen.route, + onClick = { + navController.navigate(screen.route) { + popUpTo(Screen.Home.route) + } + }, + icon = { + when (screen) { + is Screen.Home -> Icon( + imageVector = Icons.Filled.Home, + contentDescription = "Home" + ) + is Screen.Profile -> Icon( + imageVector = Icons.Filled.Person, + contentDescription = "Profile" + ) + is Screen.Comments -> Icon( + imageVector = Icons.Filled.Email, + contentDescription = "Comments" + ) + else -> {} + } + }, + label = { + Text( + text = when (screen) { + is Screen.Home -> "Комнаты" + is Screen.Profile -> "Профиль" + is Screen.Comments -> "Чаты" + else -> "" + }, + fontWeight = FontWeight.Medium + ) + }, + alwaysShowLabel = true, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Color.White, + unselectedIconColor = Color.White.copy(alpha = 0.45f), + selectedTextColor = Color.White, + unselectedTextColor = Color.White.copy(alpha = 0.48f), + indicatorColor = Color.White.copy(alpha = 0.10f) + ) + ) } } - ) + } } + } +} + +@Composable +private fun PlaceholderScreen( + title: String, + subtitle: String, + icon: androidx.compose.ui.graphics.vector.ImageVector +) { + val background = Brush.radialGradient( + colors = listOf( + BlurPurpleDark.copy(alpha = 0.34f), + CoreBackground, + CoreBackground + ) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(background) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color.White.copy(alpha = 0.06f), + shape = RoundedCornerShape(28.dp), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + Color.White.copy(alpha = 0.12f) + ), + tonalElevation = 0.dp, + shadowElevation = 10.dp + ) { + Column( + modifier = Modifier + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(76.dp) + .clip(RoundedCornerShape(24.dp)) + .background( + Brush.linearGradient( + colors = listOf( + BlurCyan.copy(alpha = 0.22f), + Color.White.copy(alpha = 0.08f) + ) + ) + ) + .border( + 1.dp, + Color.White.copy(alpha = 0.15f), + RoundedCornerShape(24.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = Color.White, + modifier = Modifier.size(36.dp) + ) + } + + Text( + text = title, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + + Text( + text = subtitle, + color = Color.White.copy(alpha = 0.68f) + ) + } + } } -} \ 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 ae7c58b..fd96e5f 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 @@ -7,21 +7,21 @@ 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.lazy.rememberLazyListState 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.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -31,9 +31,10 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest import com.smartjam.app.domain.model.Connection import com.smartjam.app.domain.model.UserRole import com.smartjam.app.ui.components.AppleGlassTextField @@ -53,6 +54,7 @@ fun HomeScreen( val state by viewModel.state.collectAsState() val context = LocalContext.current val keyboard = LocalSoftwareKeyboardController.current + val listState = rememberLazyListState() LaunchedEffect(Unit) { viewModel.events.collect { event -> @@ -66,6 +68,16 @@ fun HomeScreen( } } + LaunchedEffect(listState) { + snapshotFlow { + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = listState.layoutInfo.totalItemsCount + lastVisible to total + }.collect { (lastVisible, total) -> + viewModel.onListScrolled(lastVisible, total) + } + } + Box(modifier = Modifier.fillMaxSize().background(Color(0xFF05050A))) { AppleLiquidBackground() @@ -88,6 +100,7 @@ fun HomeScreen( } LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -285,8 +298,26 @@ private fun ActiveConnectionCard(connection: Connection, onClick: () -> Unit) { .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) + val model = connection.peerAvatarBytes ?: connection.peerAvatarUrl + if (model != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(model) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier.size(48.dp).clip(RoundedCornerShape(24.dp)) + ) + } else { + 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 { 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 106b443..c202e93 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 @@ -21,6 +21,10 @@ data class HomeState( val inviteCodeInput: String = "", val teacherGeneratedCode: String? = null, val isLoading: Boolean = false, + val isPaging: Boolean = false, + val endReached: Boolean = false, + val nextPage: Int = 1, + val pageSize: Int = 20, val errorMessage: String? = null ) @@ -42,9 +46,25 @@ class HomeViewModel( val events = eventChannel.receiveAsFlow() private var connectionJob: Job? = null + private var pollingJob: Job? = null + private var hasStarted = false init { - startObservingConnections() + viewModelScope.launch { + authRepository.userRole.collect { roleString -> + val newRole = try { + UserRole.valueOf(roleString ?: "STUDENT") + } catch (e: Exception) { + UserRole.STUDENT + } + + if (!hasStarted || _state.value.currentRole != newRole) { + hasStarted = true + _state.update { it.copy(currentRole = newRole) } + startObservingConnections() + } + } + } } fun toggleDebugRole() { @@ -54,17 +74,19 @@ class HomeViewModel( UserRole.STUDENT } - _state.update { it.copy( - currentRole = newRole, - connections = emptyList(), - errorMessage = null - ) } - - startObservingConnections() + viewModelScope.launch { + val refreshed = authRepository.refreshWithRole(newRole) + if (!refreshed) { + eventChannel.send(HomeEvent.NavigateToLogin) + } + } } private fun startObservingConnections() { connectionJob?.cancel() + pollingJob?.cancel() + + _state.update { it.copy(nextPage = 1, endReached = false) } connectionJob = viewModelScope.launch { val role = _state.value.currentRole @@ -79,15 +101,40 @@ class HomeViewModel( } } - syncNetworkData() + refreshFirstPage() + startPolling() } } - fun syncNetworkData() { + private fun startPolling() { + pollingJob?.cancel() + pollingJob = viewModelScope.launch { + while (true) { + kotlinx.coroutines.delay(50_000) + refreshFirstPage() + } + } + } + + fun onListScrolled(lastVisibleIndex: Int, totalCount: Int) { + val state = _state.value + if (state.isPaging || state.endReached || totalCount == 0) return + + val threshold = (state.pageSize / 2).coerceAtLeast(1) + if (lastVisibleIndex >= totalCount - threshold) { + loadNextPage() + } + } + + private fun refreshFirstPage() { viewModelScope.launch { _state.update { it.copy(isLoading = true, errorMessage = null) } - val result = connectionRepository.syncConnections(_state.value.currentRole) + val result = connectionRepository.syncConnectionsPage( + _state.value.currentRole, + page = 0, + size = _state.value.pageSize + ) if (result.isFailure) { _state.update { it.copy(errorMessage = "Не удалось обновить данные с сервера") } @@ -97,6 +144,32 @@ class HomeViewModel( } } + private fun loadNextPage() { + viewModelScope.launch { + _state.update { it.copy(isPaging = true, errorMessage = null) } + + val result = connectionRepository.syncConnectionsPage( + _state.value.currentRole, + page = _state.value.nextPage, + size = _state.value.pageSize + ) + + if (result.isSuccess) { + val pageInfo = result.getOrNull()!! + val endReached = pageInfo.pageNumber + 1 >= pageInfo.totalPages + _state.update { it.copy(nextPage = pageInfo.pageNumber + 1, endReached = endReached) } + } else { + _state.update { it.copy(errorMessage = "Не удалось загрузить следующую страницу") } + } + + _state.update { it.copy(isPaging = false) } + } + } + + fun syncNetworkData() { + refreshFirstPage() + } + fun onInviteCodeInputChanged(code: String) { _state.update { it.copy(inviteCodeInput = code, errorMessage = null) } } 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 9ee3c24..21c5686 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,6 +1,7 @@ package com.smartjam.app.ui.screens.login import android.widget.Toast +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -18,6 +19,7 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -58,6 +60,7 @@ 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 com.smartjam.app.domain.model.UserRole import com.smartjam.app.ui.components.AppleGlassButton import com.smartjam.app.ui.components.AppleGlassTextField import com.smartjam.app.ui.components.AppleLiquidBackground @@ -65,6 +68,7 @@ import com.smartjam.app.ui.components.GoldenStringsButton import com.smartjam.app.ui.theme.CoreBackground import com.smartjam.app.ui.theme.ErrorRed import com.smartjam.app.ui.theme.BrandCyan +import com.smartjam.app.ui.theme.BrandGold @Composable fun LoginScreen( @@ -120,6 +124,13 @@ fun LoginScreen( modifier = Modifier.padding(top = 4.dp, bottom = 56.dp) ) + GlassRoleSelector( + selectedRole = state.selectedRole, + onRoleSelected = { viewModel.onRoleSelected(it) } + ) + + Spacer(modifier = Modifier.height(24.dp)) + AppleGlassTextField( value = state.emailInput, onValueChange = { viewModel.onEmailChanged(it) }, @@ -197,3 +208,67 @@ fun LoginScreen( } } +@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) BrandGold.copy(alpha = 0.2f) else Color.Transparent, + label = "RoleColorAnimation" + ) + + val textColor = if (isSelected) BrandGold 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 + ) + } +} 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 263784f..8878591 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.launch data class LoginState( val emailInput: String = "", val passwordInput: String = "", + val selectedRole: com.smartjam.app.domain.model.UserRole = com.smartjam.app.domain.model.UserRole.STUDENT, val isLoading: Boolean = false, val errorMessage: String? = null ) @@ -26,7 +27,8 @@ sealed class LoginEvent{ } class LoginViewModel ( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val tokenStorage: com.smartjam.app.data.local.TokenStorage ) : ViewModel(){ private val _state = MutableStateFlow(LoginState()) @@ -36,35 +38,34 @@ class LoginViewModel ( val events = eventChannel.receiveAsFlow() fun onPasswordChanged(newPassword: String){ - _state.value = _state.value.copy( - passwordInput = newPassword, - errorMessage = null - ) + _state.update { it.copy(passwordInput = newPassword, errorMessage = null) } } fun onEmailChanged(newEmail: String) { - _state.value = _state.value.copy( - emailInput = newEmail, - errorMessage = null - ) + _state.update { it.copy(emailInput = newEmail, errorMessage = null) } + } + + fun onRoleSelected(role: com.smartjam.app.domain.model.UserRole) { + _state.update { it.copy(selectedRole = role) } } fun onLoginClicked() { if (_state.value.isLoading){ - return; + return } val currentEmail = _state.value.emailInput val currentPassword = _state.value.passwordInput + val selectedRole = _state.value.selectedRole if (currentPassword.isBlank() || currentEmail.isBlank()){ - _state.value = _state.value.copy(errorMessage = "Fill in all fields") - return; + _state.update { it.copy(errorMessage = "Fill in all fields") } + return } viewModelScope.launch { - _state.value = _state.value.copy(isLoading = true, errorMessage = null) + _state.update { it.copy(isLoading = true, errorMessage = null) } try { - val result = authRepository.login(currentEmail, currentPassword) + val result = authRepository.login(currentEmail, currentPassword, selectedRole) if (result.isSuccess){ eventChannel.send(LoginEvent.NavigateToHome) @@ -87,13 +88,14 @@ class LoginViewModel ( class LoginViewModelFactory( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val tokenStorage: com.smartjam.app.data.local.TokenStorage ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(LoginViewModel::class.java)) { - return LoginViewModel(authRepository) as T + return LoginViewModel(authRepository, tokenStorage) as T } throw IllegalArgumentException("Unknown ViewModel class") } 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 14c5e95..127b1f2 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 @@ -101,9 +101,8 @@ class RegisterViewModel( role = currentState.selectedRole ) - _state.update { it.copy(isLoading = false) } - if (result.isSuccess) { + _state.update { it.copy(isLoading = false) } eventChannel.send(RegisterEvent.NavigateToHome) } else { val error = result.exceptionOrNull()?.message ?: "Ошибка регистрации" 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 index e69de29..2efad65 100644 --- 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 @@ -0,0 +1,409 @@ +package com.smartjam.app.ui.screens.room + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Edit +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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.smartjam.app.data.local.entity.AssignmentEntity +import com.smartjam.app.data.local.entity.SubmissionResultEntity +import com.smartjam.app.domain.model.UserRole +import com.smartjam.app.model.FeedbackEvent +import com.smartjam.app.model.FeedbackType +import com.smartjam.app.ui.components.AppleGlassTextField +import com.smartjam.app.ui.components.AppleLiquidBackground +import com.smartjam.app.ui.components.GlassContainer +import com.smartjam.app.ui.components.GoldenStringsButton +import com.smartjam.app.ui.theme.BrandCyan +import com.smartjam.app.ui.theme.BrandGold +import java.io.File +import java.util.UUID + +@Composable +fun RoomScreen( + connectionId: UUID, + role: UserRole, + viewModel: RoomViewModel, + onBack: () -> Unit +) { + val state by viewModel.uiState.collectAsState() + val context = LocalContext.current + val listState = rememberLazyListState() + + var pendingAssignmentTitle by remember { mutableStateOf("") } + var pendingAssignmentDescription by remember { mutableStateOf("") } + var pendingSubmissionAssignmentId by remember { mutableStateOf(null) } + var pendingSavePath by remember { mutableStateOf(null) } + + val assignmentPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + val file = File(context.cacheDir, "temp_assignment_upload.wav") + context.contentResolver.openInputStream(it)?.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + viewModel.uploadAssignment(file, pendingAssignmentTitle, pendingAssignmentDescription.ifBlank { null }) + pendingAssignmentTitle = "" + pendingAssignmentDescription = "" + } + } + + val submissionPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + val assignmentId = pendingSubmissionAssignmentId ?: return@rememberLauncherForActivityResult + uri?.let { + val file = File(context.cacheDir, "temp_submission_upload.wav") + context.contentResolver.openInputStream(it)?.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + viewModel.uploadSubmission(assignmentId, file) + } + } + + val saveToDeviceLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("audio/wav") + ) { uri: Uri? -> + val path = pendingSavePath + if (uri != null && !path.isNullOrBlank()) { + val input = File(path) + context.contentResolver.openOutputStream(uri)?.use { output -> + input.inputStream().use { it.copyTo(output) } + } + } + pendingSavePath = null + } + + LaunchedEffect(listState) { + snapshotFlow { + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = listState.layoutInfo.totalItemsCount + lastVisible to total + }.collect { (lastVisible, total) -> + viewModel.onListScrolled(lastVisible, total) + } + } + + Box(modifier = Modifier.fillMaxSize().background(Color(0xFF05050A))) { + AppleLiquidBackground() + + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp)) { + Spacer(modifier = Modifier.height(WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Room", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (role == UserRole.TEACHER) { + GlassContainer { + Column(modifier = Modifier.padding(16.dp)) { + Text("Новый урок", color = BrandGold, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(12.dp)) + AppleGlassTextField( + value = pendingAssignmentTitle, + onValueChange = { pendingAssignmentTitle = it }, + hint = "Название урока", + icon = Icons.Default.Edit, + enabled = !state.isUploading + ) + Spacer(modifier = Modifier.height(8.dp)) + AppleGlassTextField( + value = pendingAssignmentDescription, + onValueChange = { pendingAssignmentDescription = it }, + hint = "Описание (опционально)", + icon = Icons.Default.Edit, + enabled = !state.isUploading + ) + Spacer(modifier = Modifier.height(12.dp)) + GoldenStringsButton( + text = if (state.isUploading) "Загрузка..." else "Загрузить эталон (.wav)", + enabled = !state.isUploading && pendingAssignmentTitle.isNotBlank(), + onClick = { assignmentPicker.launch("audio/*") }, + modifier = Modifier.fillMaxWidth() + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + if (state.error != null) { + Text(state.error ?: "", color = Color(0xFFFF5252), modifier = Modifier.padding(vertical = 8.dp)) + } + + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(state.assignments) { assignment -> + AssignmentCard( + assignment = assignment, + role = role, + submissions = state.submissionsByAssignment[assignment.id].orEmpty(), + feedbackBySubmission = state.feedbackBySubmission, + onExpand = { viewModel.onAssignmentExpanded(assignment.id) }, + onUploadSubmission = { + pendingSubmissionAssignmentId = assignment.id + submissionPicker.launch("audio/*") + }, + onSaveAudio = { path -> + pendingSavePath = path + saveToDeviceLauncher.launch("${assignment.title}.wav") + }, + onDownloadReference = { aId -> + viewModel.downloadReference(aId) { path -> + if (!path.isNullOrBlank()) { + pendingSavePath = path + saveToDeviceLauncher.launch("${assignment.title}.wav") + } + } + }, + onDownloadSubmission = { submissionId, url -> + viewModel.downloadSubmissionAudio(submissionId, assignment.id, url) { path: String? -> + if (!path.isNullOrBlank()) { + pendingSavePath = path + saveToDeviceLauncher.launch("${assignment.title}_${submissionId}.wav") + } + } + }, + onSaveLocalSubmission = { path, submissionId -> + pendingSavePath = path + saveToDeviceLauncher.launch("${assignment.title}_${submissionId}.wav") + } + ) + } + } + } + } +} + +@Composable + private fun AssignmentCard( + assignment: AssignmentEntity, + role: UserRole, + submissions: List, + feedbackBySubmission: Map>, + onExpand: () -> Unit, + onUploadSubmission: () -> Unit, + onSaveAudio: (String) -> Unit + , onDownloadReference: (UUID) -> Unit, + onDownloadSubmission: (UUID, String?) -> Unit, + onSaveLocalSubmission: (String, UUID) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + GlassContainer { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text(assignment.title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp) + Text("Статус: ${assignment.status}", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp) + } + IconButton(onClick = { + expanded = !expanded + if (expanded) onExpand() + }) { + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = "Expand", + tint = Color.White + ) + } + } + + if (expanded) { + assignment.description?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(it, color = Color.White.copy(alpha = 0.8f), fontSize = 13.sp) + } + + val localPath = assignment.referenceAudioLocalPath + if (!localPath.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + GoldenStringsButton( + text = "Сохранить на устройство", + onClick = { onSaveAudio(localPath) }, + modifier = Modifier.fillMaxWidth() + ) + } else if (role == UserRole.STUDENT) { + Spacer(modifier = Modifier.height(12.dp)) + GoldenStringsButton( + text = "Скачать эталон", + onClick = { onDownloadReference(assignment.id) }, + modifier = Modifier.fillMaxWidth() + ) + } + + if (role == UserRole.STUDENT) { + Spacer(modifier = Modifier.height(12.dp)) + GoldenStringsButton( + text = "Загрузить попытку (.wav)", + onClick = onUploadSubmission, + modifier = Modifier.fillMaxWidth() + ) + } + + if (submissions.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = if (role == UserRole.TEACHER) "Попытки ученика" else "Мои попытки", + color = Color.White, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + submissions.forEach { submission -> + SubmissionCard( + submission = submission, + feedback = feedbackBySubmission[submission.id].orEmpty(), + role = role, + onDownloadSubmission = { id, url -> onDownloadSubmission(id, url) }, + onSaveLocal = { path -> onSaveLocalSubmission(path, submission.id) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + } +} + +@Composable +private fun SubmissionCard( + submission: SubmissionResultEntity, + feedback: List, + role: UserRole, + onDownloadSubmission: (UUID, String?) -> Unit, + onSaveLocal: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + GlassContainer { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text("Статус: ${submission.status}", color = Color.White) + val scoreText = submission.totalScore?.toString() ?: "N/A" + Text("Score: $scoreText", color = BrandCyan, fontWeight = FontWeight.Bold) + } + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = "Expand", + tint = Color.White + ) + } + } + + if (expanded) { + Spacer(modifier = Modifier.height(8.dp)) + Text("Результаты анализа", fontWeight = FontWeight.Bold, color = Color.White) + Spacer(modifier = Modifier.height(4.dp)) + Text("Total: ${submission.totalScore ?: 0f}%", color = Color.White.copy(alpha = 0.8f)) + Text("Pitch: ${submission.pitchScore ?: 0f}", color = Color.White.copy(alpha = 0.8f)) + Text("Rhythm: ${submission.rhythmScore ?: 0f}", color = Color.White.copy(alpha = 0.8f)) + + if (role == UserRole.TEACHER) { + Spacer(modifier = Modifier.height(8.dp)) + if (!submission.submissionAudioLocalPath.isNullOrBlank()) { + GoldenStringsButton( + text = "Скачать запись ученика", + onClick = { onSaveLocal(submission.submissionAudioLocalPath) }, + modifier = Modifier.fillMaxWidth() + ) + } else if (!submission.fileUrl.isNullOrBlank()) { + GoldenStringsButton( + text = "Скачать запись ученика", + onClick = { onDownloadSubmission(submission.id, submission.fileUrl) }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + if (feedback.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + ErrorTimelineChart(feedback) + } + } + } + } +} + +@Composable +private fun ErrorTimelineChart(feedback: List) { + val maxEnd = feedback.maxOfOrNull { it.teacherEndTime } ?: 1.0 + val height = 40.dp + + GlassContainer { + Column(modifier = Modifier.padding(12.dp)) { + Text("График ошибок", color = Color.White, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(8.dp)) + Canvas(modifier = Modifier.fillMaxWidth().height(height)) { + val width = size.width + feedback.forEach { event -> + val startX = (event.teacherStartTime / maxEnd).toFloat() * width + val endX = (event.teacherEndTime / maxEnd).toFloat() * width + val color = when (event.type) { + FeedbackType.WRONG_NOTE -> Color(0xFFFF5252) + FeedbackType.WRONG_RHYTHM -> Color(0xFFFFD166) + } + drawRect( + color = color, + topLeft = androidx.compose.ui.geometry.Offset(startX, 0f), + size = androidx.compose.ui.geometry.Size((endX - startX).coerceAtLeast(2f), size.height) + ) + } + } + } + } +} 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 index e69de29..8633c17 100644 --- 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 @@ -0,0 +1,229 @@ +package com.smartjam.app.ui.screens.room + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.smartjam.app.data.local.entity.AssignmentEntity +import com.smartjam.app.data.local.entity.SubmissionResultEntity +import com.smartjam.app.domain.repository.RoomRepository +import com.smartjam.app.model.CreateAssignmentRequest +import com.smartjam.app.model.FeedbackEvent +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.File +import java.util.* + +data class RoomUiState( + val assignments: List = emptyList(), + val submissionsByAssignment: Map> = emptyMap(), + val feedbackBySubmission: Map> = emptyMap(), + val isLoading: Boolean = false, + val isPaging: Boolean = false, + val endReached: Boolean = false, + val nextPage: Int = 1, + val pageSize: Int = 20, + val isUploading: Boolean = false, + val error: String? = null +) + +class RoomViewModel( + private val connectionId: UUID, + private val repository: RoomRepository +) : ViewModel() { + + private val _uiState = kotlinx.coroutines.flow.MutableStateFlow(RoomUiState()) + val uiState = _uiState.asStateFlow() + + private var submissionsJobs: MutableMap = mutableMapOf() + + init { + observeAssignments() + refreshFirstPage() + } + + private fun observeAssignments() { + viewModelScope.launch { + repository.getAssignmentsFlow(connectionId).collect { assignments -> + _uiState.update { it.copy(assignments = assignments) } + } + } + } + + fun onListScrolled(lastVisibleIndex: Int, totalCount: Int) { + val state = _uiState.value + if (state.isPaging || state.endReached || totalCount == 0) return + + val threshold = (state.pageSize / 2).coerceAtLeast(1) + if (lastVisibleIndex >= totalCount - threshold) { + loadNextPage() + } + } + + fun refreshFirstPage() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + val result = repository.syncAssignmentsPage(connectionId, page = 0, size = _uiState.value.pageSize) + if (result.isFailure) { + _uiState.update { it.copy(error = "Не удалось обновить список уроков") } + } + _uiState.update { it.copy(isLoading = false) } + } + } + + private fun loadNextPage() { + viewModelScope.launch { + _uiState.update { it.copy(isPaging = true, error = null) } + val result = repository.syncAssignmentsPage( + connectionId, + page = _uiState.value.nextPage, + size = _uiState.value.pageSize + ) + if (result.isSuccess) { + val pageInfo = result.getOrNull()!! + val endReached = pageInfo.pageNumber + 1 >= pageInfo.totalPages + _uiState.update { it.copy(nextPage = pageInfo.pageNumber + 1, endReached = endReached) } + } else { + _uiState.update { it.copy(error = "Не удалось загрузить следующую страницу") } + } + _uiState.update { it.copy(isPaging = false) } + } + } + + fun onAssignmentExpanded(assignmentId: UUID) { + viewModelScope.launch { + repository.ensureAssignmentDetailsCached(assignmentId) + repository.syncSubmissions(assignmentId) + observeSubmissions(assignmentId) + } + } + + fun uploadAssignment(file: File, title: String, description: String?) { + viewModelScope.launch { + _uiState.update { it.copy(isUploading = true, error = null) } + val request = CreateAssignmentRequest(connectionId, title, description) + val result = repository.createAssignment(request) + + if (result.isSuccess) { + val uploadInfo = result.getOrNull()!! + val uploadResult = repository.uploadFileToS3(uploadInfo.uploadUrl.toString(), file) + if (uploadResult.isSuccess) { + refreshFirstPage() + } else { + _uiState.update { it.copy(error = "Upload failed") } + } + } else { + _uiState.update { it.copy(error = "Creation failed") } + } + _uiState.update { it.copy(isUploading = false) } + } + } + + fun uploadSubmission(assignmentId: UUID, file: File) { + viewModelScope.launch { + _uiState.update { it.copy(isUploading = true, error = null) } + val result = repository.createSubmission(assignmentId) + + if (result.isSuccess) { + val uploadInfo = result.getOrNull()!! + val uploadResult = repository.uploadFileToS3(uploadInfo.uploadUrl.toString(), file) + if (uploadResult.isSuccess) { + repository.syncSubmissions(assignmentId) + observeSubmissions(assignmentId) + startSubmissionPolling(uploadInfo.submissionId, assignmentId) + } else { + _uiState.update { it.copy(error = "Upload failed") } + } + } else { + _uiState.update { it.copy(error = "Submission creation failed") } + } + _uiState.update { it.copy(isUploading = false) } + } + } + + /** + * Ensure reference audio for assignment is cached locally. Calls onResult with local path or null on failure. + */ + fun downloadReference(assignmentId: UUID, onResult: (String?) -> Unit) { + viewModelScope.launch { + val res = repository.ensureAssignmentDetailsCached(assignmentId) + if (res.isSuccess) { + onResult(res.getOrNull()?.referenceAudioLocalPath) + } else { + onResult(null) + } + } + } + + fun downloadSubmissionAudio(submissionId: UUID, assignmentId: UUID, fileUrl: String?, onResult: (String?) -> Unit) { + viewModelScope.launch { + val res = repository.cacheSubmissionAudioIfNeeded(submissionId, assignmentId, fileUrl) + if (res.isSuccess) { + onResult(res.getOrNull()) + } else { + onResult(null) + } + } + } + + private fun observeSubmissions(assignmentId: UUID) { + submissionsJobs[assignmentId]?.cancel() + submissionsJobs[assignmentId] = viewModelScope.launch { + repository.getSubmissionsFlow(assignmentId).collect { submissions -> + _uiState.update { state -> + state.copy( + submissionsByAssignment = state.submissionsByAssignment + (assignmentId to submissions) + ) + } + submissions.forEach { submission -> + val hasFeedback = _uiState.value.feedbackBySubmission.containsKey(submission.id) + val needsDetailFetch = submission.pitchScore == null || submission.rhythmScore == null || !hasFeedback + if (needsDetailFetch) { + viewModelScope.launch { + val res = repository.getSubmissionResult(submission.id, assignmentId) + if (res.isSuccess) { + val dto = res.getOrNull()!! + val feedback = dto.feedback ?: emptyList() + _uiState.update { st -> + st.copy(feedbackBySubmission = st.feedbackBySubmission + (submission.id to feedback)) + } + } + } + } + } + } + } + } + + private fun startSubmissionPolling(submissionId: UUID, assignmentId: UUID) { + viewModelScope.launch { + repeat(30) { + val result = repository.getSubmissionResult(submissionId, assignmentId) + if (result.isSuccess) { + val dto = result.getOrNull()!! + val feedback = dto.feedback ?: emptyList() + _uiState.update { state -> + state.copy(feedbackBySubmission = state.feedbackBySubmission + (submissionId to feedback)) + } + val status = dto.status.name + if (status == "COMPLETED" || status == "FAILED") { + return@launch + } + } + delay(2_000) + } + } + } +} + +class RoomViewModelFactory( + private val connectionId: UUID, + private val repository: RoomRepository +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return RoomViewModel(connectionId, repository) as T + } +} diff --git a/mobile/gradlew b/mobile/gradlew old mode 100644 new mode 100755 From 515a333db709a7e8ae034df204a5d024abad0a6e Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Thu, 7 May 2026 13:07:11 +0300 Subject: [PATCH 02/17] feat: implement async analysis notifications and refined error handling --- backend/compose-messaging.yaml | 6 ++- .../api/kafka/S3StorageListener.java | 10 ++--- .../application/AudioAnalysisUseCase.java | 38 +++++++++++++++++-- .../exception/AnalysisFatalException.java | 7 ++++ .../domain/port/AnalysisEventPublisher.java | 7 ++++ .../KafkaAnalysisEventPublisher.java | 21 ++++++++++ .../adapter/AssignmentPersistenceAdapter.java | 4 +- .../adapter/SubmissionPersistenceAdapter.java | 3 +- .../api/listener/S3StorageListenerTest.java | 26 ++++++------- .../AudioAnalysisIntegrationTest.java | 4 +- .../dto/analysis/AnalysisFinishedEvent.java | 10 +++++ .../common/dto/analysis/AnalysisType.java | 6 +++ .../dto/s3/{S3EventDto.java => S3Event.java} | 2 +- .../{S3EventDtoTest.java => S3EventTest.java} | 6 +-- 14 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/exception/AnalysisFatalException.java create mode 100644 backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AnalysisEventPublisher.java create mode 100644 backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java create mode 100644 backend/smartjam-common/src/main/java/com/smartjam/common/dto/analysis/AnalysisFinishedEvent.java create mode 100644 backend/smartjam-common/src/main/java/com/smartjam/common/dto/analysis/AnalysisType.java rename backend/smartjam-common/src/main/java/com/smartjam/common/dto/s3/{S3EventDto.java => S3Event.java} (95%) rename backend/smartjam-common/src/test/java/common/dto/s3/{S3EventDtoTest.java => S3EventTest.java} (90%) diff --git a/backend/compose-messaging.yaml b/backend/compose-messaging.yaml index a33bb7f..e2966fd 100644 --- a/backend/compose-messaging.yaml +++ b/backend/compose-messaging.yaml @@ -41,8 +41,12 @@ services: echo "Broker not ready, retrying in 2s..." sleep 2 done + /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --create --if-not-exists --topic s3-events --partitions 3 --replication-factor 1 - echo "Topic s3-events created." + + /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:9092 --create --if-not-exists --topic analysis-results --partitions 3 --replication-factor 1 + + echo "Topics s3-events and analysis-results created." diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/api/kafka/S3StorageListener.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/api/kafka/S3StorageListener.java index 92141dc..deddce4 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/api/kafka/S3StorageListener.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/api/kafka/S3StorageListener.java @@ -3,7 +3,7 @@ import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import com.smartjam.common.dto.s3.S3EventDto; +import com.smartjam.common.dto.s3.S3Event; import com.smartjam.smartjamanalyzer.application.AudioAnalysisUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,14 +32,14 @@ public class S3StorageListener { topics = "s3-events", groupId = "smartjam-analyzer-group", concurrency = "3", - properties = {"spring.json.value.default.type=com.smartjam.common.dto.s3.S3EventDto"}) - public void onFileUploaded(S3EventDto event, Acknowledgment ack) { + properties = {"spring.json.value.default.type=com.smartjam.common.dto.s3.S3Event"}) + public void onFileUploaded(S3Event event, Acknowledgment ack) { if (event == null || event.records() == null || event.records().isEmpty()) { if (ack != null) ack.acknowledge(); return; } - for (S3EventDto.S3Record s3Record : event.records()) { + for (S3Event.S3Record s3Record : event.records()) { try { @@ -61,7 +61,7 @@ public void onFileUploaded(S3EventDto event, Acknowledgment ack) { } } - private boolean isValid(S3EventDto.S3Record r) { + private boolean isValid(S3Event.S3Record r) { return r != null && r.s3() != null && r.s3().bucket() != null diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java index ef33d08..1b2297f 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java @@ -3,7 +3,10 @@ import java.nio.file.Path; import java.util.UUID; +import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; +import com.smartjam.common.dto.analysis.AnalysisType; import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.smartjamanalyzer.domain.exception.AnalysisFatalException; import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; import com.smartjam.smartjamanalyzer.domain.port.*; @@ -30,6 +33,8 @@ public class AudioAnalysisUseCase { private final ResultRepository resultRepository; private final DebugVisualizer debugVisualizer; + private final AnalysisEventPublisher eventPublisher; + public void execute(String bucket, String fileKey) { if (!BUCKET_REFERENCES.equals(bucket) && !BUCKET_SUBMISSIONS.equals(bucket)) { @@ -74,15 +79,27 @@ public void execute(String bucket, String fileKey) { log.info("Результаты обработки {}: \n{}", fileKey, watch.prettyPrint()); + } catch (AnalysisFatalException e) { + log.error("Fatal analysis error for file {}: {}", fileKey, e.getMessage(), e); + + updateStatus(bucket, entityId, AudioProcessingStatus.FAILED, e.getMessage()); + eventPublisher.publish(AnalysisFinishedEvent.builder() + .targetId(entityId) + .type(BUCKET_REFERENCES.equals(bucket) ? AnalysisType.REFERENCE : AnalysisType.SUBMISSION) + .status(AudioProcessingStatus.FAILED) + .errorMessage(e.getMessage()) + .build()); } catch (Exception e) { String errorMsg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); - log.error("Ошибка в UseCase для файла {}: {}", fileKey, errorMsg, e); + log.error("Technical error for file {}: {}\n Retrying...", fileKey, errorMsg, e); + updateStatus(bucket, entityId, AudioProcessingStatus.FAILED, errorMsg); - throw new RuntimeException("Business logic failed", e); + throw new RuntimeException("Technical failure, retrying...", e); // Хотелось бы сюда + // DLT и не ходить в базу при каждом ретрае } } @@ -101,6 +118,12 @@ private void updateStatus(String bucket, UUID id, AudioProcessingStatus status, private void handleTeacherReference(UUID assignmentId, FeatureSequence teacherFeatures) { log.info("Сохраняем извлеченные признаки учителя для задания: {}", assignmentId); referenceRepository.save(assignmentId, teacherFeatures); + + eventPublisher.publish(AnalysisFinishedEvent.builder() + .targetId(assignmentId) + .type(AnalysisType.REFERENCE) + .status(AudioProcessingStatus.COMPLETED) + .build()); } private void handleStudentSubmission(UUID submissionId, FeatureSequence studentFeatures) { @@ -109,17 +132,24 @@ private void handleStudentSubmission(UUID submissionId, FeatureSequence studentF UUID assignmentId = resultRepository .findAssignmentIdBySubmissionId(submissionId) .orElseThrow(() -> - new IllegalStateException("Submission " + submissionId + " is not linked to any assignment")); + new AnalysisFatalException("Submission " + submissionId + " is not linked to any assignment")); FeatureSequence teacherFeatures = referenceRepository .findFeaturesById(assignmentId) - .orElseThrow(() -> new IllegalStateException( + .orElseThrow(() -> new AnalysisFatalException( "Teacher reference features not found for assignment: " + assignmentId)); AnalysisResult result = performanceEvaluator.evaluate(teacherFeatures, studentFeatures); resultRepository.save(submissionId, result); + eventPublisher.publish(AnalysisFinishedEvent.builder() + .targetId(submissionId) + .type(AnalysisType.SUBMISSION) + .status(AudioProcessingStatus.COMPLETED) + .totalScore(result.totalScore()) + .build()); + try { debugVisualizer.generateHeatmap(result, "debug_" + submissionId + ".png"); } catch (Exception e) { diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/exception/AnalysisFatalException.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/exception/AnalysisFatalException.java new file mode 100644 index 0000000..510a6bc --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/exception/AnalysisFatalException.java @@ -0,0 +1,7 @@ +package com.smartjam.smartjamanalyzer.domain.exception; + +public class AnalysisFatalException extends RuntimeException { + public AnalysisFatalException(String message) { + super(message); + } +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AnalysisEventPublisher.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AnalysisEventPublisher.java new file mode 100644 index 0000000..2c2fe3d --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AnalysisEventPublisher.java @@ -0,0 +1,7 @@ +package com.smartjam.smartjamanalyzer.domain.port; + +import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; + +public interface AnalysisEventPublisher { + void publish(AnalysisFinishedEvent event); +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java new file mode 100644 index 0000000..cffaf37 --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java @@ -0,0 +1,21 @@ +package com.smartjam.smartjamanalyzer.infrastructure.messaging; + +import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; +import com.smartjam.smartjamanalyzer.domain.port.AnalysisEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class KafkaAnalysisEventPublisher implements AnalysisEventPublisher { + + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "analysis-results"; + + @Override + public void publish(AnalysisFinishedEvent event) { + kafkaTemplate.send(TOPIC, event.targetId().toString(), event); + } +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java index fe354a2..23014f8 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java @@ -4,6 +4,7 @@ import java.util.UUID; import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.smartjamanalyzer.domain.exception.AnalysisFatalException; import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; import com.smartjam.smartjamanalyzer.domain.port.ReferenceRepository; import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.AssignmentEntity; @@ -33,8 +34,7 @@ public void save(UUID assignmentId, FeatureSequence features) { AssignmentEntity entity = repository .findById(assignmentId) - .orElseThrow(() -> new IllegalStateException("Assignment metadata missing for ID: " + assignmentId - + ". It might have " + "been deleted or not created yet.")); + .orElseThrow(() -> new AnalysisFatalException("Assignment metadata missing for ID: " + assignmentId)); entity.setReferenceSpectreCache(bytes); entity.setStatus(AudioProcessingStatus.COMPLETED); diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java index c1b73dc..d358172 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java @@ -4,6 +4,7 @@ import java.util.UUID; import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.smartjamanalyzer.domain.exception.AnalysisFatalException; import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; import com.smartjam.smartjamanalyzer.domain.port.ResultRepository; import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.SubmissionEntity; @@ -30,7 +31,7 @@ public class SubmissionPersistenceAdapter implements ResultRepository { public void save(UUID submissionId, AnalysisResult result) { SubmissionEntity entity = repository .findById(submissionId) - .orElseThrow(() -> new IllegalStateException("Submission record missing for ID: " + submissionId)); + .orElseThrow(() -> new AnalysisFatalException("Submission record missing for ID: " + submissionId)); entity.setTotalScore(result.totalScore()); entity.setPitchScore(result.pitchScore()); diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/api/listener/S3StorageListenerTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/api/listener/S3StorageListenerTest.java index 9e68f5f..ad915d8 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/api/listener/S3StorageListenerTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/api/listener/S3StorageListenerTest.java @@ -3,7 +3,7 @@ import java.util.Collections; import java.util.List; -import com.smartjam.common.dto.s3.S3EventDto; +import com.smartjam.common.dto.s3.S3Event; import com.smartjam.smartjamanalyzer.api.kafka.S3StorageListener; import com.smartjam.smartjamanalyzer.application.AudioAnalysisUseCase; import org.junit.jupiter.api.DisplayName; @@ -26,13 +26,13 @@ class S3StorageListenerTest { @InjectMocks private S3StorageListener listener; - private S3EventDto createEvent(String bucket, String key) { - return S3EventDto.builder() - .records(List.of(S3EventDto.S3Record.builder() + private S3Event createEvent(String bucket, String key) { + return S3Event.builder() + .records(List.of(S3Event.S3Record.builder() .eventName("s3:ObjectCreated:Put") - .s3(S3EventDto.S3Data.builder() - .bucket(S3EventDto.Bucket.builder().name(bucket).build()) - .object(S3EventDto.S3Object.builder().key(key).build()) + .s3(S3Event.S3Data.builder() + .bucket(S3Event.Bucket.builder().name(bucket).build()) + .object(S3Event.S3Object.builder().key(key).build()) .build()) .build())) .build(); @@ -43,7 +43,7 @@ private S3EventDto createEvent(String bucket, String key) { void shouldCallUseCaseOnEvent() { String bucket = "references"; String key = "teacher_riff.wav"; - S3EventDto event = createEvent(bucket, key); + S3Event event = createEvent(bucket, key); Acknowledgment ack = mock(Acknowledgment.class); listener.onFileUploaded(event, ack); @@ -59,10 +59,10 @@ void shouldAckOnEmptyEvents() { listener.onFileUploaded(null, ack); - listener.onFileUploaded(S3EventDto.builder().records(null).build(), ack); + listener.onFileUploaded(S3Event.builder().records(null).build(), ack); listener.onFileUploaded( - S3EventDto.builder().records(Collections.emptyList()).build(), ack); + S3Event.builder().records(Collections.emptyList()).build(), ack); verify(ack, times(3)).acknowledge(); verifyNoInteractions(analysisUseCase); @@ -71,8 +71,8 @@ void shouldAckOnEmptyEvents() { @Test @DisplayName("Должен пропускать некорректные записи (skip) и не вызывать UseCase") void shouldSkipInvalidRecords() { - S3EventDto event = S3EventDto.builder() - .records(List.of(S3EventDto.S3Record.builder().s3(null).build())) + S3Event event = S3Event.builder() + .records(List.of(S3Event.S3Record.builder().s3(null).build())) .build(); Acknowledgment ack = mock(Acknowledgment.class); @@ -87,7 +87,7 @@ void shouldSkipInvalidRecords() { void shouldThrowExceptionWhenUseCaseFails() { String bucket = "references"; String key = "fail.wav"; - S3EventDto event = createEvent(bucket, key); + S3Event event = createEvent(bucket, key); Acknowledgment ack = mock(Acknowledgment.class); doThrow(new RuntimeException("Math failed")).when(analysisUseCase).execute(anyString(), anyString()); diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisIntegrationTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisIntegrationTest.java index 9df5e55..4afbe44 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisIntegrationTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisIntegrationTest.java @@ -43,7 +43,7 @@ class AudioAnalysisIntegrationTest { @DisplayName("Полный цикл анализа с замером производительности") void shouldPerformFullAnalysisCycle() throws Exception { Path teacherPath = Path.of("src/test/resources/californication_teacher.m4a"); - Path studentPath = Path.of("src/test/resources/californication_stud.m4a"); + Path studentPath = Path.of("src/test/resources/cant_stop_bad.m4a"); StopWatch sw = new StopWatch("Audio Pipeline Benchmark"); @@ -80,7 +80,7 @@ void shouldPerformFullAnalysisCycle() throws Exception { =========================================================== АНАЛИЗ ЗАВЕРШЕН: %s vs %s =========================================================== - МЕТРИКИ КАЧЕСТВА: + МЕТРИКИ: -> Общий балл: %6.2f%% -> Точность нот: %6.2f%% -> Ритм и темп: %6.2f%% diff --git a/backend/smartjam-common/src/main/java/com/smartjam/common/dto/analysis/AnalysisFinishedEvent.java b/backend/smartjam-common/src/main/java/com/smartjam/common/dto/analysis/AnalysisFinishedEvent.java new file mode 100644 index 0000000..1b00aab --- /dev/null +++ b/backend/smartjam-common/src/main/java/com/smartjam/common/dto/analysis/AnalysisFinishedEvent.java @@ -0,0 +1,10 @@ +package com.smartjam.common.dto.analysis; + +import java.util.UUID; + +import com.smartjam.common.model.AudioProcessingStatus; +import lombok.Builder; + +@Builder +public record AnalysisFinishedEvent( + UUID targetId, AnalysisType type, AudioProcessingStatus status, Double totalScore, String errorMessage) {} diff --git a/backend/smartjam-common/src/main/java/com/smartjam/common/dto/analysis/AnalysisType.java b/backend/smartjam-common/src/main/java/com/smartjam/common/dto/analysis/AnalysisType.java new file mode 100644 index 0000000..a565141 --- /dev/null +++ b/backend/smartjam-common/src/main/java/com/smartjam/common/dto/analysis/AnalysisType.java @@ -0,0 +1,6 @@ +package com.smartjam.common.dto.analysis; + +public enum AnalysisType { + REFERENCE, + SUBMISSION +} diff --git a/backend/smartjam-common/src/main/java/com/smartjam/common/dto/s3/S3EventDto.java b/backend/smartjam-common/src/main/java/com/smartjam/common/dto/s3/S3Event.java similarity index 95% rename from backend/smartjam-common/src/main/java/com/smartjam/common/dto/s3/S3EventDto.java rename to backend/smartjam-common/src/main/java/com/smartjam/common/dto/s3/S3Event.java index 2ffa0d9..ea7139b 100644 --- a/backend/smartjam-common/src/main/java/com/smartjam/common/dto/s3/S3EventDto.java +++ b/backend/smartjam-common/src/main/java/com/smartjam/common/dto/s3/S3Event.java @@ -13,7 +13,7 @@ */ @Builder @JsonIgnoreProperties(ignoreUnknown = true) -public record S3EventDto(@JsonProperty("Records") List records) { +public record S3Event(@JsonProperty("Records") List records) { /** * Record detail. diff --git a/backend/smartjam-common/src/test/java/common/dto/s3/S3EventDtoTest.java b/backend/smartjam-common/src/test/java/common/dto/s3/S3EventTest.java similarity index 90% rename from backend/smartjam-common/src/test/java/common/dto/s3/S3EventDtoTest.java rename to backend/smartjam-common/src/test/java/common/dto/s3/S3EventTest.java index 18e70c3..eafd7d4 100644 --- a/backend/smartjam-common/src/test/java/common/dto/s3/S3EventDtoTest.java +++ b/backend/smartjam-common/src/test/java/common/dto/s3/S3EventTest.java @@ -1,12 +1,12 @@ package common.dto.s3; import com.fasterxml.jackson.databind.ObjectMapper; -import com.smartjam.common.dto.s3.S3EventDto; +import com.smartjam.common.dto.s3.S3Event; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; -class S3EventDtoTest { +class S3EventTest { private final ObjectMapper objectMapper = new ObjectMapper(); @Test @@ -33,7 +33,7 @@ void shouldDeserializeMinioJsonWithExtraFieldsCorrectly() throws Exception { } """; - S3EventDto dto = objectMapper.readValue(json, S3EventDto.class); + S3Event dto = objectMapper.readValue(json, S3Event.class); assertEquals(1, dto.records().size()); assertEquals("references", dto.records().getFirst().s3().bucket().name()); From 1c33bd3c7e46df4e5ca05eac396aaeb59bca168e Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 11 May 2026 00:11:46 +0300 Subject: [PATCH 03/17] feat: add smartjam-notification skeleton with clean architecture, rename smartjam-analyzer module package --- backend/settings.gradle | 3 +- .../SmartjamAnalyzerApplication.java | 2 +- .../api/kafka/S3StorageListener.java | 4 +- .../application/AudioAnalysisUseCase.java | 10 ++--- .../exception/AnalysisFatalException.java | 2 +- .../domain/model/AnalysisResult.java | 2 +- .../domain/model/FeatureSequence.java | 2 +- .../domain/port/AnalysisEventPublisher.java | 2 +- .../domain/port/AudioConverter.java | 2 +- .../domain/port/AudioStorage.java | 2 +- .../domain/port/DebugVisualizer.java | 4 +- .../domain/port/FeatureExtractor.java | 4 +- .../domain/port/PerformanceEvaluator.java | 8 ++++ .../domain/port/ReferenceRepository.java | 4 +- .../domain/port/ResultRepository.java | 4 +- .../domain/port/Workspace.java | 2 +- .../domain/port/WorkspaceFactory.java | 2 +- .../service/DtwPerformanceEvaluator.java | 8 ++-- .../analysis/DspProperties.java | 2 +- .../infrastructure/analysis/DtwConfig.java | 6 +-- .../analysis/TarsosFeatureExtractor.java | 6 +-- .../converter/FFmpegConfig.java | 2 +- .../converter/FfmpegAudioConverter.java | 6 +-- .../KafkaAnalysisEventPublisher.java | 4 +- .../adapter/AssignmentPersistenceAdapter.java | 14 +++--- .../adapter/SubmissionPersistenceAdapter.java | 12 ++--- .../persistence/entity/AssignmentEntity.java | 2 +- .../persistence/entity/SubmissionEntity.java | 2 +- .../repository/JpaAssignmentRepository.java | 4 +- .../repository/JpaSubmissionRepository.java | 4 +- .../storage/MinioAudioStorage.java | 6 +-- .../infrastructure/storage/MinioConfig.java | 2 +- .../utils/FeatureBinarySerializer.java | 4 +- .../infrastructure/utils/FsWorkspace.java | 4 +- .../utils/FsWorkspaceFactory.java | 6 +-- .../visualizer/ImageIoDebugVisualizer.java | 6 +-- .../visualizer/NoOpDebugVisualizer.java | 6 +-- .../domain/port/PerformanceEvaluator.java | 8 ---- .../SmartjamAnalyzerApplicationTests.java | 2 +- .../api/listener/S3StorageListenerTest.java | 6 +-- .../AudioAnalysisIntegrationTest.java | 8 ++-- .../application/AudioAnalysisUseCaseTest.java | 6 +-- .../service/DtwPerformanceEvaluatorTest.java | 6 +-- .../converter/FfmpegAudioConverterTest.java | 4 +- .../storage/MinioAudioStorageTest.java | 4 +- .../utils/FeatureBinarySerializerTest.java | 4 +- .../infrastructure/utils/FsWorkspaceTest.java | 4 +- backend/smartjam-notification/build.gradle | 8 ++++ .../SmartjamNotificationApplication.java | 12 +++++ .../ProcessAnalysisResultUseCase.java | 45 +++++++++++++++++++ .../domain/port/PushPublisher.java | 7 +++ .../domain/port/RecipientResolver.java | 9 ++++ .../domain/port/UiSignalPublisher.java | 9 ++++ .../fcm/DebugLoggingPushAdapter.java | 16 +++++++ .../redis/RedisUiSignalAdapter.java | 33 ++++++++++++++ .../SmartjamNotificationApplicationTests.java | 11 +++++ 56 files changed, 259 insertions(+), 108 deletions(-) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/SmartjamAnalyzerApplication.java (93%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/api/kafka/S3StorageListener.java (95%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/application/AudioAnalysisUseCase.java (95%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/exception/AnalysisFatalException.java (72%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/model/AnalysisResult.java (96%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/model/FeatureSequence.java (97%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/AnalysisEventPublisher.java (75%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/AudioConverter.java (93%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/AudioStorage.java (92%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/DebugVisualizer.java (81%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/FeatureExtractor.java (76%) create mode 100644 backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/PerformanceEvaluator.java rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/ReferenceRepository.java (90%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/ResultRepository.java (91%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/Workspace.java (88%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/port/WorkspaceFactory.java (87%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/service/DtwPerformanceEvaluator.java (97%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/analysis/DspProperties.java (91%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/analysis/DtwConfig.java (82%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/analysis/TarsosFeatureExtractor.java (93%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/converter/FFmpegConfig.java (95%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/converter/FfmpegAudioConverter.java (95%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/messaging/KafkaAnalysisEventPublisher.java (81%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java (77%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java (79%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/persistence/entity/AssignmentEntity.java (94%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/persistence/entity/SubmissionEntity.java (95%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/persistence/repository/JpaAssignmentRepository.java (83%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/persistence/repository/JpaSubmissionRepository.java (86%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/storage/MinioAudioStorage.java (88%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/storage/MinioConfig.java (95%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/utils/FeatureBinarySerializer.java (97%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/utils/FsWorkspace.java (94%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/utils/FsWorkspaceFactory.java (68%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/visualizer/ImageIoDebugVisualizer.java (97%) rename backend/smartjam-analyzer/src/main/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/visualizer/NoOpDebugVisualizer.java (81%) delete mode 100644 backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/PerformanceEvaluator.java rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/SmartjamAnalyzerApplicationTests.java (89%) rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/api/listener/S3StorageListenerTest.java (94%) rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/application/AudioAnalysisIntegrationTest.java (94%) rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/application/AudioAnalysisUseCaseTest.java (96%) rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/domain/service/DtwPerformanceEvaluatorTest.java (98%) rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/converter/FfmpegAudioConverterTest.java (92%) rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/storage/MinioAudioStorageTest.java (94%) rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/utils/FeatureBinarySerializerTest.java (98%) rename backend/smartjam-analyzer/src/test/java/com/smartjam/{smartjamanalyzer => analyzer}/infrastructure/utils/FsWorkspaceTest.java (86%) create mode 100644 backend/smartjam-notification/build.gradle create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/SmartjamNotificationApplication.java create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/UiSignalPublisher.java create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/redis/RedisUiSignalAdapter.java create mode 100644 backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java diff --git a/backend/settings.gradle b/backend/settings.gradle index e355154..a9fddb7 100644 --- a/backend/settings.gradle +++ b/backend/settings.gradle @@ -1,4 +1,5 @@ rootProject.name = 'backend' include 'smartjam-api' include 'smartjam-analyzer' -include 'smartjam-common' \ No newline at end of file +include 'smartjam-common' +include 'smartjam-notification' \ No newline at end of file diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/SmartjamAnalyzerApplication.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/SmartjamAnalyzerApplication.java similarity index 93% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/SmartjamAnalyzerApplication.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/SmartjamAnalyzerApplication.java index 1132726..95fbbf2 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/SmartjamAnalyzerApplication.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/SmartjamAnalyzerApplication.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer; +package com.smartjam.analyzer; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/api/kafka/S3StorageListener.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/api/kafka/S3StorageListener.java similarity index 95% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/api/kafka/S3StorageListener.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/api/kafka/S3StorageListener.java index deddce4..4066d45 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/api/kafka/S3StorageListener.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/api/kafka/S3StorageListener.java @@ -1,10 +1,10 @@ -package com.smartjam.smartjamanalyzer.api.kafka; +package com.smartjam.analyzer.api.kafka; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import com.smartjam.analyzer.application.AudioAnalysisUseCase; import com.smartjam.common.dto.s3.S3Event; -import com.smartjam.smartjamanalyzer.application.AudioAnalysisUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/application/AudioAnalysisUseCase.java similarity index 95% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/application/AudioAnalysisUseCase.java index 1b2297f..507edc9 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCase.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/application/AudioAnalysisUseCase.java @@ -1,15 +1,15 @@ -package com.smartjam.smartjamanalyzer.application; +package com.smartjam.analyzer.application; import java.nio.file.Path; import java.util.UUID; +import com.smartjam.analyzer.domain.exception.AnalysisFatalException; +import com.smartjam.analyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.port.*; import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; import com.smartjam.common.dto.analysis.AnalysisType; import com.smartjam.common.model.AudioProcessingStatus; -import com.smartjam.smartjamanalyzer.domain.exception.AnalysisFatalException; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; -import com.smartjam.smartjamanalyzer.domain.port.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/exception/AnalysisFatalException.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java similarity index 72% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/exception/AnalysisFatalException.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java index 510a6bc..374c0a3 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/exception/AnalysisFatalException.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.domain.exception; +package com.smartjam.analyzer.domain.exception; public class AnalysisFatalException extends RuntimeException { public AnalysisFatalException(String message) { diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/model/AnalysisResult.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/model/AnalysisResult.java similarity index 96% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/model/AnalysisResult.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/model/AnalysisResult.java index 4ddfcef..0ebaa25 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/model/AnalysisResult.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/model/AnalysisResult.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.domain.model; +package com.smartjam.analyzer.domain.model; import java.util.List; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/model/FeatureSequence.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/model/FeatureSequence.java similarity index 97% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/model/FeatureSequence.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/model/FeatureSequence.java index fb23051..9d47640 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/model/FeatureSequence.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/model/FeatureSequence.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.domain.model; +package com.smartjam.analyzer.domain.model; import java.util.List; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AnalysisEventPublisher.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AnalysisEventPublisher.java similarity index 75% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AnalysisEventPublisher.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AnalysisEventPublisher.java index 2c2fe3d..009c394 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AnalysisEventPublisher.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AnalysisEventPublisher.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AudioConverter.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AudioConverter.java similarity index 93% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AudioConverter.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AudioConverter.java index 9eb00b0..4ef48e3 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AudioConverter.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AudioConverter.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; import java.nio.file.Path; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AudioStorage.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AudioStorage.java similarity index 92% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AudioStorage.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AudioStorage.java index 32ddfff..a321494 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/AudioStorage.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AudioStorage.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; import java.nio.file.Path; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/DebugVisualizer.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/DebugVisualizer.java similarity index 81% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/DebugVisualizer.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/DebugVisualizer.java index b53fb8d..d834202 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/DebugVisualizer.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/DebugVisualizer.java @@ -1,6 +1,6 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.model.AnalysisResult; /** * Port for generating visual artifacts of the analysis process. Typically used for debugging and fine-tuning DTW diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/FeatureExtractor.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/FeatureExtractor.java similarity index 76% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/FeatureExtractor.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/FeatureExtractor.java index de8d313..90d6b04 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/FeatureExtractor.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/FeatureExtractor.java @@ -1,8 +1,8 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; import java.nio.file.Path; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.model.FeatureSequence; /** Port for extracting musical features from an audio file. */ public interface FeatureExtractor { diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/PerformanceEvaluator.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/PerformanceEvaluator.java new file mode 100644 index 0000000..ca7bd8e --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/PerformanceEvaluator.java @@ -0,0 +1,8 @@ +package com.smartjam.analyzer.domain.port; + +import com.smartjam.analyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.model.FeatureSequence; + +public interface PerformanceEvaluator { + AnalysisResult evaluate(FeatureSequence reference, FeatureSequence student); +} diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ReferenceRepository.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/ReferenceRepository.java similarity index 90% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ReferenceRepository.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/ReferenceRepository.java index 5826eaa..fd589f0 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ReferenceRepository.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/ReferenceRepository.java @@ -1,10 +1,10 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; import java.util.Optional; import java.util.UUID; +import com.smartjam.analyzer.domain.model.FeatureSequence; import com.smartjam.common.model.AudioProcessingStatus; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; /** Domain port for managing teacher reference features. */ public interface ReferenceRepository { diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ResultRepository.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/ResultRepository.java similarity index 91% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ResultRepository.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/ResultRepository.java index 3a37094..3332eb4 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/ResultRepository.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/ResultRepository.java @@ -1,10 +1,10 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; import java.util.Optional; import java.util.UUID; +import com.smartjam.analyzer.domain.model.AnalysisResult; import com.smartjam.common.model.AudioProcessingStatus; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; /** * Port for managing student submissions and their analysis results. Handles the persistence of evaluation scores and diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/Workspace.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/Workspace.java similarity index 88% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/Workspace.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/Workspace.java index 58210bd..c162e27 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/Workspace.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/Workspace.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; import java.io.IOException; import java.nio.file.Path; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/WorkspaceFactory.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/WorkspaceFactory.java similarity index 87% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/WorkspaceFactory.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/WorkspaceFactory.java index 753633e..78f0bb7 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/WorkspaceFactory.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/WorkspaceFactory.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.domain.port; +package com.smartjam.analyzer.domain.port; /** * Factory port for creating isolated {@link Workspace} instances. This abstraction allows business logic to acquire diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/service/DtwPerformanceEvaluator.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/service/DtwPerformanceEvaluator.java similarity index 97% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/service/DtwPerformanceEvaluator.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/service/DtwPerformanceEvaluator.java index 78e2835..274bcf7 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/service/DtwPerformanceEvaluator.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/service/DtwPerformanceEvaluator.java @@ -1,14 +1,14 @@ -package com.smartjam.smartjamanalyzer.domain.service; +package com.smartjam.analyzer.domain.service; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import com.smartjam.analyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.port.PerformanceEvaluator; import com.smartjam.common.model.FeedbackEvent; import com.smartjam.common.model.FeedbackType; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; -import com.smartjam.smartjamanalyzer.domain.port.PerformanceEvaluator; /** * Performance evaluator using Dynamic Time Warping (DTW) and Cosine Similarity. Provides granular scoring for pitch and diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/DspProperties.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/DspProperties.java similarity index 91% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/DspProperties.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/DspProperties.java index d927cab..8815793 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/DspProperties.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/DspProperties.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.analysis; +package com.smartjam.analyzer.infrastructure.analysis; import jakarta.validation.constraints.Min; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/DtwConfig.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/DtwConfig.java similarity index 82% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/DtwConfig.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/DtwConfig.java index f856f72..4e5de54 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/DtwConfig.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/DtwConfig.java @@ -1,7 +1,7 @@ -package com.smartjam.smartjamanalyzer.infrastructure.analysis; +package com.smartjam.analyzer.infrastructure.analysis; -import com.smartjam.smartjamanalyzer.domain.port.PerformanceEvaluator; -import com.smartjam.smartjamanalyzer.domain.service.DtwPerformanceEvaluator; +import com.smartjam.analyzer.domain.port.PerformanceEvaluator; +import com.smartjam.analyzer.domain.service.DtwPerformanceEvaluator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/TarsosFeatureExtractor.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/TarsosFeatureExtractor.java similarity index 93% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/TarsosFeatureExtractor.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/TarsosFeatureExtractor.java index 9b0d7c7..af4d2bf 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/analysis/TarsosFeatureExtractor.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/analysis/TarsosFeatureExtractor.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.analysis; +package com.smartjam.analyzer.infrastructure.analysis; import java.io.File; import java.nio.file.Path; @@ -10,8 +10,8 @@ import be.tarsos.dsp.AudioProcessor; import be.tarsos.dsp.ConstantQ; import be.tarsos.dsp.io.jvm.AudioDispatcherFactory; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; -import com.smartjam.smartjamanalyzer.domain.port.FeatureExtractor; +import com.smartjam.analyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.port.FeatureExtractor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.EnableConfigurationProperties; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FFmpegConfig.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/converter/FFmpegConfig.java similarity index 95% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FFmpegConfig.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/converter/FFmpegConfig.java index 26138ba..eaa4aa2 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FFmpegConfig.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/converter/FFmpegConfig.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.converter; +package com.smartjam.analyzer.infrastructure.converter; import java.io.IOException; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverter.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/converter/FfmpegAudioConverter.java similarity index 95% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverter.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/converter/FfmpegAudioConverter.java index 1acb8b3..3c6d033 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverter.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/converter/FfmpegAudioConverter.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.converter; +package com.smartjam.analyzer.infrastructure.converter; import java.io.IOException; import java.nio.file.Path; @@ -9,8 +9,8 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; -import com.smartjam.smartjamanalyzer.domain.port.AudioConverter; -import com.smartjam.smartjamanalyzer.domain.port.Workspace; +import com.smartjam.analyzer.domain.port.AudioConverter; +import com.smartjam.analyzer.domain.port.Workspace; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.bramp.ffmpeg.FFmpeg; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java similarity index 81% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java index cffaf37..17e8a90 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java @@ -1,7 +1,7 @@ -package com.smartjam.smartjamanalyzer.infrastructure.messaging; +package com.smartjam.analyzer.infrastructure.messaging; +import com.smartjam.analyzer.domain.port.AnalysisEventPublisher; import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; -import com.smartjam.smartjamanalyzer.domain.port.AnalysisEventPublisher; import lombok.RequiredArgsConstructor; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java similarity index 77% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java index 23014f8..a930158 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java @@ -1,15 +1,15 @@ -package com.smartjam.smartjamanalyzer.infrastructure.persistence.adapter; +package com.smartjam.analyzer.infrastructure.persistence.adapter; import java.util.Optional; import java.util.UUID; +import com.smartjam.analyzer.domain.exception.AnalysisFatalException; +import com.smartjam.analyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.port.ReferenceRepository; +import com.smartjam.analyzer.infrastructure.persistence.entity.AssignmentEntity; +import com.smartjam.analyzer.infrastructure.persistence.repository.JpaAssignmentRepository; +import com.smartjam.analyzer.infrastructure.utils.FeatureBinarySerializer; import com.smartjam.common.model.AudioProcessingStatus; -import com.smartjam.smartjamanalyzer.domain.exception.AnalysisFatalException; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; -import com.smartjam.smartjamanalyzer.domain.port.ReferenceRepository; -import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.AssignmentEntity; -import com.smartjam.smartjamanalyzer.infrastructure.persistence.repository.JpaAssignmentRepository; -import com.smartjam.smartjamanalyzer.infrastructure.utils.FeatureBinarySerializer; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java similarity index 79% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java index d358172..ec7bfd2 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java @@ -1,14 +1,14 @@ -package com.smartjam.smartjamanalyzer.infrastructure.persistence.adapter; +package com.smartjam.analyzer.infrastructure.persistence.adapter; import java.util.Optional; import java.util.UUID; +import com.smartjam.analyzer.domain.exception.AnalysisFatalException; +import com.smartjam.analyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.port.ResultRepository; +import com.smartjam.analyzer.infrastructure.persistence.entity.SubmissionEntity; +import com.smartjam.analyzer.infrastructure.persistence.repository.JpaSubmissionRepository; import com.smartjam.common.model.AudioProcessingStatus; -import com.smartjam.smartjamanalyzer.domain.exception.AnalysisFatalException; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; -import com.smartjam.smartjamanalyzer.domain.port.ResultRepository; -import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.SubmissionEntity; -import com.smartjam.smartjamanalyzer.infrastructure.persistence.repository.JpaSubmissionRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/AssignmentEntity.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/entity/AssignmentEntity.java similarity index 94% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/AssignmentEntity.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/entity/AssignmentEntity.java index 4f27f05..9ac674b 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/AssignmentEntity.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/entity/AssignmentEntity.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.persistence.entity; +package com.smartjam.analyzer.infrastructure.persistence.entity; import java.time.Instant; import java.util.UUID; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/SubmissionEntity.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/entity/SubmissionEntity.java similarity index 95% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/SubmissionEntity.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/entity/SubmissionEntity.java index 01f476b..8857394 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/entity/SubmissionEntity.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/entity/SubmissionEntity.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.persistence.entity; +package com.smartjam.analyzer.infrastructure.persistence.entity; import java.time.Instant; import java.util.List; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java similarity index 83% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java index 3b5185e..43bdb1c 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/repository/JpaAssignmentRepository.java @@ -1,9 +1,9 @@ -package com.smartjam.smartjamanalyzer.infrastructure.persistence.repository; +package com.smartjam.analyzer.infrastructure.persistence.repository; import java.util.UUID; +import com.smartjam.analyzer.infrastructure.persistence.entity.AssignmentEntity; import com.smartjam.common.model.AudioProcessingStatus; -import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.AssignmentEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java similarity index 86% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java index 792d99f..98fe948 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/persistence/repository/JpaSubmissionRepository.java @@ -1,9 +1,9 @@ -package com.smartjam.smartjamanalyzer.infrastructure.persistence.repository; +package com.smartjam.analyzer.infrastructure.persistence.repository; import java.util.UUID; +import com.smartjam.analyzer.infrastructure.persistence.entity.SubmissionEntity; import com.smartjam.common.model.AudioProcessingStatus; -import com.smartjam.smartjamanalyzer.infrastructure.persistence.entity.SubmissionEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioAudioStorage.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/storage/MinioAudioStorage.java similarity index 88% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioAudioStorage.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/storage/MinioAudioStorage.java index 08d3396..b724644 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioAudioStorage.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/storage/MinioAudioStorage.java @@ -1,9 +1,9 @@ -package com.smartjam.smartjamanalyzer.infrastructure.storage; +package com.smartjam.analyzer.infrastructure.storage; import java.nio.file.Path; -import com.smartjam.smartjamanalyzer.domain.port.AudioStorage; -import com.smartjam.smartjamanalyzer.domain.port.Workspace; +import com.smartjam.analyzer.domain.port.AudioStorage; +import com.smartjam.analyzer.domain.port.Workspace; import io.minio.DownloadObjectArgs; import io.minio.MinioClient; import lombok.RequiredArgsConstructor; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioConfig.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/storage/MinioConfig.java similarity index 95% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioConfig.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/storage/MinioConfig.java index 4dbb3af..f288724 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioConfig.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/storage/MinioConfig.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.storage; +package com.smartjam.analyzer.infrastructure.storage; import jakarta.validation.constraints.NotBlank; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializer.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FeatureBinarySerializer.java similarity index 97% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializer.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FeatureBinarySerializer.java index ea982b9..8c99bd9 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializer.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FeatureBinarySerializer.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.utils; +package com.smartjam.analyzer.infrastructure.utils; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -6,7 +6,7 @@ import java.util.List; import java.util.Objects; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.model.FeatureSequence; /** * High-performance binary serializer for spectral feature matrices. Storage format (Little-Endian): [0-3 bytes] - int: diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspace.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspace.java similarity index 94% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspace.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspace.java index 5071bbc..4591fda 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspace.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspace.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.utils; +package com.smartjam.analyzer.infrastructure.utils; import java.io.IOException; import java.nio.file.Files; @@ -6,7 +6,7 @@ import java.util.ArrayList; import java.util.List; -import com.smartjam.smartjamanalyzer.domain.port.Workspace; +import com.smartjam.analyzer.domain.port.Workspace; import lombok.extern.slf4j.Slf4j; /** diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspaceFactory.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspaceFactory.java similarity index 68% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspaceFactory.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspaceFactory.java index f131a8c..74145ce 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspaceFactory.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspaceFactory.java @@ -1,7 +1,7 @@ -package com.smartjam.smartjamanalyzer.infrastructure.utils; +package com.smartjam.analyzer.infrastructure.utils; -import com.smartjam.smartjamanalyzer.domain.port.Workspace; -import com.smartjam.smartjamanalyzer.domain.port.WorkspaceFactory; +import com.smartjam.analyzer.domain.port.Workspace; +import com.smartjam.analyzer.domain.port.WorkspaceFactory; import org.springframework.stereotype.Component; /** File-system based implementation of {@link WorkspaceFactory}. */ diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/visualizer/ImageIoDebugVisualizer.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/visualizer/ImageIoDebugVisualizer.java similarity index 97% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/visualizer/ImageIoDebugVisualizer.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/visualizer/ImageIoDebugVisualizer.java index 8921c32..6cbafc6 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/visualizer/ImageIoDebugVisualizer.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/visualizer/ImageIoDebugVisualizer.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer.infrastructure.visualizer; +package com.smartjam.analyzer.infrastructure.visualizer; import java.awt.*; import java.awt.geom.AffineTransform; @@ -7,8 +7,8 @@ import java.util.Arrays; import javax.imageio.ImageIO; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; -import com.smartjam.smartjamanalyzer.domain.port.DebugVisualizer; +import com.smartjam.analyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.port.DebugVisualizer; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/visualizer/NoOpDebugVisualizer.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/visualizer/NoOpDebugVisualizer.java similarity index 81% rename from backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/visualizer/NoOpDebugVisualizer.java rename to backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/visualizer/NoOpDebugVisualizer.java index 38b73ad..b00a92b 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/infrastructure/visualizer/NoOpDebugVisualizer.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/visualizer/NoOpDebugVisualizer.java @@ -1,7 +1,7 @@ -package com.smartjam.smartjamanalyzer.infrastructure.visualizer; +package com.smartjam.analyzer.infrastructure.visualizer; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; -import com.smartjam.smartjamanalyzer.domain.port.DebugVisualizer; +import com.smartjam.analyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.port.DebugVisualizer; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/PerformanceEvaluator.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/PerformanceEvaluator.java deleted file mode 100644 index 5e79749..0000000 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/smartjamanalyzer/domain/port/PerformanceEvaluator.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.smartjam.smartjamanalyzer.domain.port; - -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; - -public interface PerformanceEvaluator { - AnalysisResult evaluate(FeatureSequence reference, FeatureSequence student); -} diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/SmartjamAnalyzerApplicationTests.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/SmartjamAnalyzerApplicationTests.java similarity index 89% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/SmartjamAnalyzerApplicationTests.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/SmartjamAnalyzerApplicationTests.java index 1b477d8..c46fd7b 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/SmartjamAnalyzerApplicationTests.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/SmartjamAnalyzerApplicationTests.java @@ -1,4 +1,4 @@ -package com.smartjam.smartjamanalyzer; +package com.smartjam.analyzer; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/api/listener/S3StorageListenerTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/api/listener/S3StorageListenerTest.java similarity index 94% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/api/listener/S3StorageListenerTest.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/api/listener/S3StorageListenerTest.java index ad915d8..30eb8f4 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/api/listener/S3StorageListenerTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/api/listener/S3StorageListenerTest.java @@ -1,11 +1,11 @@ -package com.smartjam.smartjamanalyzer.api.listener; +package com.smartjam.analyzer.api.listener; import java.util.Collections; import java.util.List; +import com.smartjam.analyzer.api.kafka.S3StorageListener; +import com.smartjam.analyzer.application.AudioAnalysisUseCase; import com.smartjam.common.dto.s3.S3Event; -import com.smartjam.smartjamanalyzer.api.kafka.S3StorageListener; -import com.smartjam.smartjamanalyzer.application.AudioAnalysisUseCase; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisIntegrationTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisIntegrationTest.java similarity index 94% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisIntegrationTest.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisIntegrationTest.java index 4afbe44..dce6268 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisIntegrationTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisIntegrationTest.java @@ -1,12 +1,12 @@ -package com.smartjam.smartjamanalyzer.application; +package com.smartjam.analyzer.application; import java.nio.file.Files; import java.nio.file.Path; import java.util.stream.Collectors; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; -import com.smartjam.smartjamanalyzer.domain.port.*; +import com.smartjam.analyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.port.*; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCaseTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisUseCaseTest.java similarity index 96% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCaseTest.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisUseCaseTest.java index ec50419..f0b1830 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/application/AudioAnalysisUseCaseTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisUseCaseTest.java @@ -1,12 +1,12 @@ -package com.smartjam.smartjamanalyzer.application; +package com.smartjam.analyzer.application; import java.nio.file.Path; import java.util.List; import java.util.UUID; +import com.smartjam.analyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.port.*; import com.smartjam.common.model.AudioProcessingStatus; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; -import com.smartjam.smartjamanalyzer.domain.port.*; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/domain/service/DtwPerformanceEvaluatorTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/domain/service/DtwPerformanceEvaluatorTest.java similarity index 98% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/domain/service/DtwPerformanceEvaluatorTest.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/domain/service/DtwPerformanceEvaluatorTest.java index eda88e8..bb40671 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/domain/service/DtwPerformanceEvaluatorTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/domain/service/DtwPerformanceEvaluatorTest.java @@ -1,13 +1,13 @@ -package com.smartjam.smartjamanalyzer.domain.service; +package com.smartjam.analyzer.domain.service; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import com.smartjam.analyzer.domain.model.AnalysisResult; +import com.smartjam.analyzer.domain.model.FeatureSequence; import com.smartjam.common.model.FeedbackEvent; import com.smartjam.common.model.FeedbackType; -import com.smartjam.smartjamanalyzer.domain.model.AnalysisResult; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverterTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/converter/FfmpegAudioConverterTest.java similarity index 92% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverterTest.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/converter/FfmpegAudioConverterTest.java index 6561f49..733dbb9 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/converter/FfmpegAudioConverterTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/converter/FfmpegAudioConverterTest.java @@ -1,9 +1,9 @@ -package com.smartjam.smartjamanalyzer.infrastructure.converter; +package com.smartjam.analyzer.infrastructure.converter; import java.io.IOException; import java.nio.file.Path; -import com.smartjam.smartjamanalyzer.domain.port.Workspace; +import com.smartjam.analyzer.domain.port.Workspace; import net.bramp.ffmpeg.FFmpeg; import net.bramp.ffmpeg.FFprobe; import org.junit.jupiter.api.DisplayName; diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioAudioStorageTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/storage/MinioAudioStorageTest.java similarity index 94% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioAudioStorageTest.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/storage/MinioAudioStorageTest.java index 9139cde..52cb658 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/storage/MinioAudioStorageTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/storage/MinioAudioStorageTest.java @@ -1,8 +1,8 @@ -package com.smartjam.smartjamanalyzer.infrastructure.storage; +package com.smartjam.analyzer.infrastructure.storage; import java.nio.file.Path; -import com.smartjam.smartjamanalyzer.infrastructure.utils.FsWorkspace; +import com.smartjam.analyzer.infrastructure.utils.FsWorkspace; import io.minio.DownloadObjectArgs; import io.minio.MinioClient; import org.junit.jupiter.api.Test; diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializerTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/utils/FeatureBinarySerializerTest.java similarity index 98% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializerTest.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/utils/FeatureBinarySerializerTest.java index b12df71..6efd6c9 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FeatureBinarySerializerTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/utils/FeatureBinarySerializerTest.java @@ -1,11 +1,11 @@ -package com.smartjam.smartjamanalyzer.infrastructure.utils; +package com.smartjam.analyzer.infrastructure.utils; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.List; -import com.smartjam.smartjamanalyzer.domain.model.FeatureSequence; +import com.smartjam.analyzer.domain.model.FeatureSequence; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspaceTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspaceTest.java similarity index 86% rename from backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspaceTest.java rename to backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspaceTest.java index 31485bd..08723bc 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/smartjamanalyzer/infrastructure/utils/FsWorkspaceTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/infrastructure/utils/FsWorkspaceTest.java @@ -1,10 +1,10 @@ -package com.smartjam.smartjamanalyzer.infrastructure.utils; +package com.smartjam.analyzer.infrastructure.utils; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import com.smartjam.smartjamanalyzer.domain.port.Workspace; +import com.smartjam.analyzer.domain.port.Workspace; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/backend/smartjam-notification/build.gradle b/backend/smartjam-notification/build.gradle new file mode 100644 index 0000000..aa67bea --- /dev/null +++ b/backend/smartjam-notification/build.gradle @@ -0,0 +1,8 @@ +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-kafka' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation project(':smartjam-common') + + testImplementation 'org.springframework.boot:spring-boot-starter-data-redis-test' + testImplementation 'org.springframework.kafka:spring-kafka-test' +} \ No newline at end of file diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/SmartjamNotificationApplication.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/SmartjamNotificationApplication.java new file mode 100644 index 0000000..07ff4f5 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/SmartjamNotificationApplication.java @@ -0,0 +1,12 @@ +package com.smartjam.notification; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SmartjamNotificationApplication { + + public static void main(String[] args) { + SpringApplication.run(SmartjamNotificationApplication.class, args); + } +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java new file mode 100644 index 0000000..259fa57 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java @@ -0,0 +1,45 @@ +package com.smartjam.notification.application; + +import java.util.UUID; + +import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; +import com.smartjam.common.dto.analysis.AnalysisType; +import com.smartjam.common.model.AudioProcessingStatus; +import com.smartjam.notification.domain.port.PushPublisher; +import com.smartjam.notification.domain.port.RecipientResolver; +import com.smartjam.notification.domain.port.UiSignalPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProcessAnalysisResultUseCase { + private final RecipientResolver recipientResolver; + private final UiSignalPublisher uiSignalPublisher; + private final PushPublisher pushPublisher; + + public void execute(AnalysisFinishedEvent event) { + log.info( + "Processing analysis result for target ID: {}, type: {}, status: {}", + event.targetId(), + event.type(), + event.status()); + + uiSignalPublisher.sendRefreshSignal(event.targetId(), event.type()); + + if (event.status() == AudioProcessingStatus.COMPLETED) { + try { + UUID userId = recipientResolver.findOwnerId(event.targetId(), event.type()); + + String message = (event.type() == AnalysisType.SUBMISSION) + ? "Твоя игра " + "проанализирована! Балл: " + event.totalScore() + : "Твоя запись " + "обработана!"; + pushPublisher.sendPush(userId, message); + } catch (Exception e) { + log.error("Failed to send push notification for {}: {}", event.targetId(), e.getMessage()); + } + } + } +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java new file mode 100644 index 0000000..fd36da3 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java @@ -0,0 +1,7 @@ +package com.smartjam.notification.domain.port; + +import java.util.UUID; + +public interface PushPublisher { + void sendPush(UUID userId, String message); +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java new file mode 100644 index 0000000..3ca2e04 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java @@ -0,0 +1,9 @@ +package com.smartjam.notification.domain.port; + +import java.util.UUID; + +import com.smartjam.common.dto.analysis.AnalysisType; + +public interface RecipientResolver { + UUID findOwnerId(UUID targetId, AnalysisType type); +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/UiSignalPublisher.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/UiSignalPublisher.java new file mode 100644 index 0000000..07258c9 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/UiSignalPublisher.java @@ -0,0 +1,9 @@ +package com.smartjam.notification.domain.port; + +import java.util.UUID; + +import com.smartjam.common.dto.analysis.AnalysisType; + +public interface UiSignalPublisher { + void sendRefreshSignal(UUID targetId, AnalysisType type); +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java new file mode 100644 index 0000000..44d221c --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java @@ -0,0 +1,16 @@ +package com.smartjam.notification.infrastructure.fcm; + +import java.util.UUID; + +import com.smartjam.notification.domain.port.PushPublisher; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class DebugLoggingPushAdapter implements PushPublisher { + @Override + public void sendPush(UUID userId, String message) { + log.info("[MOCK PUSH] Sending to User {}: {}", userId, message); + } +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/redis/RedisUiSignalAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/redis/RedisUiSignalAdapter.java new file mode 100644 index 0000000..392f776 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/redis/RedisUiSignalAdapter.java @@ -0,0 +1,33 @@ +package com.smartjam.notification.infrastructure.redis; + +import java.util.UUID; + +import com.smartjam.common.dto.analysis.AnalysisType; +import com.smartjam.notification.domain.port.UiSignalPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisUiSignalAdapter implements UiSignalPublisher { + + private final StringRedisTemplate redisTemplate; + + private static final String CHANNEL = "ui-updates"; + + @Override + public void sendRefreshSignal(UUID targetId, AnalysisType type) { + String payload = type.name() + ':' + targetId.toString(); + + try { + redisTemplate.convertAndSend(CHANNEL, payload); + log.info("Signal sent to Redis channel [{}]: {}", CHANNEL, payload); + } catch (Exception e) { + log.error("Redis signal failed for {}: {}", payload, e.getMessage()); + throw new RuntimeException("Redis error", e); + } + } +} diff --git a/backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java b/backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java new file mode 100644 index 0000000..7f29720 --- /dev/null +++ b/backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java @@ -0,0 +1,11 @@ +package com.smartjam.notification; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SmartjamNotificationApplicationTests { + + @Test + void contextLoads() {} +} From 8d85e12fd01313eb5e35764c1b30a1d7daeccb90 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 18 May 2026 00:45:00 +0300 Subject: [PATCH 04/17] refactor: refactor: remove redis from notification service, implement jdbc recipient resolver and use case --- .../analyzer/api/kafka/S3StorageListener.java | 4 +-- backend/smartjam-notification/build.gradle | 7 ++++ .../api/kafka/AnalysisResultListener.java | 36 +++++++++++++++++++ .../ProcessAnalysisResultUseCase.java | 4 --- .../domain/port/UiSignalPublisher.java | 9 ----- .../adapter/RecipientPersistenceAdapter.java | 25 +++++++++++++ .../redis/RedisUiSignalAdapter.java | 33 ----------------- 7 files changed, 70 insertions(+), 48 deletions(-) create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java delete mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/UiSignalPublisher.java create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistance/adapter/RecipientPersistenceAdapter.java delete mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/redis/RedisUiSignalAdapter.java diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/api/kafka/S3StorageListener.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/api/kafka/S3StorageListener.java index 4066d45..2395e62 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/api/kafka/S3StorageListener.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/api/kafka/S3StorageListener.java @@ -51,9 +51,9 @@ public void onFileUploaded(S3Event event, Acknowledgment ack) { analysisUseCase.execute(bucket, fileKey); } catch (Exception e) { - log.error("Ошибка при разборе события S3: {}", e.getMessage()); + log.error("Ошибка при разборе события S3: {}", e.getMessage(), e); - throw new RuntimeException(e); + throw e; } } if (ack != null) { diff --git a/backend/smartjam-notification/build.gradle b/backend/smartjam-notification/build.gradle index aa67bea..b87637f 100644 --- a/backend/smartjam-notification/build.gradle +++ b/backend/smartjam-notification/build.gradle @@ -1,6 +1,13 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-kafka' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' + runtimeOnly 'org.postgresql:postgresql' + + implementation 'org.springframework.boot:spring-boot-starter-json' + + implementation project(':smartjam-common') testImplementation 'org.springframework.boot:spring-boot-starter-data-redis-test' diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java new file mode 100644 index 0000000..c7132c1 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java @@ -0,0 +1,36 @@ +package com.smartjam.notification.api.kafka; + +import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; +import com.smartjam.notification.application.ProcessAnalysisResultUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AnalysisResultListener { + private final ProcessAnalysisResultUseCase analysisResultUseCase; + + @KafkaListener( + topics = "analysis-results", + groupId = "smartjam-notification-group", + concurrency = "3", + properties = {"spring.json.value.default.type=com.smartjam.common.dto" + ".analysis.AnalysisFinishedEvent"}) + public void onAnalysisFinished(AnalysisFinishedEvent event, Acknowledgment ack) { + log.info("Received analysis result event from Kafka for ID: {}", event.targetId()); + + try { + analysisResultUseCase.execute(event); + if (ack != null) ack.acknowledge(); + + log.debug("Acknowledged message for ID: {}", event.targetId()); + } catch (Exception e) { + log.error("Failed to process analysis result for ID: {}. Error: {}", event.targetId(), e.getMessage(), e); + + throw e; + } + } +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java index 259fa57..3f0f519 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java @@ -7,7 +7,6 @@ import com.smartjam.common.model.AudioProcessingStatus; import com.smartjam.notification.domain.port.PushPublisher; import com.smartjam.notification.domain.port.RecipientResolver; -import com.smartjam.notification.domain.port.UiSignalPublisher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -17,7 +16,6 @@ @RequiredArgsConstructor public class ProcessAnalysisResultUseCase { private final RecipientResolver recipientResolver; - private final UiSignalPublisher uiSignalPublisher; private final PushPublisher pushPublisher; public void execute(AnalysisFinishedEvent event) { @@ -27,8 +25,6 @@ public void execute(AnalysisFinishedEvent event) { event.type(), event.status()); - uiSignalPublisher.sendRefreshSignal(event.targetId(), event.type()); - if (event.status() == AudioProcessingStatus.COMPLETED) { try { UUID userId = recipientResolver.findOwnerId(event.targetId(), event.type()); diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/UiSignalPublisher.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/UiSignalPublisher.java deleted file mode 100644 index 07258c9..0000000 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/UiSignalPublisher.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.smartjam.notification.domain.port; - -import java.util.UUID; - -import com.smartjam.common.dto.analysis.AnalysisType; - -public interface UiSignalPublisher { - void sendRefreshSignal(UUID targetId, AnalysisType type); -} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistance/adapter/RecipientPersistenceAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistance/adapter/RecipientPersistenceAdapter.java new file mode 100644 index 0000000..73f0ef1 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistance/adapter/RecipientPersistenceAdapter.java @@ -0,0 +1,25 @@ +package com.smartjam.notification.infrastructure.persistance.adapter; + +import java.util.UUID; + +import com.smartjam.common.dto.analysis.AnalysisType; +import com.smartjam.notification.domain.port.RecipientResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecipientPersistenceAdapter implements RecipientResolver { + private final JdbcTemplate jdbcTemplate; + + @Override + public UUID findOwnerId(UUID targetId, AnalysisType type) { + String query = (type == AnalysisType.SUBMISSION) + ? "SELECT student_id FROM submissions " + "WHERE id = ?" + : "SELECT c.teacher_id FROM connections c JOIN assignments a ON a.connection_id =" + + " c.id WHERE a.id = ?"; + + return jdbcTemplate.queryForObject(query, UUID.class, targetId); + } +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/redis/RedisUiSignalAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/redis/RedisUiSignalAdapter.java deleted file mode 100644 index 392f776..0000000 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/redis/RedisUiSignalAdapter.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.smartjam.notification.infrastructure.redis; - -import java.util.UUID; - -import com.smartjam.common.dto.analysis.AnalysisType; -import com.smartjam.notification.domain.port.UiSignalPublisher; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class RedisUiSignalAdapter implements UiSignalPublisher { - - private final StringRedisTemplate redisTemplate; - - private static final String CHANNEL = "ui-updates"; - - @Override - public void sendRefreshSignal(UUID targetId, AnalysisType type) { - String payload = type.name() + ':' + targetId.toString(); - - try { - redisTemplate.convertAndSend(CHANNEL, payload); - log.info("Signal sent to Redis channel [{}]: {}", CHANNEL, payload); - } catch (Exception e) { - log.error("Redis signal failed for {}: {}", payload, e.getMessage()); - throw new RuntimeException("Redis error", e); - } - } -} From 7f46502e1ef88edcec1c6e65c7e3f5ccf78253f8 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 18 May 2026 16:14:40 +0300 Subject: [PATCH 05/17] feat: implement notification service with FCM --- backend/smartjam-notification/build.gradle | 3 ++ .../ProcessAnalysisResultUseCase.java | 9 ++++- .../domain/port/PushPublisher.java | 4 +- .../domain/port/RecipientResolver.java | 2 + .../fcm/DebugLoggingPushAdapter.java | 8 ++-- .../infrastructure/fcm/FcmPushAdapter.java | 37 ++++++++++++++++++ .../infrastructure/fcm/FirebaseConfig.java | 39 +++++++++++++++++++ .../adapter/RecipientPersistenceAdapter.java | 7 +++- 8 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java create mode 100644 backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java rename backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/{persistance => persistence}/adapter/RecipientPersistenceAdapter.java (78%) diff --git a/backend/smartjam-notification/build.gradle b/backend/smartjam-notification/build.gradle index b87637f..66d504a 100644 --- a/backend/smartjam-notification/build.gradle +++ b/backend/smartjam-notification/build.gradle @@ -8,6 +8,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-json' + implementation 'com.google.firebase:firebase-admin:9.9.0' + + implementation project(':smartjam-common') testImplementation 'org.springframework.boot:spring-boot-starter-data-redis-test' diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java index 3f0f519..e4198fb 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java @@ -29,10 +29,17 @@ public void execute(AnalysisFinishedEvent event) { try { UUID userId = recipientResolver.findOwnerId(event.targetId(), event.type()); + String token = recipientResolver.findFcmToken(userId); + + if (token == null || token.isEmpty()) { + log.warn("User {} does not have fcm token, push will not be send", userId); + return; + } + String message = (event.type() == AnalysisType.SUBMISSION) ? "Твоя игра " + "проанализирована! Балл: " + event.totalScore() : "Твоя запись " + "обработана!"; - pushPublisher.sendPush(userId, message); + pushPublisher.sendPush(token, message); } catch (Exception e) { log.error("Failed to send push notification for {}: {}", event.targetId(), e.getMessage()); } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java index fd36da3..b48b4d4 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java @@ -1,7 +1,5 @@ package com.smartjam.notification.domain.port; -import java.util.UUID; - public interface PushPublisher { - void sendPush(UUID userId, String message); + void sendPush(String fcmToken, String message); } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java index 3ca2e04..2648ef4 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java @@ -6,4 +6,6 @@ public interface RecipientResolver { UUID findOwnerId(UUID targetId, AnalysisType type); + + String findFcmToken(UUID userId); } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java index 44d221c..2ae8aee 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java @@ -1,16 +1,16 @@ package com.smartjam.notification.infrastructure.fcm; -import java.util.UUID; - import com.smartjam.notification.domain.port.PushPublisher; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @Slf4j @Component +@Profile("debug") public class DebugLoggingPushAdapter implements PushPublisher { @Override - public void sendPush(UUID userId, String message) { - log.info("[MOCK PUSH] Sending to User {}: {}", userId, message); + public void sendPush(String fcmToken, String message) { + log.info("[MOCK PUSH] Sending to User {}: {}", fcmToken, message); } } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java new file mode 100644 index 0000000..2ccd876 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java @@ -0,0 +1,37 @@ +package com.smartjam.notification.infrastructure.fcm; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import com.smartjam.notification.domain.port.PushPublisher; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@Profile("!debug") +public class FcmPushAdapter implements PushPublisher { + + @Override + public void sendPush(String fcmToken, String messageText) { + try { + Notification notification = Notification.builder() + .setTitle("SmartJam") + .setBody(messageText) + .build(); + + Message message = Message.builder() + .setToken(fcmToken) + .setNotification(notification) + .build(); + + String response = FirebaseMessaging.getInstance().send(message); + + log.info("Successfully sent push notification. Firebase response: {}", response); + + } catch (Exception e) { + log.error("Firebase cloud messaging error for token {}: {}", fcmToken, e.getMessage(), e); + } + } +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java new file mode 100644 index 0000000..49135eb --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java @@ -0,0 +1,39 @@ +package com.smartjam.notification.infrastructure.fcm; + +import java.io.InputStream; + +import jakarta.annotation.PostConstruct; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; + +@Slf4j +@Configuration +public class FirebaseConfig { + @PostConstruct + public void init() { + try { + InputStream serviceAccount = getClass().getClassLoader().getResourceAsStream("firebase-adminsdk.json"); + + if (serviceAccount == null) { + log.warn("firebase-adminsdk.json file not found. Push notifications will not " + "work!"); + + return; + } + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + log.info("Firebase Admin SDK initialized successfully"); + } + } catch (Exception e) { + log.error("Error during Firebase initialization: {}", e.getMessage(), e); + } + } +} diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistance/adapter/RecipientPersistenceAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java similarity index 78% rename from backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistance/adapter/RecipientPersistenceAdapter.java rename to backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java index 73f0ef1..bfb1ddd 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistance/adapter/RecipientPersistenceAdapter.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java @@ -1,4 +1,4 @@ -package com.smartjam.notification.infrastructure.persistance.adapter; +package com.smartjam.notification.infrastructure.persistence.adapter; import java.util.UUID; @@ -22,4 +22,9 @@ public UUID findOwnerId(UUID targetId, AnalysisType type) { return jdbcTemplate.queryForObject(query, UUID.class, targetId); } + + @Override + public String findFcmToken(UUID userId) { + return jdbcTemplate.queryForObject("SELECT fcm_token FROM users WHERE id = ?", String.class, userId); + } } From 06c9a57360d8150f2da77baffe150f0d4b2ad144 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 18 May 2026 17:46:51 +0300 Subject: [PATCH 06/17] feat: verify notification is working via test web app, update .gitignore --- .gitignore | 4 ++- .../infrastructure/fcm/FirebaseConfig.java | 2 ++ .../src/main/resources/application.yaml | 31 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 backend/smartjam-notification/src/main/resources/application.yaml diff --git a/.gitignore b/.gitignore index 7c496e5..7d77b5f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ *.iml *.iws -.vscode/ \ No newline at end of file +.vscode/ + +firebase-adminsdk.json \ No newline at end of file diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java index 49135eb..a53511a 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java @@ -9,9 +9,11 @@ import com.google.firebase.FirebaseOptions; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; @Slf4j @Configuration +@Profile("!debug") public class FirebaseConfig { @PostConstruct public void init() { diff --git a/backend/smartjam-notification/src/main/resources/application.yaml b/backend/smartjam-notification/src/main/resources/application.yaml new file mode 100644 index 0000000..2e4ceab --- /dev/null +++ b/backend/smartjam-notification/src/main/resources/application.yaml @@ -0,0 +1,31 @@ +spring: + profiles: + active: prod + application: + name: smartjam-notification + threads: + virtual: + enabled: true + + + datasource: + url: jdbc:postgresql://localhost:5432/smartjam + username: admin + password: admin + driver-class-name: org.postgresql.Driver + + + kafka: + bootstrap-servers: localhost:29092 + + consumer: + group-id: smartjam-notification-group + auto-offset-reset: earliest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JacksonJsonDeserializer + properties: + spring.json.trusted.packages: "*" + + enable-auto-commit: false + listener: + ack-mode: manual_immediate \ No newline at end of file From b12affddfad71d080d38e50848e16d931daa4384 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Tue, 19 May 2026 19:16:40 +0300 Subject: [PATCH 07/17] docs: add javadocs and refine logging security --- .../domain/exception/AnalysisFatalException.java | 4 ++++ .../notification/api/kafka/AnalysisResultListener.java | 8 +++++++- .../application/ProcessAnalysisResultUseCase.java | 6 +++++- .../smartjam/notification/domain/port/PushPublisher.java | 9 +++++++++ .../notification/domain/port/RecipientResolver.java | 6 ++++++ .../infrastructure/fcm/DebugLoggingPushAdapter.java | 7 ++++++- .../notification/infrastructure/fcm/FcmPushAdapter.java | 9 ++++++++- .../notification/infrastructure/fcm/FirebaseConfig.java | 4 ++++ .../persistence/adapter/RecipientPersistenceAdapter.java | 4 ++++ 9 files changed, 53 insertions(+), 4 deletions(-) diff --git a/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java index 374c0a3..c4c5ed0 100644 --- a/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java @@ -1,5 +1,9 @@ package com.smartjam.analyzer.domain.exception; +/** + * Exception thrown when a non-recoverable error occurs during audio analysis. Indicates that the process cannot be + * successfully retried (e.g., missing metadata in DB). + */ public class AnalysisFatalException extends RuntimeException { public AnalysisFatalException(String message) { super(message); diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java index c7132c1..5bf9835 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java @@ -8,6 +8,10 @@ import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; +/** + * Inbound Kafka adapter that listens for analysis completion events. Coordinates the notification process by triggering + * the corresponding use case. Uses manual acknowledgment to ensure "At-least-once" delivery semantics. + */ @Slf4j @Component @RequiredArgsConstructor @@ -18,7 +22,9 @@ public class AnalysisResultListener { topics = "analysis-results", groupId = "smartjam-notification-group", concurrency = "3", - properties = {"spring.json.value.default.type=com.smartjam.common.dto" + ".analysis.AnalysisFinishedEvent"}) + properties = { + "spring.json.value.default.type=com.smartjam.common.dto" + ".analysis" + ".AnalysisFinishedEvent" + }) public void onAnalysisFinished(AnalysisFinishedEvent event, Acknowledgment ack) { log.info("Received analysis result event from Kafka for ID: {}", event.targetId()); diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java index e4198fb..951d664 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java @@ -11,6 +11,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +/** + * Core application service (orchestrator) for handling analysis results. Responsible for resolving the recipient's + * identity and triggering notification delivery through various channels (e.g., Push notifications). + */ @Slf4j @Service @RequiredArgsConstructor @@ -32,7 +36,7 @@ public void execute(AnalysisFinishedEvent event) { String token = recipientResolver.findFcmToken(userId); if (token == null || token.isEmpty()) { - log.warn("User {} does not have fcm token, push will not be send", userId); + log.warn("User {} does not have fcm token, push will not be sent", userId); return; } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java index b48b4d4..d4e70a8 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java @@ -1,5 +1,14 @@ package com.smartjam.notification.domain.port; +/** + * Outbound port for sending push notifications. Abstracts the underlying delivery mechanism (like Firebase or APNs). + */ public interface PushPublisher { + /** + * Sends a push notification to a specific device. + * + * @param fcmToken The target device's registration token. + * @param message The text content of the notification. + */ void sendPush(String fcmToken, String message); } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java index 2648ef4..732a216 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java @@ -4,8 +4,14 @@ import com.smartjam.common.dto.analysis.AnalysisType; +/** + * Outbound port for looking up notification recipients. Links technical entity IDs (assignments/submissions) to real + * users and their devices. + */ public interface RecipientResolver { + /** Finds the owner ID for the given target (Student or Teacher). */ UUID findOwnerId(UUID targetId, AnalysisType type); + /** Retrieves the FCM registration token for a specific user. */ String findFcmToken(UUID userId); } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java index 2ae8aee..f12384b 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java @@ -5,12 +5,17 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +/** + * Mock implementation of {@link PushPublisher} for local development and testing. Redirects notifications to the + * application logs instead of sending real requests to Google. Active only when the 'debug' profile is enabled. + */ @Slf4j @Component @Profile("debug") public class DebugLoggingPushAdapter implements PushPublisher { @Override public void sendPush(String fcmToken, String message) { - log.info("[MOCK PUSH] Sending to User {}: {}", fcmToken, message); + String maskedToken = (fcmToken != null && fcmToken.length() > 10) ? fcmToken.substring(0, 10) + "..." : "***"; + log.info("[MOCK PUSH] Sending to User {}: {}", maskedToken, message); } } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java index 2ccd876..d9b397b 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java @@ -8,6 +8,10 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +/** + * Production implementation of {@link PushPublisher} using Firebase Cloud Messaging (FCM). Communicates with Google's + * Firebase Admin SDK to deliver real-time notifications. Active under any profile except 'debug'. + */ @Component @Slf4j @Profile("!debug") @@ -31,7 +35,10 @@ public void sendPush(String fcmToken, String messageText) { log.info("Successfully sent push notification. Firebase response: {}", response); } catch (Exception e) { - log.error("Firebase cloud messaging error for token {}: {}", fcmToken, e.getMessage(), e); + + String maskedToken = + (fcmToken != null && fcmToken.length() > 10) ? fcmToken.substring(0, 10) + "..." : "***"; + log.error("Firebase cloud messaging error for token {}: {}", maskedToken, e.getMessage(), e); } } } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java index a53511a..da5a384 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java @@ -11,6 +11,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +/** + * Configuration class responsible for initializing the Firebase Admin SDK. Loads security credentials from the + * 'firebase-adminsdk.json' resource file. + */ @Slf4j @Configuration @Profile("!debug") diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java index bfb1ddd..04d36ec 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java @@ -8,6 +8,10 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +/** + * Persistence adapter implementing {@link RecipientResolver} using high-performance JDBC queries. Bypasses full ORM + * overhead for lightweight data retrieval. + */ @Component @RequiredArgsConstructor public class RecipientPersistenceAdapter implements RecipientResolver { From b41ea48a80977e4bda88f4e8c6e4f026233df9e4 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Tue, 19 May 2026 19:21:47 +0300 Subject: [PATCH 08/17] chore: Temporarily disable Spring Boot Test --- .../notification/SmartjamNotificationApplicationTests.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java b/backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java index 7f29720..aab081c 100644 --- a/backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java +++ b/backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java @@ -1,8 +1,10 @@ package com.smartjam.notification; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +@Disabled("TODO: Enable after configuring Testcontainers for Postgres/Kafka in CI/CD") @SpringBootTest class SmartjamNotificationApplicationTests { From 965186701415d2b3b96b83ddcbdeecf52b5d7420 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Tue, 19 May 2026 19:38:00 +0300 Subject: [PATCH 09/17] fix: fix test logic --- .../application/AudioAnalysisUseCaseTest.java | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisUseCaseTest.java b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisUseCaseTest.java index f0b1830..b131401 100644 --- a/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisUseCaseTest.java +++ b/backend/smartjam-analyzer/src/test/java/com/smartjam/analyzer/application/AudioAnalysisUseCaseTest.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.UUID; +import com.smartjam.analyzer.domain.exception.AnalysisFatalException; import com.smartjam.analyzer.domain.model.FeatureSequence; import com.smartjam.analyzer.domain.port.*; import com.smartjam.common.model.AudioProcessingStatus; @@ -49,6 +50,9 @@ class AudioAnalysisUseCaseTest { @Mock private ResultRepository resultRepository; + @Mock + private AnalysisEventPublisher eventPublisher; + @Mock private DebugVisualizer debugVisualizer; @@ -71,31 +75,36 @@ void shouldProcessInOrder() { useCase.execute(bucket, fileKey); - InOrder inOrder = inOrder(referenceRepository, storage, converter, featureExtractor); + InOrder inOrder = inOrder(referenceRepository, storage, converter, featureExtractor, eventPublisher); inOrder.verify(referenceRepository).updateStatus(VALID_UUID, AudioProcessingStatus.ANALYZING, null); - inOrder.verify(storage).downloadAudioFile(eq(bucket), eq(fileKey), any()); inOrder.verify(converter).convertToStandardWav(eq(mockPath), any()); - inOrder.verify(featureExtractor).extract(mockWav); inOrder.verify(referenceRepository).save(VALID_UUID, mockSeq); + inOrder.verify(eventPublisher).publish(any()); } @Test - @DisplayName("UseCase должен бросать ошибку, если конвертация зависла и писать FAILED в БД") + @DisplayName("UseCase должен бросать ошибку ретрая, если конвертация зависла") void shouldThrowExceptionWhenConverterTimesOut() { when(workspaceFactory.create()).thenReturn(workspace); when(storage.downloadAudioFile(any(), any(), any())).thenReturn(Path.of("input")); - when(converter.convertToStandardWav(any(), any())).thenThrow(new RuntimeException("FFmpeg timeout exceeded")); - assertThrows(RuntimeException.class, () -> useCase.execute("submissions", VALID_UUID_STR)); + String originalError = "FFmpeg timeout exceeded"; + when(converter.convertToStandardWav(any(), any())).thenThrow(new RuntimeException(originalError)); - verify(resultRepository).updateStatus(VALID_UUID, AudioProcessingStatus.FAILED, "FFmpeg timeout exceeded"); + RuntimeException exception = + assertThrows(RuntimeException.class, () -> useCase.execute("submissions", VALID_UUID_STR)); + + assertTrue(exception.getMessage().contains("Technical failure")); + verify(resultRepository).updateStatus(VALID_UUID, AudioProcessingStatus.FAILED, originalError); + + verifyNoInteractions(eventPublisher); } @Test - @DisplayName("UseCase должен оборачивать ошибку скачивания в свою ошибку") + @DisplayName("UseCase должен пробрасывать техническую ошибку в RuntimeException для ретрая") void shouldWrapStorageException() { String errorMessage = "MinIO is down"; @@ -105,10 +114,22 @@ void shouldWrapStorageException() { RuntimeException exception = assertThrows(RuntimeException.class, () -> useCase.execute("references", VALID_UUID_STR)); - assertTrue(exception.getMessage().contains("Business logic failed")); - assertEquals(errorMessage, exception.getCause().getMessage()); - + assertTrue(exception.getMessage().contains("Technical failure")); verify(referenceRepository).updateStatus(VALID_UUID, AudioProcessingStatus.FAILED, errorMessage); + + verifyNoInteractions(eventPublisher); + } + + @Test + @DisplayName("UseCase должен отправить FAILED в Кафку и НЕ ретраить при фатальной ошибке") + void shouldHandleFatalExceptionWithoutRetry() { + when(workspaceFactory.create()).thenReturn(workspace); + when(storage.downloadAudioFile(any(), any(), any())).thenThrow(new AnalysisFatalException("Metadata missing")); + + assertDoesNotThrow(() -> useCase.execute("references", VALID_UUID_STR)); + + verify(referenceRepository).updateStatus(eq(VALID_UUID), eq(AudioProcessingStatus.FAILED), anyString()); + verify(eventPublisher, times(1)).publish(any()); } @Test From f39ebe28ce9f55bd23fd62242dc6d574ae047b7d Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Sat, 23 May 2026 23:25:53 +0300 Subject: [PATCH 10/17] feat: update OpenAPI spec and DB schema for multi-device push support --- .../changelog/changes/04-add-user-devices.sql | 15 ++++++ .../db/changelog/db.changelog-master.yaml | 4 +- openapi-spec/api.yaml | 53 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 backend/smartjam-common/src/main/resources/db/changelog/changes/04-add-user-devices.sql diff --git a/backend/smartjam-common/src/main/resources/db/changelog/changes/04-add-user-devices.sql b/backend/smartjam-common/src/main/resources/db/changelog/changes/04-add-user-devices.sql new file mode 100644 index 0000000..81c4b6e --- /dev/null +++ b/backend/smartjam-common/src/main/resources/db/changelog/changes/04-add-user-devices.sql @@ -0,0 +1,15 @@ +-- liquibase formatted sql + +-- changeset sanjar:17 +ALTER TABLE users DROP COLUMN IF EXISTS fcm_token; + +-- changeset sanjar:18 +CREATE TABLE user_devices ( + fcm_token VARCHAR(255) PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_type VARCHAR(50) DEFAULT 'ANDROID', + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- changeset sanjar:19 +CREATE INDEX idx_user_devices_user_id ON user_devices(user_id); \ No newline at end of file diff --git a/backend/smartjam-common/src/main/resources/db/changelog/db.changelog-master.yaml b/backend/smartjam-common/src/main/resources/db/changelog/db.changelog-master.yaml index 6c9981a..7186684 100644 --- a/backend/smartjam-common/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/backend/smartjam-common/src/main/resources/db/changelog/db.changelog-master.yaml @@ -4,4 +4,6 @@ databaseChangeLog: - include: file: db/changelog/changes/02-add-indexes.sql - include: - file: db/changelog/changes/03-add-missing-audit-columns.sql \ No newline at end of file + file: db/changelog/changes/03-add-missing-audit-columns.sql + - include: + file: db/changelog/changes/04-add-user-devices.sql \ No newline at end of file diff --git a/openapi-spec/api.yaml b/openapi-spec/api.yaml index 28acc80..c30de68 100644 --- a/openapi-spec/api.yaml +++ b/openapi-spec/api.yaml @@ -114,6 +114,45 @@ paths: $ref: '#/components/schemas/ErrorResponse' + /api/v1/devices/register: + post: + tags: + - Devices + summary: Register device + description: Links the FCM token of the current device to the user account. + operationId: registerDevice + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceRegistrationRequest' + responses: + '200': + description: Device registered successfully. + '401': + $ref: '#/components/responses/UnauthorizedError' + + /api/v1/devices/unregister: + post: + tags: + - Devices + summary: Unregister device + description: Удаляет привязку FCM токена. Вызывается при логауте. + operationId: unregisterDevice + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceRegistrationRequest' + responses: + '204': + description: Device unregistered successfully. + '401': + $ref: '#/components/responses/UnauthorizedError' + + /api/v1/users/me: get: tags: @@ -131,6 +170,9 @@ paths: '401': $ref: '#/components/responses/UnauthorizedError' + + + /api/v1/connections/invite: post: tags: @@ -773,6 +815,17 @@ components: items: $ref: './common-models.yaml#/components/schemas/FeedbackEvent' + DeviceRegistrationRequest: + type: object + required: + - token + properties: + token: + type: string + example: "bk3rn1...v2" + description: "FCM registration token from Firebase SDK" + + ErrorResponse: type: object description: Standardized error response returned by the API. From a549f75aabd8c1e4eaa748f545c2db126f05cb31 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 25 May 2026 14:06:09 +0300 Subject: [PATCH 11/17] feat: implement an API side of push-notification system. --- .../controller/DevicesController.java | 35 ++++++++++++ .../smartjamapi/entity/DeviceEntity.java | 39 +++++++++++++ .../smartjamapi/enums/DeviceType.java | 11 ++++ .../repository/DeviceRepository.java | 19 +++++++ .../smartjamapi/service/DeviceService.java | 55 +++++++++++++++++++ openapi-spec/api.yaml | 2 +- 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/DevicesController.java create mode 100644 backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/DeviceEntity.java create mode 100644 backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/DeviceType.java create mode 100644 backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/DeviceRepository.java create mode 100644 backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/DeviceService.java diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/DevicesController.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/DevicesController.java new file mode 100644 index 0000000..b07812c --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/controller/DevicesController.java @@ -0,0 +1,35 @@ +package com.smartjam.smartjamapi.controller; + +import com.smartjam.api.api.DevicesApi; +import com.smartjam.api.model.DeviceRegistrationRequest; +import com.smartjam.smartjamapi.service.DeviceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller implementing the {@link DevicesApi} interface generated from the OpenAPI specification. Provides endpoints + * for mobile clients to manage their notification tokens. + */ +@Slf4j +@RestController +@RequiredArgsConstructor +public class DevicesController implements DevicesApi { + + private final DeviceService deviceService; + + @Override + public ResponseEntity registerDevice(DeviceRegistrationRequest body) { + log.info("Request to register device received"); + deviceService.register(body.token()); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity unregisterDevice(DeviceRegistrationRequest body) { + log.info("Request to unregister device received"); + deviceService.unregister(body.token()); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/DeviceEntity.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/DeviceEntity.java new file mode 100644 index 0000000..6c66464 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/DeviceEntity.java @@ -0,0 +1,39 @@ +package com.smartjam.smartjamapi.entity; + +import java.util.UUID; + +import jakarta.persistence.*; + +import com.smartjam.smartjamapi.enums.DeviceType; +import lombok.*; + +/** + * JPA entity representing a user's device registration. Stores the FCM (Firebase Cloud Messaging) token used to deliver + * push notifications. + */ +@Entity +@Table(name = "user_devices") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceEntity { + + /** Unique registration token provided by the Firebase SDK. Acts as the Primary Key. */ + @Id + @Column(name = "fcm_token", nullable = false) + private String fcmToken; + + /** Unique identifier of the user who owns this device. */ + @Column(name = "user_id", nullable = false) + private UUID userId; + + /** Type of the device (e.g., ANDROID, IOS, WEB). Defaults to {@link DeviceType#ANDROID}. */ + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "device_type", length = 50) + private DeviceType deviceType = DeviceType.ANDROID; // Just because we have only android app + // right now + +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/DeviceType.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/DeviceType.java new file mode 100644 index 0000000..cb813d7 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/enums/DeviceType.java @@ -0,0 +1,11 @@ +package com.smartjam.smartjamapi.enums; + +/** + * Supported client device types. Used to differentiate notification delivery strategies(in future. Right it's not such + * useful =)). + */ +public enum DeviceType { + ANDROID, + IOS, + WEB +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/DeviceRepository.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/DeviceRepository.java new file mode 100644 index 0000000..15c5a13 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/repository/DeviceRepository.java @@ -0,0 +1,19 @@ +package com.smartjam.smartjamapi.repository; + +import java.util.UUID; + +import com.smartjam.smartjamapi.entity.DeviceEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Repository interface for managing {@link DeviceEntity} persistence. */ +public interface DeviceRepository extends JpaRepository { + + /** + * Safely deletes a device registration record. Verification by both token and userId prevents unauthorized removal + * of tokens. + * + * @param fcmToken the unique Firebase token to remove + * @param userId the ID of the user who should own this token + */ + void deleteByFcmTokenAndUserId(String fcmToken, UUID userId); +} diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/DeviceService.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/DeviceService.java new file mode 100644 index 0000000..77feb39 --- /dev/null +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/service/DeviceService.java @@ -0,0 +1,55 @@ +package com.smartjam.smartjamapi.service; + +import java.util.UUID; + +import jakarta.transaction.Transactional; + +import com.smartjam.smartjamapi.entity.DeviceEntity; +import com.smartjam.smartjamapi.repository.DeviceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +/** + * Service layer responsible for business logic related to device management. Coordinates registration and + * unregistration of notification tokens. + */ +@Service +@RequiredArgsConstructor +public class DeviceService { + private final DeviceRepository deviceRepository; + + /** + * Registers a new device token or updates an existing one for the current user. Uses the "Last Device Wins" + * strategy for token-user mapping. + * + * @param fcmToken the registration token received from the mobile client + */ + @Transactional + public void register(String fcmToken) { + UUID userId = getCurrentUserId(); + + DeviceEntity device = + DeviceEntity.builder().fcmToken(fcmToken).userId(userId).build(); + + deviceRepository.save(device); + } + + /** + * Removes a device registration, effectively disabling push notifications for that device. Usually called when the + * user logs out. + * + * @param fcmToken the token to be invalidated + */ + @Transactional + public void unregister(String fcmToken) { + UUID userId = getCurrentUserId(); + deviceRepository.deleteByFcmTokenAndUserId(fcmToken, userId); + } + + private UUID getCurrentUserId() { + String userIdStr = + SecurityContextHolder.getContext().getAuthentication().getName(); + return UUID.fromString(userIdStr); + } +} diff --git a/openapi-spec/api.yaml b/openapi-spec/api.yaml index c30de68..9c26b40 100644 --- a/openapi-spec/api.yaml +++ b/openapi-spec/api.yaml @@ -138,7 +138,7 @@ paths: tags: - Devices summary: Unregister device - description: Удаляет привязку FCM токена. Вызывается при логауте. + description: Removes FCM token link for user. Call it during user's logout. operationId: unregisterDevice requestBody: required: true From f1df19c085ebadf2757e6a39d9260cc2132cb1b1 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 25 May 2026 14:24:16 +0300 Subject: [PATCH 12/17] fix: add constraints to api.yaml --- openapi-spec/api.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openapi-spec/api.yaml b/openapi-spec/api.yaml index 9c26b40..5c6b11e 100644 --- a/openapi-spec/api.yaml +++ b/openapi-spec/api.yaml @@ -822,6 +822,8 @@ components: properties: token: type: string + minLength: 1 + pattern: '.*\S.*' example: "bk3rn1...v2" description: "FCM registration token from Firebase SDK" From c16b761663257fdf4822a0f943b7eb94bcec465f Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 25 May 2026 20:26:20 +0300 Subject: [PATCH 13/17] feat: integrate Firebase FCM and device registration into the mobile app. Add spotless & ktfmt code formatting # Conflicts: # mobile/app/build.gradle.kts # mobile/app/src/main/AndroidManifest.xml # mobile/app/src/main/java/com/smartjam/app/MainActivity.kt # mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt # mobile/app/src/main/java/com/smartjam/app/data/local/SmartJamDatabase.kt # mobile/app/src/main/java/com/smartjam/app/data/local/TokenStorage.kt # mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt # mobile/app/src/main/java/com/smartjam/app/data/model/CommentModels.kt # mobile/app/src/main/java/com/smartjam/app/data/model/ConnectionModels.kt # mobile/app/src/main/java/com/smartjam/app/data/model/LoginRequest.kt # mobile/app/src/main/java/com/smartjam/app/data/model/LoginResponse.kt # mobile/app/src/main/java/com/smartjam/app/data/model/RefreshRequest.kt # mobile/app/src/main/java/com/smartjam/app/data/model/RegisterRequest.kt # mobile/app/src/main/java/com/smartjam/app/data/model/TaskModels.kt # mobile/app/src/main/java/com/smartjam/app/domain/model/Connection.kt # mobile/app/src/main/java/com/smartjam/app/domain/repository/AuthRepository.kt # mobile/app/src/main/java/com/smartjam/app/domain/repository/ConnectionRepository.kt # mobile/app/src/main/java/com/smartjam/app/domain/repository/RoomRepository.kt # mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt # mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeScreen.kt # mobile/app/src/main/java/com/smartjam/app/ui/screens/home/HomeViewModel.kt # mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginScreen.kt # mobile/app/src/main/java/com/smartjam/app/ui/screens/login/LoginViewModel.kt # mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomScreen.kt # mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomViewModel.kt --- .github/workflows/mobile-ci.yml | 6 +- .gitignore | 3 +- mobile/app/build.gradle.kts | 38 +++-- .../smartjam/app/ExampleInstrumentedTest.kt | 8 +- mobile/app/src/main/AndroidManifest.xml | 12 +- .../java/com/smartjam/app/MainActivity.kt | 45 +++--- .../smartjam/app/data/api/FcmPushService.kt | 60 ++++++++ .../com/smartjam/app/data/local/Converters.kt | 29 +--- .../app/data/local/dao/ConnectionDao.kt | 3 +- .../com/smartjam/app/data/model/LoginState.kt | 6 +- .../com/smartjam/app/domain/model/UserRole.kt | 4 +- .../app/domain/repository/AuthRepository.kt | 57 +++++--- .../smartjam/app/ui/components/Backgrounds.kt | 43 +++--- .../com/smartjam/app/ui/components/Buttons.kt | 138 +++++++++--------- .../smartjam/app/ui/components/Containers.kt | 12 +- .../smartjam/app/ui/components/TextFields.kt | 43 +++--- .../app/ui/screens/home/HomeScreen.kt | 17 ++- .../app/ui/screens/register/RegisterScreen.kt | 130 ++++++++--------- .../ui/screens/register/RegisterViewModel.kt | 39 +++-- .../java/com/smartjam/app/ui/theme/Color.kt | 1 - .../java/com/smartjam/app/ui/theme/Theme.kt | 64 ++++---- .../java/com/smartjam/app/ui/theme/Type.kt | 20 +-- .../java/com/smartjam/app/ExampleUnitTest.kt | 5 +- mobile/build.gradle.kts | 20 +++ mobile/gradle/libs.versions.toml | 43 +++++- .../gradle/wrapper/gradle-wrapper.properties | 4 +- 26 files changed, 493 insertions(+), 357 deletions(-) create mode 100644 mobile/app/src/main/java/com/smartjam/app/data/api/FcmPushService.kt diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index cb16e5e..9de8ac4 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -21,10 +21,10 @@ jobs: - name: Checkout Repository uses: actions/checkout@v5 - - name: Setup Java 17 + - name: Setup Java 25 uses: actions/setup-java@v5 with: - java-version: '17' + java-version: '25' distribution: 'temurin' cache: 'gradle' @@ -33,4 +33,4 @@ jobs: - name: Build Android App - run: ./gradlew assembleDebug lintDebug testDebugUnitTest --no-daemon \ No newline at end of file + run: ./gradlew spotlessCheck assembleDebug lintDebug testDebugUnitTest --no-daemon \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7d77b5f..88adf4f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ .vscode/ -firebase-adminsdk.json \ No newline at end of file +firebase-adminsdk.json +google-services.json \ No newline at end of file diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index 44023e8..a032027 100644 --- a/mobile/app/build.gradle.kts +++ b/mobile/app/build.gradle.kts @@ -3,7 +3,10 @@ plugins { alias(libs.plugins.kotlin.compose) id("com.google.devtools.ksp") - id("org.openapi.generator") version "7.21.0" + id("org.openapi.generator") version "7.22.0" + + alias(libs.plugins.google.services) + } android { @@ -65,32 +68,31 @@ android { dependencies { implementation(libs.androidx.room.common.jvm) - val nav_version = "2.9.7" // Jetpack Compose integration - implementation("androidx.navigation:navigation-compose:$nav_version+") + implementation(libs.androidx.navigation.compose) //network - implementation("com.squareup.retrofit2:retrofit:2.11.+") - implementation("com.squareup.okhttp3:okhttp:4.12.+") + implementation(libs.retrofit) + implementation(libs.okhttp) //serialization - implementation("com.squareup.retrofit2:converter-gson:2.11.+") + implementation(libs.converter.gson) - implementation("androidx.room:room-runtime:2.7.0-alpha11") - implementation("androidx.room:room-ktx:2.7.0-alpha11") - ksp("androidx.room:room-compiler:2.7.0-alpha11") + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) //logging - implementation("com.squareup.okhttp3:logging-interceptor:4.12.+") + implementation(libs.logging.interceptor) //database - implementation("androidx.datastore:datastore-preferences:1.1.+") + implementation(libs.androidx.datastore.preferences) //coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.+") + implementation(libs.kotlinx.coroutines.android) //ne pon - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.+") + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -100,6 +102,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.converter.scalars) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) @@ -108,7 +111,14 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) - implementation("io.coil-kt:coil-compose:2.6.0") + + + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) + + implementation(libs.kotlinx.coroutines.play.services) + implementation(libs.coil.compose) } diff --git a/mobile/app/src/androidTest/java/com/smartjam/app/ExampleInstrumentedTest.kt b/mobile/app/src/androidTest/java/com/smartjam/app/ExampleInstrumentedTest.kt index 73d8041..b03fd85 100644 --- a/mobile/app/src/androidTest/java/com/smartjam/app/ExampleInstrumentedTest.kt +++ b/mobile/app/src/androidTest/java/com/smartjam/app/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package com.smartjam.app -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * @@ -21,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.smartjam.app", appContext.packageName) } -} \ No newline at end of file +} diff --git a/mobile/app/src/main/AndroidManifest.xml b/mobile/app/src/main/AndroidManifest.xml index 98da7c8..f7b5bae 100644 --- a/mobile/app/src/main/AndroidManifest.xml +++ b/mobile/app/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ - + + @@ -26,6 +25,13 @@ + + + + + \ 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 620a6b5..37030cf 100644 --- a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt @@ -1,34 +1,34 @@ package com.smartjam.app +import android.Manifest +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.core.app.ActivityCompat import androidx.navigation.compose.rememberNavController import androidx.room.Room import com.smartjam.app.api.AuthApi import com.smartjam.app.api.ConnectionsApi -import com.smartjam.app.api.AssignmentsApi -import com.smartjam.app.api.SubmissionsApi +import com.smartjam.app.api.DevicesApi import com.smartjam.app.data.api.AuthAuthenticator -import com.smartjam.app.data.api.InstantAdapter import com.smartjam.app.data.local.SmartJamDatabase import com.smartjam.app.data.local.TokenStorage -import com.smartjam.app.data.local.AudioFileStore import com.smartjam.app.domain.repository.AuthRepository import com.smartjam.app.domain.repository.ConnectionRepository -import com.smartjam.app.domain.repository.RoomRepository import com.smartjam.app.ui.navigation.Screen import com.smartjam.app.ui.navigation.SmartJamNavGraph -import androidx.compose.runtime.* -import androidx.lifecycle.lifecycleScope -import java.time.Instant import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import org.openapitools.client.infrastructure.ApiClient @@ -36,17 +36,26 @@ import org.openapitools.client.infrastructure.ApiClient class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 101, + ) + } enableEdgeToEdge() val tokenStorage = TokenStorage(context = this) - val appDatabase = Room.databaseBuilder( - applicationContext, - SmartJamDatabase::class.java, - "smartjam_database" - ) - .fallbackToDestructiveMigration(dropAllTables = true) - .build() + val appDatabase = + Room.databaseBuilder( + applicationContext, + SmartJamDatabase::class.java, + "smartjam_database", + ) + .fallbackToDestructiveMigration(dropAllTables = true) + .build() val baseUrl = BuildConfig.BASE_URL val authenticator = AuthAuthenticator(tokenStorage, baseUrl) @@ -89,8 +98,9 @@ class MainActivity : ComponentActivity() { val connectionsApi = apiClient.createService(ConnectionsApi::class.java) val assignmentsApi = apiClient.createService(AssignmentsApi::class.java) val submissionsApi = apiClient.createService(SubmissionsApi::class.java) + val devicesApi = apiClient.createService(DevicesApi::class.java) - val authRepository = AuthRepository(tokenStorage, authApi, apiClient) + val authRepository = AuthRepository(tokenStorage, authApi, apiClient, devicesApi) val connectionRepository = ConnectionRepository(connectionsApi, appDatabase.connectionDao()) val roomRepository = RoomRepository( assignmentsApi = assignmentsApi, @@ -148,7 +158,6 @@ class MainActivity : ComponentActivity() { navController = navController, authRepository = authRepository, connectionRepository = connectionRepository, - roomRepository = roomRepository, tokenStorage = tokenStorage, startDestination = startDestination!! ) diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/FcmPushService.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/FcmPushService.kt new file mode 100644 index 0000000..52a4033 --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/FcmPushService.kt @@ -0,0 +1,60 @@ +package com.smartjam.app.data.api + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.smartjam.app.BuildConfig +import com.smartjam.app.api.DevicesApi +import com.smartjam.app.data.local.TokenStorage +import com.smartjam.app.model.DeviceRegistrationRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.openapitools.client.infrastructure.ApiClient + +class FcmPushService : FirebaseMessagingService() { + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d("SmartJam_FCM", "New token from Google received") + + scope.launch { + try { + val tokenStorage = TokenStorage(applicationContext) + val baseUrl = BuildConfig.BASE_URL + val authenticator = AuthAuthenticator(tokenStorage, baseUrl) + + val okHttpClientBuilder = OkHttpClient.Builder().authenticator(authenticator) + + val apiClient = + ApiClient(baseUrl = baseUrl, okHttpClientBuilder = okHttpClientBuilder) + authenticator.apiClient = apiClient + + val devicesApi = apiClient.createService(DevicesApi::class.java) + + devicesApi.registerDevice(DeviceRegistrationRequest(token = token)) + Log.i("SmartJam_FCM", "FCM token updated successfully") + } catch (e: Exception) { + Log.e("SmartJam_FCM", "Error during FCM token update", e) + } + } + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + val body = message.notification?.body + + Log.d("SmartjJam_FCM", "Push notification received: $body") + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } +} diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/Converters.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/Converters.kt index 622fe76..42d415a 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/local/Converters.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/Converters.kt @@ -8,42 +8,29 @@ import java.util.UUID class Converters { @TypeConverter - fun fromTimestamp(value: Long?): Instant? { - return value?.let { Instant.ofEpochMilli(it) } - } + fun fromTimestamp(value: Long?): Instant? = value?.let { Instant.ofEpochMilli(it) } - @TypeConverter - fun dateToTimestamp(date: Instant?): Long? { - return date?.toEpochMilli() - } + @TypeConverter fun dateToTimestamp(date: Instant?): Long? = date?.toEpochMilli() @TypeConverter - fun fromUUID(value: String?): UUID? { - return try { + fun fromUUID(value: String?): UUID? = + try { value?.let { UUID.fromString(it) } } catch (e: IllegalArgumentException) { Log.e("Converters", "Failed to parse UUID from string: $value", e) null } - } - @TypeConverter - fun uuidToString(uuid: UUID?): String? { - return uuid?.toString() - } + @TypeConverter fun uuidToString(uuid: UUID?): String? = uuid?.toString() @TypeConverter - fun fromURI(value: String?): URI? { - return try { + fun fromURI(value: String?): URI? = + try { value?.let { URI.create(it) } } catch (e: Exception) { Log.e("Converters", "Failed to parse URI from string: $value", e) null } - } - @TypeConverter - fun uriToString(uri: URI?): String? { - return uri?.toString() - } + @TypeConverter fun uriToString(uri: URI?): String? = uri?.toString() } 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 index 2861e38..7fd2f23 100644 --- 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 @@ -7,7 +7,6 @@ import androidx.room.Query import com.smartjam.app.data.local.entity.ConnectionEntity import kotlinx.coroutines.flow.Flow - @Dao interface ConnectionDao { @Query("SELECT * FROM connections WHERE myRole = :role ORDER BY createdAt DESC") @@ -21,4 +20,4 @@ interface ConnectionDao { @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/model/LoginState.kt b/mobile/app/src/main/java/com/smartjam/app/data/model/LoginState.kt index 06cdc5e..414d9aa 100644 --- 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 @@ -1,8 +1,8 @@ package com.smartjam.app.data.model -data class LoginState ( +data class LoginState( val email: String = "", val password: String = "", val isLoading: Boolean = false, - val error: String? = null -) \ No newline at end of file + val error: String? = null, +) 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 index 260fef8..0805dd1 100644 --- 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 @@ -2,5 +2,5 @@ package com.smartjam.app.domain.model enum class UserRole { STUDENT, - TEACHER -} \ No newline at end of file + TEACHER, +} 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 4244168..4ded6df 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 @@ -15,7 +15,8 @@ import com.smartjam.app.BuildConfig class AuthRepository ( private val tokenStorage: TokenStorage, private val authApi: AuthApi, - private val apiClient: ApiClient + private val apiClient: ApiClient, + private val devicesApi: DevicesApi, ) { val userRole: Flow = tokenStorage.userRole @@ -23,8 +24,13 @@ class AuthRepository ( tokenStorage.saveRole(role) } - suspend fun register(email: String, password: String, username: String, role: UserRole): Result { - return try { + suspend fun register( + email: String, + password: String, + username: String, + role: UserRole, + ): Result = + try { val response = authApi.registerUser(RegisterRequest(email, username, password)) if (response.isSuccessful && response.body() != null) { @@ -40,7 +46,7 @@ class AuthRepository ( Result.failure(Exception("Registration failed: ${response.code()}")) } } catch (e: Exception) { - if (e is CancellationException) throw e; + if (e is CancellationException) throw e Result.failure(e) } } @@ -74,15 +80,25 @@ class AuthRepository ( } else { Result.failure(Exception("Login failed: ${response.code()}")) } - } catch (e: Exception){ - if (e is CancellationException) throw e; + } catch (e: Exception) { + if (e is CancellationException) throw e Result.failure(e) } } + private suspend fun registerDevicePushToken() { + try { + val fcmToken = FirebaseMessaging.getInstance().token.await() + devicesApi.registerDevice(DeviceRegistrationRequest(token = fcmToken)) + Log.d("SmartJam_Auth", "Device registered for pushes successfully") + } catch (e: Exception) { + Log.e("SmartJam_Auth", "Failed to register device for pushes", e) + } + } + suspend fun refreshToken(): Boolean { - return try{ - val refreshTokenStr = tokenStorage.refreshToken.first() + return try { + val refreshTokenStr = tokenStorage.refreshToken.first() ?: return false if (refreshTokenStr == null){ return false @@ -106,17 +122,12 @@ class AuthRepository ( apiClient.setBearerToken("") false } - - } catch (e: Exception){ - if (e is CancellationException) { - throw e - } - else{ - tokenStorage.clearTokens() - apiClient.setBearerToken("") - return false - } - + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + tokenStorage.clearTokens() + apiClient.setBearerToken("") + false } } @@ -148,6 +159,14 @@ class AuthRepository ( } suspend fun logout() { + try { + val fcmToken = FirebaseMessaging.getInstance().token.await() + devicesApi.unregisterDevice(DeviceRegistrationRequest(token = fcmToken)) + Log.d("SmartJam_Auth", "Device unregistered successfully") + } catch (e: Exception) { + Log.e("SmartJam_Auth", "Failed to unregister device during logout", e) + } + tokenStorage.clearTokens() apiClient.setBearerToken("") } diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/components/Backgrounds.kt b/mobile/app/src/main/java/com/smartjam/app/ui/components/Backgrounds.kt index 07a60ad..109e9df 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/components/Backgrounds.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/components/Backgrounds.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.smartjam.app.ui.theme.BlurCyan import com.smartjam.app.ui.theme.BlurPurpleDark @@ -23,10 +22,13 @@ import kotlin.math.sin @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" - ) + 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)) { @@ -36,28 +38,35 @@ fun AppleLiquidBackground() { drawCircle( color = BlurPurpleDark.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 - ) + center = + Offset( + x = width * 0.5f + sin(Math.toRadians(phase1.toDouble())).toFloat() * 200f, + y = height * 0.2f, + ), ) drawCircle( color = BlurPurpleLight.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 - ) + center = + Offset( + x = width * 0.8f, + y = + height * 0.6f + + sin(Math.toRadians(phase1.toDouble() + 90)).toFloat() * 300f, + ), ) drawCircle( color = BlurCyan.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 - ) + center = + Offset( + x = + width * 0.2f + + sin(Math.toRadians(phase1.toDouble() + 180)).toFloat() * 150f, + y = height * 0.8f, + ), ) } } diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/components/Buttons.kt b/mobile/app/src/main/java/com/smartjam/app/ui/components/Buttons.kt index 89868a7..118d87f 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/components/Buttons.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/components/Buttons.kt @@ -46,41 +46,47 @@ fun GoldenStringsButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true + 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" - ) + 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( - BrandGold.copy(alpha = 0.5f), - BrandPink.copy(alpha = 0.3f), - BrandCyan.copy(alpha = 0.3f) - ) + 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( + BrandGold.copy(alpha = 0.5f), + BrandPink.copy(alpha = 0.3f), + BrandCyan.copy(alpha = 0.3f), + ) + ), + shape = RoundedCornerShape(24.dp), + ) + .clickable( + enabled = enabled, + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + onClick = onClick, ), - shape = RoundedCornerShape(24.dp) - ) - .clickable( - enabled = enabled, - interactionSource = remember { MutableInteractionSource() }, - indication = LocalIndication.current, - onClick = onClick - ), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(24.dp))) { val width = size.width @@ -137,19 +143,21 @@ fun GoldenStringsButton( drawPath( path = path, color = stringColors[i].copy(alpha = glowAlpha), - style = Stroke(width = baseThickness * 3f * (1f + energyAlpha)) + 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) + 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), ) } } @@ -159,41 +167,35 @@ fun GoldenStringsButton( 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 - ) - ) + 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 -) { +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 + 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 - ) + Text(text = text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White) } } diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/components/Containers.kt b/mobile/app/src/main/java/com/smartjam/app/ui/components/Containers.kt index 589e779..fa6e358 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/components/Containers.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/components/Containers.kt @@ -15,12 +15,12 @@ import androidx.compose.ui.unit.dp @Composable 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) + 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() } diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/components/TextFields.kt b/mobile/app/src/main/java/com/smartjam/app/ui/components/TextFields.kt index a111cfb..299c221 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/components/TextFields.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/components/TextFields.kt @@ -38,42 +38,43 @@ fun AppleGlassTextField( visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, - enabled: Boolean = true + 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 - ), + 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 + 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) + modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(16.dp)) Box(modifier = Modifier.weight(1f)) { @@ -81,12 +82,12 @@ fun AppleGlassTextField( Text( text = hint, color = Color.White.copy(alpha = if (enabled) 0.3f else 0.15f), - fontSize = 16.sp + fontSize = 16.sp, ) } innerTextField() } } - } + }, ) } 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 fd96e5f..29ad0a6 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 @@ -12,10 +12,12 @@ 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.automirrored.filled.ExitToApp 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 @@ -31,6 +33,7 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage @@ -87,7 +90,7 @@ fun HomeScreen( isLoading = state.isLoading, onLogout = viewModel::onLogoutClicked, onSync = viewModel::syncNetworkData, - onToggleDebugRole = viewModel::toggleDebugRole + onToggleDebugRole = viewModel::toggleDebugRole, ) if (state.errorMessage != null) { @@ -95,7 +98,7 @@ fun HomeScreen( text = state.errorMessage!!, color = Color(0xFFFF5252), modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - fontSize = 14.sp + fontSize = 14.sp, ) } @@ -103,14 +106,14 @@ fun HomeScreen( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { if (state.currentRole == UserRole.TEACHER) { item { TeacherInviteSection( code = state.teacherGeneratedCode, isLoading = state.isLoading, - onGenerate = viewModel::onGenerateCodeClicked + onGenerate = viewModel::onGenerateCodeClicked, ) } @@ -191,7 +194,11 @@ private fun HomeHeader( } IconButton(onClick = onLogout) { - Icon(Icons.Default.ExitToApp, contentDescription = "Выйти", tint = Color.White.copy(alpha = 0.7f)) + Icon( + Icons.AutoMirrored.Default.ExitToApp, + contentDescription = "Выйти", + tint = Color.White.copy(alpha = 0.7f), + ) } } } 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 3a133a7..cfe3f14 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 @@ -1,6 +1,5 @@ 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 @@ -43,7 +42,7 @@ import com.smartjam.app.ui.theme.ErrorRed fun RegisterScreen( viewModel: RegisterViewModel, onNavigateToHome: () -> Unit, - onNavigateBack: () -> Unit + onNavigateBack: () -> Unit, ) { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -57,20 +56,16 @@ fun RegisterScreen( } } - Box( - modifier = Modifier - .fillMaxSize() - .background(CoreBackground) - ) { + Box(modifier = Modifier.fillMaxSize().background(CoreBackground)) { AppleLiquidBackground() Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 32.dp) - .verticalScroll(rememberScrollState()), + modifier = + Modifier.fillMaxSize() + .padding(horizontal = 32.dp) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Spacer(modifier = Modifier.height(48.dp)) @@ -78,29 +73,25 @@ fun RegisterScreen( text = "Создать аккаунт", fontSize = 32.sp, fontWeight = FontWeight.ExtraBold, - color = Color.White + color = Color.White, ) Spacer(modifier = Modifier.height(24.dp)) - GlassRoleSelector( selectedRole = state.selectedRole, - onRoleSelected = { viewModel.onRoleSelected(it) } + 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 - ) + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), ) Spacer(modifier = Modifier.height(16.dp)) @@ -110,10 +101,8 @@ fun RegisterScreen( onValueChange = { viewModel.onEmailChanged(it) }, hint = "Email", icon = Icons.Default.Email, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next - ) + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), ) Spacer(modifier = Modifier.height(16.dp)) @@ -124,10 +113,11 @@ fun RegisterScreen( hint = "Пароль", icon = Icons.Default.Lock, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next - ) + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), ) Spacer(modifier = Modifier.height(16.dp)) @@ -138,25 +128,24 @@ fun RegisterScreen( hint = "Повторите пароль", icon = Icons.Default.Lock, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { viewModel.onRegisterClicked() }) + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { viewModel.onRegisterClicked() }), ) Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxWidth().height(48.dp), + contentAlignment = Alignment.Center, ) { if (state.errorMessage != null) { Text( text = state.errorMessage!!, color = ErrorRed, fontSize = 13.sp, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, ) } } @@ -164,7 +153,7 @@ fun RegisterScreen( GoldenStringsButton( text = if (state.isLoading) "Создание..." else "Зарегистрироваться", onClick = { viewModel.onRegisterClicked() }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(24.dp)) @@ -173,9 +162,7 @@ fun RegisterScreen( text = "Уже есть аккаунт? Войти", fontSize = 14.sp, color = Color.White.copy(alpha = 0.6f), - modifier = Modifier - .clickable { viewModel.onBackClicked() } - .padding(16.dp) + modifier = Modifier.clickable { viewModel.onBackClicked() }.padding(16.dp), ) Spacer(modifier = Modifier.height(48.dp)) @@ -184,35 +171,32 @@ fun RegisterScreen( } @Composable -fun GlassRoleSelector( - selectedRole: UserRole, - onRoleSelected: (UserRole) -> Unit -) { +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 + 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) + modifier = Modifier.weight(1f), ) RoleButton( text = "Я преподаватель", isSelected = selectedRole == UserRole.TEACHER, onClick = { onRoleSelected(UserRole.TEACHER) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } } @@ -222,28 +206,30 @@ fun RoleButton( text: String, isSelected: Boolean, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - val backgroundColor by animateColorAsState( - targetValue = if (isSelected) BrandGold.copy(alpha = 0.2f) else Color.Transparent, - label = "RoleColorAnimation" - ) + val backgroundColor by + animateColorAsState( + targetValue = if (isSelected) BrandGold.copy(alpha = 0.2f) else Color.Transparent, + label = "RoleColorAnimation", + ) val textColor = if (isSelected) BrandGold else Color.White.copy(alpha = 0.5f) Box( - modifier = modifier - .fillMaxHeight() - .clip(RoundedCornerShape(24.dp)) - .background(backgroundColor) - .clickable { onClick() }, - contentAlignment = Alignment.Center + 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 + 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 index 127b1f2..afb7928 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 @@ -19,17 +19,16 @@ data class RegisterState( val repeatPasswordInput: String = "", val selectedRole: UserRole = UserRole.STUDENT, val isLoading: Boolean = false, - val errorMessage: String? = null + val errorMessage: String? = null, ) sealed class RegisterEvent { object NavigateToHome : RegisterEvent() + object NavigateBack : RegisterEvent() } -class RegisterViewModel( - private val authRepository: AuthRepository -) : ViewModel() { +class RegisterViewModel(private val authRepository: AuthRepository) : ViewModel() { private val _state = MutableStateFlow(RegisterState()) val state = _state.asStateFlow() @@ -60,14 +59,12 @@ class RegisterViewModel( } fun onBackClicked() { - viewModelScope.launch { - eventChannel.send(RegisterEvent.NavigateBack) - } + viewModelScope.launch { eventChannel.send(RegisterEvent.NavigateBack) } } fun onRegisterClicked() { - if (_state.value.isLoading){ - return; + if (_state.value.isLoading) { + return } val currentState = _state.value @@ -82,7 +79,9 @@ class RegisterViewModel( } if (!passwordRegex.matches(currentState.passwordInput)) { - _state.update { it.copy(errorMessage = "Пароль: мин. 8 символов, латинские буквы и цифры") } + _state.update { + it.copy(errorMessage = "Пароль: мин. 8 символов, латинские буквы и цифры") + } return } @@ -94,12 +93,13 @@ class RegisterViewModel( 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 - ) + val result = + authRepository.register( + email = currentState.emailInput, + password = currentState.passwordInput, + username = currentState.usernameInput, + role = currentState.selectedRole, + ) if (result.isSuccess) { _state.update { it.copy(isLoading = false) } @@ -112,9 +112,8 @@ class RegisterViewModel( } } -class RegisterViewModelFactory( - private val authRepository: AuthRepository -) : ViewModelProvider.Factory { +class RegisterViewModelFactory(private val authRepository: AuthRepository) : + ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(RegisterViewModel::class.java)) { @@ -122,4 +121,4 @@ class RegisterViewModelFactory( } throw IllegalArgumentException("Unknown ViewModel class") } -} \ No newline at end of file +} diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/theme/Color.kt b/mobile/app/src/main/java/com/smartjam/app/ui/theme/Color.kt index 65cf1f8..8a1b480 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/theme/Color.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/theme/Color.kt @@ -10,7 +10,6 @@ val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) - val CoreBackground = Color(0xFF05050A) val BrandCyan = Color(0xFF00E5FF) val BrandGold = Color(0xFFFFD700) diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/theme/Theme.kt b/mobile/app/src/main/java/com/smartjam/app/ui/theme/Theme.kt index bdaf581..d948258 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/theme/Theme.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package com.smartjam.app.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -11,48 +10,43 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) +private val DarkColorScheme = + darkColorScheme(primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80) -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 +private val LightColorScheme = + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ + ) @Composable fun SmartJamTheme( darkTheme: Boolean = isSystemInDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file + MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) +} diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/theme/Type.kt b/mobile/app/src/main/java/com/smartjam/app/ui/theme/Type.kt index 2287cee..b62514f 100644 --- a/mobile/app/src/main/java/com/smartjam/app/ui/theme/Type.kt +++ b/mobile/app/src/main/java/com/smartjam/app/ui/theme/Type.kt @@ -6,14 +6,14 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp - -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ) ) - -) \ No newline at end of file diff --git a/mobile/app/src/test/java/com/smartjam/app/ExampleUnitTest.kt b/mobile/app/src/test/java/com/smartjam/app/ExampleUnitTest.kt index 82f4cf4..8b8ab5e 100644 --- a/mobile/app/src/test/java/com/smartjam/app/ExampleUnitTest.kt +++ b/mobile/app/src/test/java/com/smartjam/app/ExampleUnitTest.kt @@ -1,8 +1,7 @@ package com.smartjam.app -import org.junit.Test - import org.junit.Assert.* +import org.junit.Test /** * Example local unit test, which will execute on the development machine (host). @@ -14,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index e40a07a..5af0413 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -2,4 +2,24 @@ 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 + alias(libs.plugins.google.services) apply false + + id("com.diffplug.spotless") version "8.5.1" apply true +} + + + +subprojects { + apply(plugin = "com.diffplug.spotless") + + spotless { + kotlin { + target("src/**/*.kt") + targetExclude("**/build/**/*.kt", "**/generated/**/*.kt") + + ktfmt("0.62").kotlinlangStyle() + + toggleOffOn() + } + } } \ No newline at end of file diff --git a/mobile/gradle/libs.versions.toml b/mobile/gradle/libs.versions.toml index 1a2a295..fe62c4a 100644 --- a/mobile/gradle/libs.versions.toml +++ b/mobile/gradle/libs.versions.toml @@ -1,18 +1,40 @@ [versions] -agp = "9.1.1" -converterScalars = "2.11.0" -coreKtx = "1.17.0" +agp = "9.2.1" +coilCompose = "2.7.0" +converterGson = "3.0.0" +converterScalars = "3.0.0" +coreKtx = "1.18.0" +datastorePreferences = "1.2.1" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +kotlinxCoroutinesAndroid = "1.11.0" +kotlinxCoroutinesPlayServices = "1.11.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.4" -kotlin = "2.2.10" -composeBom = "2024.09.00" +activityCompose = "1.13.0" +kotlin = "2.3.21" +composeBom = "2026.05.01" +loggingInterceptor = "5.3.2" +navigationCompose = "2.9.8" +okhttp = "5.3.2" +retrofit = "3.0.0" roomCommonJvm = "2.8.4" +googleServices = "4.4.4" +firebaseBom = "34.13.0" +roomCompiler = "2.8.4" +roomKtx = "2.8.4" +roomRuntime = "2.8.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "converterScalars" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -27,9 +49,18 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended"} androidx-room-common-jvm = { group = "androidx.room", name = "room-common-jvm", version.ref = "roomCommonJvm" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } diff --git a/mobile/gradle/wrapper/gradle-wrapper.properties b/mobile/gradle/wrapper/gradle-wrapper.properties index 01e4f89..80fbe5a 100644 --- a/mobile/gradle/wrapper/gradle-wrapper.properties +++ b/mobile/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ #Sun Mar 01 02:31:29 MSK 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From de399ad70691ea361d29bcc98f273ad32056e716 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 25 May 2026 20:34:40 +0300 Subject: [PATCH 14/17] fix: add fcm json config file to Github secrets --- .github/workflows/mobile-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 9de8ac4..dd0894f 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -31,6 +31,9 @@ jobs: - name: Make gradlew executable run: chmod +x gradlew + - name: Create google-services.json + run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 --decode > app/google-services.json + - name: Build Android App run: ./gradlew spotlessCheck assembleDebug lintDebug testDebugUnitTest --no-daemon \ No newline at end of file From 0083deb5c18a1624be7252322afe1cec043ac2a8 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 25 May 2026 21:55:35 +0300 Subject: [PATCH 15/17] fix: fix notification service --- .../smartjamapi/entity/UserEntity.java | 71 ++++++++++++------- .../ProcessAnalysisResultUseCase.java | 9 +-- .../domain/port/PushPublisher.java | 6 +- .../domain/port/RecipientResolver.java | 5 +- .../fcm/DebugLoggingPushAdapter.java | 16 ++++- .../infrastructure/fcm/FcmPushAdapter.java | 32 ++++++--- .../infrastructure/fcm/FirebaseConfig.java | 38 ++++++---- .../adapter/RecipientPersistenceAdapter.java | 5 +- mobile/app/src/main/AndroidManifest.xml | 2 + .../ui/screens/register/RegisterViewModel.kt | 2 +- 10 files changed, 122 insertions(+), 64 deletions(-) diff --git a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java index 1e04dcc..4db7209 100644 --- a/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java +++ b/backend/smartjam-api/src/main/java/com/smartjam/smartjamapi/entity/UserEntity.java @@ -1,31 +1,35 @@ package com.smartjam.smartjamapi.entity; -import java.time.Instant; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; - +import com.smartjam.api.model.UserRole; import jakarta.persistence.*; import jakarta.validation.constraints.Email; - -import com.smartjam.api.model.UserRole; import lombok.*; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + /** * JPA entity representing a registered user in the SmartJam platform. * - *

Mapped to the {@code users} database table. The primary key is a UUID generated automatically by the persistence - * provider. Equality and hash-code are based solely on {@link #id} to ensure correct behavior in JPA-managed + *

Mapped to the {@code users} database table. The primary key is a UUID generated + * automatically by the persistence + * provider. Equality and hash-code are based solely on {@link #id} to ensure correct behavior in + * JPA-managed * collections and during entity detachment/reattachment cycles. * - *

The {@code email} field is the unique login identifier. {@code username} is a unique display name. Passwords are + *

The {@code email} field is the unique login identifier. {@code username} is a unique + * display name. Passwords are * never stored in plain text — only the hashed value is persisted in {@code passwordHash}. * - *

Persistence exceptions: Attempting to persist or merge an instance with a duplicate {@code email}, a - * {@code null} required field, or a value that violates a column constraint will cause the underlying JPA provider to + *

Persistence exceptions: Attempting to persist or merge an instance with a duplicate + * {@code email}, a + * {@code null} required field, or a value that violates a column constraint will cause the + * underlying JPA provider to * throw a {@link jakarta.persistence.PersistenceException} (typically wrapped by Spring as * {@link org.springframework.dao.DataIntegrityViolationException}). */ @@ -37,55 +41,69 @@ @Entity @EntityListeners(AuditingEntityListener.class) @EqualsAndHashCode(onlyExplicitlyIncluded = true) -public class UserEntity { +public class UserEntity +{ - /** Unique identifier for the user, generated automatically as a UUID. */ + /** + * Unique identifier for the user, generated automatically as a UUID. + */ @Id @GeneratedValue(strategy = GenerationType.UUID) @EqualsAndHashCode.Include private UUID id; - /** Unique username of the user shown in the UI. Must not be {@code null}. */ + /** + * Unique username of the user shown in the UI. Must not be {@code null}. + */ @Column(nullable = false, unique = true) private String username; /** - * The user's email address, used as the unique login identifier. Must be a valid email format, non-null, and unique + * The user's email address, used as the unique login identifier. Must be a valid email + * format, non-null, and unique * across all users. */ @Column(nullable = false, unique = true) @Email private String email; - /** Bcrypt (or equivalent) hash of the user's password. The plain-text password is never stored. */ + /** + * Bcrypt (or equivalent) hash of the user's password. The plain-text password is never stored. + */ @Column(name = "password_hash", nullable = false) private String passwordHash; - /** Optional first name of the user. */ + /** + * Optional first name of the user. + */ @Column(name = "first_name") private String firstName; - /** Optional last name of the user. */ + /** + * Optional last name of the user. + */ @Column(name = "last_name") private String lastName; - /** Optional URL pointing to the user's avatar image. */ + /** + * Optional URL pointing to the user's avatar image. + */ @Column(name = "avatar_url", length = 500) private String avatarUrl; - /** Set of user Roles {@link UserRole} */ + /** + * Set of user Roles {@link UserRole} + */ @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) @Enumerated(EnumType.STRING) @Column(name = "role", nullable = false) private Set roles = new HashSet<>(); - /** Optional Firebase Cloud Messaging token used to send push notifications to the user's device. */ - @Column(name = "fcm_token") - private String fcmToken; /** - * Timestamp of when the user record was first created. Set automatically by Hibernate on insert and never updated + * Timestamp of when the user record was first created. Set automatically by Hibernate on + * insert and never updated * afterward. */ @CreatedDate @@ -93,7 +111,8 @@ public class UserEntity { private Instant createdAt; /** - * Timestamp of the most recent update to the user record. Updated automatically by Hibernate on every merge/flush. + * Timestamp of the most recent update to the user record. Updated automatically by Hibernate + * on every merge/flush. */ @LastModifiedDate @Column(name = "updated_at", nullable = false) diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java index 951d664..7d6e004 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java @@ -1,5 +1,6 @@ package com.smartjam.notification.application; +import java.util.List; import java.util.UUID; import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; @@ -33,17 +34,17 @@ public void execute(AnalysisFinishedEvent event) { try { UUID userId = recipientResolver.findOwnerId(event.targetId(), event.type()); - String token = recipientResolver.findFcmToken(userId); + List tokens = recipientResolver.findFcmTokens(userId); - if (token == null || token.isEmpty()) { - log.warn("User {} does not have fcm token, push will not be sent", userId); + if (tokens.isEmpty()) { + log.warn("User {} has no registered devices, push skipped", userId); return; } String message = (event.type() == AnalysisType.SUBMISSION) ? "Твоя игра " + "проанализирована! Балл: " + event.totalScore() : "Твоя запись " + "обработана!"; - pushPublisher.sendPush(token, message); + pushPublisher.sendPush(tokens, message); } catch (Exception e) { log.error("Failed to send push notification for {}: {}", event.targetId(), e.getMessage()); } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java index d4e70a8..de55056 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java @@ -1,5 +1,7 @@ package com.smartjam.notification.domain.port; +import java.util.List; + /** * Outbound port for sending push notifications. Abstracts the underlying delivery mechanism (like Firebase or APNs). */ @@ -7,8 +9,8 @@ public interface PushPublisher { /** * Sends a push notification to a specific device. * - * @param fcmToken The target device's registration token. + * @param fcmTokens The target device's registration token. * @param message The text content of the notification. */ - void sendPush(String fcmToken, String message); + void sendPush(List fcmTokens, String message); } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java index 732a216..5c6ebbf 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java @@ -1,5 +1,6 @@ package com.smartjam.notification.domain.port; +import java.util.List; import java.util.UUID; import com.smartjam.common.dto.analysis.AnalysisType; @@ -12,6 +13,6 @@ public interface RecipientResolver { /** Finds the owner ID for the given target (Student or Teacher). */ UUID findOwnerId(UUID targetId, AnalysisType type); - /** Retrieves the FCM registration token for a specific user. */ - String findFcmToken(UUID userId); + /** Retrieves the FCM registration tokens for a specific user. */ + List findFcmTokens(UUID userId); } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java index f12384b..d580209 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java @@ -1,5 +1,8 @@ package com.smartjam.notification.infrastructure.fcm; +import java.util.List; +import java.util.stream.Collectors; + import com.smartjam.notification.domain.port.PushPublisher; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Profile; @@ -14,8 +17,15 @@ @Profile("debug") public class DebugLoggingPushAdapter implements PushPublisher { @Override - public void sendPush(String fcmToken, String message) { - String maskedToken = (fcmToken != null && fcmToken.length() > 10) ? fcmToken.substring(0, 10) + "..." : "***"; - log.info("[MOCK PUSH] Sending to User {}: {}", maskedToken, message); + public void sendPush(List fcmTokens, String message) { + String tokensPreview = fcmTokens.stream() + .map(t -> (t.length() > 10) ? t.substring(0, 10) + "..." : "***") + .collect(Collectors.joining(", ")); + + log.info( + "[MOCK PUSH] Sending to {} devices. Tokens: [{}]. Message: {}", + fcmTokens.size(), + tokensPreview, + message); } } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java index d9b397b..2fefca5 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java @@ -1,7 +1,10 @@ package com.smartjam.notification.infrastructure.fcm; +import java.util.List; + +import com.google.firebase.messaging.BatchResponse; import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MulticastMessage; import com.google.firebase.messaging.Notification; import com.smartjam.notification.domain.port.PushPublisher; import lombok.extern.slf4j.Slf4j; @@ -18,27 +21,38 @@ public class FcmPushAdapter implements PushPublisher { @Override - public void sendPush(String fcmToken, String messageText) { + public void sendPush(List fcmTokens, String messageText) { try { Notification notification = Notification.builder() .setTitle("SmartJam") .setBody(messageText) .build(); - Message message = Message.builder() - .setToken(fcmToken) + MulticastMessage message = MulticastMessage.builder() + .addAllTokens(fcmTokens) .setNotification(notification) .build(); - String response = FirebaseMessaging.getInstance().send(message); + BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message); + + log.info( + "Multicast push sent. Success: {}, Failure: {}", + response.getSuccessCount(), + response.getFailureCount()); - log.info("Successfully sent push notification. Firebase response: {}", response); + if (response.getFailureCount() > 0) { + response.getResponses().forEach(res -> { + if (!res.isSuccessful()) { + log.warn( + "Failed to send to a device: {}", + res.getException().getMessage()); + } + }); + } } catch (Exception e) { - String maskedToken = - (fcmToken != null && fcmToken.length() > 10) ? fcmToken.substring(0, 10) + "..." : "***"; - log.error("Firebase cloud messaging error for token {}: {}", maskedToken, e.getMessage(), e); + log.error("Critical error during FCM multicast sending", e); } } } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java index da5a384..7e8c8d0 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java @@ -1,45 +1,53 @@ package com.smartjam.notification.infrastructure.fcm; -import java.io.InputStream; - -import jakarta.annotation.PostConstruct; - import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import java.io.InputStream; + /** - * Configuration class responsible for initializing the Firebase Admin SDK. Loads security credentials from the + * Configuration class responsible for initializing the Firebase Admin SDK. Loads security + * credentials from the * 'firebase-adminsdk.json' resource file. */ @Slf4j @Configuration @Profile("!debug") -public class FirebaseConfig { +public class FirebaseConfig +{ @PostConstruct - public void init() { - try { - InputStream serviceAccount = getClass().getClassLoader().getResourceAsStream("firebase-adminsdk.json"); + public void init() + { + try (InputStream serviceAccount = getClass().getClassLoader() + .getResourceAsStream("firebase-adminsdk.json");) + { + + + if (serviceAccount == null) + { - if (serviceAccount == null) { - log.warn("firebase-adminsdk.json file not found. Push notifications will not " + "work!"); + throw new IllegalStateException("firebase-adminsdk.json not found in resources"); - return; } FirebaseOptions options = FirebaseOptions.builder() .setCredentials(GoogleCredentials.fromStream(serviceAccount)) .build(); - if (FirebaseApp.getApps().isEmpty()) { + if (FirebaseApp.getApps().isEmpty()) + { FirebaseApp.initializeApp(options); log.info("Firebase Admin SDK initialized successfully"); } - } catch (Exception e) { - log.error("Error during Firebase initialization: {}", e.getMessage(), e); + } catch (Exception e) + { + log.error("Failed to initialize Firebase Admin SDK: {}", e.getMessage(), e); + throw new RuntimeException("Firebase bootstrapper failed", e); } } } diff --git a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java index 04d36ec..221c00d 100644 --- a/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java @@ -1,5 +1,6 @@ package com.smartjam.notification.infrastructure.persistence.adapter; +import java.util.List; import java.util.UUID; import com.smartjam.common.dto.analysis.AnalysisType; @@ -28,7 +29,7 @@ public UUID findOwnerId(UUID targetId, AnalysisType type) { } @Override - public String findFcmToken(UUID userId) { - return jdbcTemplate.queryForObject("SELECT fcm_token FROM users WHERE id = ?", String.class, userId); + public List findFcmTokens(UUID userId) { + return jdbcTemplate.queryForList("SELECT fcm_token FROM user_devices WHERE user_id = ?", String.class, userId); } } diff --git a/mobile/app/src/main/AndroidManifest.xml b/mobile/app/src/main/AndroidManifest.xml index f7b5bae..55a3601 100644 --- a/mobile/app/src/main/AndroidManifest.xml +++ b/mobile/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + (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() + private val passwordRegex = "^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\\D*\\d)(?=[^#?!@\$%^&*-]*[#?!@\$%^&*-]).{8,20}".toRegex() fun onUsernameChanged(username: String) { _state.update { it.copy(usernameInput = username, errorMessage = null) } From e84b68a8d4628cfc4b2dc07c7d5e3c8c788da83e Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Mon, 25 May 2026 23:44:29 +0300 Subject: [PATCH 16/17] bug: help me pls, it doesn't work --- .../app/ConnectionSeedInstrumentedTest.kt | 92 ++-- .../java/com/smartjam/app/MainActivity.kt | 106 ++--- .../app/data/api/AuthAuthenticator.kt | 21 +- .../smartjam/app/data/api/InstantAdapter.kt | 1 - .../smartjam/app/data/local/AudioFileStore.kt | 10 +- .../app/data/local/SmartJamDatabase.kt | 12 +- .../smartjam/app/data/local/TokenStorage.kt | 75 ++-- .../app/data/local/dao/SubmissionResultDao.kt | 4 +- .../app/data/local/entity/AssignmentEntity.kt | 2 +- .../app/data/local/entity/ConnectionEntity.kt | 4 +- .../local/entity/SubmissionResultEntity.kt | 3 +- .../smartjam/app/domain/model/Connection.kt | 5 +- .../app/domain/repository/AuthRepository.kt | 45 +- .../domain/repository/ConnectionRepository.kt | 65 +-- .../app/domain/repository/RoomRepository.kt | 397 ++++++++++-------- .../smartjam/app/ui/navigation/NavGraph.kt | 300 ++++++------- .../app/ui/screens/home/HomeScreen.kt | 153 ++++--- .../app/ui/screens/home/HomeViewModel.kt | 68 +-- .../app/ui/screens/login/LoginScreen.kt | 145 +++---- .../app/ui/screens/login/LoginViewModel.kt | 40 +- .../ui/screens/register/RegisterViewModel.kt | 4 +- .../app/ui/screens/room/RoomScreen.kt | 298 +++++++------ .../app/ui/screens/room/RoomViewModel.kt | 68 +-- mobile/build.gradle.kts | 1 - 24 files changed, 1016 insertions(+), 903 deletions(-) diff --git a/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt b/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt index 51f73a5..60904d1 100644 --- a/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt +++ b/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt @@ -3,16 +3,18 @@ package com.smartjam.app import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.smartjam.app.api.AuthApi import com.smartjam.app.data.local.SmartJamDatabase import com.smartjam.app.data.local.TokenStorage import com.smartjam.app.data.local.entity.ConnectionEntity import com.smartjam.app.domain.model.UserRole -import com.smartjam.app.api.AuthApi import com.smartjam.app.domain.repository.AuthRepository import com.smartjam.app.model.AuthResponse import com.smartjam.app.model.LoginRequest import com.smartjam.app.model.RefreshRequest import com.smartjam.app.model.RegisterRequest +import java.time.Instant +import java.util.UUID import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -21,8 +23,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.openapitools.client.infrastructure.ApiClient import retrofit2.Response -import java.time.Instant -import java.util.UUID @RunWith(AndroidJUnit4::class) class ConnectionSeedInstrumentedTest { @@ -33,64 +33,71 @@ class ConnectionSeedInstrumentedTest { val tokenStorage = TokenStorage(context) val apiClient = ApiClient(baseUrl = "http://localhost") - val fakeAuthApi = object : AuthApi { - override suspend fun loginUser(loginRequest: LoginRequest): Response { - return Response.success( - AuthResponse( - accessToken = "mock_access_token", - refreshToken = "mock_refresh_token" + val fakeAuthApi = + object : AuthApi { + override suspend fun loginUser(loginRequest: LoginRequest): Response { + return Response.success( + AuthResponse( + accessToken = "mock_access_token", + refreshToken = "mock_refresh_token", + ) ) - ) - } + } - override suspend fun refreshToken(refreshRequest: RefreshRequest): Response { - return Response.success( - AuthResponse( - accessToken = "mock_access_token", - refreshToken = "mock_refresh_token" + override suspend fun refreshToken( + refreshRequest: RefreshRequest + ): Response { + return Response.success( + AuthResponse( + accessToken = "mock_access_token", + refreshToken = "mock_refresh_token", + ) ) - ) - } + } - override suspend fun registerUser(registerRequest: RegisterRequest): Response { - return Response.success( - AuthResponse( - accessToken = "mock_access_token", - refreshToken = "mock_refresh_token" + override suspend fun registerUser( + registerRequest: RegisterRequest + ): Response { + return Response.success( + AuthResponse( + accessToken = "mock_access_token", + refreshToken = "mock_refresh_token", + ) ) - ) + } } - } val authRepository = AuthRepository(tokenStorage, fakeAuthApi, apiClient) authRepository.register( email = "mmm", password = "Qwerty1!", username = "mmm", - role = UserRole.STUDENT + role = UserRole.STUDENT, ) - val db = Room.inMemoryDatabaseBuilder(context, SmartJamDatabase::class.java) - .allowMainThreadQueries() - .build() + val db = + Room.inMemoryDatabaseBuilder(context, SmartJamDatabase::class.java) + .allowMainThreadQueries() + .build() try { val dao = db.connectionDao() val now = Instant.now() - val connections = (1..50).map { index -> - ConnectionEntity( - connectionId = UUID.randomUUID(), - peerId = UUID.randomUUID(), - peerUsername = "User$index", - createdAt = now, - peerFirstName = null, - peerLastName = null, - peerAvatarUrl = null, - peerAvatarBytes = null, - myRole = UserRole.STUDENT.name - ) - } + val connections = + (1..50).map { index -> + ConnectionEntity( + connectionId = UUID.randomUUID(), + peerId = UUID.randomUUID(), + peerUsername = "User$index", + createdAt = now, + peerFirstName = null, + peerLastName = null, + peerAvatarUrl = null, + peerAvatarBytes = null, + myRole = UserRole.STUDENT.name, + ) + } dao.insertConnections(connections) @@ -105,4 +112,3 @@ class ConnectionSeedInstrumentedTest { } } } - 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 37030cf..ece30a3 100644 --- a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt @@ -16,22 +16,32 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.core.app.ActivityCompat +import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController import androidx.room.Room +import com.smartjam.app.api.AssignmentsApi import com.smartjam.app.api.AuthApi import com.smartjam.app.api.ConnectionsApi import com.smartjam.app.api.DevicesApi +import com.smartjam.app.api.SubmissionsApi import com.smartjam.app.data.api.AuthAuthenticator +import com.smartjam.app.data.api.InstantAdapter +import com.smartjam.app.data.local.AudioFileStore 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.domain.repository.RoomRepository import com.smartjam.app.ui.navigation.Screen import com.smartjam.app.ui.navigation.SmartJamNavGraph +import kotlin.time.Instant import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor import org.openapitools.client.infrastructure.ApiClient +import org.openapitools.client.infrastructure.Serializer class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -60,33 +70,34 @@ class MainActivity : ComponentActivity() { val baseUrl = BuildConfig.BASE_URL val authenticator = AuthAuthenticator(tokenStorage, baseUrl) - val serializerBuilder = org.openapitools.client.infrastructure.Serializer.gsonBuilder - .registerTypeAdapter(Instant::class.java, InstantAdapter()) - - val okHttpClientBuilder = OkHttpClient.Builder() - .authenticator(authenticator) - .addInterceptor(okhttp3.logging.HttpLoggingInterceptor().apply { - level = okhttp3.logging.HttpLoggingInterceptor.Level.BODY - }) - .addInterceptor { chain -> - val original = chain.request() - val token = runBlocking { tokenStorage.accessToken.first() } - if (token != null && original.header("Authorization") == null) { - val request = original.newBuilder() - .header("Authorization", "Bearer $token") - .build() - chain.proceed(request) - } else { - chain.proceed(original) + val serializerBuilder = + Serializer.gsonBuilder.registerTypeAdapter(Instant::class.java, InstantAdapter()) + + val okHttpClientBuilder = + OkHttpClient.Builder() + .authenticator(authenticator) + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } + ) + .addInterceptor { chain -> + val original = chain.request() + val token = runBlocking { tokenStorage.accessToken.first() } + if (token != null && original.header("Authorization") == null) { + val request = + original.newBuilder().header("Authorization", "Bearer $token").build() + chain.proceed(request) + } else { + chain.proceed(original) + } } - } - val apiClient = ApiClient( - baseUrl = baseUrl, - okHttpClientBuilder = okHttpClientBuilder, - serializerBuilder = serializerBuilder, - authNames = arrayOf("bearerAuth") - ) + val apiClient = + ApiClient( + baseUrl = baseUrl, + okHttpClientBuilder = okHttpClientBuilder, + serializerBuilder = serializerBuilder, + authNames = arrayOf("bearerAuth"), + ) authenticator.apiClient = apiClient val token = runBlocking { tokenStorage.accessToken.first() } @@ -102,13 +113,14 @@ class MainActivity : ComponentActivity() { val authRepository = AuthRepository(tokenStorage, authApi, apiClient, devicesApi) val connectionRepository = ConnectionRepository(connectionsApi, appDatabase.connectionDao()) - val roomRepository = RoomRepository( - assignmentsApi = assignmentsApi, - submissionsApi = submissionsApi, - assignmentDao = appDatabase.assignmentDao(), - submissionResultDao = appDatabase.submissionResultDao(), - audioFileStore = AudioFileStore(applicationContext) - ) + val roomRepository = + RoomRepository( + assignmentsApi = assignmentsApi, + submissionsApi = submissionsApi, + assignmentDao = appDatabase.assignmentDao(), + submissionResultDao = appDatabase.submissionResultDao(), + audioFileStore = AudioFileStore(applicationContext), + ) lifecycleScope.launch { tokenStorage.accessToken.collect { newToken -> @@ -124,16 +136,18 @@ class MainActivity : ComponentActivity() { LaunchedEffect(Unit) { val tokenExists = tokenStorage.isAuthenticated() - startDestination = if (tokenExists) { - val isValid = try { - authRepository.verifyAuthentication() - } catch (e: Exception) { - false + startDestination = + if (tokenExists) { + val isValid = + try { + authRepository.verifyAuthentication() + } catch (e: Exception) { + false + } + if (isValid) Screen.Home.route else Screen.Login.route + } else { + Screen.Login.route } - if (isValid) Screen.Home.route else Screen.Login.route - } else { - Screen.Login.route - } } LaunchedEffect(startDestination) { @@ -149,20 +163,18 @@ class MainActivity : ComponentActivity() { } } - Surface( - modifier = Modifier.fillMaxSize(), - color = Color(0xFF05050A) - ) { + Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF05050A)) { if (startDestination != null) { SmartJamNavGraph( navController = navController, authRepository = authRepository, connectionRepository = connectionRepository, tokenStorage = tokenStorage, - startDestination = startDestination!! + startDestination = startDestination!!, + roomRepository = roomRepository, ) } } } } -} \ No newline at end of file +} diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt index f211c71..6582fd3 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/AuthAuthenticator.kt @@ -13,10 +13,8 @@ import okhttp3.Response import okhttp3.Route import org.openapitools.client.infrastructure.ApiClient -class AuthAuthenticator( - private val tokenStorage: TokenStorage, - private val baseUrl: String -) : Authenticator { +class AuthAuthenticator(private val tokenStorage: TokenStorage, private val baseUrl: String) : + Authenticator { private val mutex = Mutex() var apiClient: ApiClient? = null @@ -29,7 +27,8 @@ class AuthAuthenticator( val currentToken = tokenStorage.accessToken.first() val requestToken = response.request.header("Authorization")?.removePrefix("Bearer ") if (currentToken != null && currentToken != requestToken) { - return@runBlocking response.request.newBuilder() + return@runBlocking response.request + .newBuilder() .header("Authorization", "Bearer $currentToken") .build() } @@ -41,21 +40,23 @@ class AuthAuthenticator( val authApi = authApiClient.createService(AuthApi::class.java) try { - val refreshResponse = authApi.refreshToken( - RefreshRequest(refreshToken, storedRole?.let { toApiRole(it) }) - ) + val refreshResponse = + authApi.refreshToken( + RefreshRequest(refreshToken, storedRole?.let { toApiRole(it) }) + ) if (refreshResponse.isSuccessful && refreshResponse.body() != null) { val newAuthResponse = refreshResponse.body()!! tokenStorage.saveToken( accessToken = newAuthResponse.accessToken, refreshToken = newAuthResponse.refreshToken, - role = storedRole + role = storedRole, ) apiClient?.setBearerToken(newAuthResponse.accessToken) - response.request.newBuilder() + response.request + .newBuilder() .header("Authorization", "Bearer ${newAuthResponse.accessToken}") .build() } else { diff --git a/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt index f4744e0..072423e 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt @@ -28,4 +28,3 @@ class InstantAdapter : TypeAdapter() { } } } - diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/AudioFileStore.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/AudioFileStore.kt index 0f34407..54ec415 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/local/AudioFileStore.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/AudioFileStore.kt @@ -5,11 +5,12 @@ import java.io.File import java.util.UUID class AudioFileStore(context: Context) { - private val baseDir: File = File(context.filesDir, "assignment_audio").apply { - if (!exists()) { - mkdirs() + private val baseDir: File = + File(context.filesDir, "assignment_audio").apply { + if (!exists()) { + mkdirs() + } } - } fun getAssignmentAudioFile(assignmentId: UUID): File { return File(baseDir, "$assignmentId.wav") @@ -19,4 +20,3 @@ class AudioFileStore(context: Context) { return File(baseDir, "submission_$submissionId.wav") } } - 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 index c90a7ce..b21799e 100644 --- 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 @@ -9,17 +9,15 @@ import com.smartjam.app.data.local.entity.ConnectionEntity import com.smartjam.app.data.local.entity.SubmissionResultEntity @Database( - entities = [ - ConnectionEntity::class, - AssignmentEntity::class, - SubmissionResultEntity::class - ], + entities = [ConnectionEntity::class, AssignmentEntity::class, SubmissionResultEntity::class], version = 6, - exportSchema = false + exportSchema = false, ) @TypeConverters(Converters::class) abstract class SmartJamDatabase : RoomDatabase() { abstract fun connectionDao(): ConnectionDao + abstract fun assignmentDao(): AssignmentDao + abstract fun submissionResultDao(): SubmissionResultDao -} \ 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 181b3b8..0a15592 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 @@ -10,51 +10,50 @@ 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 +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 USER_ROLE = stringPreferencesKey("user_role") - } + ) - suspend fun saveToken(accessToken: String, refreshToken: String, role: String? = null){ - context.dataStore.edit { preferences -> - preferences[ACCESS_TOKEN] = accessToken - preferences[REFRESH_TOKEN] = refreshToken - if (role != null) { - preferences[USER_ROLE] = role - } - } +class TokenStorage(private val context: Context) { + private companion object Keys { + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + val USER_ROLE = stringPreferencesKey("user_role") + } + + suspend fun saveToken(accessToken: String, refreshToken: String, role: String? = null) { + context.dataStore.edit { preferences -> + preferences[ACCESS_TOKEN] = accessToken + preferences[REFRESH_TOKEN] = refreshToken + if (role != null) { + preferences[USER_ROLE] = role + } } + } - val accessToken : Flow = context.dataStore.data - .map { preferences -> preferences[ACCESS_TOKEN] } + val accessToken: Flow = + context.dataStore.data.map { preferences -> preferences[ACCESS_TOKEN] } - val refreshToken : Flow = context.dataStore.data - .map { preferences -> preferences[REFRESH_TOKEN] } + val refreshToken: Flow = + context.dataStore.data.map { preferences -> preferences[REFRESH_TOKEN] } - val userRole : Flow = context.dataStore.data - .map { preferences -> preferences[USER_ROLE] } + val userRole: Flow = + context.dataStore.data.map { preferences -> preferences[USER_ROLE] } - suspend fun saveRole(role: String){ - context.dataStore.edit { preferences -> - preferences[USER_ROLE] = role - } - } - - suspend fun clearTokens(){ - context.dataStore.edit { preferences -> - preferences.remove(ACCESS_TOKEN) - preferences.remove(REFRESH_TOKEN) - } - } + suspend fun saveRole(role: String) { + context.dataStore.edit { preferences -> preferences[USER_ROLE] = role } + } - suspend fun isAuthenticated(): Boolean { - val token = refreshToken.first() - return token != null && token.isNotEmpty() + suspend fun clearTokens() { + context.dataStore.edit { preferences -> + preferences.remove(ACCESS_TOKEN) + preferences.remove(REFRESH_TOKEN) } + } -} \ No newline at end of file + suspend fun isAuthenticated(): Boolean { + val token = refreshToken.first() + return token != null && token.isNotEmpty() + } +} diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt index f126707..da7854b 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt @@ -10,7 +10,9 @@ import kotlinx.coroutines.flow.Flow @Dao interface SubmissionResultDao { - @Query("SELECT * FROM submission_results WHERE assignmentId = :assignmentId ORDER BY createdAt DESC") + @Query( + "SELECT * FROM submission_results WHERE assignmentId = :assignmentId ORDER BY createdAt DESC" + ) fun getResultsForAssignment(assignmentId: UUID): Flow> @Query("SELECT * FROM submission_results WHERE assignmentId = :assignmentId") diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/AssignmentEntity.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/AssignmentEntity.kt index 35b118b..66b858a 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/AssignmentEntity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/AssignmentEntity.kt @@ -15,5 +15,5 @@ data class AssignmentEntity( val referenceAudioUrl: URI?, val referenceAudioLocalPath: String?, val status: String, - val createdAt: Instant + val createdAt: Instant, ) diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt index c5306d6..b0e4cac 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/ConnectionEntity.kt @@ -15,5 +15,5 @@ data class ConnectionEntity( val peerLastName: String? = null, val peerAvatarUrl: String? = null, val peerAvatarBytes: ByteArray? = null, - val myRole: String -) \ No newline at end of file + val myRole: String, +) diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt index aa35dae..350805f 100644 --- a/mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt @@ -16,6 +16,5 @@ data class SubmissionResultEntity( val errorMessage: String?, val fileUrl: String?, val submissionAudioLocalPath: String?, - val createdAt: Instant + val createdAt: Instant, ) - 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 index cd50815..1c15224 100644 --- 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 @@ -1,10 +1,9 @@ package com.smartjam.app.domain.model - data class Connection( val id: String, val peerId: String, val peerName: String, val peerAvatarUrl: String? = null, - val peerAvatarBytes: ByteArray? = null -) \ No newline at end of file + val peerAvatarBytes: ByteArray? = null, +) 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 4ded6df..37a6cda 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,18 +1,23 @@ package com.smartjam.app.domain.repository +import android.util.Log +import com.google.firebase.messaging.FirebaseMessaging +import com.smartjam.app.BuildConfig import com.smartjam.app.api.AuthApi +import com.smartjam.app.api.DevicesApi +import com.smartjam.app.data.local.TokenStorage +import com.smartjam.app.domain.model.UserRole +import com.smartjam.app.model.DeviceRegistrationRequest import com.smartjam.app.model.LoginRequest import com.smartjam.app.model.RefreshRequest import com.smartjam.app.model.RegisterRequest -import com.smartjam.app.data.local.TokenStorage -import com.smartjam.app.domain.model.UserRole import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.tasks.await import org.openapitools.client.infrastructure.ApiClient -import com.smartjam.app.BuildConfig -class AuthRepository ( +class AuthRepository( private val tokenStorage: TokenStorage, private val authApi: AuthApi, private val apiClient: ApiClient, @@ -38,9 +43,11 @@ class AuthRepository ( tokenStorage.saveToken( accessToken = authResponse.accessToken, refreshToken = authResponse.refreshToken, - role = role.name + role = role.name, ) apiClient.setBearerToken(authResponse.accessToken) + + registerDevicePushToken() Result.success(Unit) } else { Result.failure(Exception("Registration failed: ${response.code()}")) @@ -49,7 +56,6 @@ class AuthRepository ( if (e is CancellationException) throw e Result.failure(e) } - } suspend fun login(email: String, password: String, role: UserRole): Result { return try { @@ -57,7 +63,7 @@ class AuthRepository ( tokenStorage.saveToken( accessToken = "mock_admin_access_token", refreshToken = "mock_admin_refresh_token", - role = role.name + role = role.name, ) apiClient.setBearerToken("mock_admin_access_token") return Result.success(Unit) @@ -70,12 +76,14 @@ class AuthRepository ( tokenStorage.saveToken( accessToken = authResponse.accessToken, refreshToken = authResponse.refreshToken, - role = role.name + role = role.name, ) apiClient.setBearerToken(authResponse.accessToken) refreshWithRole(role) + registerDevicePushToken() + Result.success(Unit) } else { Result.failure(Exception("Login failed: ${response.code()}")) @@ -98,9 +106,9 @@ class AuthRepository ( suspend fun refreshToken(): Boolean { return try { - val refreshTokenStr = tokenStorage.refreshToken.first() ?: return false + val refreshTokenStr = tokenStorage.refreshToken.first() - if (refreshTokenStr == null){ + if (refreshTokenStr == null) { return false } @@ -113,7 +121,7 @@ class AuthRepository ( tokenStorage.saveToken( accessToken = authResponse.accessToken, refreshToken = authResponse.refreshToken, - role = storedRole + role = storedRole, ) apiClient.setBearerToken(authResponse.accessToken) true @@ -135,14 +143,15 @@ class AuthRepository ( return try { val refreshTokenStr = tokenStorage.refreshToken.first() ?: return false - val response = authApi.refreshToken(RefreshRequest(refreshTokenStr, toApiRole(role.name))) + val response = + authApi.refreshToken(RefreshRequest(refreshTokenStr, toApiRole(role.name))) if (response.isSuccessful && response.body() != null) { val authResponse = response.body()!! tokenStorage.saveToken( accessToken = authResponse.accessToken, refreshToken = authResponse.refreshToken, - role = role.name + role = role.name, ) apiClient.setBearerToken(authResponse.accessToken) true @@ -171,15 +180,15 @@ class AuthRepository ( apiClient.setBearerToken("") } - suspend fun isAuthenticated(): Boolean{ + suspend fun isAuthenticated(): Boolean { return tokenStorage.isAuthenticated() } - suspend fun getAccessToken(): String?{ + suspend fun getAccessToken(): String? { return tokenStorage.accessToken.first() } - suspend fun getRefreshToken(): String?{ + suspend fun getRefreshToken(): String? { return tokenStorage.refreshToken.first() } @@ -204,4 +213,4 @@ class AuthRepository ( null } } -} \ 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 index 9e21002..947e6e2 100644 --- 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 @@ -1,29 +1,26 @@ package com.smartjam.app.domain.repository - +import android.util.Log +import com.smartjam.app.api.ConnectionsApi import com.smartjam.app.data.local.dao.ConnectionDao import com.smartjam.app.data.local.entity.ConnectionEntity import com.smartjam.app.domain.model.Connection import com.smartjam.app.domain.model.UserRole +import com.smartjam.app.model.JoinRequest import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import com.smartjam.app.api.ConnectionsApi -import com.smartjam.app.model.JoinRequest import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -class ConnectionRepository ( - private val api: ConnectionsApi, - private val dao: ConnectionDao -){ +class ConnectionRepository(private val api: ConnectionsApi, private val dao: ConnectionDao) { data class ConnectionPageInfo( val pageNumber: Int, val totalPages: Int, val pageSize: Int, - val totalElements: Long + val totalElements: Long, ) private val avatarClient = OkHttpClient.Builder().build() @@ -36,16 +33,22 @@ class ConnectionRepository ( peerId = entity.peerId.toString(), peerName = entity.peerUsername, peerAvatarUrl = entity.peerAvatarUrl, - peerAvatarBytes = entity.peerAvatarBytes + peerAvatarBytes = entity.peerAvatarBytes, ) } } } - suspend fun syncConnectionsPage(role: UserRole, page: Int, size: Int): Result { + suspend fun syncConnectionsPage( + role: UserRole, + page: Int, + size: Int, + ): Result { return try { val activeResponse = api.getMyConnections(page = page, size = size) + Log.e("HUESOS", activeResponse.toString()) + Log.e("HUESOS", activeResponse.body()!!.content.toString()) if (activeResponse.isSuccessful && activeResponse.body() != null) { val body = activeResponse.body()!! val activeItems = body.content @@ -55,11 +58,14 @@ class ConnectionRepository ( val allEntities = activeItems.map { dto -> val avatarUrl = dto.peerAvatarUrl?.toString() val cached = existing[dto.id] - val avatarBytes = when { - avatarUrl.isNullOrBlank() -> null - cached != null && cached.peerAvatarUrl == avatarUrl && cached.peerAvatarBytes != null -> cached.peerAvatarBytes - else -> downloadAvatar(avatarUrl) - } + val avatarBytes = + when { + avatarUrl.isNullOrBlank() -> null + cached != null && + cached.peerAvatarUrl == avatarUrl && + cached.peerAvatarBytes != null -> cached.peerAvatarBytes + else -> downloadAvatar(avatarUrl) + } ConnectionEntity( connectionId = dto.id, @@ -70,7 +76,7 @@ class ConnectionRepository ( peerLastName = dto.peerLastName, peerAvatarUrl = avatarUrl, peerAvatarBytes = avatarBytes, - myRole = role.name + myRole = role.name, ) } @@ -81,7 +87,7 @@ class ConnectionRepository ( pageNumber = body.page.number, totalPages = body.page.totalPages, pageSize = body.page.propertySize, - totalElements = body.page.totalElements + totalElements = body.page.totalElements, ) ) } else { @@ -95,7 +101,7 @@ class ConnectionRepository ( @Deprecated("Use syncConnectionsPage for paged loading") suspend fun syncConnections(role: UserRole): Result { - return syncConnectionsPage(role, page = 0, size = 20).map { } + return syncConnectionsPage(role, page = 0, size = 20).map {} } suspend fun generateInviteCode(): Result { @@ -130,17 +136,18 @@ class ConnectionRepository ( return Result.success(Unit) } - private suspend fun downloadAvatar(url: String): ByteArray? = withContext(Dispatchers.IO) { - try { - val request = Request.Builder().url(url).build() - val response = avatarClient.newCall(request).execute() - if (response.isSuccessful) { - response.body?.bytes() - } else { + private suspend fun downloadAvatar(url: String): ByteArray? = + withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + val response = avatarClient.newCall(request).execute() + if (response.isSuccessful) { + response.body?.bytes() + } else { + null + } + } catch (e: Exception) { null } - } catch (e: Exception) { - null } - } -} \ 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 a3f13bb..78a79ef 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 @@ -8,40 +8,37 @@ import com.smartjam.app.data.local.dao.AssignmentDao import com.smartjam.app.data.local.dao.SubmissionResultDao import com.smartjam.app.data.local.entity.AssignmentEntity import com.smartjam.app.data.local.entity.SubmissionResultEntity -import com.smartjam.app.model.CreateAssignmentRequest import com.smartjam.app.model.AssignmentResponseDetailed import com.smartjam.app.model.AssignmentUploadResponse -import com.smartjam.app.model.SubmissionUploadResponse +import com.smartjam.app.model.CreateAssignmentRequest import com.smartjam.app.model.SubmissionResultResponse +import com.smartjam.app.model.SubmissionUploadResponse +import java.io.File import java.util.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody -import java.io.File class RoomRepository( private val assignmentsApi: AssignmentsApi, private val submissionsApi: SubmissionsApi, private val assignmentDao: AssignmentDao, private val submissionResultDao: SubmissionResultDao, - private val audioFileStore: AudioFileStore + private val audioFileStore: AudioFileStore, ) { data class AssignmentPageInfo( val pageNumber: Int, val totalPages: Int, val pageSize: Int, - val totalElements: Long + val totalElements: Long, ) - private val httpClient = OkHttpClient.Builder() - .followRedirects(true) - .followSslRedirects(true) - .build() + private val httpClient = + OkHttpClient.Builder().followRedirects(true).followSslRedirects(true).build() fun getAssignmentsFlow(connectionId: UUID): Flow> { return assignmentDao.getAssignmentsForConnection(connectionId) @@ -51,27 +48,35 @@ class RoomRepository( return submissionResultDao.getResultsForAssignment(assignmentId) } - suspend fun syncAssignmentsPage(connectionId: UUID, page: Int, size: Int): Result { + suspend fun syncAssignmentsPage( + connectionId: UUID, + page: Int, + size: Int, + ): Result { return try { - val response = assignmentsApi.getAssignmentsByConnection(connectionId, page = page, size = size) + val response = + assignmentsApi.getAssignmentsByConnection(connectionId, page = page, size = size) if (response.isSuccessful && response.body() != null) { val body = response.body()!! - val existing = assignmentDao.getAssignmentsByIds(body.content.map { it.id }) - .associateBy { it.id } - - val entities = body.content.map { dto -> - val cached = existing[dto.id] - AssignmentEntity( - id = dto.id, - connectionId = connectionId, - title = dto.title, - description = cached?.description, - referenceAudioUrl = cached?.referenceAudioUrl, - referenceAudioLocalPath = cached?.referenceAudioLocalPath, - status = dto.status.name, - createdAt = dto.createdAt - ) - } + val existing = + assignmentDao.getAssignmentsByIds(body.content.map { it.id }).associateBy { + it.id + } + + val entities = + body.content.map { dto -> + val cached = existing[dto.id] + AssignmentEntity( + id = dto.id, + connectionId = connectionId, + title = dto.title, + description = cached?.description, + referenceAudioUrl = cached?.referenceAudioUrl, + referenceAudioLocalPath = cached?.referenceAudioLocalPath, + status = dto.status.name, + createdAt = dto.createdAt, + ) + } assignmentDao.insertAll(entities) Result.success( @@ -79,7 +84,7 @@ class RoomRepository( pageNumber = body.page.number, totalPages = body.page.totalPages, pageSize = body.page.propertySize, - totalElements = body.page.totalElements + totalElements = body.page.totalElements, ) ) } else { @@ -91,27 +96,35 @@ class RoomRepository( } suspend fun syncAssignments(connectionId: UUID): Result { - return syncAssignmentsPage(connectionId, page = 0, size = 20).map { } + return syncAssignmentsPage(connectionId, page = 0, size = 20).map {} } suspend fun ensureAssignmentDetailsCached(assignmentId: UUID): Result { return try { - val existing = assignmentDao.getAssignmentById(assignmentId) - ?: return Result.failure(Exception("Assignment not found in cache")) + val existing = + assignmentDao.getAssignmentById(assignmentId) + ?: return Result.failure(Exception("Assignment not found in cache")) val response = assignmentsApi.getAssignment(assignmentId) if (response.isSuccessful && response.body() != null) { val dto = response.body()!! - Log.d("RoomRepository", "Fetched assignment details: id=${dto.id} description=${dto.description?.take(100)} referenceAudioUrl=${dto.referenceAudioUrl}") + Log.d( + "RoomRepository", + "Fetched assignment details: id=${dto.id} description=${dto.description?.take(100)} referenceAudioUrl=${dto.referenceAudioUrl}", + ) val localPath = cacheReferenceAudioIfNeeded(existing, dto) - Log.d("RoomRepository", "Reference audio localPath for assignment ${existing.id}: $localPath") - val updated = existing.copy( - title = dto.title, - description = dto.description, - referenceAudioUrl = dto.referenceAudioUrl, - referenceAudioLocalPath = localPath, - status = dto.status.name + Log.d( + "RoomRepository", + "Reference audio localPath for assignment ${existing.id}: $localPath", ) + val updated = + existing.copy( + title = dto.title, + description = dto.description, + referenceAudioUrl = dto.referenceAudioUrl, + referenceAudioLocalPath = localPath, + status = dto.status.name, + ) assignmentDao.insertAll(listOf(updated)) Result.success(updated) } else { @@ -122,14 +135,19 @@ class RoomRepository( } } - suspend fun createAssignment(request: CreateAssignmentRequest): Result { + suspend fun createAssignment( + request: CreateAssignmentRequest + ): Result { return try { val response = assignmentsApi.createAssignment(request) if (response.isSuccessful && response.body() != null) { Result.success(response.body()!!) } else { val errorBody = response.errorBody()?.string() - Log.w("RoomRepository", "createAssignment failed: code=${response.code()} body=$errorBody") + Log.w( + "RoomRepository", + "createAssignment failed: code=${response.code()} body=$errorBody", + ) Result.failure(Exception("Failed to create assignment: ${response.code()}")) } } catch (e: Exception) { @@ -143,23 +161,27 @@ class RoomRepository( val response = submissionsApi.getSubmissionsByAssignment(assignmentId) if (response.isSuccessful && response.body() != null) { val body = response.body()!! - val existing = submissionResultDao.getResultsForAssignmentOnce(assignmentId).associateBy { it.id } + val existing = + submissionResultDao.getResultsForAssignmentOnce(assignmentId).associateBy { + it.id + } - val entities = body.content.map { dto -> - val cached = existing[dto.id] - SubmissionResultEntity( - id = dto.id, - assignmentId = assignmentId, - status = dto.status.name, - totalScore = dto.totalScore?.toFloat(), - pitchScore = cached?.pitchScore, - rhythmScore = cached?.rhythmScore, - errorMessage = cached?.errorMessage, - fileUrl = cached?.fileUrl, - submissionAudioLocalPath = cached?.submissionAudioLocalPath, - createdAt = dto.createdAt - ) - } + val entities = + body.content.map { dto -> + val cached = existing[dto.id] + SubmissionResultEntity( + id = dto.id, + assignmentId = assignmentId, + status = dto.status.name, + totalScore = dto.totalScore?.toFloat(), + pitchScore = cached?.pitchScore, + rhythmScore = cached?.rhythmScore, + errorMessage = cached?.errorMessage, + fileUrl = cached?.fileUrl, + submissionAudioLocalPath = cached?.submissionAudioLocalPath, + createdAt = dto.createdAt, + ) + } submissionResultDao.insertAll(entities) Result.success(Unit) } else { @@ -177,7 +199,10 @@ class RoomRepository( Result.success(response.body()!!) } else { val errorBody = response.errorBody()?.string() - Log.w("RoomRepository", "createSubmission failed: code=${response.code()} body=$errorBody") + Log.w( + "RoomRepository", + "createSubmission failed: code=${response.code()} body=$errorBody", + ) Result.failure(Exception("Failed to create submission")) } } catch (e: Exception) { @@ -186,27 +211,35 @@ class RoomRepository( } } - suspend fun getSubmissionResult(submissionId: UUID, assignmentId: UUID): Result { + suspend fun getSubmissionResult( + submissionId: UUID, + assignmentId: UUID, + ): Result { return try { val response = submissionsApi.getSubmissionResult(submissionId) if (response.isSuccessful && response.body() != null) { val dto = response.body()!! val created = java.time.Instant.now() - val existing = submissionResultDao.getResultsForAssignmentOnce(assignmentId).associateBy { it.id }[dto.id] - submissionResultDao.insertAll(listOf( - SubmissionResultEntity( - id = dto.id, - assignmentId = assignmentId, - status = dto.status.name, - totalScore = dto.totalScore?.toFloat(), - pitchScore = dto.pitchScore?.toFloat(), - rhythmScore = dto.rhythmScore?.toFloat(), - errorMessage = dto.errorMessage, - fileUrl = dto.submissionAudioUrl?.toString(), - submissionAudioLocalPath = existing?.submissionAudioLocalPath, - createdAt = created + val existing = + submissionResultDao + .getResultsForAssignmentOnce(assignmentId) + .associateBy { it.id }[dto.id] + submissionResultDao.insertAll( + listOf( + SubmissionResultEntity( + id = dto.id, + assignmentId = assignmentId, + status = dto.status.name, + totalScore = dto.totalScore?.toFloat(), + pitchScore = dto.pitchScore?.toFloat(), + rhythmScore = dto.rhythmScore?.toFloat(), + errorMessage = dto.errorMessage, + fileUrl = dto.submissionAudioUrl?.toString(), + submissionAudioLocalPath = existing?.submissionAudioLocalPath, + createdAt = created, + ) ) - )) + ) Result.success(dto) } else { Result.failure(Exception("Failed to fetch submission result")) @@ -216,135 +249,139 @@ class RoomRepository( } } - suspend fun cacheSubmissionAudioIfNeeded(submissionId: UUID, assignmentId: UUID, urlString: String?): Result = withContext(Dispatchers.IO) { - try { - if (urlString.isNullOrBlank()) { - return@withContext Result.success(null) - } + suspend fun cacheSubmissionAudioIfNeeded( + submissionId: UUID, + assignmentId: UUID, + urlString: String?, + ): Result = + withContext(Dispatchers.IO) { + try { + if (urlString.isNullOrBlank()) { + return@withContext Result.success(null) + } - val existing = submissionResultDao.getResultsForAssignmentOnce(assignmentId).associateBy { it.id }[submissionId] - val existingPath = existing?.submissionAudioLocalPath - if (!existingPath.isNullOrBlank()) { - val f = java.io.File(existingPath) - if (f.exists()) return@withContext Result.success(existingPath) - } - val uri = java.net.URI(urlString) - val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" + val existing = + submissionResultDao + .getResultsForAssignmentOnce(assignmentId) + .associateBy { it.id }[submissionId] + val existingPath = existing?.submissionAudioLocalPath + if (!existingPath.isNullOrBlank()) { + val f = java.io.File(existingPath) + if (f.exists()) return@withContext Result.success(existingPath) + } + val uri = java.net.URI(urlString) + val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" - val fixedUrl = urlString - .replace("localhost", "10.0.2.2") - .replace("127.0.0.1", "10.0.2.2") + val fixedUrl = + urlString.replace("localhost", "10.0.2.2").replace("127.0.0.1", "10.0.2.2") - val target = audioFileStore.getSubmissionAudioFile(submissionId) - val request = Request.Builder() - .url(fixedUrl) - .header("Host", originalHost) - .build() + val target = audioFileStore.getSubmissionAudioFile(submissionId) + val request = Request.Builder().url(fixedUrl).header("Host", originalHost).build() - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - return@withContext Result.success(null) - } + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext Result.success(null) + } - response.body?.byteStream()?.use { input -> - target.outputStream().use { output -> - input.copyTo(output) + response.body?.byteStream()?.use { input -> + target.outputStream().use { output -> input.copyTo(output) } } } - } - val updated = SubmissionResultEntity( - id = submissionId, - assignmentId = assignmentId, - status = existing?.status ?: "", - totalScore = existing?.totalScore, - pitchScore = existing?.pitchScore, - rhythmScore = existing?.rhythmScore, - errorMessage = existing?.errorMessage, - fileUrl = existing?.fileUrl, - submissionAudioLocalPath = target.absolutePath, - createdAt = existing?.createdAt ?: java.time.Instant.now() - ) - submissionResultDao.insertAll(listOf(updated)) - Result.success(target.absolutePath) - } catch (e: Exception) { - Log.w("RoomRepository", "cacheSubmissionAudioIfNeeded exception", e) - Result.failure(e) + val updated = + SubmissionResultEntity( + id = submissionId, + assignmentId = assignmentId, + status = existing?.status ?: "", + totalScore = existing?.totalScore, + pitchScore = existing?.pitchScore, + rhythmScore = existing?.rhythmScore, + errorMessage = existing?.errorMessage, + fileUrl = existing?.fileUrl, + submissionAudioLocalPath = target.absolutePath, + createdAt = existing?.createdAt ?: java.time.Instant.now(), + ) + submissionResultDao.insertAll(listOf(updated)) + Result.success(target.absolutePath) + } catch (e: Exception) { + Log.w("RoomRepository", "cacheSubmissionAudioIfNeeded exception", e) + Result.failure(e) + } } - } - - suspend fun uploadFileToS3(uploadUrl: String, file: File): Result = withContext(Dispatchers.IO) { - try { - val uri = java.net.URI(uploadUrl) - val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" - - val fixedUrl = uploadUrl - .replace("localhost", "10.0.2.2") - .replace("127.0.0.1", "10.0.2.2") - .replace("references.localhost", "10.0.2.2") - val requestBody = file.asRequestBody(null) - val request = Request.Builder() - .url(fixedUrl) - .header("Host", originalHost) - .put(requestBody) - .build() - - httpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) { - Result.success(Unit) - } else { - val errorBody = response.body?.string() - Log.w("RoomRepository", "uploadFileToS3 failed: code=${response.code} body=$errorBody") - Result.failure(Exception("S3 upload failed: ${response.code}")) + suspend fun uploadFileToS3(uploadUrl: String, file: File): Result = + withContext(Dispatchers.IO) { + try { + val uri = java.net.URI(uploadUrl) + val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" + + val fixedUrl = + uploadUrl + .replace("localhost", "10.0.2.2") + .replace("127.0.0.1", "10.0.2.2") + .replace("references.localhost", "10.0.2.2") + + val requestBody = file.asRequestBody(null) + val request = + Request.Builder() + .url(fixedUrl) + .header("Host", originalHost) + .put(requestBody) + .build() + + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + Result.success(Unit) + } else { + val errorBody = response.body?.string() + Log.w( + "RoomRepository", + "uploadFileToS3 failed: code=${response.code} body=$errorBody", + ) + Result.failure(Exception("S3 upload failed: ${response.code}")) + } } + } catch (e: Exception) { + Log.w("RoomRepository", "uploadFileToS3 exception", e) + Result.failure(e) } - } catch (e: Exception) { - Log.w("RoomRepository", "uploadFileToS3 exception", e) - Result.failure(e) } - } private suspend fun cacheReferenceAudioIfNeeded( existing: AssignmentEntity, - dto: AssignmentResponseDetailed - ): String? = withContext(Dispatchers.IO) { - val url = dto.referenceAudioUrl.toString().takeIf { it.isNotBlank() } - ?: return@withContext existing.referenceAudioLocalPath - - val existingPath = existing.referenceAudioLocalPath - if (!existingPath.isNullOrBlank()) { - val file = File(existingPath) - if (file.exists()) { - return@withContext existingPath + dto: AssignmentResponseDetailed, + ): String? = + withContext(Dispatchers.IO) { + val url = + dto.referenceAudioUrl.toString().takeIf { it.isNotBlank() } + ?: return@withContext existing.referenceAudioLocalPath + + val existingPath = existing.referenceAudioLocalPath + if (!existingPath.isNullOrBlank()) { + val file = File(existingPath) + if (file.exists()) { + return@withContext existingPath + } } - } - val target = audioFileStore.getAssignmentAudioFile(existing.id) + val target = audioFileStore.getAssignmentAudioFile(existing.id) - val uri = java.net.URI(url) - val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" - val fixedUrl = url - .replace("localhost", "10.0.2.2") - .replace("127.0.0.1", "10.0.2.2") + val uri = java.net.URI(url) + val originalHost = if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" + val fixedUrl = url.replace("localhost", "10.0.2.2").replace("127.0.0.1", "10.0.2.2") - val request = Request.Builder() - .url(fixedUrl) - .header("Host", originalHost) - .build() + val request = Request.Builder().url(fixedUrl).header("Host", originalHost).build() - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - return@withContext existing.referenceAudioLocalPath - } + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext existing.referenceAudioLocalPath + } - response.body?.byteStream()?.use { input -> - target.outputStream().use { output -> - input.copyTo(output) + response.body?.byteStream()?.use { input -> + target.outputStream().use { output -> input.copyTo(output) } } } - } - target.absolutePath - } + target.absolutePath + } } 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 d7c5c6c..5141fb3 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,5 +1,10 @@ package com.smartjam.app.ui.navigation +import androidx.compose.animation.core.LinearEasing +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.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -10,13 +15,7 @@ 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.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.animation.core.LinearEasing -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.material.icons.Icons import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Home @@ -63,13 +62,17 @@ import com.smartjam.app.ui.theme.BlurPurpleDark import com.smartjam.app.ui.theme.CoreBackground import java.util.* - sealed class Screen(val route: String) { object Login : Screen("login_screen") + object Register : Screen("register_screen") + object Home : Screen("home_screen") + object Profile : Screen("profile_screen") + object Comments : Screen("comments_screen") + object Room : Screen("room_screen/{connectionId}/{role}") { fun createRoute(connectionId: String, role: String) = "room_screen/$connectionId/$role" } @@ -82,51 +85,50 @@ fun SmartJamNavGraph( connectionRepository: ConnectionRepository, roomRepository: RoomRepository, tokenStorage: TokenStorage, - startDestination: String = Screen.Login.route + startDestination: String = Screen.Login.route, ) { val backStack by navController.currentBackStackEntryAsState() val currentRoute = backStack?.destination?.route - val appBackground = Brush.verticalGradient( - colors = listOf( - CoreBackground, - Color(0xFF0A0A14), - BlurPurpleDark.copy(alpha = 0.28f), - CoreBackground + val appBackground = + Brush.verticalGradient( + colors = + listOf( + CoreBackground, + Color(0xFF0A0A14), + BlurPurpleDark.copy(alpha = 0.28f), + CoreBackground, + ) ) - ) val glassShape = RoundedCornerShape(38.dp) - val glassBarBrush = Brush.verticalGradient( - colors = listOf( - CoreBackground.copy(alpha = 0.56f), - Color(0xFF0A0A14).copy(alpha = 0.36f), - BlurPurpleDark.copy(alpha = 0.14f), - Color(0xFF0A0A14).copy(alpha = 0.44f) + val glassBarBrush = + Brush.verticalGradient( + colors = + listOf( + CoreBackground.copy(alpha = 0.56f), + Color(0xFF0A0A14).copy(alpha = 0.36f), + BlurPurpleDark.copy(alpha = 0.14f), + Color(0xFF0A0A14).copy(alpha = 0.44f), + ) ) - ) val navBarTransition = rememberInfiniteTransition(label = "nav_bar_bg") - val navBarPhase by navBarTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable(tween(14000, easing = LinearEasing)), - label = "nav_bar_phase" - ) + val navBarPhase by + navBarTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(14000, easing = LinearEasing)), + label = "nav_bar_phase", + ) - Box( - modifier = Modifier - .fillMaxSize() - .background(appBackground) - ) { + Box(modifier = Modifier.fillMaxSize().background(appBackground)) { NavHost( navController = navController, startDestination = startDestination, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { - composable(route = Screen.Login.route) { - val loginViewModel: LoginViewModel = viewModel( - factory = LoginViewModelFactory(authRepository, tokenStorage) - ) + val loginViewModel: LoginViewModel = + viewModel(factory = LoginViewModelFactory(authRepository, tokenStorage)) LoginScreen( viewModel = loginViewModel, @@ -135,16 +137,13 @@ fun SmartJamNavGraph( popUpTo(Screen.Login.route) { inclusive = true } } }, - onNavigateToRegister = { - navController.navigate(Screen.Register.route) - } + onNavigateToRegister = { navController.navigate(Screen.Register.route) }, ) } composable(route = Screen.Register.route) { - val viewModel: RegisterViewModel = viewModel( - factory = RegisterViewModelFactory(authRepository) - ) + val viewModel: RegisterViewModel = + viewModel(factory = RegisterViewModelFactory(authRepository)) RegisterScreen( viewModel = viewModel, @@ -153,17 +152,13 @@ fun SmartJamNavGraph( popUpTo(Screen.Login.route) { inclusive = true } } }, - onNavigateBack = { - navController.popBackStack() - } + onNavigateBack = { navController.popBackStack() }, ) } - composable(route = Screen.Home.route) { - val viewModel: HomeViewModel = viewModel( - factory = HomeViewModelFactory(connectionRepository, authRepository) - ) + val viewModel: HomeViewModel = + viewModel(factory = HomeViewModelFactory(connectionRepository, authRepository)) HomeScreen( viewModel = viewModel, @@ -175,7 +170,7 @@ fun SmartJamNavGraph( navController.navigate(Screen.Login.route) { popUpTo(Screen.Home.route) { inclusive = true } } - } + }, ) } @@ -183,7 +178,7 @@ fun SmartJamNavGraph( PlaceholderScreen( title = "Профиль", subtitle = "Здесь появится аватар, настройки и данные аккаунта", - icon = Icons.Filled.Person + icon = Icons.Filled.Person, ) } @@ -191,113 +186,114 @@ fun SmartJamNavGraph( PlaceholderScreen( title = "Комментарии", subtitle = "Здесь будут сообщения, обсуждения и обратная связь", - icon = Icons.Filled.Email + icon = Icons.Filled.Email, ) } - composable(route = Screen.Room.route) { backStackEntry -> + composable(route = Screen.Room.route) { backStackEntry -> val connectionIdStr = backStackEntry.arguments?.getString("connectionId") ?: return@composable val roleStr = backStackEntry.arguments?.getString("role") ?: return@composable val connectionId = UUID.fromString(connectionIdStr) val role = com.smartjam.app.domain.model.UserRole.valueOf(roleStr) - val viewModel: RoomViewModel = viewModel( - factory = RoomViewModelFactory(connectionId, roomRepository) - ) + val viewModel: RoomViewModel = + viewModel(factory = RoomViewModelFactory(connectionId, roomRepository)) RoomScreen( connectionId = connectionId, role = role, viewModel = viewModel, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } } if (currentRoute != Screen.Login.route && currentRoute != Screen.Register.route) { Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 16.dp, vertical = 14.dp) - .fillMaxWidth() - .height(88.dp) - .clip(glassShape) - .shadow( - elevation = 28.dp, - shape = glassShape, - ambientColor = Color.Black.copy(alpha = 0.12f), - spotColor = Color.Black.copy(alpha = 0.22f) - ) - .background(glassBarBrush) + modifier = + Modifier.align(Alignment.BottomCenter) + .padding(horizontal = 16.dp, vertical = 14.dp) + .fillMaxWidth() + .height(88.dp) + .clip(glassShape) + .shadow( + elevation = 28.dp, + shape = glassShape, + ambientColor = Color.Black.copy(alpha = 0.12f), + spotColor = Color.Black.copy(alpha = 0.22f), + ) + .background(glassBarBrush) ) { - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - colors = listOf( + Box( + modifier = + Modifier.matchParentSize() + .background( + Brush.verticalGradient( + colors = + listOf( Color.White.copy(alpha = 0.08f), Color.Transparent, - Color.White.copy(alpha = 0.03f) + Color.White.copy(alpha = 0.03f), ) - ) ) - ) + ) + ) NavigationBar( - modifier = Modifier - .fillMaxSize() - .background(Color.Transparent), + modifier = Modifier.fillMaxSize().background(Color.Transparent), containerColor = Color.Transparent, tonalElevation = 0.dp, - windowInsets = androidx.compose.foundation.layout.WindowInsets(0) + windowInsets = androidx.compose.foundation.layout.WindowInsets(0), ) { val items = listOf(Screen.Home, Screen.Profile, Screen.Comments) items.forEach { screen -> NavigationBarItem( selected = currentRoute == screen.route, onClick = { - navController.navigate(screen.route) { - popUpTo(Screen.Home.route) - } + navController.navigate(screen.route) { popUpTo(Screen.Home.route) } }, icon = { when (screen) { - is Screen.Home -> Icon( - imageVector = Icons.Filled.Home, - contentDescription = "Home" - ) - is Screen.Profile -> Icon( - imageVector = Icons.Filled.Person, - contentDescription = "Profile" - ) - is Screen.Comments -> Icon( - imageVector = Icons.Filled.Email, - contentDescription = "Comments" - ) + is Screen.Home -> + Icon( + imageVector = Icons.Filled.Home, + contentDescription = "Home", + ) + is Screen.Profile -> + Icon( + imageVector = Icons.Filled.Person, + contentDescription = "Profile", + ) + is Screen.Comments -> + Icon( + imageVector = Icons.Filled.Email, + contentDescription = "Comments", + ) else -> {} } }, label = { Text( - text = when (screen) { - is Screen.Home -> "Комнаты" - is Screen.Profile -> "Профиль" - is Screen.Comments -> "Чаты" - else -> "" - }, - fontWeight = FontWeight.Medium + text = + when (screen) { + is Screen.Home -> "Комнаты" + is Screen.Profile -> "Профиль" + is Screen.Comments -> "Чаты" + else -> "" + }, + fontWeight = FontWeight.Medium, ) }, alwaysShowLabel = true, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = Color.White, - unselectedIconColor = Color.White.copy(alpha = 0.45f), - selectedTextColor = Color.White, - unselectedTextColor = Color.White.copy(alpha = 0.48f), - indicatorColor = Color.White.copy(alpha = 0.10f) - ) + colors = + NavigationBarItemDefaults.colors( + selectedIconColor = Color.White, + unselectedIconColor = Color.White.copy(alpha = 0.45f), + selectedTextColor = Color.White, + unselectedTextColor = Color.White.copy(alpha = 0.48f), + indicatorColor = Color.White.copy(alpha = 0.10f), + ), ) } } @@ -310,79 +306,63 @@ fun SmartJamNavGraph( private fun PlaceholderScreen( title: String, subtitle: String, - icon: androidx.compose.ui.graphics.vector.ImageVector + icon: androidx.compose.ui.graphics.vector.ImageVector, ) { - val background = Brush.radialGradient( - colors = listOf( - BlurPurpleDark.copy(alpha = 0.34f), - CoreBackground, - CoreBackground + val background = + Brush.radialGradient( + colors = listOf(BlurPurpleDark.copy(alpha = 0.34f), CoreBackground, CoreBackground) ) - ) Box( - modifier = Modifier - .fillMaxSize() - .background(background) - .padding(24.dp), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize().background(background).padding(24.dp), + contentAlignment = Alignment.Center, ) { Surface( modifier = Modifier.fillMaxWidth(), color = Color.White.copy(alpha = 0.06f), shape = RoundedCornerShape(28.dp), - border = androidx.compose.foundation.BorderStroke( - 1.dp, - Color.White.copy(alpha = 0.12f) - ), + border = + androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), tonalElevation = 0.dp, - shadowElevation = 10.dp + shadowElevation = 10.dp, ) { Column( - modifier = Modifier - .padding(28.dp), + modifier = Modifier.padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(14.dp) + verticalArrangement = Arrangement.spacedBy(14.dp), ) { Box( - modifier = Modifier - .size(76.dp) - .clip(RoundedCornerShape(24.dp)) - .background( - Brush.linearGradient( - colors = listOf( - BlurCyan.copy(alpha = 0.22f), - Color.White.copy(alpha = 0.08f) + modifier = + Modifier.size(76.dp) + .clip(RoundedCornerShape(24.dp)) + .background( + Brush.linearGradient( + colors = + listOf( + BlurCyan.copy(alpha = 0.22f), + Color.White.copy(alpha = 0.08f), + ) ) ) - ) - .border( - 1.dp, - Color.White.copy(alpha = 0.15f), - RoundedCornerShape(24.dp) - ), - contentAlignment = Alignment.Center + .border( + 1.dp, + Color.White.copy(alpha = 0.15f), + RoundedCornerShape(24.dp), + ), + contentAlignment = Alignment.Center, ) { Icon( imageVector = icon, contentDescription = title, tint = Color.White, - modifier = Modifier.size(36.dp) + modifier = Modifier.size(36.dp), ) } - Text( - text = title, - color = Color.White, - fontWeight = FontWeight.SemiBold - ) + Text(text = title, color = Color.White, fontWeight = FontWeight.SemiBold) - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.68f) - ) + Text(text = subtitle, color = Color.White.copy(alpha = 0.68f)) } } } } - 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 29ad0a6..01ba188 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 @@ -17,7 +17,6 @@ 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 @@ -33,7 +32,6 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage @@ -52,7 +50,7 @@ import com.smartjam.app.ui.theme.ErrorRed fun HomeScreen( viewModel: HomeViewModel, onNavigateToRoom: (String) -> Unit, - onNavigateToLogin: () -> Unit + onNavigateToLogin: () -> Unit, ) { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -73,12 +71,11 @@ fun HomeScreen( LaunchedEffect(listState) { snapshotFlow { - val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - val total = listState.layoutInfo.totalItemsCount - lastVisible to total - }.collect { (lastVisible, total) -> - viewModel.onListScrolled(lastVisible, total) - } + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = listState.layoutInfo.totalItemsCount + lastVisible to total + } + .collect { (lastVisible, total) -> viewModel.onListScrolled(lastVisible, total) } } Box(modifier = Modifier.fillMaxSize().background(Color(0xFF05050A))) { @@ -119,7 +116,6 @@ fun HomeScreen( item { Spacer(modifier = Modifier.height(8.dp)) } item { SectionTitle("Мои ученики") } - } else { item { StudentJoinSection( @@ -129,7 +125,7 @@ fun HomeScreen( onJoin = { keyboard?.hide() viewModel.onJoinRoomClicked() - } + }, ) } @@ -142,14 +138,14 @@ fun HomeScreen( Text( text = "Список пуст", color = Color.White.copy(alpha = 0.5f), - modifier = Modifier.padding(top = 16.dp) + modifier = Modifier.padding(top = 16.dp), ) } } else { items(state.connections) { connection -> ActiveConnectionCard( connection = connection, - onClick = { viewModel.onConnectionClicked(connection.id) } + onClick = { viewModel.onConnectionClicked(connection.id) }, ) } } @@ -164,32 +160,36 @@ private fun HomeHeader( isLoading: Boolean, onLogout: () -> Unit, onSync: () -> Unit, - onToggleDebugRole: () -> Unit + onToggleDebugRole: () -> Unit, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 48.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), + modifier = + Modifier.fillMaxWidth() + .padding(top = 48.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.clickable { onToggleDebugRole() }) { Text( text = "SmartJam", fontSize = 24.sp, fontWeight = FontWeight.Bold, - color = Color.White + color = Color.White, ) Text( text = if (role == UserRole.TEACHER) "Режим преподавателя" else "Режим ученика", fontSize = 12.sp, - color = BrandCyan + color = BrandCyan, ) } Row(verticalAlignment = Alignment.CenterVertically) { if (isLoading) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp, + ) Spacer(modifier = Modifier.width(16.dp)) } @@ -210,7 +210,7 @@ private fun SectionTitle(text: String) { text = text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.8f) + color = Color.White.copy(alpha = 0.8f), ) } @@ -222,7 +222,13 @@ private fun TeacherInviteSection(code: String?, isLoading: Boolean, onGenerate: Spacer(modifier = Modifier.height(12.dp)) if (code != null) { - Text(text = code, fontSize = 36.sp, fontWeight = FontWeight.ExtraBold, color = BrandGold, letterSpacing = 4.sp) + Text( + text = code, + fontSize = 36.sp, + fontWeight = FontWeight.ExtraBold, + color = BrandGold, + letterSpacing = 4.sp, + ) Spacer(modifier = Modifier.height(16.dp)) } @@ -230,18 +236,26 @@ private fun TeacherInviteSection(code: String?, isLoading: Boolean, onGenerate: text = if (code == null) "Сгенерировать код" else "Обновить код", onClick = onGenerate, enabled = !isLoading, - modifier = Modifier.fillMaxWidth().height(50.dp) + modifier = Modifier.fillMaxWidth().height(50.dp), ) } } } - @Composable -private fun StudentJoinSection(inputValue: String, isLoading: Boolean, onInputChange: (String) -> Unit, onJoin: () -> Unit) { +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) + Text( + "Присоединиться к классу", + color = Color.White.copy(alpha = 0.6f), + fontSize = 14.sp, + ) Spacer(modifier = Modifier.height(12.dp)) AppleGlassTextField( @@ -249,12 +263,10 @@ private fun StudentJoinSection(inputValue: String, isLoading: Boolean, onInputCh onValueChange = onInputChange, hint = "Введите код (напр. A1B2C)", icon = Icons.Default.Person, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { onJoin() }), - enabled = !isLoading + enabled = !isLoading, ) Spacer(modifier = Modifier.height(16.dp)) @@ -262,30 +274,50 @@ private fun StudentJoinSection(inputValue: String, isLoading: Boolean, onInputCh text = "Отправить заявку", onClick = onJoin, enabled = !isLoading && inputValue.isNotBlank(), - modifier = Modifier.fillMaxWidth().height(50.dp) + modifier = Modifier.fillMaxWidth().height(50.dp), ) } } } @Composable -private fun PendingRequestCard(connection: Connection, onAccept: (String) -> Unit, onReject: (String) -> Unit) { +private fun PendingRequestCard( + connection: Connection, + onAccept: (String) -> Unit, + onReject: (String) -> Unit, +) { GlassContainer { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Column { - Text("Новая заявка", color = BrandGold, fontSize = 12.sp, fontWeight = FontWeight.Bold) - Text(connection.peerName, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Text( + "Новая заявка", + color = BrandGold, + 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(ErrorRed.copy(0.2f), RoundedCornerShape(12.dp))) { + IconButton( + onClick = { onReject(connection.id) }, + modifier = Modifier.background(ErrorRed.copy(0.2f), RoundedCornerShape(12.dp)), + ) { Icon(Icons.Default.Close, contentDescription = "Отклонить", tint = ErrorRed) } Spacer(modifier = Modifier.width(8.dp)) - IconButton(onClick = { onAccept(connection.id) }, modifier = Modifier.background(BrandCyan.copy(0.2f), RoundedCornerShape(12.dp))) { + IconButton( + onClick = { onAccept(connection.id) }, + modifier = Modifier.background(BrandCyan.copy(0.2f), RoundedCornerShape(12.dp)), + ) { Icon(Icons.Default.Check, contentDescription = "Принять", tint = BrandCyan) } } @@ -296,42 +328,47 @@ private fun PendingRequestCard(connection: Connection, onAccept: (String) -> Uni @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) + 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) { val model = connection.peerAvatarBytes ?: connection.peerAvatarUrl if (model != null) { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(model) - .crossfade(true) - .build(), + model = + ImageRequest.Builder(LocalContext.current) + .data(model) + .crossfade(true) + .build(), contentDescription = null, - modifier = Modifier.size(48.dp).clip(RoundedCornerShape(24.dp)) + modifier = Modifier.size(48.dp).clip(RoundedCornerShape(24.dp)), ) } else { Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(24.dp)) - .background(Color.White.copy(0.1f)), - contentAlignment = Alignment.Center + 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( + connection.peerName, + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) Text("Нажмите, чтобы открыть", color = Color.White.copy(0.5f), fontSize = 13.sp) } } } } - 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 c202e93..6dc31e8 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 @@ -25,18 +25,20 @@ data class HomeState( val endReached: Boolean = false, val nextPage: Int = 1, val pageSize: Int = 20, - val errorMessage: String? = null + 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 + private val authRepository: AuthRepository, ) : ViewModel() { private val _state = MutableStateFlow(HomeState()) @@ -52,11 +54,12 @@ class HomeViewModel( init { viewModelScope.launch { authRepository.userRole.collect { roleString -> - val newRole = try { - UserRole.valueOf(roleString ?: "STUDENT") - } catch (e: Exception) { - UserRole.STUDENT - } + val newRole = + try { + UserRole.valueOf(roleString ?: "STUDENT") + } catch (e: Exception) { + UserRole.STUDENT + } if (!hasStarted || _state.value.currentRole != newRole) { hasStarted = true @@ -68,11 +71,12 @@ class HomeViewModel( } fun toggleDebugRole() { - val newRole = if (_state.value.currentRole == UserRole.STUDENT) { - UserRole.TEACHER - } else { - UserRole.STUDENT - } + val newRole = + if (_state.value.currentRole == UserRole.STUDENT) { + UserRole.TEACHER + } else { + UserRole.STUDENT + } viewModelScope.launch { val refreshed = authRepository.refreshWithRole(newRole) @@ -93,11 +97,7 @@ class HomeViewModel( launch { connectionRepository.getConnectionsFlow(role).collect { connections -> - _state.update { currentState -> - currentState.copy( - connections = connections - ) - } + _state.update { currentState -> currentState.copy(connections = connections) } } } @@ -130,11 +130,12 @@ class HomeViewModel( viewModelScope.launch { _state.update { it.copy(isLoading = true, errorMessage = null) } - val result = connectionRepository.syncConnectionsPage( - _state.value.currentRole, - page = 0, - size = _state.value.pageSize - ) + val result = + connectionRepository.syncConnectionsPage( + _state.value.currentRole, + page = 0, + size = _state.value.pageSize, + ) if (result.isFailure) { _state.update { it.copy(errorMessage = "Не удалось обновить данные с сервера") } @@ -148,16 +149,19 @@ class HomeViewModel( viewModelScope.launch { _state.update { it.copy(isPaging = true, errorMessage = null) } - val result = connectionRepository.syncConnectionsPage( - _state.value.currentRole, - page = _state.value.nextPage, - size = _state.value.pageSize - ) + val result = + connectionRepository.syncConnectionsPage( + _state.value.currentRole, + page = _state.value.nextPage, + size = _state.value.pageSize, + ) if (result.isSuccess) { val pageInfo = result.getOrNull()!! val endReached = pageInfo.pageNumber + 1 >= pageInfo.totalPages - _state.update { it.copy(nextPage = pageInfo.pageNumber + 1, endReached = endReached) } + _state.update { + it.copy(nextPage = pageInfo.pageNumber + 1, endReached = endReached) + } } else { _state.update { it.copy(errorMessage = "Не удалось загрузить следующую страницу") } } @@ -230,9 +234,7 @@ class HomeViewModel( } fun onConnectionClicked(connectionId: String) { - viewModelScope.launch { - eventChannel.send(HomeEvent.NavigateToRoom(connectionId)) - } + viewModelScope.launch { eventChannel.send(HomeEvent.NavigateToRoom(connectionId)) } } fun onLogoutClicked() { @@ -245,10 +247,10 @@ class HomeViewModel( class HomeViewModelFactory( private val connectionRepository: ConnectionRepository, - private val authRepository: AuthRepository + 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 index 21c5686..ae2007e 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 @@ -2,18 +2,9 @@ package com.smartjam.app.ui.screens.login import android.widget.Toast import androidx.compose.animation.animateColorAsState -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 @@ -24,40 +15,29 @@ 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 com.smartjam.app.domain.model.UserRole @@ -65,17 +45,16 @@ import com.smartjam.app.ui.components.AppleGlassButton import com.smartjam.app.ui.components.AppleGlassTextField import com.smartjam.app.ui.components.AppleLiquidBackground import com.smartjam.app.ui.components.GoldenStringsButton -import com.smartjam.app.ui.theme.CoreBackground -import com.smartjam.app.ui.theme.ErrorRed import com.smartjam.app.ui.theme.BrandCyan import com.smartjam.app.ui.theme.BrandGold +import com.smartjam.app.ui.theme.CoreBackground +import com.smartjam.app.ui.theme.ErrorRed @Composable fun LoginScreen( - viewModel: LoginViewModel, onNavigateToHome: () -> Unit = {}, - onNavigateToRegister: () -> Unit = {} + onNavigateToRegister: () -> Unit = {}, ) { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -91,29 +70,23 @@ fun LoginScreen( } } - Box( - modifier = Modifier - .fillMaxSize() - .background(CoreBackground) - ) { + Box(modifier = Modifier.fillMaxSize().background(CoreBackground)) { AppleLiquidBackground() Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 32.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text( text = "SmartJam", fontSize = 42.sp, fontWeight = FontWeight.ExtraBold, - style = TextStyle( - brush = Brush.linearGradient( - colors = listOf(Color.White, Color(0xFFE0E0E0)) - ) - ) + style = + TextStyle( + brush = + Brush.linearGradient(colors = listOf(Color.White, Color(0xFFE0E0E0))) + ), ) Text( @@ -121,12 +94,12 @@ fun LoginScreen( fontSize = 16.sp, fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.5f), - modifier = Modifier.padding(top = 4.dp, bottom = 56.dp) + modifier = Modifier.padding(top = 4.dp, bottom = 56.dp), ) GlassRoleSelector( selectedRole = state.selectedRole, - onRoleSelected = { viewModel.onRoleSelected(it) } + onRoleSelected = { viewModel.onRoleSelected(it) }, ) Spacer(modifier = Modifier.height(24.dp)) @@ -136,10 +109,8 @@ fun LoginScreen( onValueChange = { viewModel.onEmailChanged(it) }, hint = "Email", icon = Icons.Default.Email, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next - ) + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), ) Spacer(modifier = Modifier.height(20.dp)) @@ -150,25 +121,24 @@ fun LoginScreen( hint = "Пароль", icon = Icons.Default.Lock, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { viewModel.onLoginClicked() }) + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { viewModel.onLoginClicked() }), ) Box( - modifier = Modifier - .fillMaxWidth() - .height(40.dp), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxWidth().height(40.dp), + contentAlignment = Alignment.Center, ) { if (state.errorMessage != null) { Text( text = state.errorMessage!!, color = ErrorRed, fontSize = 14.sp, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, ) } } @@ -176,7 +146,7 @@ fun LoginScreen( GoldenStringsButton( text = if (state.isLoading) "Загрузка..." else "Войти", onClick = { viewModel.onLoginClicked() }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(16.dp)) @@ -185,9 +155,7 @@ fun LoginScreen( fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = BrandCyan, - modifier = Modifier - .clickable { onNavigateToRegister() } - .padding(8.dp) + modifier = Modifier.clickable { onNavigateToRegister() }.padding(8.dp), ) Spacer(modifier = Modifier.height(16.dp)) @@ -196,48 +164,45 @@ fun LoginScreen( text = "или продолжить через", fontSize = 13.sp, color = Color.White.copy(alpha = 0.4f), - modifier = Modifier.padding(vertical = 16.dp) + modifier = Modifier.padding(vertical = 16.dp), ) AppleGlassButton( onClick = { /* TODO: Google Auth */ }, text = "Google", - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } } @Composable -fun GlassRoleSelector( - selectedRole: UserRole, - onRoleSelected: (UserRole) -> Unit -) { +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 + 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) + modifier = Modifier.weight(1f), ) RoleButton( text = "Я преподаватель", isSelected = selectedRole == UserRole.TEACHER, onClick = { onRoleSelected(UserRole.TEACHER) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } } @@ -247,28 +212,30 @@ fun RoleButton( text: String, isSelected: Boolean, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - val backgroundColor by animateColorAsState( - targetValue = if (isSelected) BrandGold.copy(alpha = 0.2f) else Color.Transparent, - label = "RoleColorAnimation" - ) + val backgroundColor by + animateColorAsState( + targetValue = if (isSelected) BrandGold.copy(alpha = 0.2f) else Color.Transparent, + label = "RoleColorAnimation", + ) val textColor = if (isSelected) BrandGold else Color.White.copy(alpha = 0.5f) Box( - modifier = modifier - .fillMaxHeight() - .clip(RoundedCornerShape(24.dp)) - .background(backgroundColor) - .clickable { onClick() }, - contentAlignment = Alignment.Center + 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 + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, ) } } 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 8878591..b87aa0f 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,6 +1,5 @@ package com.smartjam.app.ui.screens.login - import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -16,28 +15,30 @@ import kotlinx.coroutines.launch data class LoginState( val emailInput: String = "", val passwordInput: String = "", - val selectedRole: com.smartjam.app.domain.model.UserRole = com.smartjam.app.domain.model.UserRole.STUDENT, + val selectedRole: com.smartjam.app.domain.model.UserRole = + com.smartjam.app.domain.model.UserRole.STUDENT, val isLoading: Boolean = false, - val errorMessage: String? = null + val errorMessage: String? = null, ) -sealed class LoginEvent{ +sealed class LoginEvent { object NavigateToHome : LoginEvent() + data class ShowToast(val message: String) : LoginEvent() } -class LoginViewModel ( +class LoginViewModel( private val authRepository: AuthRepository, - private val tokenStorage: com.smartjam.app.data.local.TokenStorage -) : ViewModel(){ + private val tokenStorage: com.smartjam.app.data.local.TokenStorage, +) : ViewModel() { private val _state = MutableStateFlow(LoginState()) - val state : StateFlow = _state.asStateFlow() + val state: StateFlow = _state.asStateFlow() private val eventChannel = Channel(Channel.BUFFERED) val events = eventChannel.receiveAsFlow() - fun onPasswordChanged(newPassword: String){ + fun onPasswordChanged(newPassword: String) { _state.update { it.copy(passwordInput = newPassword, errorMessage = null) } } @@ -50,14 +51,14 @@ class LoginViewModel ( } fun onLoginClicked() { - if (_state.value.isLoading){ + if (_state.value.isLoading) { return } val currentEmail = _state.value.emailInput val currentPassword = _state.value.passwordInput val selectedRole = _state.value.selectedRole - if (currentPassword.isBlank() || currentEmail.isBlank()){ + if (currentPassword.isBlank() || currentEmail.isBlank()) { _state.update { it.copy(errorMessage = "Fill in all fields") } return } @@ -67,29 +68,24 @@ class LoginViewModel ( try { val result = authRepository.login(currentEmail, currentPassword, selectedRole) - if (result.isSuccess){ + if (result.isSuccess) { eventChannel.send(LoginEvent.NavigateToHome) - } - else{ + } 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" - ) + } 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, - private val tokenStorage: com.smartjam.app.data.local.TokenStorage + private val tokenStorage: com.smartjam.app.data.local.TokenStorage, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -99,4 +95,4 @@ class LoginViewModelFactory( } 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/RegisterViewModel.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterViewModel.kt index 56adc91..3a0b21e 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 @@ -36,7 +36,9 @@ class RegisterViewModel(private val authRepository: AuthRepository) : ViewModel( 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-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\\D*\\d)(?=[^#?!@\$%^&*-]*[#?!@\$%^&*-]).{8,20}".toRegex() + private val passwordRegex = + "^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\\D*\\d)(?=[^#?!@\$%^&*-]*[#?!@\$%^&*-]).{8,20}" + .toRegex() fun onUsernameChanged(username: String) { _state.update { it.copy(usernameInput = username, errorMessage = null) } 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 index 2efad65..fcb7a26 100644 --- 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 @@ -11,9 +11,9 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -45,12 +45,7 @@ import java.io.File import java.util.UUID @Composable -fun RoomScreen( - connectionId: UUID, - role: UserRole, - viewModel: RoomViewModel, - onBack: () -> Unit -) { +fun RoomScreen(connectionId: UUID, role: UserRole, viewModel: RoomViewModel, onBack: () -> Unit) { val state by viewModel.uiState.collectAsState() val context = LocalContext.current val listState = rememberLazyListState() @@ -60,78 +55,88 @@ fun RoomScreen( var pendingSubmissionAssignmentId by remember { mutableStateOf(null) } var pendingSavePath by remember { mutableStateOf(null) } - val assignmentPicker = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - uri?.let { - val file = File(context.cacheDir, "temp_assignment_upload.wav") - context.contentResolver.openInputStream(it)?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) + val assignmentPicker = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { + uri: Uri? -> + uri?.let { + val file = File(context.cacheDir, "temp_assignment_upload.wav") + context.contentResolver.openInputStream(it)?.use { input -> + file.outputStream().use { output -> input.copyTo(output) } } + viewModel.uploadAssignment( + file, + pendingAssignmentTitle, + pendingAssignmentDescription.ifBlank { null }, + ) + pendingAssignmentTitle = "" + pendingAssignmentDescription = "" } - viewModel.uploadAssignment(file, pendingAssignmentTitle, pendingAssignmentDescription.ifBlank { null }) - pendingAssignmentTitle = "" - pendingAssignmentDescription = "" } - } - val submissionPicker = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - val assignmentId = pendingSubmissionAssignmentId ?: return@rememberLauncherForActivityResult - uri?.let { - val file = File(context.cacheDir, "temp_submission_upload.wav") - context.contentResolver.openInputStream(it)?.use { input -> - file.outputStream().use { output -> - input.copyTo(output) + val submissionPicker = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { + uri: Uri? -> + val assignmentId = + pendingSubmissionAssignmentId ?: return@rememberLauncherForActivityResult + uri?.let { + val file = File(context.cacheDir, "temp_submission_upload.wav") + context.contentResolver.openInputStream(it)?.use { input -> + file.outputStream().use { output -> input.copyTo(output) } } + viewModel.uploadSubmission(assignmentId, file) } - viewModel.uploadSubmission(assignmentId, file) } - } - val saveToDeviceLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("audio/wav") - ) { uri: Uri? -> - val path = pendingSavePath - if (uri != null && !path.isNullOrBlank()) { - val input = File(path) - context.contentResolver.openOutputStream(uri)?.use { output -> - input.inputStream().use { it.copyTo(output) } + val saveToDeviceLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("audio/wav") + ) { uri: Uri? -> + val path = pendingSavePath + if (uri != null && !path.isNullOrBlank()) { + val input = File(path) + context.contentResolver.openOutputStream(uri)?.use { output -> + input.inputStream().use { it.copyTo(output) } + } } + pendingSavePath = null } - pendingSavePath = null - } LaunchedEffect(listState) { snapshotFlow { - val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - val total = listState.layoutInfo.totalItemsCount - lastVisible to total - }.collect { (lastVisible, total) -> - viewModel.onListScrolled(lastVisible, total) - } + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = listState.layoutInfo.totalItemsCount + lastVisible to total + } + .collect { (lastVisible, total) -> viewModel.onListScrolled(lastVisible, total) } } Box(modifier = Modifier.fillMaxSize().background(Color(0xFF05050A))) { AppleLiquidBackground() Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp)) { - Spacer(modifier = Modifier.height(WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 16.dp)) + Spacer( + modifier = + Modifier.height( + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 16.dp + ) + ) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White) + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White, + ) } Spacer(modifier = Modifier.width(8.dp)) Text( text = "Room", fontSize = 28.sp, fontWeight = FontWeight.Bold, - color = Color.White + color = Color.White, ) } @@ -147,7 +152,7 @@ fun RoomScreen( onValueChange = { pendingAssignmentTitle = it }, hint = "Название урока", icon = Icons.Default.Edit, - enabled = !state.isUploading + enabled = !state.isUploading, ) Spacer(modifier = Modifier.height(8.dp)) AppleGlassTextField( @@ -155,14 +160,15 @@ fun RoomScreen( onValueChange = { pendingAssignmentDescription = it }, hint = "Описание (опционально)", icon = Icons.Default.Edit, - enabled = !state.isUploading + enabled = !state.isUploading, ) Spacer(modifier = Modifier.height(12.dp)) GoldenStringsButton( - text = if (state.isUploading) "Загрузка..." else "Загрузить эталон (.wav)", + text = + if (state.isUploading) "Загрузка..." else "Загрузить эталон (.wav)", enabled = !state.isUploading && pendingAssignmentTitle.isNotBlank(), onClick = { assignmentPicker.launch("audio/*") }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } @@ -170,50 +176,57 @@ fun RoomScreen( } if (state.error != null) { - Text(state.error ?: "", color = Color(0xFFFF5252), modifier = Modifier.padding(vertical = 8.dp)) + Text( + state.error ?: "", + color = Color(0xFFFF5252), + modifier = Modifier.padding(vertical = 8.dp), + ) } LazyColumn( state = listState, modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(state.assignments) { assignment -> - AssignmentCard( - assignment = assignment, - role = role, - submissions = state.submissionsByAssignment[assignment.id].orEmpty(), - feedbackBySubmission = state.feedbackBySubmission, - onExpand = { viewModel.onAssignmentExpanded(assignment.id) }, - onUploadSubmission = { - pendingSubmissionAssignmentId = assignment.id - submissionPicker.launch("audio/*") - }, - onSaveAudio = { path -> + AssignmentCard( + assignment = assignment, + role = role, + submissions = state.submissionsByAssignment[assignment.id].orEmpty(), + feedbackBySubmission = state.feedbackBySubmission, + onExpand = { viewModel.onAssignmentExpanded(assignment.id) }, + onUploadSubmission = { + pendingSubmissionAssignmentId = assignment.id + submissionPicker.launch("audio/*") + }, + onSaveAudio = { path -> + pendingSavePath = path + saveToDeviceLauncher.launch("${assignment.title}.wav") + }, + onDownloadReference = { aId -> + viewModel.downloadReference(aId) { path -> + if (!path.isNullOrBlank()) { pendingSavePath = path saveToDeviceLauncher.launch("${assignment.title}.wav") - }, - onDownloadReference = { aId -> - viewModel.downloadReference(aId) { path -> - if (!path.isNullOrBlank()) { - pendingSavePath = path - saveToDeviceLauncher.launch("${assignment.title}.wav") - } - } - }, - onDownloadSubmission = { submissionId, url -> - viewModel.downloadSubmissionAudio(submissionId, assignment.id, url) { path: String? -> - if (!path.isNullOrBlank()) { - pendingSavePath = path - saveToDeviceLauncher.launch("${assignment.title}_${submissionId}.wav") - } - } - }, - onSaveLocalSubmission = { path, submissionId -> + } + } + }, + onDownloadSubmission = { submissionId, url -> + viewModel.downloadSubmissionAudio(submissionId, assignment.id, url) { + path: String? -> + if (!path.isNullOrBlank()) { pendingSavePath = path - saveToDeviceLauncher.launch("${assignment.title}_${submissionId}.wav") + saveToDeviceLauncher.launch( + "${assignment.title}_${submissionId}.wav" + ) } - ) + } + }, + onSaveLocalSubmission = { path, submissionId -> + pendingSavePath = path + saveToDeviceLauncher.launch("${assignment.title}_${submissionId}.wav") + }, + ) } } } @@ -221,17 +234,17 @@ fun RoomScreen( } @Composable - private fun AssignmentCard( +private fun AssignmentCard( assignment: AssignmentEntity, role: UserRole, submissions: List, feedbackBySubmission: Map>, onExpand: () -> Unit, onUploadSubmission: () -> Unit, - onSaveAudio: (String) -> Unit - , onDownloadReference: (UUID) -> Unit, + onSaveAudio: (String) -> Unit, + onDownloadReference: (UUID) -> Unit, onDownloadSubmission: (UUID, String?) -> Unit, - onSaveLocalSubmission: (String, UUID) -> Unit + onSaveLocalSubmission: (String, UUID) -> Unit, ) { var expanded by remember { mutableStateOf(false) } @@ -240,20 +253,33 @@ fun RoomScreen( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { Column(modifier = Modifier.weight(1f)) { - Text(assignment.title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp) - Text("Статус: ${assignment.status}", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp) + Text( + assignment.title, + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ) + Text( + "Статус: ${assignment.status}", + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp, + ) } - IconButton(onClick = { - expanded = !expanded - if (expanded) onExpand() - }) { + IconButton( + onClick = { + expanded = !expanded + if (expanded) onExpand() + } + ) { Icon( - imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + imageVector = + if (expanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, contentDescription = "Expand", - tint = Color.White + tint = Color.White, ) } } @@ -270,14 +296,14 @@ fun RoomScreen( GoldenStringsButton( text = "Сохранить на устройство", onClick = { onSaveAudio(localPath) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } else if (role == UserRole.STUDENT) { Spacer(modifier = Modifier.height(12.dp)) GoldenStringsButton( text = "Скачать эталон", onClick = { onDownloadReference(assignment.id) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } @@ -286,7 +312,7 @@ fun RoomScreen( GoldenStringsButton( text = "Загрузить попытку (.wav)", onClick = onUploadSubmission, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } @@ -295,19 +321,19 @@ fun RoomScreen( Text( text = if (role == UserRole.TEACHER) "Попытки ученика" else "Мои попытки", color = Color.White, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(8.dp)) - submissions.forEach { submission -> - SubmissionCard( - submission = submission, - feedback = feedbackBySubmission[submission.id].orEmpty(), - role = role, - onDownloadSubmission = { id, url -> onDownloadSubmission(id, url) }, - onSaveLocal = { path -> onSaveLocalSubmission(path, submission.id) } - ) - Spacer(modifier = Modifier.height(8.dp)) - } + submissions.forEach { submission -> + SubmissionCard( + submission = submission, + feedback = feedbackBySubmission[submission.id].orEmpty(), + role = role, + onDownloadSubmission = { id, url -> onDownloadSubmission(id, url) }, + onSaveLocal = { path -> onSaveLocalSubmission(path, submission.id) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } } } } @@ -320,7 +346,7 @@ private fun SubmissionCard( feedback: List, role: UserRole, onDownloadSubmission: (UUID, String?) -> Unit, - onSaveLocal: (String) -> Unit + onSaveLocal: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } @@ -329,7 +355,7 @@ private fun SubmissionCard( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { Column { Text("Статус: ${submission.status}", color = Color.White) @@ -338,9 +364,11 @@ private fun SubmissionCard( } IconButton(onClick = { expanded = !expanded }) { Icon( - imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + imageVector = + if (expanded) Icons.Default.KeyboardArrowUp + else Icons.Default.KeyboardArrowDown, contentDescription = "Expand", - tint = Color.White + tint = Color.White, ) } } @@ -349,9 +377,18 @@ private fun SubmissionCard( Spacer(modifier = Modifier.height(8.dp)) Text("Результаты анализа", fontWeight = FontWeight.Bold, color = Color.White) Spacer(modifier = Modifier.height(4.dp)) - Text("Total: ${submission.totalScore ?: 0f}%", color = Color.White.copy(alpha = 0.8f)) - Text("Pitch: ${submission.pitchScore ?: 0f}", color = Color.White.copy(alpha = 0.8f)) - Text("Rhythm: ${submission.rhythmScore ?: 0f}", color = Color.White.copy(alpha = 0.8f)) + Text( + "Total: ${submission.totalScore ?: 0f}%", + color = Color.White.copy(alpha = 0.8f), + ) + Text( + "Pitch: ${submission.pitchScore ?: 0f}", + color = Color.White.copy(alpha = 0.8f), + ) + Text( + "Rhythm: ${submission.rhythmScore ?: 0f}", + color = Color.White.copy(alpha = 0.8f), + ) if (role == UserRole.TEACHER) { Spacer(modifier = Modifier.height(8.dp)) @@ -359,13 +396,13 @@ private fun SubmissionCard( GoldenStringsButton( text = "Скачать запись ученика", onClick = { onSaveLocal(submission.submissionAudioLocalPath) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } else if (!submission.fileUrl.isNullOrBlank()) { GoldenStringsButton( text = "Скачать запись ученика", onClick = { onDownloadSubmission(submission.id, submission.fileUrl) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } @@ -393,14 +430,19 @@ private fun ErrorTimelineChart(feedback: List) { feedback.forEach { event -> val startX = (event.teacherStartTime / maxEnd).toFloat() * width val endX = (event.teacherEndTime / maxEnd).toFloat() * width - val color = when (event.type) { - FeedbackType.WRONG_NOTE -> Color(0xFFFF5252) - FeedbackType.WRONG_RHYTHM -> Color(0xFFFFD166) - } + val color = + when (event.type) { + FeedbackType.WRONG_NOTE -> Color(0xFFFF5252) + FeedbackType.WRONG_RHYTHM -> Color(0xFFFFD166) + } drawRect( color = color, topLeft = androidx.compose.ui.geometry.Offset(startX, 0f), - size = androidx.compose.ui.geometry.Size((endX - startX).coerceAtLeast(2f), size.height) + size = + androidx.compose.ui.geometry.Size( + (endX - startX).coerceAtLeast(2f), + size.height, + ), ) } } 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 index 8633c17..fe3a236 100644 --- 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 @@ -8,13 +8,13 @@ import com.smartjam.app.data.local.entity.SubmissionResultEntity import com.smartjam.app.domain.repository.RoomRepository import com.smartjam.app.model.CreateAssignmentRequest import com.smartjam.app.model.FeedbackEvent +import java.io.File +import java.util.* import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.io.File -import java.util.* data class RoomUiState( val assignments: List = emptyList(), @@ -26,13 +26,11 @@ data class RoomUiState( val nextPage: Int = 1, val pageSize: Int = 20, val isUploading: Boolean = false, - val error: String? = null + val error: String? = null, ) -class RoomViewModel( - private val connectionId: UUID, - private val repository: RoomRepository -) : ViewModel() { +class RoomViewModel(private val connectionId: UUID, private val repository: RoomRepository) : + ViewModel() { private val _uiState = kotlinx.coroutines.flow.MutableStateFlow(RoomUiState()) val uiState = _uiState.asStateFlow() @@ -65,7 +63,12 @@ class RoomViewModel( fun refreshFirstPage() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } - val result = repository.syncAssignmentsPage(connectionId, page = 0, size = _uiState.value.pageSize) + val result = + repository.syncAssignmentsPage( + connectionId, + page = 0, + size = _uiState.value.pageSize, + ) if (result.isFailure) { _uiState.update { it.copy(error = "Не удалось обновить список уроков") } } @@ -76,15 +79,18 @@ class RoomViewModel( private fun loadNextPage() { viewModelScope.launch { _uiState.update { it.copy(isPaging = true, error = null) } - val result = repository.syncAssignmentsPage( - connectionId, - page = _uiState.value.nextPage, - size = _uiState.value.pageSize - ) + val result = + repository.syncAssignmentsPage( + connectionId, + page = _uiState.value.nextPage, + size = _uiState.value.pageSize, + ) if (result.isSuccess) { val pageInfo = result.getOrNull()!! val endReached = pageInfo.pageNumber + 1 >= pageInfo.totalPages - _uiState.update { it.copy(nextPage = pageInfo.pageNumber + 1, endReached = endReached) } + _uiState.update { + it.copy(nextPage = pageInfo.pageNumber + 1, endReached = endReached) + } } else { _uiState.update { it.copy(error = "Не удалось загрузить следующую страницу") } } @@ -144,7 +150,8 @@ class RoomViewModel( } /** - * Ensure reference audio for assignment is cached locally. Calls onResult with local path or null on failure. + * Ensure reference audio for assignment is cached locally. Calls onResult with local path or + * null on failure. */ fun downloadReference(assignmentId: UUID, onResult: (String?) -> Unit) { viewModelScope.launch { @@ -157,7 +164,12 @@ class RoomViewModel( } } - fun downloadSubmissionAudio(submissionId: UUID, assignmentId: UUID, fileUrl: String?, onResult: (String?) -> Unit) { + fun downloadSubmissionAudio( + submissionId: UUID, + assignmentId: UUID, + fileUrl: String?, + onResult: (String?) -> Unit, + ) { viewModelScope.launch { val res = repository.cacheSubmissionAudioIfNeeded(submissionId, assignmentId, fileUrl) if (res.isSuccess) { @@ -174,12 +186,16 @@ class RoomViewModel( repository.getSubmissionsFlow(assignmentId).collect { submissions -> _uiState.update { state -> state.copy( - submissionsByAssignment = state.submissionsByAssignment + (assignmentId to submissions) + submissionsByAssignment = + state.submissionsByAssignment + (assignmentId to submissions) ) } submissions.forEach { submission -> val hasFeedback = _uiState.value.feedbackBySubmission.containsKey(submission.id) - val needsDetailFetch = submission.pitchScore == null || submission.rhythmScore == null || !hasFeedback + val needsDetailFetch = + submission.pitchScore == null || + submission.rhythmScore == null || + !hasFeedback if (needsDetailFetch) { viewModelScope.launch { val res = repository.getSubmissionResult(submission.id, assignmentId) @@ -187,7 +203,10 @@ class RoomViewModel( val dto = res.getOrNull()!! val feedback = dto.feedback ?: emptyList() _uiState.update { st -> - st.copy(feedbackBySubmission = st.feedbackBySubmission + (submission.id to feedback)) + st.copy( + feedbackBySubmission = + st.feedbackBySubmission + (submission.id to feedback) + ) } } } @@ -205,7 +224,10 @@ class RoomViewModel( val dto = result.getOrNull()!! val feedback = dto.feedback ?: emptyList() _uiState.update { state -> - state.copy(feedbackBySubmission = state.feedbackBySubmission + (submissionId to feedback)) + state.copy( + feedbackBySubmission = + state.feedbackBySubmission + (submissionId to feedback) + ) } val status = dto.status.name if (status == "COMPLETED" || status == "FAILED") { @@ -218,10 +240,8 @@ class RoomViewModel( } } -class RoomViewModelFactory( - private val connectionId: UUID, - private val repository: RoomRepository -) : ViewModelProvider.Factory { +class RoomViewModelFactory(private val connectionId: UUID, private val repository: RoomRepository) : + ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { return RoomViewModel(connectionId, repository) as T diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index 5af0413..8dfe542 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -8,7 +8,6 @@ plugins { } - subprojects { apply(plugin = "com.diffplug.spotless") From e96ef4a9494e03f06539f815e761ac8aa26368c2 Mon Sep 17 00:00:00 2001 From: Satlykovs Date: Tue, 26 May 2026 01:21:16 +0300 Subject: [PATCH 17/17] fix: it's working (almost everything =) --- .../java/com/smartjam/app/MainActivity.kt | 2 +- .../domain/repository/ConnectionRepository.kt | 5 +---- .../app/ui/screens/home/HomeViewModel.kt | 22 +++++++++++++++---- mobile/gradle/libs.versions.toml | 2 ++ 4 files changed, 22 insertions(+), 9 deletions(-) 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 ece30a3..9a3c912 100644 --- a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt @@ -34,7 +34,7 @@ import com.smartjam.app.domain.repository.ConnectionRepository import com.smartjam.app.domain.repository.RoomRepository import com.smartjam.app.ui.navigation.Screen import com.smartjam.app.ui.navigation.SmartJamNavGraph -import kotlin.time.Instant +import java.time.Instant import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking 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 index 947e6e2..13f3f2d 100644 --- 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 @@ -1,6 +1,5 @@ package com.smartjam.app.domain.repository -import android.util.Log import com.smartjam.app.api.ConnectionsApi import com.smartjam.app.data.local.dao.ConnectionDao import com.smartjam.app.data.local.entity.ConnectionEntity @@ -46,9 +45,6 @@ class ConnectionRepository(private val api: ConnectionsApi, private val dao: Con ): Result { return try { val activeResponse = api.getMyConnections(page = page, size = size) - - Log.e("HUESOS", activeResponse.toString()) - Log.e("HUESOS", activeResponse.body()!!.content.toString()) if (activeResponse.isSuccessful && activeResponse.body() != null) { val body = activeResponse.body()!! val activeItems = body.content @@ -95,6 +91,7 @@ class ConnectionRepository(private val api: ConnectionsApi, private val dao: Con } } catch (e: Exception) { if (e is CancellationException) throw e + Result.failure(e) } } 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 6dc31e8..4252e5a 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 @@ -128,6 +128,7 @@ class HomeViewModel( private fun refreshFirstPage() { viewModelScope.launch { + // 1. Включаем загрузку и сбрасываем старую ошибку _state.update { it.copy(isLoading = true, errorMessage = null) } val result = @@ -137,11 +138,24 @@ class HomeViewModel( size = _state.value.pageSize, ) - if (result.isFailure) { - _state.update { it.copy(errorMessage = "Не удалось обновить данные с сервера") } + // 2. Обрабатываем результат и выключаем загрузку в зависимости от исхода + if (result.isSuccess) { + _state.update { + it.copy( + isLoading = false, + errorMessage = null, + // Здесь также можно обновить список студентов, если они берутся из state + // students = result.getOrNull()?.content ?: emptyList() + ) + } + } else { + _state.update { + it.copy( + isLoading = false, + errorMessage = "Не удалось обновить данные с сервера", + ) + } } - - _state.update { it.copy(isLoading = false) } } } diff --git a/mobile/gradle/libs.versions.toml b/mobile/gradle/libs.versions.toml index fe62c4a..0468807 100644 --- a/mobile/gradle/libs.versions.toml +++ b/mobile/gradle/libs.versions.toml @@ -5,6 +5,7 @@ converterGson = "3.0.0" converterScalars = "3.0.0" coreKtx = "1.18.0" datastorePreferences = "1.2.1" +gsonJavatimeSerialisers = "1.1.2" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" @@ -36,6 +37,7 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "converterScalars" } +gson-javatime-serialisers = { module = "com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers", version.ref = "gsonJavatimeSerialisers" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }