diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index cb16e5e..dd0894f 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -21,16 +21,19 @@ 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' - 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 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 7c496e5..88adf4f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ *.iml *.iws -.vscode/ \ No newline at end of file +.vscode/ + +firebase-adminsdk.json +google-services.json \ No newline at end of file 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/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 80% 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 92141dc..2395e62 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.common.dto.s3.S3EventDto; -import com.smartjam.smartjamanalyzer.application.AudioAnalysisUseCase; +import com.smartjam.analyzer.application.AudioAnalysisUseCase; +import com.smartjam.common.dto.s3.S3Event; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; @@ -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 { @@ -51,9 +51,9 @@ public void onFileUploaded(S3EventDto 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) { @@ -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/analyzer/application/AudioAnalysisUseCase.java similarity index 72% 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 ef33d08..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,12 +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.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; @@ -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/analyzer/domain/exception/AnalysisFatalException.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java new file mode 100644 index 0000000..c4c5ed0 --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/exception/AnalysisFatalException.java @@ -0,0 +1,11 @@ +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-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/analyzer/domain/port/AnalysisEventPublisher.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AnalysisEventPublisher.java new file mode 100644 index 0000000..009c394 --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/domain/port/AnalysisEventPublisher.java @@ -0,0 +1,7 @@ +package com.smartjam.analyzer.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/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/analyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java new file mode 100644 index 0000000..17e8a90 --- /dev/null +++ b/backend/smartjam-analyzer/src/main/java/com/smartjam/analyzer/infrastructure/messaging/KafkaAnalysisEventPublisher.java @@ -0,0 +1,21 @@ +package com.smartjam.analyzer.infrastructure.messaging; + +import com.smartjam.analyzer.domain.port.AnalysisEventPublisher; +import com.smartjam.common.dto.analysis.AnalysisFinishedEvent; +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/analyzer/infrastructure/persistence/adapter/AssignmentPersistenceAdapter.java similarity index 73% 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 fe354a2..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,14 +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.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; @@ -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/analyzer/infrastructure/persistence/adapter/SubmissionPersistenceAdapter.java similarity index 76% 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 c1b73dc..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,13 +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.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; @@ -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/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 71% 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 9e68f5f..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.common.dto.s3.S3EventDto; -import com.smartjam.smartjamanalyzer.api.kafka.S3StorageListener; -import com.smartjam.smartjamanalyzer.application.AudioAnalysisUseCase; +import com.smartjam.analyzer.api.kafka.S3StorageListener; +import com.smartjam.analyzer.application.AudioAnalysisUseCase; +import com.smartjam.common.dto.s3.S3Event; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -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/analyzer/application/AudioAnalysisIntegrationTest.java similarity index 91% 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 9df5e55..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; @@ -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-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 68% 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..b131401 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,13 @@ -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.exception.AnalysisFatalException; +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; @@ -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 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-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/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-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/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/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/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()); diff --git a/backend/smartjam-notification/build.gradle b/backend/smartjam-notification/build.gradle new file mode 100644 index 0000000..66d504a --- /dev/null +++ b/backend/smartjam-notification/build.gradle @@ -0,0 +1,18 @@ +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 'com.google.firebase:firebase-admin:9.9.0' + + + 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/api/kafka/AnalysisResultListener.java b/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java new file mode 100644 index 0000000..5bf9835 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/api/kafka/AnalysisResultListener.java @@ -0,0 +1,42 @@ +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; + +/** + * 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 +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 new file mode 100644 index 0000000..7d6e004 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/application/ProcessAnalysisResultUseCase.java @@ -0,0 +1,53 @@ +package com.smartjam.notification.application; + +import java.util.List; +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 lombok.RequiredArgsConstructor; +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 +public class ProcessAnalysisResultUseCase { + private final RecipientResolver recipientResolver; + 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()); + + if (event.status() == AudioProcessingStatus.COMPLETED) { + try { + UUID userId = recipientResolver.findOwnerId(event.targetId(), event.type()); + + List tokens = recipientResolver.findFcmTokens(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(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 new file mode 100644 index 0000000..de55056 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/PushPublisher.java @@ -0,0 +1,16 @@ +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). + */ +public interface PushPublisher { + /** + * Sends a push notification to a specific device. + * + * @param fcmTokens The target device's registration token. + * @param message The text content of the notification. + */ + 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 new file mode 100644 index 0000000..5c6ebbf --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/domain/port/RecipientResolver.java @@ -0,0 +1,18 @@ +package com.smartjam.notification.domain.port; + +import java.util.List; +import java.util.UUID; + +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 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 new file mode 100644 index 0000000..d580209 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/DebugLoggingPushAdapter.java @@ -0,0 +1,31 @@ +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; +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(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 new file mode 100644 index 0000000..2fefca5 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FcmPushAdapter.java @@ -0,0 +1,58 @@ +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.MulticastMessage; +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; + +/** + * 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") +public class FcmPushAdapter implements PushPublisher { + + @Override + public void sendPush(List fcmTokens, String messageText) { + try { + Notification notification = Notification.builder() + .setTitle("SmartJam") + .setBody(messageText) + .build(); + + MulticastMessage message = MulticastMessage.builder() + .addAllTokens(fcmTokens) + .setNotification(notification) + .build(); + + BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message); + + log.info( + "Multicast push sent. Success: {}, Failure: {}", + response.getSuccessCount(), + response.getFailureCount()); + + 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) { + + 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 new file mode 100644 index 0000000..7e8c8d0 --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/fcm/FirebaseConfig.java @@ -0,0 +1,53 @@ +package com.smartjam.notification.infrastructure.fcm; + +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 + * 'firebase-adminsdk.json' resource file. + */ +@Slf4j +@Configuration +@Profile("!debug") +public class FirebaseConfig +{ + @PostConstruct + public void init() + { + try (InputStream serviceAccount = getClass().getClassLoader() + .getResourceAsStream("firebase-adminsdk.json");) + { + + + if (serviceAccount == null) + { + + throw new IllegalStateException("firebase-adminsdk.json not found in resources"); + + } + + 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("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 new file mode 100644 index 0000000..221c00d --- /dev/null +++ b/backend/smartjam-notification/src/main/java/com/smartjam/notification/infrastructure/persistence/adapter/RecipientPersistenceAdapter.java @@ -0,0 +1,35 @@ +package com.smartjam.notification.infrastructure.persistence.adapter; + +import java.util.List; +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; + +/** + * 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 { + 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); + } + + @Override + public List findFcmTokens(UUID userId) { + return jdbcTemplate.queryForList("SELECT fcm_token FROM user_devices WHERE user_id = ?", String.class, userId); + } +} 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 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..aab081c --- /dev/null +++ b/backend/smartjam-notification/src/test/java/com/smartjam/notification/SmartjamNotificationApplicationTests.java @@ -0,0 +1,13 @@ +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 { + + @Test + void contextLoads() {} +} diff --git a/mobile/app/build.gradle.kts b/mobile/app/build.gradle.kts index f5d0d02..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.6.1") - implementation("androidx.room:room-ktx:2.6.1") - ksp("androidx.room:room-compiler:2.5.0") + 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,6 +111,14 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + + + + 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/ConnectionSeedInstrumentedTest.kt b/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt new file mode 100644 index 0000000..60904d1 --- /dev/null +++ b/mobile/app/src/androidTest/java/com/smartjam/app/ConnectionSeedInstrumentedTest.kt @@ -0,0 +1,114 @@ +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.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 +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.openapitools.client.infrastructure.ApiClient +import retrofit2.Response + +@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/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 a75e66e..55a3601 100644 --- a/mobile/app/src/main/AndroidManifest.xml +++ b/mobile/app/src/main/AndroidManifest.xml @@ -1,8 +1,14 @@ - + + + + + + + @@ -22,6 +27,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 606d5c1..9a3c912 100644 --- a/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt +++ b/mobile/app/src/main/java/com/smartjam/app/MainActivity.kt @@ -1,61 +1,103 @@ 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.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController import androidx.room.Room -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.openapitools.client.infrastructure.ApiClient -import okhttp3.OkHttpClient +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 androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.LaunchedEffect +import java.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?) { 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) - val okHttpClientBuilder = OkHttpClient.Builder() - .authenticator(authenticator) + 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, - authNames = arrayOf("bearerAuth") - ) + val apiClient = + ApiClient( + baseUrl = baseUrl, + okHttpClientBuilder = okHttpClientBuilder, + serializerBuilder = serializerBuilder, + authNames = arrayOf("bearerAuth"), + ) authenticator.apiClient = apiClient val token = runBlocking { tokenStorage.accessToken.first() } @@ -65,9 +107,28 @@ 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 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, + 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() @@ -75,32 +136,45 @@ 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 + } + } + + 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 + } + } } - if (isValid) Screen.Home.route else Screen.Login.route - } else { - Screen.Login.route } } - 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/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..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,29 +27,36 @@ 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() } 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) - response.request.newBuilder() + response.request + .newBuilder() .header("Authorization", "Bearer ${newAuthResponse.accessToken}") .build() } else { @@ -78,4 +83,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/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/api/InstantAdapter.kt b/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt new file mode 100644 index 0000000..072423e --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/api/InstantAdapter.kt @@ -0,0 +1,30 @@ +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..54ec415 --- /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/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/SmartJamDatabase.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/SmartJamDatabase.kt index d1d6db3..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 @@ -1,11 +1,23 @@ 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 -} \ No newline at end of file + + abstract fun assignmentDao(): AssignmentDao + + abstract fun submissionResultDao(): SubmissionResultDao +} 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..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,38 +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") + 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 + } } + } - suspend fun saveToken(accessToken: String, refreshToken: String){ - context.dataStore.edit { preferences -> - preferences[ACCESS_TOKEN] = accessToken - preferences[REFRESH_TOKEN] = refreshToken - } - } + 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] } - 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/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..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 @@ -5,18 +5,19 @@ 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 @Query("DELETE FROM connections WHERE myRole = :role") suspend fun clearConnections(role: String): Int -} \ No newline at end of file +} diff --git a/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt new file mode 100644 index 0000000..da7854b --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/dao/SubmissionResultDao.kt @@ -0,0 +1,26 @@ +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..66b858a --- /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..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 @@ -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 myRole: String -) \ No newline at end of file + val peerAvatarUrl: String? = null, + val peerAvatarBytes: ByteArray? = null, + 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 new file mode 100644 index 0000000..350805f --- /dev/null +++ b/mobile/app/src/main/java/com/smartjam/app/data/local/entity/SubmissionResultEntity.kt @@ -0,0 +1,20 @@ +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/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/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..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,8 +1,9 @@ package com.smartjam.app.domain.model - data class Connection( val id: String, val peerId: String, - val peerName: String -) \ No newline at end of file + val peerName: String, + val peerAvatarUrl: String? = null, + val peerAvatarBytes: ByteArray? = 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 d0e1b82..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,49 +1,69 @@ 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.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 + private val apiClient: ApiClient, + private val devicesApi: DevicesApi, ) { + val userRole: Flow = tokenStorage.userRole - suspend fun register(email: String, password: String, username: String, role: UserRole): Result { - return try { + suspend fun saveRole(role: String) { + tokenStorage.saveRole(role) + } + + 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) { val authResponse = response.body()!! tokenStorage.saveToken( accessToken = authResponse.accessToken, - refreshToken = authResponse.refreshToken + refreshToken = authResponse.refreshToken, + role = role.name, ) apiClient.setBearerToken(authResponse.accessToken) + + registerDevicePushToken() Result.success(Unit) } else { 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) } - } - 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,69 +75,120 @@ 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) + + registerDevicePushToken() + Result.success(Unit) } 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{ + return try { val refreshTokenStr = tokenStorage.refreshToken.first() - if (refreshTokenStr == null){ + 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: CancellationException) { + throw e + } catch (_: Exception) { + tokenStorage.clearTokens() + apiClient.setBearerToken("") + false + } + } - } catch (e: Exception){ - if (e is CancellationException) { - throw e - } - else{ + 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("") - return false + false } - + } catch (e: Exception) { + tokenStorage.clearTokens() + apiClient.setBearerToken("") + false } } 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("") } - 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() } @@ -134,4 +205,12 @@ class AuthRepository ( } return refreshToken() } -} \ No newline at end of file + + 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/domain/repository/ConnectionRepository.kt b/mobile/app/src/main/java/com/smartjam/app/domain/repository/ConnectionRepository.kt index 76c87aa..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,43 +1,68 @@ package com.smartjam.app.domain.repository - +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 kotlin.collections.emptyList +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request -import com.smartjam.app.api.ConnectionsApi -import com.smartjam.app.model.JoinRequest +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() -class ConnectionRepository ( - private val api: ConnectionsApi, - private val dao: ConnectionDao -){ 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 activeItems = activeResponse.body()?.content ?: emptyList() + val activeResponse = api.getMyConnections(page = page, size = size) + 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,24 +70,37 @@ class ConnectionRepository ( createdAt = dto.createdAt, peerFirstName = dto.peerFirstName, peerLastName = dto.peerLastName, - peerAvatarUrl = dto.peerAvatarUrl, - myRole = role.name + 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()}")) } } catch (e: Exception) { if (e is CancellationException) throw e + Result.failure(e) } } + @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 +133,18 @@ class ConnectionRepository ( return Result.success(Unit) } - -} \ No newline at end of file + 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 + } + } +} 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..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 @@ -0,0 +1,387 @@ +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.AssignmentResponseDetailed +import com.smartjam.app.model.AssignmentUploadResponse +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.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody + +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/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/navigation/NavGraph.kt b/mobile/app/src/main/java/com/smartjam/app/ui/navigation/NavGraph.kt index c9c80fe..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,14 +1,50 @@ 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 +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.shape.RoundedCornerShape +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 +54,28 @@ 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 +83,286 @@ fun SmartJamNavGraph( navController: NavHostController, authRepository: AuthRepository, connectionRepository: ConnectionRepository, + roomRepository: RoomRepository, tokenStorage: TokenStorage, - startDestination: String = Screen.Login.route + startDestination: String = Screen.Login.route, ) { - NavHost( - navController = navController, - startDestination = startDestination - ) { - composable(route = Screen.Login.route) { - val viewModel: LoginViewModel = viewModel( - factory = LoginViewModelFactory(authRepository) - ) + 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", + ) - LoginScreen( - viewModel = viewModel, - onNavigateToHome = { - navController.navigate(Screen.Home.route) { - popUpTo(Screen.Login.route) { inclusive = true } - } - }, - onNavigateToRegister = { - navController.navigate(Screen.Register.route) - } - ) + Box(modifier = Modifier.fillMaxSize().background(appBackground)) { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = Modifier.fillMaxSize(), + ) { + composable(route = Screen.Login.route) { + val loginViewModel: LoginViewModel = + viewModel(factory = LoginViewModelFactory(authRepository, tokenStorage)) + + LoginScreen( + viewModel = loginViewModel, + onNavigateToHome = { + navController.navigate(Screen.Home.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onNavigateToRegister = { navController.navigate(Screen.Register.route) }, + ) + } + + composable(route = Screen.Register.route) { + val viewModel: RegisterViewModel = + viewModel(factory = RegisterViewModelFactory(authRepository)) + + RegisterScreen( + viewModel = viewModel, + onNavigateToHome = { + navController.navigate(Screen.Home.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onNavigateBack = { navController.popBackStack() }, + ) + } + + composable(route = Screen.Home.route) { + val viewModel: HomeViewModel = + viewModel(factory = HomeViewModelFactory(connectionRepository, authRepository)) + + HomeScreen( + viewModel = viewModel, + onNavigateToRoom = { connectionId -> + 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)) + + RoomScreen( + connectionId = connectionId, + role = role, + viewModel = viewModel, + onBack = { navController.popBackStack() }, + ) + } } - composable(route = Screen.Register.route) { - val viewModel: RegisterViewModel = viewModel( - factory = RegisterViewModelFactory(authRepository) - ) + 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), + ) + ) + ) + ) - RegisterScreen( - viewModel = viewModel, - onNavigateToHome = { - navController.navigate(Screen.Home.route) { - popUpTo(Screen.Login.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), + ), + ) } - }, - onNavigateBack = { - navController.popBackStack() } - ) + } } + } +} +@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) + ) - composable(route = Screen.Home.route) { - val viewModel: HomeViewModel = viewModel( - factory = HomeViewModelFactory(connectionRepository, authRepository) - ) - - HomeScreen( - viewModel = viewModel, - onNavigateToRoom = { connectionId -> - navController.navigate(Screen.Room.route) - }, - onNavigateToLogin = { - navController.navigate(Screen.Login.route) { - popUpTo(Screen.Home.route) { inclusive = true } - } + 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..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 @@ -7,21 +7,22 @@ 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.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 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 +32,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 @@ -48,11 +50,12 @@ 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 val keyboard = LocalSoftwareKeyboardController.current + val listState = rememberLazyListState() LaunchedEffect(Unit) { viewModel.events.collect { event -> @@ -66,6 +69,15 @@ 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() @@ -75,7 +87,7 @@ fun HomeScreen( isLoading = state.isLoading, onLogout = viewModel::onLogoutClicked, onSync = viewModel::syncNetworkData, - onToggleDebugRole = viewModel::toggleDebugRole + onToggleDebugRole = viewModel::toggleDebugRole, ) if (state.errorMessage != null) { @@ -83,27 +95,27 @@ fun HomeScreen( text = state.errorMessage!!, color = Color(0xFFFF5252), modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - fontSize = 14.sp + fontSize = 14.sp, ) } LazyColumn( + 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, ) } item { Spacer(modifier = Modifier.height(8.dp)) } item { SectionTitle("Мои ученики") } - } else { item { StudentJoinSection( @@ -113,7 +125,7 @@ fun HomeScreen( onJoin = { keyboard?.hide() viewModel.onJoinRoomClicked() - } + }, ) } @@ -126,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) }, ) } } @@ -148,37 +160,45 @@ 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)) } 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), + ) } } } @@ -190,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), ) } @@ -202,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)) } @@ -210,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( @@ -229,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)) @@ -242,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) } } @@ -276,24 +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) { - 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 { - 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 106b443..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 @@ -21,18 +21,24 @@ data class HomeState( val inviteCodeInput: String = "", val teacherGeneratedCode: String? = null, val isLoading: Boolean = false, - val errorMessage: String? = null + val isPaging: Boolean = false, + val endReached: Boolean = false, + val nextPage: Int = 1, + val pageSize: Int = 20, + 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()) @@ -42,61 +48,146 @@ 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 + } - fun toggleDebugRole() { - val newRole = if (_state.value.currentRole == UserRole.STUDENT) { - UserRole.TEACHER - } else { - UserRole.STUDENT + if (!hasStarted || _state.value.currentRole != newRole) { + hasStarted = true + _state.update { it.copy(currentRole = newRole) } + startObservingConnections() + } + } } + } - _state.update { it.copy( - currentRole = newRole, - connections = emptyList(), - errorMessage = null - ) } + fun toggleDebugRole() { + val newRole = + if (_state.value.currentRole == UserRole.STUDENT) { + UserRole.TEACHER + } else { + UserRole.STUDENT + } - 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 launch { connectionRepository.getConnectionsFlow(role).collect { connections -> - _state.update { currentState -> - currentState.copy( - connections = connections - ) - } + _state.update { currentState -> currentState.copy(connections = connections) } } } - 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 { + // 1. Включаем загрузку и сбрасываем старую ошибку _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 = "Не удалось обновить данные с сервера") } + // 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) } + 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) } } @@ -157,9 +248,7 @@ class HomeViewModel( } fun onConnectionClicked(connectionId: String) { - viewModelScope.launch { - eventChannel.send(HomeEvent.NavigateToRoom(connectionId)) - } + viewModelScope.launch { eventChannel.send(HomeEvent.NavigateToRoom(connectionId)) } } fun onLogoutClicked() { @@ -172,10 +261,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 9ee3c24..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 @@ -1,77 +1,60 @@ package com.smartjam.app.ui.screens.login import android.widget.Toast -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.LocalIndication +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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 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.BrandCyan +import com.smartjam.app.ui.theme.BrandGold import com.smartjam.app.ui.theme.CoreBackground import com.smartjam.app.ui.theme.ErrorRed -import com.smartjam.app.ui.theme.BrandCyan @Composable fun LoginScreen( - viewModel: LoginViewModel, onNavigateToHome: () -> Unit = {}, - onNavigateToRegister: () -> Unit = {} + onNavigateToRegister: () -> Unit = {}, ) { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -87,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( @@ -117,18 +94,23 @@ 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) }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + AppleGlassTextField( value = state.emailInput, 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)) @@ -139,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, ) } } @@ -165,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)) @@ -174,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)) @@ -185,15 +164,78 @@ 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) { + 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..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,85 +15,84 @@ 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 + val errorMessage: String? = null, ) -sealed class LoginEvent{ +sealed class LoginEvent { object NavigateToHome : LoginEvent() + data class ShowToast(val message: String) : LoginEvent() } -class LoginViewModel ( - private val authRepository: AuthRepository -) : ViewModel(){ +class LoginViewModel( + private val authRepository: AuthRepository, + 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){ - _state.value = _state.value.copy( - passwordInput = newPassword, - errorMessage = null - ) + fun onPasswordChanged(newPassword: String) { + _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; + 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()){ - _state.value = _state.value.copy(errorMessage = "Fill in all fields") - return; + if (currentPassword.isBlank() || currentEmail.isBlank()) { + _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){ + 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 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") } -} \ No newline at end of file +} diff --git a/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/register/RegisterScreen.kt 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 14c5e95..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 @@ -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() @@ -37,7 +36,9 @@ class RegisterViewModel( private val eventChannel = Channel(Channel.BUFFERED) val events = eventChannel.receiveAsFlow() private val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\$".toRegex() - private val passwordRegex = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,}\$".toRegex() + 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) } @@ -60,14 +61,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 +81,9 @@ class RegisterViewModel( } if (!passwordRegex.matches(currentState.passwordInput)) { - _state.update { it.copy(errorMessage = "Пароль: мин. 8 символов, латинские буквы и цифры") } + _state.update { + it.copy(errorMessage = "Пароль: мин. 8 символов, латинские буквы и цифры") + } return } @@ -94,16 +95,16 @@ 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 - ) - - _state.update { it.copy(isLoading = false) } + 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) } eventChannel.send(RegisterEvent.NavigateToHome) } else { val error = result.exceptionOrNull()?.message ?: "Ошибка регистрации" @@ -113,9 +114,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)) { @@ -123,4 +123,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/screens/room/RoomScreen.kt b/mobile/app/src/main/java/com/smartjam/app/ui/screens/room/RoomScreen.kt index e69de29..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 @@ -0,0 +1,451 @@ +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.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +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..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 @@ -0,0 +1,249 @@ +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 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 + +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/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..8dfe542 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -2,4 +2,23 @@ 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..0468807 100644 --- a/mobile/gradle/libs.versions.toml +++ b/mobile/gradle/libs.versions.toml @@ -1,19 +1,43 @@ [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" +gsonJavatimeSerialisers = "1.1.2" 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" } +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" } @@ -27,9 +51,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 diff --git a/mobile/gradlew b/mobile/gradlew old mode 100644 new mode 100755 diff --git a/openapi-spec/api.yaml b/openapi-spec/api.yaml index 28acc80..5c6b11e 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: Removes FCM token link for user. Call it during user's logout. + 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,19 @@ components: items: $ref: './common-models.yaml#/components/schemas/FeedbackEvent' + DeviceRegistrationRequest: + type: object + required: + - token + properties: + token: + type: string + minLength: 1 + pattern: '.*\S.*' + example: "bk3rn1...v2" + description: "FCM registration token from Firebase SDK" + + ErrorResponse: type: object description: Standardized error response returned by the API.