From ca8ecea9d63e076df740d66c9ea33dbfa246ab22 Mon Sep 17 00:00:00 2001 From: Oleg Mozhegov <63643868+dev-darck@users.noreply.github.com> Date: Sun, 3 May 2026 16:30:29 +0200 Subject: [PATCH] Add session workspace share and compare flows --- .../sessionAnalysis/impl/build.gradle.kts | 1 + .../composeResources/values/strings.xml | 13 + .../analysis/di/SessionAnalysisBindings.kt | 7 + .../usecase/SessionAnalysisShareResults.kt | 16 + .../SessionAnalysisShareResultsUseCase.kt | 14 + .../SessionAnalysisShareResultsUseCaseImpl.kt | 116 +++++++ .../presentation/SessionAnalysisScreen.kt | 190 +++++++--- .../SessionAnalysisShareDialogFactory.kt | 70 ++++ .../presentation/SessionAnalysisViewModel.kt | 170 ++++++++- .../SessionAnalysisShareResultsDialog.kt | 239 +++++++++++++ .../SessionAnalysisWorkspaceFabBar.kt | 181 ++++++++++ .../model/SessionAnalysisAction.kt | 5 + .../SessionAnalysisCopyShareResultsIntent.kt | 3 + ...essionAnalysisDismissShareResultsIntent.kt | 3 + ...ysisExportShareResultsToDirectoryIntent.kt | 4 + .../SessionAnalysisOpenShareResultsIntent.kt | 3 + ...sionAnalysisOpenShareSessionFilesIntent.kt | 3 + .../model/SessionAnalysisState.kt | 2 + .../share/SessionAnalysisShareDialogUi.kt | 9 + .../SessionAnalysisViewModelTest.kt | 48 ++- .../screens/sessionDetails/build.gradle.kts | 2 + .../composeResources/values/strings.xml | 20 +- .../details/di/SessionDetailsBindings.kt | 21 ++ .../mapper/SessionDetailDomainMapper.kt | 19 +- .../domain/model/SessionDetailDomainModels.kt | 5 + .../usecase/SessionDetailCompareCriteria.kt | 11 + .../usecase/SessionDetailCompareSuggestion.kt | 12 + .../SessionDetailCompareSuggestions.kt | 6 + .../SessionDetailCompareSuggestionsUseCase.kt | 9 + ...sionDetailCompareSuggestionsUseCaseImpl.kt | 126 +++++++ ...SessionDetailImportCompareSessionResult.kt | 12 + ...essionDetailImportCompareSessionUseCase.kt | 9 + ...onDetailImportCompareSessionUseCaseImpl.kt | 173 +++++++++ .../usecase/SessionDetailShareResults.kt | 10 + .../SessionDetailShareResultsUseCase.kt | 14 + .../SessionDetailShareResultsUseCaseImpl.kt | 116 +++++++ .../SessionDetailCompareCriteriaFactory.kt | 38 ++ .../SessionDetailCompareSelection.kt | 53 +++ ...essionDetailCompareSessionPickerFactory.kt | 73 ++++ .../SessionDetailShareDialogFactory.kt | 52 +++ .../presentation/SessionDetailStateExt.kt | 6 + .../presentation/SessionDetailViewModel.kt | 290 +++++++++++++--- .../SessionDetailWorkspaceActionFactory.kt | 117 +++++++ .../presentation/SessionDetailsScreen.kt | 269 +++++++++----- .../components/SessionDetailsCompareFabBar.kt | 297 +--------------- .../SessionDetailsCompareSelectionDock.kt | 222 ++++++++++++ ...essionDetailsCompareSessionPickerDialog.kt | 327 ++++++++++++++++++ .../SessionDetailsShareResultsDialog.kt | 238 +++++++++++++ .../SessionDetailsWorkspaceActionItem.kt | 14 + .../SessionDetailsWorkspaceSpeedDial.kt | 286 +++++++++++++++ .../presentation/model/SessionDetailAction.kt | 5 + .../model/SessionDetailCompareLapUi.kt | 9 + .../SessionDetailCompareSessionCandidateUi.kt | 12 + .../SessionDetailCompareSessionPickerUi.kt | 15 + .../model/SessionDetailFilterKind.kt | 7 + .../model/SessionDetailFilterOptionUi.kt | 3 + .../model/SessionDetailFilterUiModel.kt | 10 + .../presentation/model/SessionDetailIntent.kt | 8 + .../model/SessionDetailShareDialogUi.kt | 9 + .../presentation/model/SessionDetailState.kt | 24 +- ...sionDetailCompareSuggestionsUseCaseTest.kt | 129 +++++++ .../SessionDetailViewModelTest.kt | 192 +++++++++- .../presentation/SessionDetailsScreenTest.kt | 121 ++++++- 63 files changed, 3968 insertions(+), 520 deletions(-) create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResults.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResultsUseCase.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResultsUseCaseImpl.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisShareDialogFactory.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/components/SessionAnalysisShareResultsDialog.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/components/SessionAnalysisWorkspaceFabBar.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisAction.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisCopyShareResultsIntent.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisDismissShareResultsIntent.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisExportShareResultsToDirectoryIntent.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisOpenShareResultsIntent.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisOpenShareSessionFilesIntent.kt create mode 100644 feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/share/SessionAnalysisShareDialogUi.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareCriteria.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestion.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestions.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCase.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCaseImpl.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionResult.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionUseCase.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionUseCaseImpl.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResults.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResultsUseCase.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResultsUseCaseImpl.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareCriteriaFactory.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareSelection.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareSessionPickerFactory.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailShareDialogFactory.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailWorkspaceActionFactory.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareSelectionDock.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareSessionPickerDialog.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsShareResultsDialog.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsWorkspaceActionItem.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsWorkspaceSpeedDial.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailAction.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareLapUi.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareSessionCandidateUi.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareSessionPickerUi.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterKind.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterOptionUi.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterUiModel.kt create mode 100644 feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailShareDialogUi.kt create mode 100644 feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCaseTest.kt diff --git a/feature/screens/sessionAnalysis/impl/build.gradle.kts b/feature/screens/sessionAnalysis/impl/build.gradle.kts index 9f946c46..1cf17010 100644 --- a/feature/screens/sessionAnalysis/impl/build.gradle.kts +++ b/feature/screens/sessionAnalysis/impl/build.gradle.kts @@ -16,6 +16,7 @@ moduleImpl { projects.core.theme.jvmImpl projects.core.ui.jvmImpl projects.core.utils.jvmImpl + projects.feature.screens.chooser.jvmImpl projects.games.telemetry.ac.api.jvmImpl lib.metro.metrox.viewmodel.compose.jvmImpl diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/composeResources/values/strings.xml b/feature/screens/sessionAnalysis/impl/src/jvmMain/composeResources/values/strings.xml index 0286b082..7b641a0d 100644 --- a/feature/screens/sessionAnalysis/impl/src/jvmMain/composeResources/values/strings.xml +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/composeResources/values/strings.xml @@ -144,6 +144,9 @@ Latest -- Analysis + Share Analysis + More analysis actions + Close analysis actions Lap %1$d Refresh %1$s • %2$s @@ -470,4 +473,14 @@ Corner %1$d: %2$s this section + Share preview + Choose how to share this analysis + Export filename + Copy Chat Summary + Copies clean plain text to the clipboard for Discord, Telegram or chat. + Save Markdown Report + Writes a readable .md report to the folder you choose. + Open Source Session Files + Only if you need the original recorded folder, not the share-ready summary. + Close diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/di/SessionAnalysisBindings.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/di/SessionAnalysisBindings.kt index 0e904836..8bdcbdef 100644 --- a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/di/SessionAnalysisBindings.kt +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/di/SessionAnalysisBindings.kt @@ -3,6 +3,8 @@ package com.analyzer.session.analysis.di import androidx.lifecycle.ViewModel import com.analyzer.session.analysis.data.repository.SessionAnalysisRepositoryImpl import com.analyzer.session.analysis.domain.repository.SessionAnalysisRepository +import com.analyzer.session.analysis.domain.usecase.SessionAnalysisShareResultsUseCase +import com.analyzer.session.analysis.domain.usecase.SessionAnalysisShareResultsUseCaseImpl import com.analyzer.session.analysis.domain.usecase.SessionAnalysisUseCase import com.analyzer.session.analysis.domain.usecase.SessionAnalysisUseCaseImpl import com.analyzer.session.analysis.presentation.SessionAnalysisViewModel @@ -29,6 +31,11 @@ interface SessionAnalysisBindings { @Provides private fun provideSessionAnalysisUseCase(impl: SessionAnalysisUseCaseImpl): SessionAnalysisUseCase = impl + @Provides + private fun provideSessionAnalysisShareResultsUseCase( + impl: SessionAnalysisShareResultsUseCaseImpl, + ): SessionAnalysisShareResultsUseCase = impl + @Provides @IntoMap @ViewModelKey(SessionAnalysisViewModel::class) diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResults.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResults.kt new file mode 100644 index 00000000..8d31bde6 --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResults.kt @@ -0,0 +1,16 @@ +package com.analyzer.session.analysis.domain.usecase + +internal sealed interface SessionAnalysisShareResults { + + data object CopiedSummary : SessionAnalysisShareResults + + data class ExportedReport(val filePath: String) : SessionAnalysisShareResults + + data object OpenedSessionFiles : SessionAnalysisShareResults + + data object OpenedSessionFilesAndCopiedPath : SessionAnalysisShareResults + + data object CopiedSessionFilesPath : SessionAnalysisShareResults + + data class Failure(val reason: String) : SessionAnalysisShareResults +} diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResultsUseCase.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResultsUseCase.kt new file mode 100644 index 00000000..1fd86f0b --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResultsUseCase.kt @@ -0,0 +1,14 @@ +package com.analyzer.session.analysis.domain.usecase + +internal interface SessionAnalysisShareResultsUseCase { + + suspend fun copySummary(summaryText: String): SessionAnalysisShareResults + + suspend fun exportReport( + directoryPath: String, + reportFileName: String, + summaryText: String, + ): SessionAnalysisShareResults + + suspend fun openSessionFiles(sessionId: Long): SessionAnalysisShareResults +} diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResultsUseCaseImpl.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResultsUseCaseImpl.kt new file mode 100644 index 00000000..8b3a8d01 --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/domain/usecase/SessionAnalysisShareResultsUseCaseImpl.kt @@ -0,0 +1,116 @@ +package com.analyzer.session.analysis.domain.usecase + +import com.project.analyzer.api.di.ScreenScope +import com.project.analyzer.telemetry.recording.api.session.RecordedTelemetrySessionLocation +import com.project.analyzer.telemetry.recording.api.session.RecordedTelemetrySessionStorage +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import java.awt.Desktop +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.File +import java.nio.charset.StandardCharsets + +@Inject +@SingleIn(ScreenScope::class) +internal class SessionAnalysisShareResultsUseCaseImpl(private val storage: RecordedTelemetrySessionStorage,) : + SessionAnalysisShareResultsUseCase { + + override suspend fun copySummary(summaryText: String): SessionAnalysisShareResults { + val copied = runCatching { + Toolkit.getDefaultToolkit().systemClipboard.setContents( + StringSelection(summaryText), + null, + ) + }.isSuccess + return if (copied) { + SessionAnalysisShareResults.CopiedSummary + } else { + SessionAnalysisShareResults.Failure("Couldn't copy the analysis summary to the clipboard.") + } + } + + override suspend fun exportReport( + directoryPath: String, + reportFileName: String, + summaryText: String, + ): SessionAnalysisShareResults { + val directory = File(directoryPath) + if (!directory.exists() || !directory.isDirectory) { + return SessionAnalysisShareResults.Failure("The selected export folder is no longer available.") + } + + return runCatching { + val target = directory.resolveUniqueReportFile(reportFileName) + target.writeText(summaryText, StandardCharsets.UTF_8) + SessionAnalysisShareResults.ExportedReport(target.absolutePath) + }.getOrElse { error -> + SessionAnalysisShareResults.Failure( + error.message ?: "Couldn't export the analysis report to the selected folder.", + ) + } + } + + override suspend fun openSessionFiles(sessionId: Long): SessionAnalysisShareResults { + val bundle = storage.findBundle(sessionId = sessionId) + ?: return SessionAnalysisShareResults.Failure("No recorded files were found for this session.") + val directories = bundle.locations.map(RecordedTelemetrySessionLocation::dir).distinct() + val target = directories.resolveShareTarget() + ?: return SessionAnalysisShareResults.Failure("The recorded session folder is no longer available.") + + val openedFolder = runCatching { + check(Desktop.isDesktopSupported()) { "Desktop integration is not available on this system." } + Desktop.getDesktop().open(target) + }.isSuccess + val copiedPath = runCatching { + Toolkit.getDefaultToolkit().systemClipboard.setContents( + StringSelection(target.absolutePath), + null, + ) + }.isSuccess + + return when { + openedFolder && copiedPath -> SessionAnalysisShareResults.OpenedSessionFilesAndCopiedPath + openedFolder -> SessionAnalysisShareResults.OpenedSessionFiles + copiedPath -> SessionAnalysisShareResults.CopiedSessionFilesPath + else -> SessionAnalysisShareResults.Failure("Couldn't open the session folder or copy its path.") + } + } +} + +private fun List.resolveShareTarget(): File? = when { + isEmpty() -> null + + size == 1 -> first().takeIf { it.exists() } + + else -> { + val parents = mapNotNull(File::getParentFile) + .filter { it.exists() } + .distinctBy { it.absolutePath } + when { + parents.size == 1 -> parents.single() + else -> maxByOrNull(File::lastModified)?.takeIf { it.exists() } + } + } +} + +private fun File.resolveUniqueReportFile(reportFileName: String): File { + val extension = reportFileName.substringAfterLast('.', "") + val baseName = if (extension.isBlank()) { + reportFileName + } else { + reportFileName.removeSuffix(".$extension") + } + var candidate = File(this, reportFileName) + var index = 2 + while (candidate.exists()) { + val suffix = if (extension.isBlank()) { + "$baseName-$index" + } else { + "$baseName-$index.$extension" + } + candidate = File(this, suffix) + index += 1 + } + return candidate +} diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisScreen.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisScreen.kt index 8907d0d2..251d5ce5 100644 --- a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisScreen.kt +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisScreen.kt @@ -8,26 +8,42 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.analyzer.session.analysis.presentation.components.SessionAnalysisShareResultsDialog +import com.analyzer.session.analysis.presentation.components.SessionAnalysisWorkspaceFabBar import com.analyzer.session.analysis.presentation.components.common.SessionAnalysisStudioPanel import com.analyzer.session.analysis.presentation.components.layout.SessionAnalysisStudioLayout import com.analyzer.session.analysis.presentation.model.SessionAnalysisBindSessionIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisCopyShareResultsIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisDismissShareResultsIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisExportShareResultsToDirectoryIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisOpenShareResultsIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisOpenShareSessionFilesIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisRefreshIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisSelectLapIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisSelectReferenceLapIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisSelectSessionIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisState import com.analyzer.session.analysis.presentation.preview.sessionAnalysisPreviewState +import com.project.analyzer.chooser.FileChooserDialog +import com.project.analyzer.chooser.SelectionMode +import com.project.analyzer.chooser.rememberFileChooserState import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.Res import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_screen_building_message import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_screen_building_title @@ -38,6 +54,8 @@ import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_ana import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_screen_updating_message import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_screen_updating_title import com.project.analyzer.theme.SimAnalyzerTheme +import com.project.analyzer.ui.components.InfoBarSnackbarHost +import com.project.analyzer.ui.components.InfoBarSnackbarVisuals import dev.zacsweers.metrox.viewmodel.metroViewModel import org.jetbrains.compose.resources.stringResource @@ -52,6 +70,16 @@ fun SessionAnalysisScreen( ) { val viewModel = metroViewModel() val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val exportDirectoryChooserState = rememberFileChooserState( + title = "Choose export folder", + selectionMode = SelectionMode.DIRECTORY, + onResult = { selectedPath -> + selectedPath?.let { path -> + viewModel.dispatch(SessionAnalysisExportShareResultsToDirectoryIntent(path)) + } + }, + ) LaunchedEffect( sessionId, @@ -73,16 +101,43 @@ fun SessionAnalysisScreen( ) } - SessionAnalysisContent( - state = state, - onIntent = viewModel::dispatch, - ) + LaunchedEffect(viewModel) { + viewModel.actions.collect { action -> + snackbarHostState.showSnackbar( + visuals = InfoBarSnackbarVisuals( + title = action.title, + message = action.message, + severity = action.severity, + duration = SnackbarDuration.Short, + ), + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + SessionAnalysisContent( + state = state, + onRequestShareExportDirectory = { exportDirectoryChooserState.show() }, + onIntent = viewModel::dispatch, + ) + + InfoBarSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 16.dp, vertical = 16.dp) + .widthIn(max = 560.dp), + ) + } + + FileChooserDialog(state = exportDirectoryChooserState) } @Composable internal fun SessionAnalysisContent( state: SessionAnalysisState, modifier: Modifier = Modifier, + onRequestShareExportDirectory: () -> Unit = {}, onIntent: (SessionAnalysisIntent) -> Unit = {}, ) { Box( @@ -90,63 +145,88 @@ internal fun SessionAnalysisContent( .fillMaxSize() .background(SimAnalyzerTheme.material.background), ) { - when { - state.header != null -> { - SessionAnalysisStudioLayout( - state = state, - modifier = Modifier.fillMaxSize(), - onRefresh = { - onIntent(SessionAnalysisRefreshIntent) - }, - onSessionSelected = { segmentId -> - onIntent(SessionAnalysisSelectSessionIntent(segmentId)) - }, - onLapSelected = { lapNumber -> - onIntent(SessionAnalysisSelectLapIntent(lapNumber)) - }, - onReferenceLapSelected = { lapNumber -> - onIntent(SessionAnalysisSelectReferenceLapIntent(lapNumber)) - }, + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = SimAnalyzerTheme.material.background, + floatingActionButton = { + SessionAnalysisWorkspaceFabBar( + actionsEnabled = state.header != null && state.error == null, + onShareResults = { onIntent(SessionAnalysisOpenShareResultsIntent) }, ) - if (state.isLoading) { - SessionAnalysisInlineLoadingOverlay( - modifier = Modifier - .align(Alignment.TopEnd) - .padding( - top = SessionAnalysisUiTokens.overlayInset, - end = SessionAnalysisUiTokens.overlayInset, - ), - ) - } - } + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + when { + state.header != null -> { + SessionAnalysisStudioLayout( + state = state, + modifier = Modifier.fillMaxSize(), + onRefresh = { + onIntent(SessionAnalysisRefreshIntent) + }, + onSessionSelected = { segmentId -> + onIntent(SessionAnalysisSelectSessionIntent(segmentId)) + }, + onLapSelected = { lapNumber -> + onIntent(SessionAnalysisSelectLapIntent(lapNumber)) + }, + onReferenceLapSelected = { lapNumber -> + onIntent(SessionAnalysisSelectReferenceLapIntent(lapNumber)) + }, + ) + if (state.isLoading) { + SessionAnalysisInlineLoadingOverlay( + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + top = SessionAnalysisUiTokens.overlayInset, + end = SessionAnalysisUiTokens.overlayInset, + ), + ) + } + } - state.isLoading -> { - SessionAnalysisStatePanel( - title = stringResource(Res.string.session_analysis_screen_building_title), - message = stringResource(Res.string.session_analysis_screen_building_message), - loading = true, - modifier = Modifier.align(Alignment.Center), - ) - } + state.isLoading -> { + SessionAnalysisStatePanel( + title = stringResource(Res.string.session_analysis_screen_building_title), + message = stringResource(Res.string.session_analysis_screen_building_message), + loading = true, + modifier = Modifier.align(Alignment.Center), + ) + } - state.error != null -> { - SessionAnalysisStatePanel( - title = stringResource(Res.string.session_analysis_screen_unavailable_title), - message = stringResource(Res.string.session_analysis_screen_unavailable_message), - loading = false, - modifier = Modifier.align(Alignment.Center), - ) - } + state.error != null -> { + SessionAnalysisStatePanel( + title = stringResource(Res.string.session_analysis_screen_unavailable_title), + message = stringResource(Res.string.session_analysis_screen_unavailable_message), + loading = false, + modifier = Modifier.align(Alignment.Center), + ) + } - else -> { - SessionAnalysisStatePanel( - title = stringResource(Res.string.session_analysis_screen_empty_title), - message = stringResource(Res.string.session_analysis_screen_empty_message), - loading = false, - modifier = Modifier.align(Alignment.Center), - ) + else -> { + SessionAnalysisStatePanel( + title = stringResource(Res.string.session_analysis_screen_empty_title), + message = stringResource(Res.string.session_analysis_screen_empty_message), + loading = false, + modifier = Modifier.align(Alignment.Center), + ) + } + } } } + + SessionAnalysisShareResultsDialog( + shareDialog = state.shareDialog, + onDismiss = { onIntent(SessionAnalysisDismissShareResultsIntent) }, + onCopySummary = { onIntent(SessionAnalysisCopyShareResultsIntent) }, + onExportReport = onRequestShareExportDirectory, + onOpenRawFiles = { onIntent(SessionAnalysisOpenShareSessionFilesIntent) }, + ) } } diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisShareDialogFactory.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisShareDialogFactory.kt new file mode 100644 index 00000000..36c17afc --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisShareDialogFactory.kt @@ -0,0 +1,70 @@ +package com.analyzer.session.analysis.presentation + +import com.analyzer.session.analysis.presentation.model.SessionAnalysisScreenMode +import com.analyzer.session.analysis.presentation.model.SessionAnalysisState +import com.analyzer.session.analysis.presentation.model.share.SessionAnalysisShareDialogUi +import com.project.analyzer.utils.toSlugId + +internal fun SessionAnalysisState.toShareDialogUi(sessionId: Long): SessionAnalysisShareDialogUi { + val header = header ?: return SessionAnalysisShareDialogUi() + return SessionAnalysisShareDialogUi( + isVisible = true, + title = if (header.trackLabel.isNotBlank()) { + "Share ${header.trackLabel} analysis" + } else { + "Share session analysis" + }, + supportingText = "Choose a share-ready format: copy the chat summary, save a Markdown report, or open the source recording only when you need raw files.", + summaryText = buildShareSummaryText(sessionId = sessionId), + reportFileName = buildShareReportFileName(header = header, sessionId = sessionId), + ) +} + +private fun SessionAnalysisState.buildShareSummaryText(sessionId: Long): String { + val header = header ?: return "" + val summary = summary + return buildString { + appendLine("Sim Analyzer Analysis Summary") + appendLine() + appendLine("Track: ${header.trackLabel.ifBlank { "Unknown track" }}") + appendLine("Car: ${header.carLabel.ifBlank { "Unknown car" }}") + appendLine("Session: ${header.sessionTypeLabel.ifBlank { "Unknown session" }}") + appendLine("Started: ${header.startedAtLabel.ifBlank { "Session $sessionId" }}") + appendLine("Mode: ${screenMode.toShareModeLabel()}") + appendLine("Selected lap: ${header.selectedLapLabel.ifBlank { "--" }}") + if (referenceLapNumber != null) { + appendLine("Reference lap: $referenceLapNumber") + } + appendLine("Best lap: ${header.bestLapLabel.ifBlank { "--" }}") + appendLine("Top speed: ${header.topSpeedLabel.ifBlank { "--" }}") + summary?.let { + appendLine() + appendLine("Highlights") + appendLine("- Lap delta: ${it.lapDeltaLabel}") + appendLine("- Biggest loss: ${it.biggestLossLabel} (${it.biggestLossValueLabel})") + appendLine("- Consistency: ${it.consistencyLabel}") + appendLine("- Fuel: ${it.fuelLabel}") + appendLine("- Top speed trend: ${it.topSpeedLabel}") + } + appendLine() + append("Shared from Sim Analyzer") + } +} + +private fun buildShareReportFileName( + header: com.analyzer.session.analysis.presentation.model.SessionAnalysisHeaderUi, + sessionId: Long, +): String { + val parts = listOf( + "simanalyzer", + header.trackLabel.ifBlank { "analysis" }.toSlugId(), + "analysis", + header.startedAtLabel.ifBlank { "session_$sessionId" }.toSlugId(), + ).filter(String::isNotBlank) + return parts.joinToString(separator = "_") + ".md" +} + +private fun SessionAnalysisScreenMode.toShareModeLabel(): String = when (this) { + SessionAnalysisScreenMode.Analysis -> "Analysis" + SessionAnalysisScreenMode.Comparison -> "Comparison" +} diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisViewModel.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisViewModel.kt index 8713ca07..f9bcaa48 100644 --- a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisViewModel.kt +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisViewModel.kt @@ -2,53 +2,88 @@ package com.analyzer.session.analysis.presentation import com.analyzer.session.analysis.domain.model.SessionAnalysisWorkspaceData import com.analyzer.session.analysis.domain.model.SessionAnalysisWorkspaceRequest +import com.analyzer.session.analysis.domain.usecase.SessionAnalysisShareResults +import com.analyzer.session.analysis.domain.usecase.SessionAnalysisShareResultsUseCase import com.analyzer.session.analysis.domain.usecase.SessionAnalysisUseCase import com.analyzer.session.analysis.presentation.builder.state.toShellState import com.analyzer.session.analysis.presentation.builder.state.toState import com.analyzer.session.analysis.presentation.cache.SelectionStateCache import com.analyzer.session.analysis.presentation.cache.SelectionStateKey +import com.analyzer.session.analysis.presentation.model.SessionAnalysisAction import com.analyzer.session.analysis.presentation.model.SessionAnalysisBindSessionIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisCopyShareResultsIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisDismissShareResultsIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisError +import com.analyzer.session.analysis.presentation.model.SessionAnalysisExportShareResultsToDirectoryIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisOpenShareResultsIntent +import com.analyzer.session.analysis.presentation.model.SessionAnalysisOpenShareSessionFilesIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisRefreshIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisSelectLapIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisSelectReferenceLapIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisSelectSessionIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisState +import com.analyzer.session.analysis.presentation.model.share.SessionAnalysisShareDialogUi import com.project.analyzer.api.di.Default import com.project.analyzer.leak.api.LeakAwareMviViewModel +import com.project.analyzer.ui.components.InfoBarSeverity import dev.zacsweers.metro.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.withContext +import java.io.File @Suppress("TooManyFunctions") @Inject internal class SessionAnalysisViewModel( private val useCase: SessionAnalysisUseCase, + private val shareResultsUseCase: SessionAnalysisShareResultsUseCase, @param:Default private val defaultDispatcher: CoroutineDispatcher, ) : LeakAwareMviViewModel(SessionAnalysisState()) { private var binding: SessionBinding = SessionBinding() + private var shareDialog = SessionAnalysisShareDialogUi() + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) // Keep only a couple of recent selection permutations; full states are large for this screen. private val selectionStateCache = SelectionStateCache(maxEntries = 3) + val actions = _actions.asSharedFlow() + init { addCloseable { selectionStateCache.clear() binding = SessionBinding() + shareDialog = SessionAnalysisShareDialogUi() } } override suspend fun handleIntent(intent: SessionAnalysisIntent) { when (intent) { is SessionAnalysisBindSessionIntent -> bindSession(intent.toBindingRequest()) + SessionAnalysisRefreshIntent -> reload(forceRefresh = true) + is SessionAnalysisSelectSessionIntent -> selectSession(intent.segmentId) + is SessionAnalysisSelectLapIntent -> selectLap(intent.lapNumber) + is SessionAnalysisSelectReferenceLapIntent -> selectReferenceLap(intent.lapNumber) + + SessionAnalysisOpenShareResultsIntent -> openShareResultsDialog() + + SessionAnalysisDismissShareResultsIntent -> dismissShareResultsDialog() + + SessionAnalysisCopyShareResultsIntent -> copyShareResults() + + is SessionAnalysisExportShareResultsToDirectoryIntent -> { + exportShareResultsToDirectory(intent.directoryPath) + } + + SessionAnalysisOpenShareSessionFilesIntent -> openShareSessionFiles() } } @@ -61,6 +96,7 @@ internal class SessionAnalysisViewModel( publish(workspaceData = workspaceData, selection = request.selection) return } + shareDialog = SessionAnalysisShareDialogUi() load(request = request, forceRefresh = false) } @@ -114,7 +150,7 @@ internal class SessionAnalysisViewModel( SessionAnalysisState( isLoading = false, error = SessionAnalysisError.Unavailable, - ), + ).withTransientUi(), ) return } @@ -153,7 +189,7 @@ internal class SessionAnalysisViewModel( selectedReferenceLapNumber = resolvedSelection.referenceLapNumber, ) } - setState(shellState) + setState(shellState.withTransientUi()) val workspaceData = try { useCase.enrichWorkspace(shellWorkspaceData) } catch (error: CancellationException) { @@ -163,7 +199,7 @@ internal class SessionAnalysisViewModel( loadedSessionId = sessionId, data = shellWorkspaceData, ) - setState(state.value.copy(isLoading = false, error = null)) + setState(state.value.copy(isLoading = false, error = null).withTransientUi()) return } binding = binding.copy( @@ -176,7 +212,7 @@ internal class SessionAnalysisViewModel( workspaceData = workspaceData, selection = resolvedSelection, ) - setState(nextState) + setState(nextState.withTransientUi()) } private suspend fun selectLap(lapNumber: Int?) { @@ -273,7 +309,7 @@ internal class SessionAnalysisViewModel( private fun handleLoadFailure(error: Throwable, fallbackState: SessionAnalysisState?) { if (error is CancellationException) throw error if (fallbackState != null) { - setState(fallbackState.copy(isLoading = false, error = null)) + setState(fallbackState.copy(isLoading = false, error = null).withTransientUi()) return } binding = binding.copy( @@ -284,13 +320,129 @@ internal class SessionAnalysisViewModel( SessionAnalysisState( isLoading = false, error = SessionAnalysisError.Unavailable, - ), + ).withTransientUi(), ) } private fun publishStateIfChanged(nextState: SessionAnalysisState) { - if (state.value != nextState) { - setState(nextState) + val stateWithUi = nextState.withTransientUi() + if (state.value != stateWithUi) { + setState(stateWithUi) + } + } + + private fun openShareResultsDialog() { + val sessionId = binding.loadedSessionId ?: binding.requestedSessionId ?: return + if (state.value.header == null) return + shareDialog = state.value.toShareDialogUi(sessionId) + publishStateIfChanged(state.value) + } + + private fun dismissShareResultsDialog() { + if (!shareDialog.isVisible) return + shareDialog = SessionAnalysisShareDialogUi() + publishStateIfChanged(state.value) + } + + private suspend fun copyShareResults() { + val summaryText = shareDialog.summaryText.takeIf(String::isNotBlank) ?: return + when (val result = shareResultsUseCase.copySummary(summaryText)) { + SessionAnalysisShareResults.CopiedSummary -> { + _actions.emit( + SessionAnalysisAction( + title = "Analysis copied", + message = "Paste it anywhere and send it.", + severity = InfoBarSeverity.Success, + ), + ) + } + + is SessionAnalysisShareResults.Failure -> { + _actions.emit( + SessionAnalysisAction( + title = "Copy failed", + message = result.reason, + severity = InfoBarSeverity.Error, + ), + ) + } + + else -> Unit + } + } + + private suspend fun exportShareResultsToDirectory(directoryPath: String) { + val reportFileName = shareDialog.reportFileName.takeIf(String::isNotBlank) ?: return + val summaryText = shareDialog.summaryText.takeIf(String::isNotBlank) ?: return + when (val result = shareResultsUseCase.exportReport(directoryPath, reportFileName, summaryText)) { + is SessionAnalysisShareResults.ExportedReport -> { + _actions.emit( + SessionAnalysisAction( + title = "Analysis report exported", + message = "Saved ${File(result.filePath).name} to the selected folder.", + severity = InfoBarSeverity.Success, + ), + ) + } + + is SessionAnalysisShareResults.Failure -> { + _actions.emit( + SessionAnalysisAction( + title = "Export failed", + message = result.reason, + severity = InfoBarSeverity.Error, + ), + ) + } + + else -> Unit + } + } + + private suspend fun openShareSessionFiles() { + val sessionId = binding.loadedSessionId ?: binding.requestedSessionId ?: return + when (val result = shareResultsUseCase.openSessionFiles(sessionId)) { + SessionAnalysisShareResults.OpenedSessionFilesAndCopiedPath -> { + _actions.emit( + SessionAnalysisAction( + title = "Raw files ready", + message = "Opened the recording folder and copied its path.", + severity = InfoBarSeverity.Success, + ), + ) + } + + SessionAnalysisShareResults.OpenedSessionFiles -> { + _actions.emit( + SessionAnalysisAction( + title = "Raw files opened", + message = "The recording folder is open in Explorer.", + severity = InfoBarSeverity.Success, + ), + ) + } + + SessionAnalysisShareResults.CopiedSessionFilesPath -> { + _actions.emit( + SessionAnalysisAction( + title = "Raw file path copied", + message = "The folder could not be opened, but its path is in the clipboard.", + severity = InfoBarSeverity.Info, + ), + ) + } + + is SessionAnalysisShareResults.Failure -> { + _actions.emit( + SessionAnalysisAction( + title = "Open raw files failed", + message = result.reason, + severity = InfoBarSeverity.Error, + ), + ) + } + + else -> Unit } } @@ -364,6 +516,8 @@ internal class SessionAnalysisViewModel( referenceSegmentId = referenceSegmentId, referenceLapNumber = referenceLapNumber, ) + + private fun SessionAnalysisState.withTransientUi(): SessionAnalysisState = copy(shareDialog = shareDialog) } private data class SessionAnalysisBindingRequest(val sessionId: Long, val selection: SessionAnalysisSelection) diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/components/SessionAnalysisShareResultsDialog.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/components/SessionAnalysisShareResultsDialog.kt new file mode 100644 index 00000000..3e2c97bf --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/components/SessionAnalysisShareResultsDialog.kt @@ -0,0 +1,239 @@ +package com.analyzer.session.analysis.presentation.components + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.analyzer.session.analysis.presentation.model.share.SessionAnalysisShareDialogUi +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.Res +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_close +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_copy +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_copy_description +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_export +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_export_description +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_export_name +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_formats +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_preview +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_raw_files +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_share_raw_files_description +import com.project.analyzer.theme.SimAnalyzerTheme +import com.project.analyzer.ui.components.Button +import com.project.analyzer.ui.components.InfoDialog +import com.project.analyzer.ui.components.SimAnalyzerButtonSize +import com.project.analyzer.ui.components.SimAnalyzerButtonVariant +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SessionAnalysisShareResultsDialog( + shareDialog: SessionAnalysisShareDialogUi, + onDismiss: () -> Unit, + onCopySummary: () -> Unit, + onExportReport: () -> Unit, + onOpenRawFiles: () -> Unit, +) { + if (!shareDialog.isVisible) return + + InfoDialog( + title = shareDialog.title, + message = shareDialog.supportingText, + onDismissRequest = onDismiss, + modifier = Modifier.width(640.dp), + ) { + SessionAnalysisShareMetaRow( + label = stringResource(Res.string.session_analysis_share_export_name), + value = shareDialog.reportFileName, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(Res.string.session_analysis_share_formats), + style = SimAnalyzerTheme.typography.labelLarge, + color = SimAnalyzerTheme.material.onSurface, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + SessionAnalysisShareActionCard( + title = stringResource(Res.string.session_analysis_share_copy), + description = stringResource(Res.string.session_analysis_share_copy_description), + buttonText = "Copy", + icon = Icons.Filled.ContentCopy, + variant = SimAnalyzerButtonVariant.Primary, + onClick = onCopySummary, + ) + SessionAnalysisShareActionCard( + title = stringResource(Res.string.session_analysis_share_export), + description = stringResource(Res.string.session_analysis_share_export_description), + buttonText = "Save", + icon = Icons.Filled.Description, + variant = SimAnalyzerButtonVariant.Secondary, + onClick = onExportReport, + ) + SessionAnalysisShareActionCard( + title = stringResource(Res.string.session_analysis_share_raw_files), + description = stringResource(Res.string.session_analysis_share_raw_files_description), + buttonText = "Open", + icon = Icons.Filled.FolderOpen, + variant = SimAnalyzerButtonVariant.Outline, + onClick = onOpenRawFiles, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(Res.string.session_analysis_share_preview), + style = SimAnalyzerTheme.typography.labelLarge, + color = SimAnalyzerTheme.material.onSurface, + ) + + SelectionContainer { + Text( + text = shareDialog.summaryText, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 260.dp) + .clip(SimAnalyzerTheme.corners.panel) + .background(SimAnalyzerTheme.chrome.fillMuted) + .border( + width = 1.dp, + color = SimAnalyzerTheme.chrome.borderSubtle, + shape = SimAnalyzerTheme.corners.panel, + ) + .verticalScroll(rememberScrollState()) + .padding(14.dp), + style = SimAnalyzerTheme.typography.bodyMedium, + color = SimAnalyzerTheme.material.onSurface, + ) + } + + Button( + text = stringResource(Res.string.session_analysis_share_close), + onClick = onDismiss, + variant = SimAnalyzerButtonVariant.Outline, + size = SimAnalyzerButtonSize.Compact, + modifier = Modifier.align(Alignment.End), + ) + } +} + +@Composable +private fun SessionAnalysisShareActionCard( + title: String, + description: String, + buttonText: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + variant: SimAnalyzerButtonVariant, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(SimAnalyzerTheme.corners.panel) + .background(SimAnalyzerTheme.chrome.fillMuted) + .border( + width = 1.dp, + color = SimAnalyzerTheme.chrome.borderSubtle, + shape = SimAnalyzerTheme.corners.panel, + ) + .padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = SimAnalyzerTheme.typography.titleSmall, + color = SimAnalyzerTheme.material.onSurface, + ) + Text( + text = description, + style = SimAnalyzerTheme.typography.bodySmall, + color = SimAnalyzerTheme.material.onSurfaceVariant, + ) + } + Button( + text = buttonText, + onClick = onClick, + variant = variant, + size = SimAnalyzerButtonSize.Compact, + leadingIcon = icon, + ) + } +} + +@Composable +private fun SessionAnalysisShareMetaRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(SimAnalyzerTheme.corners.item) + .background(SimAnalyzerTheme.chrome.fillMuted) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = SimAnalyzerTheme.typography.bodySmall, + color = SimAnalyzerTheme.material.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(SimAnalyzerTheme.material.secondary.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = SimAnalyzerTheme.material.secondary, + ) + } + Text( + text = value, + style = SimAnalyzerTheme.typography.labelMedium, + color = SimAnalyzerTheme.material.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/components/SessionAnalysisWorkspaceFabBar.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/components/SessionAnalysisWorkspaceFabBar.kt new file mode 100644 index 00000000..c1eb33a0 --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/components/SessionAnalysisWorkspaceFabBar.kt @@ -0,0 +1,181 @@ +package com.analyzer.session.analysis.presentation.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.Res +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_action_more +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_action_more_close +import com.project.analyzer.feature.screens.sessionAnalysis.impl.Res.session_analysis_action_share +import com.project.analyzer.theme.SimAnalyzerTheme +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SessionAnalysisWorkspaceFabBar(actionsEnabled: Boolean, onShareResults: () -> Unit,) { + var expanded by rememberSaveable { mutableStateOf(false) } + val openActionsLabel = stringResource(Res.string.session_analysis_action_more) + val closeActionsLabel = stringResource(Res.string.session_analysis_action_more_close) + val mainFabRotation by animateFloatAsState( + targetValue = if (expanded) 45f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "session-analysis-speed-dial-fab-rotation", + ) + + LaunchedEffect(actionsEnabled) { + if (!actionsEnabled) expanded = false + } + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AnimatedVisibility( + visible = expanded, + enter = fadeIn( + animationSpec = tween(durationMillis = 160, easing = LinearOutSlowInEasing), + ) + slideInVertically( + initialOffsetY = { it / 2 }, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + ) + scaleIn( + initialScale = 0.92f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + ), + exit = fadeOut(animationSpec = tween(durationMillis = 120)) + + slideOutVertically( + targetOffsetY = { it / 3 }, + animationSpec = tween(durationMillis = 140), + ) + + scaleOut( + targetScale = 0.92f, + animationSpec = tween(durationMillis = 140), + ), + label = "session-analysis-speed-dial-item", + ) { + SessionAnalysisWorkspaceActionRow( + label = stringResource(Res.string.session_analysis_action_share), + contentDescription = stringResource(Res.string.session_analysis_action_share), + enabled = actionsEnabled, + onClick = { + expanded = false + onShareResults() + }, + ) + } + + FloatingActionButton( + onClick = { + if (actionsEnabled) expanded = !expanded + }, + modifier = Modifier.semantics { + contentDescription = if (expanded) closeActionsLabel else openActionsLabel + }, + containerColor = if (expanded) { + SimAnalyzerTheme.material.secondaryContainer + } else { + SimAnalyzerTheme.material.primaryContainer + }, + contentColor = if (expanded) { + SimAnalyzerTheme.material.onSecondaryContainer + } else { + SimAnalyzerTheme.material.onPrimaryContainer + }, + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.rotate(mainFabRotation), + ) + } + } +} + +@Composable +private fun SessionAnalysisWorkspaceActionRow( + label: String, + contentDescription: String, + enabled: Boolean, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + + Row( + modifier = Modifier + .defaultMinSize(minHeight = 40.dp) + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + } + .alpha(if (enabled) 1f else 0.54f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = label, + style = SimAnalyzerTheme.typography.labelLarge, + color = SimAnalyzerTheme.material.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp), + ) + + Surface( + color = SimAnalyzerTheme.material.tertiaryContainer, + contentColor = SimAnalyzerTheme.material.onTertiaryContainer, + shape = CircleShape, + shadowElevation = 8.dp, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = null, + ) + } + } + } +} diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisAction.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisAction.kt new file mode 100644 index 00000000..1d2fa8a4 --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisAction.kt @@ -0,0 +1,5 @@ +package com.analyzer.session.analysis.presentation.model + +import com.project.analyzer.ui.components.InfoBarSeverity + +internal data class SessionAnalysisAction(val title: String, val message: String, val severity: InfoBarSeverity,) diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisCopyShareResultsIntent.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisCopyShareResultsIntent.kt new file mode 100644 index 00000000..635380ae --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisCopyShareResultsIntent.kt @@ -0,0 +1,3 @@ +package com.analyzer.session.analysis.presentation.model + +internal data object SessionAnalysisCopyShareResultsIntent : SessionAnalysisIntent diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisDismissShareResultsIntent.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisDismissShareResultsIntent.kt new file mode 100644 index 00000000..1887c79f --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisDismissShareResultsIntent.kt @@ -0,0 +1,3 @@ +package com.analyzer.session.analysis.presentation.model + +internal data object SessionAnalysisDismissShareResultsIntent : SessionAnalysisIntent diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisExportShareResultsToDirectoryIntent.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisExportShareResultsToDirectoryIntent.kt new file mode 100644 index 00000000..ba6990c6 --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisExportShareResultsToDirectoryIntent.kt @@ -0,0 +1,4 @@ +package com.analyzer.session.analysis.presentation.model + +internal data class SessionAnalysisExportShareResultsToDirectoryIntent(val directoryPath: String,) : + SessionAnalysisIntent diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisOpenShareResultsIntent.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisOpenShareResultsIntent.kt new file mode 100644 index 00000000..9dec0262 --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisOpenShareResultsIntent.kt @@ -0,0 +1,3 @@ +package com.analyzer.session.analysis.presentation.model + +internal data object SessionAnalysisOpenShareResultsIntent : SessionAnalysisIntent diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisOpenShareSessionFilesIntent.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisOpenShareSessionFilesIntent.kt new file mode 100644 index 00000000..8da296bf --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisOpenShareSessionFilesIntent.kt @@ -0,0 +1,3 @@ +package com.analyzer.session.analysis.presentation.model + +internal data object SessionAnalysisOpenShareSessionFilesIntent : SessionAnalysisIntent diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisState.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisState.kt index 19f3591c..45250794 100644 --- a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisState.kt +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/SessionAnalysisState.kt @@ -1,6 +1,7 @@ package com.analyzer.session.analysis.presentation.model import androidx.compose.runtime.Immutable +import com.analyzer.session.analysis.presentation.model.share.SessionAnalysisShareDialogUi import com.analyzer.session.analysis.presentation.model.studio.SessionAnalysisStudioState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,4 +30,5 @@ internal data class SessionAnalysisState( val referenceLapIsCustom: Boolean = false, val hasExternalReference: Boolean = false, val selectedFrameId: Long? = null, + val shareDialog: SessionAnalysisShareDialogUi = SessionAnalysisShareDialogUi(), ) diff --git a/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/share/SessionAnalysisShareDialogUi.kt b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/share/SessionAnalysisShareDialogUi.kt new file mode 100644 index 00000000..05a25403 --- /dev/null +++ b/feature/screens/sessionAnalysis/impl/src/jvmMain/kotlin/com/analyzer/session/analysis/presentation/model/share/SessionAnalysisShareDialogUi.kt @@ -0,0 +1,9 @@ +package com.analyzer.session.analysis.presentation.model.share + +internal data class SessionAnalysisShareDialogUi( + val isVisible: Boolean = false, + val title: String = "", + val supportingText: String = "", + val summaryText: String = "", + val reportFileName: String = "", +) diff --git a/feature/screens/sessionAnalysis/impl/src/jvmTest/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisViewModelTest.kt b/feature/screens/sessionAnalysis/impl/src/jvmTest/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisViewModelTest.kt index d0129d18..f439e420 100644 --- a/feature/screens/sessionAnalysis/impl/src/jvmTest/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisViewModelTest.kt +++ b/feature/screens/sessionAnalysis/impl/src/jvmTest/kotlin/com/analyzer/session/analysis/presentation/SessionAnalysisViewModelTest.kt @@ -4,6 +4,8 @@ package com.analyzer.session.analysis.presentation import com.analyzer.session.analysis.domain.model.SessionAnalysisWorkspaceData import com.analyzer.session.analysis.domain.model.SessionAnalysisWorkspaceRequest +import com.analyzer.session.analysis.domain.usecase.SessionAnalysisShareResults +import com.analyzer.session.analysis.domain.usecase.SessionAnalysisShareResultsUseCase import com.analyzer.session.analysis.domain.usecase.SessionAnalysisUseCase import com.analyzer.session.analysis.presentation.model.SessionAnalysisBindSessionIntent import com.analyzer.session.analysis.presentation.model.SessionAnalysisError @@ -40,7 +42,7 @@ class SessionAnalysisViewModelTest { enrichedWorkspace = shellWorkspace, enrichGate = enrichGate, ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -71,7 +73,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = workspace, enrichedWorkspace = workspace, ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -95,7 +97,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = workspace, enrichedWorkspace = workspace, ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -124,7 +126,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = workspace, enrichedWorkspace = workspace, ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -147,7 +149,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = workspace, enrichedWorkspace = workspace, ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -170,7 +172,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = workspace, enrichedWorkspace = workspace, ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -197,7 +199,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = workspace, enrichedWorkspace = workspace, ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch( SessionAnalysisBindSessionIntent( @@ -230,7 +232,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = workspace.copy(referenceReport = externalReferenceReport), enrichedWorkspace = workspace.copy(referenceReport = externalReferenceReport), ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch( SessionAnalysisBindSessionIntent( @@ -268,7 +270,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = null, enrichedWorkspace = sessionAnalysisWorkspaceData(), ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -291,7 +293,7 @@ class SessionAnalysisViewModelTest { shellWorkspace = workspace, enrichedWorkspace = workspace, ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -319,7 +321,7 @@ class SessionAnalysisViewModelTest { enrichedWorkspace = workspace, enrichError = IllegalStateException("boom"), ) - val viewModel = SessionAnalysisViewModel(useCase, dispatcher) + val viewModel = buildViewModel(useCase, dispatcher) viewModel.dispatch(SessionAnalysisBindSessionIntent(77L)) advanceUntilIdle() @@ -361,3 +363,27 @@ private class FakeSessionAnalysisUseCase( return enrichedWorkspace } } + +private fun buildViewModel( + useCase: SessionAnalysisUseCase, + dispatcher: kotlinx.coroutines.CoroutineDispatcher, +): SessionAnalysisViewModel = SessionAnalysisViewModel( + useCase = useCase, + shareResultsUseCase = FakeSessionAnalysisShareResultsUseCase(), + defaultDispatcher = dispatcher, +) + +private class FakeSessionAnalysisShareResultsUseCase : SessionAnalysisShareResultsUseCase { + + override suspend fun copySummary(summaryText: String): SessionAnalysisShareResults = + SessionAnalysisShareResults.CopiedSummary + + override suspend fun exportReport( + directoryPath: String, + reportFileName: String, + summaryText: String, + ): SessionAnalysisShareResults = SessionAnalysisShareResults.ExportedReport("$directoryPath/$reportFileName") + + override suspend fun openSessionFiles(sessionId: Long): SessionAnalysisShareResults = + SessionAnalysisShareResults.OpenedSessionFiles +} diff --git a/feature/screens/sessionDetails/build.gradle.kts b/feature/screens/sessionDetails/build.gradle.kts index fb6529f0..e7e0da61 100644 --- a/feature/screens/sessionDetails/build.gradle.kts +++ b/feature/screens/sessionDetails/build.gradle.kts @@ -10,9 +10,11 @@ moduleImpl { projects.core.di.api.jvmImpl projects.core.leak.api.jvmImpl projects.core.navigation.api.jvmImpl + projects.core.telemetry.recording.api.jvmImpl projects.core.theme.jvmImpl projects.core.ui.jvmImpl projects.core.utils.jvmImpl + projects.feature.screens.chooser.jvmImpl projects.feature.screens.session.api.jvmImpl lib.metro.metrox.viewmodel.compose.jvmImpl diff --git a/feature/screens/sessionDetails/src/jvmMain/composeResources/values/strings.xml b/feature/screens/sessionDetails/src/jvmMain/composeResources/values/strings.xml index e90ace10..80072674 100644 --- a/feature/screens/sessionDetails/src/jvmMain/composeResources/values/strings.xml +++ b/feature/screens/sessionDetails/src/jvmMain/composeResources/values/strings.xml @@ -28,14 +28,28 @@ Air: Track: Analysis + Share Results + Add Compare Session Compare Laps + More session actions + Close session actions Select 2 Laps %1$d of 2 Selected Ready to Compare Cancel lap compare - Cancel - Open lap comparison - Compare + Cancel + Open lap comparison + Compare + Share preview + Choose how to share this session + Export filename + Copy Chat Summary + Copies clean plain text to the clipboard for Discord, Telegram or chat. + Save Markdown Report + Writes a readable .md report to the folder you choose. + Open Source Session Files + Only if you need the original recorded folder, not the share-ready summary. + Close Best Lap Avg Lap (Valid) Total Incidents diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/di/SessionDetailsBindings.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/di/SessionDetailsBindings.kt index 060375ad..6c26b405 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/di/SessionDetailsBindings.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/di/SessionDetailsBindings.kt @@ -1,8 +1,14 @@ package com.analyzer.session.details.di import androidx.lifecycle.ViewModel +import com.analyzer.session.details.domain.usecase.SessionDetailCompareSuggestionsUseCase +import com.analyzer.session.details.domain.usecase.SessionDetailCompareSuggestionsUseCaseImpl import com.analyzer.session.details.domain.usecase.SessionDetailDataUseCase import com.analyzer.session.details.domain.usecase.SessionDetailDataUseCaseImpl +import com.analyzer.session.details.domain.usecase.SessionDetailImportCompareSessionUseCase +import com.analyzer.session.details.domain.usecase.SessionDetailImportCompareSessionUseCaseImpl +import com.analyzer.session.details.domain.usecase.SessionDetailShareResultsUseCase +import com.analyzer.session.details.domain.usecase.SessionDetailShareResultsUseCaseImpl import com.analyzer.session.details.presentation.SessionDetailViewModel import com.project.analyzer.api.di.ScreenScope import dev.zacsweers.metro.BindingContainer @@ -19,6 +25,21 @@ interface SessionDetailsBindings { @Provides private fun provideSessionDetailDataUseCase(impl: SessionDetailDataUseCaseImpl): SessionDetailDataUseCase = impl + @Provides + private fun provideSessionDetailCompareSuggestionsUseCase( + impl: SessionDetailCompareSuggestionsUseCaseImpl, + ): SessionDetailCompareSuggestionsUseCase = impl + + @Provides + private fun provideSessionDetailImportCompareSessionUseCase( + impl: SessionDetailImportCompareSessionUseCaseImpl, + ): SessionDetailImportCompareSessionUseCase = impl + + @Provides + private fun provideSessionDetailShareResultsUseCase( + impl: SessionDetailShareResultsUseCaseImpl, + ): SessionDetailShareResultsUseCase = impl + @Provides @IntoMap @ViewModelKey(SessionDetailViewModel::class) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/mapper/SessionDetailDomainMapper.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/mapper/SessionDetailDomainMapper.kt index 7c24ace2..8af0511d 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/mapper/SessionDetailDomainMapper.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/mapper/SessionDetailDomainMapper.kt @@ -74,7 +74,10 @@ class SessionDetailDomainMapper( val summary = details.summary val dateLabel = dateFormatter.format(Instant.ofEpochMilli(summary.startedAtMs).atZone(zoneId)) val timeLabel = timeFormatter.format(Instant.ofEpochMilli(summary.startedAtMs).atZone(zoneId)) - val trackLabel = summary.trackName.toDisplayTrackLabel(summary.trackId) + val trackLabel = summary.trackName.toDisplayTrackLabel( + trackId = summary.trackId, + layoutId = summary.layoutId, + ) val carLabel = summary.carName.toDisplayCarLabel(summary.carModel) val airTemp = summary.airTempC.formatTemperatureLabel() val trackTemp = summary.trackTempC.formatTemperatureLabel() @@ -86,6 +89,11 @@ class SessionDetailDomainMapper( trackTempLabel = trackTemp, carLabel = carLabel, trackLabel = trackLabel, + gameId = summary.gameId, + trackId = summary.trackId, + layoutId = summary.layoutId, + carModel = summary.carModel, + carId = summary.carId, savedCarId = thumbnail?.savedCarId, thumbnailPath = thumbnail?.texturePath, ) @@ -148,8 +156,13 @@ class SessionDetailDomainMapper( return SessionLapDomainStatus.Clean } - private fun String?.toDisplayTrackLabel(trackId: String?): String = this?.takeIf { it.isNotBlank() } - ?: TelemetryIdentityFormatter.formatTrackName(trackName = null, trackId = trackId) + private fun String?.toDisplayTrackLabel(trackId: String?, layoutId: String?): String = + this?.takeIf { it.isNotBlank() } + ?: TelemetryIdentityFormatter.formatTrackName( + trackName = null, + trackId = trackId, + layoutId = layoutId, + ) ?: "Unknown" private fun String?.toDisplayCarLabel(carModel: String?): String = this?.takeIf { it.isNotBlank() } diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/model/SessionDetailDomainModels.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/model/SessionDetailDomainModels.kt index 319a16af..c987befe 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/model/SessionDetailDomainModels.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/model/SessionDetailDomainModels.kt @@ -37,6 +37,11 @@ data class SessionDetailDomainHeader( val trackTempLabel: String = "--°C", val carLabel: String = "", val trackLabel: String = "", + val gameId: String = "", + val trackId: String? = null, + val layoutId: String? = null, + val carModel: String? = null, + val carId: Int? = null, val savedCarId: String? = null, val thumbnailPath: String? = null, ) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareCriteria.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareCriteria.kt new file mode 100644 index 00000000..81345277 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareCriteria.kt @@ -0,0 +1,11 @@ +package com.analyzer.session.details.domain.usecase + +internal data class SessionDetailCompareCriteria( + val currentSessionId: Long, + val gameId: String, + val gameLabel: String, + val trackId: String, + val layoutId: String? = null, + val trackLabel: String, + val preferredCarIdentityKey: String? = null, +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestion.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestion.kt new file mode 100644 index 00000000..ecaa3867 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestion.kt @@ -0,0 +1,12 @@ +package com.analyzer.session.details.domain.usecase + +internal data class SessionDetailCompareSuggestion( + val sessionId: Long, + val carLabel: String, + val sessionTypeLabel: String, + val dateLabel: String, + val timeLabel: String, + val bestLapLabel: String, + val lapsLabel: String, + val recommendationLabel: String? = null, +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestions.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestions.kt new file mode 100644 index 00000000..58f1c26d --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestions.kt @@ -0,0 +1,6 @@ +package com.analyzer.session.details.domain.usecase + +internal data class SessionDetailCompareSuggestions( + val candidates: List, + val excludedDifferentLayoutCount: Int = 0, +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCase.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCase.kt new file mode 100644 index 00000000..3a32df4f --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCase.kt @@ -0,0 +1,9 @@ +package com.analyzer.session.details.domain.usecase + +internal interface SessionDetailCompareSuggestionsUseCase { + + suspend fun loadSuggestions( + criteria: SessionDetailCompareCriteria, + forceRefresh: Boolean = false, + ): SessionDetailCompareSuggestions +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCaseImpl.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCaseImpl.kt new file mode 100644 index 00000000..27bda080 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCaseImpl.kt @@ -0,0 +1,126 @@ +package com.analyzer.session.details.domain.usecase + +import com.analyzer.session.data.model.RecordedSessionSummary +import com.analyzer.session.data.repository.RecordedSessionListRequest +import com.analyzer.session.data.repository.RecordedSessionRepository +import com.analyzer.session.data.repository.RecordedSessionSummarySort +import com.project.analyzer.api.di.ScreenScope +import com.project.analyzer.utils.TelemetryIdentityFormatter +import com.project.analyzer.utils.ext.fromMsToLapTime +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Inject +@SingleIn(ScreenScope::class) +internal class SessionDetailCompareSuggestionsUseCaseImpl(private val repository: RecordedSessionRepository,) : + SessionDetailCompareSuggestionsUseCase { + + private val dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.US) + private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale.US) + private val zoneId = ZoneId.systemDefault() + + override suspend fun loadSuggestions( + criteria: SessionDetailCompareCriteria, + forceRefresh: Boolean, + ): SessionDetailCompareSuggestions { + val matches = mutableListOf() + var excludedDifferentLayoutCount = 0 + var page = 1 + var pageCount = 1 + + while (page <= pageCount && matches.size < CANDIDATE_LIMIT) { + val result = repository.loadSessionListPage( + request = RecordedSessionListRequest( + gameId = criteria.gameId, + trackId = criteria.trackId, + sort = RecordedSessionSummarySort.StartedAtDesc, + page = page, + pageSize = PAGE_SIZE, + ), + forceRefresh = forceRefresh, + ) + pageCount = result.pageCount + result.items.forEach { session -> + when { + session.sessionId == criteria.currentSessionId -> Unit + !matchesLayout(criteria.layoutId, session.layoutId) -> excludedDifferentLayoutCount += 1 + else -> matches += session + } + } + if (result.items.isEmpty()) break + page += 1 + } + + val candidates = matches + .sortedWith( + compareByDescending { criteria.preferredCarIdentityKey == it.carIdentityKey() } + .thenByDescending { it.startedAtMs }, + ) + .take(CANDIDATE_LIMIT) + .map { session -> + val sameCar = criteria.preferredCarIdentityKey != null && + criteria.preferredCarIdentityKey == session.carIdentityKey() + SessionDetailCompareSuggestion( + sessionId = session.sessionId, + carLabel = session.carName.toDisplayCarLabel(session.carModel), + sessionTypeLabel = session.sessionType.toSessionTypeLabel(), + dateLabel = formatDate(session.startedAtMs), + timeLabel = formatTime(session.startedAtMs), + bestLapLabel = session.bestLapTimeMs?.fromMsToLapTime() ?: "0:00.000", + lapsLabel = session.lapCount.toString(), + recommendationLabel = if (sameCar) "Same car" else "Same track", + ) + } + + return SessionDetailCompareSuggestions( + candidates = candidates, + excludedDifferentLayoutCount = excludedDifferentLayoutCount, + ) + } + + private fun matchesLayout(expectedLayoutId: String?, actualLayoutId: String?): Boolean { + val normalizedExpected = expectedLayoutId.normalizeIdentity() + val normalizedActual = actualLayoutId.normalizeIdentity() + return when { + normalizedExpected == null && normalizedActual == null -> true + normalizedExpected == null || normalizedActual == null -> false + else -> normalizedExpected == normalizedActual + } + } + + private fun formatDate(epochMs: Long): String = dateFormatter.format(Instant.ofEpochMilli(epochMs).atZone(zoneId)) + + private fun formatTime(epochMs: Long): String = timeFormatter.format(Instant.ofEpochMilli(epochMs).atZone(zoneId)) + + private fun RecordedSessionSummary.carIdentityKey(): String? = carId + ?.takeIf { it > 0 } + ?.toString() + ?: carModel.normalizeIdentity() + + private fun String?.toDisplayCarLabel(carModel: String?): String = this?.takeIf(String::isNotBlank) + ?: TelemetryIdentityFormatter.formatCarName(carModel = carModel) + ?: "Unknown" + + private fun String?.toSessionTypeLabel(): String { + val raw = this?.trim().orEmpty() + if (raw.isBlank()) return "Unknown" + return raw + .lowercase(Locale.US) + .split('_') + .joinToString(" ") { part -> part.replaceFirstChar { c -> c.titlecase(Locale.US) } } + } + + private fun String?.normalizeIdentity(): String? = this + ?.trim() + ?.takeIf(String::isNotBlank) + ?.lowercase(Locale.US) + + private companion object { + const val PAGE_SIZE = 32 + const val CANDIDATE_LIMIT = 12 + } +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionResult.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionResult.kt new file mode 100644 index 00000000..6026ea10 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionResult.kt @@ -0,0 +1,12 @@ +package com.analyzer.session.details.domain.usecase + +internal sealed interface SessionDetailImportCompareSessionResult { + + data class Imported(val sessionId: Long, val destinationPath: String) : SessionDetailImportCompareSessionResult + + data class AlreadyAvailable(val sessionId: Long) : SessionDetailImportCompareSessionResult + + data class Rejected(val reason: String) : SessionDetailImportCompareSessionResult + + data class Failure(val reason: String) : SessionDetailImportCompareSessionResult +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionUseCase.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionUseCase.kt new file mode 100644 index 00000000..493d4518 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionUseCase.kt @@ -0,0 +1,9 @@ +package com.analyzer.session.details.domain.usecase + +internal interface SessionDetailImportCompareSessionUseCase { + + suspend fun importSession( + criteria: SessionDetailCompareCriteria, + path: String, + ): SessionDetailImportCompareSessionResult +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionUseCaseImpl.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionUseCaseImpl.kt new file mode 100644 index 00000000..8008de7f --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailImportCompareSessionUseCaseImpl.kt @@ -0,0 +1,173 @@ +package com.analyzer.session.details.domain.usecase + +import com.project.analyzer.api.di.ScreenScope +import com.project.analyzer.telemetry.recording.api.acquisition.TelemetryAcquisitionSettings +import com.project.analyzer.telemetry.recording.api.file.TELEMETRY_SESSION_META_FILE_NAME +import com.project.analyzer.telemetry.recording.api.session.RecordedTelemetrySessionBundle +import com.project.analyzer.telemetry.recording.api.session.RecordedTelemetrySessionMetadata +import com.project.analyzer.telemetry.recording.api.session.RecordedTelemetrySessionStorage +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.serialization.json.Json +import java.io.File +import java.net.URI +import java.util.Locale + +@Inject +@SingleIn(ScreenScope::class) +internal class SessionDetailImportCompareSessionUseCaseImpl( + private val storage: RecordedTelemetrySessionStorage, + private val settings: TelemetryAcquisitionSettings, + private val json: Json, +) : SessionDetailImportCompareSessionUseCase { + + override suspend fun importSession( + criteria: SessionDetailCompareCriteria, + path: String, + ): SessionDetailImportCompareSessionResult { + val sourceDir = resolveImportSourceDir(path) + ?: return SessionDetailImportCompareSessionResult.Rejected( + reason = "Choose a Sim Analyzer session folder or drop a session.json file.", + ) + val metadataFile = File(sourceDir, TELEMETRY_SESSION_META_FILE_NAME) + if (!metadataFile.isFile) { + return SessionDetailImportCompareSessionResult.Rejected( + reason = "This folder does not contain a recorded Sim Analyzer session.", + ) + } + val metadata = runCatching { + json.decodeFromString(metadataFile.readText()) + }.getOrElse { error -> + return SessionDetailImportCompareSessionResult.Rejected( + reason = error.message ?: "The dropped session metadata could not be read.", + ) + } + validateCompatibility(criteria, metadata)?.let { reason -> + return SessionDetailImportCompareSessionResult.Rejected(reason) + } + + val storageRoot = File(settings.currentConfig().storageLocation) + if (!storageRoot.exists() && !storageRoot.mkdirs()) { + return SessionDetailImportCompareSessionResult.Failure( + reason = "The recordings folder could not be prepared for import.", + ) + } + + val sourceCanonical = sourceDir.canonicalFile + val storageCanonical = storageRoot.canonicalFile + val existingBundles = storage.loadBundles(forceRefresh = true) + if (sourceCanonical.isInside( + storageCanonical, + ) || existingBundles.containsSession(metadata.sessionId, sourceCanonical) + ) { + return SessionDetailImportCompareSessionResult.AlreadyAvailable(metadata.sessionId) + } + + val targetDir = storageCanonical.resolveUniqueSessionDirectory( + seedName = sourceCanonical.name.takeIf(String::isNotBlank) ?: "session-${metadata.sessionId}", + ) + val copied = runCatching { + sourceCanonical.copyRecursively(target = targetDir, overwrite = false) { _, _ -> + OnErrorAction.TERMINATE + } + } + if (copied.isFailure) { + targetDir.deleteRecursively() + return SessionDetailImportCompareSessionResult.Failure( + reason = copied.exceptionOrNull()?.message + ?: "The compare session could not be imported into the library.", + ) + } + + storage.loadBundles(forceRefresh = true) + return SessionDetailImportCompareSessionResult.Imported( + sessionId = metadata.sessionId, + destinationPath = targetDir.absolutePath, + ) + } + + private fun validateCompatibility( + criteria: SessionDetailCompareCriteria, + metadata: RecordedTelemetrySessionMetadata, + ): String? { + val gameId = metadata.gameId.normalizeIdentity() + if (gameId == null || gameId != criteria.gameId.normalizeIdentity()) { + return "Only ${criteria.gameLabel} recordings can be compared on this screen." + } + val trackId = metadata.trackId.normalizeIdentity() + if (trackId == null || trackId != criteria.trackId.normalizeIdentity()) { + return "Only recordings from ${criteria.trackLabel} can be compared here." + } + if (!matchesLayout(criteria.layoutId, metadata.layoutId)) { + return "This recording uses a different track layout, so it was not added." + } + return null + } + + private fun matchesLayout(expectedLayoutId: String?, actualLayoutId: String?): Boolean { + val normalizedExpected = expectedLayoutId.normalizeIdentity() + val normalizedActual = actualLayoutId.normalizeIdentity() + return when { + normalizedExpected == null && normalizedActual == null -> true + normalizedExpected == null || normalizedActual == null -> false + else -> normalizedExpected == normalizedActual + } + } + + private fun resolveImportSourceDir(path: String): File? { + val candidate = path.toImportFile() ?: return null + if (candidate.isDirectory) { + if (File(candidate, TELEMETRY_SESSION_META_FILE_NAME).isFile) return candidate + val nestedMatches = candidate.listFiles() + .orEmpty() + .filter(File::isDirectory) + .filter { directory -> File(directory, TELEMETRY_SESSION_META_FILE_NAME).isFile } + return nestedMatches.singleOrNull() + } + val parent = candidate.parentFile ?: return null + return when { + candidate.name.equals(TELEMETRY_SESSION_META_FILE_NAME, ignoreCase = true) -> parent + File(parent, TELEMETRY_SESSION_META_FILE_NAME).isFile -> parent + else -> null + } + } + + private fun String.toImportFile(): File? = runCatching { + if (startsWith("file:", ignoreCase = true)) { + File(URI(this)) + } else { + File(this) + } + }.getOrNull() +} + +private fun List.containsSession( + sessionId: Long, + sourceDir: File, +): Boolean = any { bundle -> + bundle.sessionId == sessionId || + bundle.locations.any { location -> location.dir.canonicalFile == sourceDir } +} + +private fun File.isInside(root: File): Boolean = runCatching { + canonicalFile.toPath().startsWith(root.canonicalFile.toPath()) +}.getOrDefault(false) + +private fun File.resolveUniqueSessionDirectory(seedName: String): File { + val normalizedSeed = seedName + .replace(Regex("[^A-Za-z0-9._-]+"), "-") + .trim('-') + .ifBlank { "session" } + var index = 1 + var candidate = resolve(normalizedSeed) + while (candidate.exists()) { + index += 1 + candidate = resolve("$normalizedSeed-$index") + } + return candidate +} + +private fun String?.normalizeIdentity(): String? = this + ?.trim() + ?.takeIf(String::isNotBlank) + ?.lowercase(Locale.US) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResults.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResults.kt new file mode 100644 index 00000000..96cb830e --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResults.kt @@ -0,0 +1,10 @@ +package com.analyzer.session.details.domain.usecase + +internal sealed interface SessionDetailShareResults { + data object CopiedSummary : SessionDetailShareResults + data class ExportedReport(val filePath: String) : SessionDetailShareResults + data object OpenedSessionFilesAndCopiedPath : SessionDetailShareResults + data object OpenedSessionFiles : SessionDetailShareResults + data object CopiedSessionFilesPath : SessionDetailShareResults + data class Failure(val reason: String) : SessionDetailShareResults +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResultsUseCase.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResultsUseCase.kt new file mode 100644 index 00000000..b54af91c --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResultsUseCase.kt @@ -0,0 +1,14 @@ +package com.analyzer.session.details.domain.usecase + +internal interface SessionDetailShareResultsUseCase { + + suspend fun copySummary(summaryText: String): SessionDetailShareResults + + suspend fun exportReport( + directoryPath: String, + reportFileName: String, + summaryText: String, + ): SessionDetailShareResults + + suspend fun openSessionFiles(sessionId: Long): SessionDetailShareResults +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResultsUseCaseImpl.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResultsUseCaseImpl.kt new file mode 100644 index 00000000..b3355180 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailShareResultsUseCaseImpl.kt @@ -0,0 +1,116 @@ +package com.analyzer.session.details.domain.usecase + +import com.project.analyzer.api.di.ScreenScope +import com.project.analyzer.telemetry.recording.api.session.RecordedTelemetrySessionLocation +import com.project.analyzer.telemetry.recording.api.session.RecordedTelemetrySessionStorage +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import java.awt.Desktop +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection +import java.io.File +import java.nio.charset.StandardCharsets + +@Inject +@SingleIn(ScreenScope::class) +internal class SessionDetailShareResultsUseCaseImpl(private val storage: RecordedTelemetrySessionStorage,) : + SessionDetailShareResultsUseCase { + + override suspend fun copySummary(summaryText: String): SessionDetailShareResults { + val copied = runCatching { + Toolkit.getDefaultToolkit().systemClipboard.setContents( + StringSelection(summaryText), + null, + ) + }.isSuccess + return if (copied) { + SessionDetailShareResults.CopiedSummary + } else { + SessionDetailShareResults.Failure("Couldn't copy the session summary to the clipboard.") + } + } + + override suspend fun exportReport( + directoryPath: String, + reportFileName: String, + summaryText: String, + ): SessionDetailShareResults { + val directory = File(directoryPath) + if (!directory.exists() || !directory.isDirectory) { + return SessionDetailShareResults.Failure("The selected export folder is no longer available.") + } + + return runCatching { + val target = directory.resolveUniqueReportFile(reportFileName) + target.writeText(summaryText, StandardCharsets.UTF_8) + SessionDetailShareResults.ExportedReport(target.absolutePath) + }.getOrElse { error -> + SessionDetailShareResults.Failure( + error.message ?: "Couldn't export the session report to the selected folder.", + ) + } + } + + override suspend fun openSessionFiles(sessionId: Long): SessionDetailShareResults { + val bundle = storage.findBundle(sessionId = sessionId) + ?: return SessionDetailShareResults.Failure("No recorded files were found for this session.") + val directories = bundle.locations.map(RecordedTelemetrySessionLocation::dir).distinct() + val target = directories.resolveShareTarget() + ?: return SessionDetailShareResults.Failure("The recorded session folder is no longer available.") + + val openedFolder = runCatching { + check(Desktop.isDesktopSupported()) { "Desktop integration is not available on this system." } + Desktop.getDesktop().open(target) + }.isSuccess + val copiedPath = runCatching { + Toolkit.getDefaultToolkit().systemClipboard.setContents( + StringSelection(target.absolutePath), + null, + ) + }.isSuccess + + return when { + openedFolder && copiedPath -> SessionDetailShareResults.OpenedSessionFilesAndCopiedPath + openedFolder -> SessionDetailShareResults.OpenedSessionFiles + copiedPath -> SessionDetailShareResults.CopiedSessionFilesPath + else -> SessionDetailShareResults.Failure("Couldn't open the session folder or copy its path.") + } + } +} + +private fun List.resolveShareTarget(): File? = when { + isEmpty() -> null + + size == 1 -> first().takeIf { it.exists() } + + else -> { + val parents = mapNotNull(File::getParentFile) + .filter { it.exists() } + .distinctBy { it.absolutePath } + when { + parents.size == 1 -> parents.single() + else -> maxByOrNull { it.lastModified() }?.takeIf { it.exists() } + } + } +} + +private fun File.resolveUniqueReportFile(reportFileName: String): File { + val extension = reportFileName.substringAfterLast('.', "") + val baseName = if (extension.isBlank()) { + reportFileName + } else { + reportFileName.removeSuffix(".$extension") + } + var candidate = File(this, reportFileName) + var index = 2 + while (candidate.exists()) { + val suffix = if (extension.isBlank()) { + "$baseName-$index" + } else { + "$baseName-$index.$extension" + } + candidate = File(this, suffix) + index += 1 + } + return candidate +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareCriteriaFactory.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareCriteriaFactory.kt new file mode 100644 index 00000000..f840d2a1 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareCriteriaFactory.kt @@ -0,0 +1,38 @@ +package com.analyzer.session.details.presentation + +import com.analyzer.session.details.domain.model.SessionDetailDomainHeader +import com.analyzer.session.details.domain.usecase.SessionDetailCompareCriteria +import java.util.Locale + +internal fun SessionDetailDomainHeader.toCompareCriteria(sessionId: Long): SessionDetailCompareCriteria? { + val normalizedGameId = gameId.normalizeSessionDetailIdentity() ?: return null + val resolvedTrackId = trackId + ?.trim() + ?.takeIf(String::isNotBlank) + ?: return null + return SessionDetailCompareCriteria( + currentSessionId = sessionId, + gameId = normalizedGameId, + gameLabel = normalizedGameId.toSessionDetailGameLabel(), + trackId = resolvedTrackId, + layoutId = layoutId.normalizeSessionDetailIdentity(), + trackLabel = trackLabel, + preferredCarIdentityKey = carId + ?.takeIf { it > 0 } + ?.toString() + ?: carModel.normalizeSessionDetailIdentity(), + ) +} + +private fun String?.normalizeSessionDetailIdentity(): String? = this + ?.trim() + ?.takeIf(String::isNotBlank) + ?.lowercase(Locale.US) + +private fun String.toSessionDetailGameLabel(): String = when (this) { + "ac" -> "AC" + "ace" -> "AC Evo" + "acc" -> "ACC" + "lmu" -> "LMU" + else -> uppercase(Locale.US) +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareSelection.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareSelection.kt new file mode 100644 index 00000000..7cf65890 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareSelection.kt @@ -0,0 +1,53 @@ +package com.analyzer.session.details.presentation + +import com.analyzer.session.details.presentation.model.SessionDetailCompareLapUi +import com.analyzer.session.details.presentation.model.SessionLapRowUi +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf + +internal data class SessionDetailCompareSelection( + val isSelectionMode: Boolean = false, + val selectedLaps: PersistentList = persistentListOf(), +) { + + fun start(): SessionDetailCompareSelection = + if (isSelectionMode) this else copy(isSelectionMode = true) + + fun cancel(): SessionDetailCompareSelection = + if (!isSelectionMode && selectedLaps.isEmpty()) this else SessionDetailCompareSelection() + + fun toggle( + visibleLaps: List, + segmentId: Long, + lapNumber: Int, + ): SessionDetailCompareSelection { + if (!isSelectionMode) return this + val lap = visibleLaps.firstOrNull { row -> + row.segmentId == segmentId && row.lapNumber == lapNumber + } ?: return this + val existingIndex = selectedLaps.indexOfFirst { selected -> + selected.segmentId == segmentId && selected.lapNumber == lapNumber + } + val nextSelectedLaps = when { + existingIndex >= 0 -> selectedLaps.removeAt(existingIndex) + selectedLaps.size >= 2 -> selectedLaps + selectedLaps.isNotEmpty() && selectedLaps.first().segmentId != segmentId -> selectedLaps + else -> { + selectedLaps.add( + SessionDetailCompareLapUi( + segmentId = lap.segmentId, + lapNumber = lap.lapNumber, + lapLabel = lap.lapLabel, + sessionTypeLabel = lap.sessionTypeLabel, + totalTimeMs = lap.totalTimeMs, + ), + ) + } + } + return if (nextSelectedLaps === selectedLaps) { + this + } else { + copy(selectedLaps = nextSelectedLaps) + } + } +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareSessionPickerFactory.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareSessionPickerFactory.kt new file mode 100644 index 00000000..cc8321a6 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailCompareSessionPickerFactory.kt @@ -0,0 +1,73 @@ +package com.analyzer.session.details.presentation + +import com.analyzer.session.details.domain.usecase.SessionDetailCompareCriteria +import com.analyzer.session.details.domain.usecase.SessionDetailCompareSuggestion +import com.analyzer.session.details.domain.usecase.SessionDetailCompareSuggestions +import com.analyzer.session.details.presentation.model.SessionDetailCompareSessionCandidateUi +import com.analyzer.session.details.presentation.model.SessionDetailCompareSessionPickerUi +import kotlinx.collections.immutable.toImmutableList + +internal fun missingCompareSessionPickerUi(): SessionDetailCompareSessionPickerUi = + SessionDetailCompareSessionPickerUi( + isVisible = true, + title = "Compare another session", + supportingText = "Add a Sim Analyzer session folder, then compare only matching game and track layouts.", + emptyMessage = "This session is missing the map identity needed to suggest a safe comparison.", + ) + +internal fun SessionDetailCompareCriteria.toLoadingCompareSessionPickerUi( + statusMessage: String? = null, +): SessionDetailCompareSessionPickerUi = SessionDetailCompareSessionPickerUi( + isVisible = true, + isLoading = true, + title = compareSessionPickerTitle(), + supportingText = compareSessionPickerSupportingText(), + statusMessage = statusMessage, +) + +internal fun SessionDetailCompareCriteria.toReadyCompareSessionPickerUi( + suggestions: SessionDetailCompareSuggestions, + highlightedSessionId: Long?, + statusMessage: String? = null, +): SessionDetailCompareSessionPickerUi = SessionDetailCompareSessionPickerUi( + isVisible = true, + title = compareSessionPickerTitle(), + supportingText = compareSessionPickerSupportingText(), + statusMessage = statusMessage, + emptyMessage = suggestions.toEmptyMessage(), + candidates = suggestions.candidates + .map { candidate -> candidate.toCompareSessionCandidateUi(highlightedSessionId) } + .toImmutableList(), +) + +private fun SessionDetailCompareCriteria.compareSessionPickerTitle(): String = + "Compare on $trackLabel" + +private fun SessionDetailCompareCriteria.compareSessionPickerSupportingText(): String = + "Drop or choose a Sim Analyzer session folder. Only $gameLabel recordings from " + + "the same track layout are listed below, and the same car is ranked first." + +private fun SessionDetailCompareSuggestion.toCompareSessionCandidateUi( + highlightedSessionId: Long?, +): SessionDetailCompareSessionCandidateUi = SessionDetailCompareSessionCandidateUi( + sessionId = sessionId, + carLabel = carLabel, + sessionTypeLabel = sessionTypeLabel, + dateLabel = dateLabel, + timeLabel = timeLabel, + bestLapLabel = bestLapLabel, + lapsLabel = lapsLabel, + recommendationLabel = if (sessionId == highlightedSessionId) { + "Just added" + } else { + recommendationLabel + }, +) + +private fun SessionDetailCompareSuggestions.toEmptyMessage(): String { + if (candidates.isNotEmpty()) return "" + if (excludedDifferentLayoutCount > 0) { + return "Found sessions on the same track, but they use a different layout so they were excluded." + } + return "No comparable sessions from this track layout were found in your library yet." +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailShareDialogFactory.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailShareDialogFactory.kt new file mode 100644 index 00000000..ad72cc6e --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailShareDialogFactory.kt @@ -0,0 +1,52 @@ +package com.analyzer.session.details.presentation + +import com.analyzer.session.details.domain.model.SessionDetailDomainHeader +import com.analyzer.session.details.domain.model.SessionDetailDomainStats +import com.analyzer.session.details.domain.model.SessionDetailPage +import com.analyzer.session.details.presentation.model.SessionDetailShareDialogUi +import com.project.analyzer.utils.toSlugId + +internal fun SessionDetailPage.toShareDialogUi(sessionId: Long): SessionDetailShareDialogUi { + val header = header + return SessionDetailShareDialogUi( + isVisible = true, + title = if (header.trackLabel.isNotBlank()) { + "Share ${header.trackLabel} session" + } else { + "Share session result" + }, + supportingText = "Choose a share-ready format: copy the chat summary, save a Markdown report, or open the source recording only when you need raw files.", + summaryText = buildShareSummaryText(header = header, stats = stats), + reportFileName = buildShareReportFileName(header = header, sessionId = sessionId), + ) +} + +private fun buildShareSummaryText(header: SessionDetailDomainHeader, stats: SessionDetailDomainStats): String = + buildString { + appendLine("Sim Analyzer Session Summary") + appendLine() + appendLine("Track: ${header.trackLabel.ifBlank { "Unknown track" }}") + appendLine("Car: ${header.carLabel.ifBlank { "Unknown car" }}") + appendLine("Session: ${header.sessionTypeLabel.ifBlank { "Unknown session" }}") + if (header.subtitle.isNotBlank()) { + appendLine("When: ${header.subtitle}") + } + appendLine("Air / Track: ${header.airTempLabel} / ${header.trackTempLabel}") + appendLine() + appendLine("Highlights") + appendLine("- Best lap: ${stats.bestLapLabel}") + appendLine("- Avg valid lap: ${stats.averageLapLabel}") + appendLine("- Incidents: ${stats.incidentsCount}") + appendLine() + append("Shared from Sim Analyzer") + } + +private fun buildShareReportFileName(header: SessionDetailDomainHeader, sessionId: Long): String { + val parts = listOf( + "simanalyzer", + header.trackLabel.ifBlank { "session" }.toSlugId(), + header.sessionTypeLabel.ifBlank { "result" }.toSlugId(), + header.subtitle.ifBlank { "session_$sessionId" }.toSlugId(), + ).filter(String::isNotBlank) + return parts.joinToString(separator = "_") + ".md" +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailStateExt.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailStateExt.kt index 636fc45c..ab6532a8 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailStateExt.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailStateExt.kt @@ -5,12 +5,14 @@ import com.analyzer.session.details.domain.model.SessionLapDomainItem import com.analyzer.session.details.domain.model.SessionLapDomainStatus import com.analyzer.session.details.presentation.model.LapStatus import com.analyzer.session.details.presentation.model.SessionDetailCompareLapUi +import com.analyzer.session.details.presentation.model.SessionDetailCompareSessionPickerUi import com.analyzer.session.details.presentation.model.SessionDetailFilterIdsUi import com.analyzer.session.details.presentation.model.SessionDetailFilterKind import com.analyzer.session.details.presentation.model.SessionDetailFilterOptionUi import com.analyzer.session.details.presentation.model.SessionDetailFilterUiModel import com.analyzer.session.details.presentation.model.SessionDetailHeaderUi import com.analyzer.session.details.presentation.model.SessionDetailQueryUi +import com.analyzer.session.details.presentation.model.SessionDetailShareDialogUi import com.analyzer.session.details.presentation.model.SessionDetailState import com.analyzer.session.details.presentation.model.SessionDetailStatsUi import com.analyzer.session.details.presentation.model.SessionLapRowUi @@ -49,6 +51,8 @@ internal fun SessionDetailPage.toSessionDetailState( isLoading: Boolean = false, isCompareSelectionMode: Boolean = false, selectedCompareLaps: ImmutableList = persistentListOf(), + compareSessionPicker: SessionDetailCompareSessionPickerUi = SessionDetailCompareSessionPickerUi(), + shareDialog: SessionDetailShareDialogUi = SessionDetailShareDialogUi(), ): SessionDetailState = SessionDetailState( isLoading = isLoading, error = error, @@ -93,6 +97,8 @@ internal fun SessionDetailPage.toSessionDetailState( isCompareSelectionMode = isCompareSelectionMode, selectedCompareLaps = selectedCompareLaps, compareConfirmEnabled = selectedCompareLaps.size == 2, + compareSessionPicker = compareSessionPicker, + shareDialog = shareDialog, ) private fun SessionDetailPage.sessionDetailSessionTypeOptions(): ImmutableList = diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailViewModel.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailViewModel.kt index 9f1901d9..bbd7128b 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailViewModel.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailViewModel.kt @@ -1,63 +1,151 @@ package com.analyzer.session.details.presentation import com.analyzer.session.details.domain.model.SessionDetailPageResult +import com.analyzer.session.details.domain.usecase.SessionDetailCompareCriteria +import com.analyzer.session.details.domain.usecase.SessionDetailCompareSuggestionsUseCase import com.analyzer.session.details.domain.usecase.SessionDetailDataUseCase -import com.analyzer.session.details.presentation.model.SessionDetailCompareLapUi +import com.analyzer.session.details.domain.usecase.SessionDetailImportCompareSessionUseCase +import com.analyzer.session.details.domain.usecase.SessionDetailShareResultsUseCase +import com.analyzer.session.details.presentation.model.SessionDetailAction +import com.analyzer.session.details.presentation.model.SessionDetailCompareSessionPickerUi import com.analyzer.session.details.presentation.model.SessionDetailIntent import com.analyzer.session.details.presentation.model.SessionDetailQueryUi +import com.analyzer.session.details.presentation.model.SessionDetailShareDialogUi import com.analyzer.session.details.presentation.model.SessionDetailState import com.analyzer.session.details.presentation.model.toDomain import com.analyzer.session.details.presentation.model.toUi import com.project.analyzer.leak.api.LeakAwareMviViewModel import dev.zacsweers.metro.Inject -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow @Inject -internal class SessionDetailViewModel(private val dataUseCase: SessionDetailDataUseCase) : - LeakAwareMviViewModel(SessionDetailState()) { +internal class SessionDetailViewModel( + private val dataUseCase: SessionDetailDataUseCase, + private val compareSuggestionsUseCase: SessionDetailCompareSuggestionsUseCase, + private val importCompareSessionUseCase: SessionDetailImportCompareSessionUseCase, + private val shareResultsUseCase: SessionDetailShareResultsUseCase, +) : LeakAwareMviViewModel(SessionDetailState()) { private var query = SessionDetailQueryUi() private var currentSessionId: Long? = null private var loadedSessionId: Long? = null private var currentPageResult: SessionDetailPageResult? = null - private var isCompareSelectionMode = false - private var selectedCompareLaps: PersistentList = persistentListOf() + private var compareSelection = SessionDetailCompareSelection() + private var compareSessionPicker = SessionDetailCompareSessionPickerUi() + private var currentCompareCriteria: SessionDetailCompareCriteria? = null + private var highlightedCompareSessionId: Long? = null + private var shareDialog = SessionDetailShareDialogUi() + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + + val actions = _actions.asSharedFlow() override suspend fun handleIntent(intent: SessionDetailIntent) { - when (intent) { - is SessionDetailIntent.BindSession -> bindSession(intent.sessionId) + if (handleSessionIntent(intent)) return + if (handleQueryIntent(intent)) return + if (handleCompareIntent(intent)) return + handleShareIntent(intent) + } + + private suspend fun handleSessionIntent(intent: SessionDetailIntent): Boolean = when (intent) { + is SessionDetailIntent.BindSession -> { + bindSession(intent.sessionId) + true + } - SessionDetailIntent.Refresh -> reload(forceRefresh = true) + else -> false + } - is SessionDetailIntent.ChangeSort -> updateQuery { copy(sortId = intent.optionId, page = 1) } + private suspend fun handleQueryIntent(intent: SessionDetailIntent): Boolean = when (intent) { + SessionDetailIntent.Refresh -> { + reload(forceRefresh = true) + true + } - is SessionDetailIntent.ChangeFilter -> updateQuery { copy(showId = intent.optionId, page = 1) } + is SessionDetailIntent.ChangeSort -> { + updateQuery { copy(sortId = intent.optionId, page = 1) } + true + } + + is SessionDetailIntent.ChangeFilter -> { + updateQuery { copy(showId = intent.optionId, page = 1) } + true + } - is SessionDetailIntent.ChangeSessionTypeFilter -> updateQuery { + is SessionDetailIntent.ChangeSessionTypeFilter -> { + updateQuery { copy( sessionTypeId = intent.optionId, page = 1, ) } + true + } + + is SessionDetailIntent.ChangePage -> { + updateQuery { copy(page = intent.page) } + true + } + + else -> false + } - is SessionDetailIntent.ChangePage -> updateQuery { copy(page = intent.page) } + private suspend fun handleCompareIntent(intent: SessionDetailIntent): Boolean = when (intent) { + SessionDetailIntent.StartCompareSelection -> { + startCompareSelection() + true + } + + SessionDetailIntent.CancelCompareSelection -> { + cancelCompareSelection() + true + } + + SessionDetailIntent.OpenCompareSessionPicker -> { + openCompareSessionPicker() + true + } - SessionDetailIntent.StartCompareSelection -> startCompareSelection() + SessionDetailIntent.DismissCompareSessionPicker -> { + dismissCompareSessionPicker() + true + } - SessionDetailIntent.CancelCompareSelection -> cancelCompareSelection() + is SessionDetailIntent.ImportCompareSession -> { + importCompareSession(intent.path) + true + } - is SessionDetailIntent.ToggleCompareLap -> toggleCompareLap( + is SessionDetailIntent.ToggleCompareLap -> { + toggleCompareLap( segmentId = intent.segmentId, lapNumber = intent.lapNumber, ) + true + } + + else -> false + } + + private suspend fun handleShareIntent(intent: SessionDetailIntent) { + when (intent) { + SessionDetailIntent.OpenShareResults -> openShareResultsDialog() + SessionDetailIntent.DismissShareResults -> dismissShareResultsDialog() + SessionDetailIntent.CopyShareResults -> copyShareResults() + + is SessionDetailIntent.ExportShareResultsToDirectory -> { + exportShareResultsToDirectory(intent.directoryPath) + } + + SessionDetailIntent.OpenShareSessionFiles -> openShareSessionFiles() + else -> Unit } } private suspend fun bindSession(sessionId: Long) { if (currentSessionId == sessionId && loadedSessionId == sessionId) return currentSessionId = sessionId - resetCompareSelection() + resetTransientUi() load(sessionId = sessionId) } @@ -108,58 +196,160 @@ internal class SessionDetailViewModel(private val dataUseCase: SessionDetailData setState( result.page.toSessionDetailState( query = query, - isCompareSelectionMode = isCompareSelectionMode, - selectedCompareLaps = selectedCompareLaps, + isCompareSelectionMode = compareSelection.isSelectionMode, + selectedCompareLaps = compareSelection.selectedLaps, + compareSessionPicker = compareSessionPicker, + shareDialog = shareDialog, ), ) } private fun startCompareSelection() { - if (isCompareSelectionMode) return - isCompareSelectionMode = true + val nextSelection = compareSelection.start() + if (nextSelection == compareSelection) return + compareSelection = nextSelection renderCurrentPage() } private fun cancelCompareSelection() { - if (!isCompareSelectionMode && selectedCompareLaps.isEmpty()) return - resetCompareSelection() + val nextSelection = compareSelection.cancel() + if (nextSelection == compareSelection) return + compareSelection = nextSelection renderCurrentPage() } - private fun toggleCompareLap(segmentId: Long, lapNumber: Int) { - if (!isCompareSelectionMode) return - val lap = state.value.visibleLaps.firstOrNull { row -> - row.segmentId == segmentId && row.lapNumber == lapNumber - } ?: return - val existingIndex = selectedCompareLaps.indexOfFirst { selected -> - selected.segmentId == segmentId && selected.lapNumber == lapNumber + private suspend fun openCompareSessionPicker() { + val sessionId = currentSessionId ?: return + val criteria = currentPageResult?.page?.header?.toCompareCriteria(sessionId) + if (criteria == null) { + currentCompareCriteria = null + highlightedCompareSessionId = null + compareSessionPicker = missingCompareSessionPickerUi() + renderCurrentPage() + return } - selectedCompareLaps = when { - existingIndex >= 0 -> selectedCompareLaps.removeAt(existingIndex) - selectedCompareLaps.size >= 2 -> selectedCompareLaps + currentCompareCriteria = criteria + highlightedCompareSessionId = null + loadCompareSessionPicker(criteria = criteria) + } - selectedCompareLaps.isNotEmpty() && selectedCompareLaps.first().segmentId != segmentId -> { - selectedCompareLaps - } + private fun dismissCompareSessionPicker() { + if (!compareSessionPicker.isVisible) return + currentCompareCriteria = null + highlightedCompareSessionId = null + compareSessionPicker = SessionDetailCompareSessionPickerUi() + renderCurrentPage() + } - else -> { - selectedCompareLaps.add( - SessionDetailCompareLapUi( - segmentId = lap.segmentId, - lapNumber = lap.lapNumber, - lapLabel = lap.lapLabel, - sessionTypeLabel = lap.sessionTypeLabel, - totalTimeMs = lap.totalTimeMs, - ), - ) - } + private suspend fun importCompareSession(path: String) { + val sessionId = currentSessionId ?: return + val criteria = currentCompareCriteria + ?: currentPageResult?.page?.header?.toCompareCriteria(sessionId) + ?: return + currentCompareCriteria = criteria + compareSessionPicker = compareSessionPicker.copy( + isVisible = true, + isImporting = true, + statusMessage = null, + ) + renderCurrentPage() + + val feedback = importCompareSessionUseCase + .importSession(criteria = criteria, path = path) + .toCompareImportFeedback() + highlightedCompareSessionId = feedback.highlightedSessionId + if (feedback.shouldReloadSuggestions) { + emitAction(feedback.action) + loadCompareSessionPicker( + criteria = criteria, + forceRefresh = true, + statusMessage = feedback.statusMessage, + ) + return } + compareSessionPicker = compareSessionPicker.copy(isImporting = false) + renderCurrentPage() + emitAction(feedback.action) + } + + private fun openShareResultsDialog() { + val sessionId = currentSessionId ?: return + val page = currentPageResult?.page ?: return + shareDialog = page.toShareDialogUi(sessionId = sessionId) + renderCurrentPage() + } + + private fun dismissShareResultsDialog() { + if (!shareDialog.isVisible) return + shareDialog = SessionDetailShareDialogUi() + renderCurrentPage() + } + + private fun toggleCompareLap(segmentId: Long, lapNumber: Int) { + val nextSelection = compareSelection.toggle( + visibleLaps = state.value.visibleLaps, + segmentId = segmentId, + lapNumber = lapNumber, + ) + if (nextSelection == compareSelection) return + compareSelection = nextSelection renderCurrentPage() } + private suspend fun copyShareResults() { + val summaryText = shareDialog.summaryText.takeIf(String::isNotBlank) ?: return + emitAction(shareResultsUseCase.copySummary(summaryText).toCopySummaryAction()) + } + + private suspend fun exportShareResultsToDirectory(directoryPath: String) { + val reportFileName = shareDialog.reportFileName.takeIf(String::isNotBlank) ?: return + val summaryText = shareDialog.summaryText.takeIf(String::isNotBlank) ?: return + emitAction( + shareResultsUseCase + .exportReport(directoryPath, reportFileName, summaryText) + .toExportReportAction(), + ) + } + + private suspend fun openShareSessionFiles() { + val sessionId = currentSessionId ?: return + emitAction(shareResultsUseCase.openSessionFiles(sessionId).toOpenSessionFilesAction()) + } + private fun resetCompareSelection() { - isCompareSelectionMode = false - selectedCompareLaps = persistentListOf() + compareSelection = SessionDetailCompareSelection() + } + + private fun resetTransientUi() { + resetCompareSelection() + compareSessionPicker = SessionDetailCompareSessionPickerUi() + currentCompareCriteria = null + highlightedCompareSessionId = null + shareDialog = SessionDetailShareDialogUi() + } + + private suspend fun loadCompareSessionPicker( + criteria: SessionDetailCompareCriteria, + forceRefresh: Boolean = false, + statusMessage: String? = null, + ) { + compareSessionPicker = criteria.toLoadingCompareSessionPickerUi(statusMessage) + renderCurrentPage() + + val suggestions = compareSuggestionsUseCase.loadSuggestions( + criteria = criteria, + forceRefresh = forceRefresh, + ) + compareSessionPicker = criteria.toReadyCompareSessionPickerUi( + suggestions = suggestions, + highlightedSessionId = highlightedCompareSessionId, + statusMessage = statusMessage, + ) + renderCurrentPage() + } + + private suspend fun emitAction(action: SessionDetailAction?) { + if (action != null) _actions.emit(action) } } diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailWorkspaceActionFactory.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailWorkspaceActionFactory.kt new file mode 100644 index 00000000..b125d827 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailWorkspaceActionFactory.kt @@ -0,0 +1,117 @@ +package com.analyzer.session.details.presentation + +import com.analyzer.session.details.domain.usecase.SessionDetailImportCompareSessionResult +import com.analyzer.session.details.domain.usecase.SessionDetailShareResults +import com.analyzer.session.details.presentation.model.SessionDetailAction +import com.project.analyzer.ui.components.InfoBarSeverity +import java.io.File + +internal data class SessionDetailCompareImportFeedback( + val action: SessionDetailAction, + val shouldReloadSuggestions: Boolean, + val highlightedSessionId: Long? = null, + val statusMessage: String? = null, +) + +internal fun SessionDetailImportCompareSessionResult.toCompareImportFeedback(): SessionDetailCompareImportFeedback = + when (this) { + is SessionDetailImportCompareSessionResult.Imported -> SessionDetailCompareImportFeedback( + action = SessionDetailAction( + title = "Compare session added", + message = "It is ready below. Press Compare on the session you want.", + severity = InfoBarSeverity.Success, + ), + shouldReloadSuggestions = true, + highlightedSessionId = sessionId, + statusMessage = "Session added. Pick it below to open the comparison.", + ) + + is SessionDetailImportCompareSessionResult.AlreadyAvailable -> SessionDetailCompareImportFeedback( + action = SessionDetailAction( + title = "Session already available", + message = "Pick it below to start the comparison.", + severity = InfoBarSeverity.Info, + ), + shouldReloadSuggestions = true, + highlightedSessionId = sessionId, + statusMessage = "This recording is already in your library. Pick it below to compare.", + ) + + is SessionDetailImportCompareSessionResult.Rejected -> SessionDetailCompareImportFeedback( + action = SessionDetailAction( + title = "Session not added", + message = reason, + severity = InfoBarSeverity.Warning, + ), + shouldReloadSuggestions = false, + ) + + is SessionDetailImportCompareSessionResult.Failure -> SessionDetailCompareImportFeedback( + action = SessionDetailAction( + title = "Import failed", + message = reason, + severity = InfoBarSeverity.Error, + ), + shouldReloadSuggestions = false, + ) + } + +internal fun SessionDetailShareResults.toCopySummaryAction(): SessionDetailAction? = when (this) { + SessionDetailShareResults.CopiedSummary -> SessionDetailAction( + title = "Summary copied", + message = "Paste it anywhere and send it.", + severity = InfoBarSeverity.Success, + ) + + is SessionDetailShareResults.Failure -> SessionDetailAction( + title = "Copy failed", + message = reason, + severity = InfoBarSeverity.Error, + ) + + else -> null +} + +internal fun SessionDetailShareResults.toExportReportAction(): SessionDetailAction? = when (this) { + is SessionDetailShareResults.ExportedReport -> SessionDetailAction( + title = "Report exported", + message = "Saved ${File(filePath).name} to the selected folder.", + severity = InfoBarSeverity.Success, + ) + + is SessionDetailShareResults.Failure -> SessionDetailAction( + title = "Export failed", + message = reason, + severity = InfoBarSeverity.Error, + ) + + else -> null +} + +internal fun SessionDetailShareResults.toOpenSessionFilesAction(): SessionDetailAction? = when (this) { + SessionDetailShareResults.OpenedSessionFilesAndCopiedPath -> SessionDetailAction( + title = "Raw files ready", + message = "Opened the recording folder and copied its path.", + severity = InfoBarSeverity.Success, + ) + + SessionDetailShareResults.OpenedSessionFiles -> SessionDetailAction( + title = "Raw files opened", + message = "The recording folder is open in Explorer.", + severity = InfoBarSeverity.Success, + ) + + SessionDetailShareResults.CopiedSessionFilesPath -> SessionDetailAction( + title = "Raw file path copied", + message = "The recording folder could not be opened, but its path is in the clipboard.", + severity = InfoBarSeverity.Info, + ) + + is SessionDetailShareResults.Failure -> SessionDetailAction( + title = "Open raw files failed", + message = reason, + severity = InfoBarSeverity.Error, + ) + + else -> null +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailsScreen.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailsScreen.kt index 7b819595..c53c5f0f 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailsScreen.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/SessionDetailsScreen.kt @@ -1,20 +1,29 @@ package com.analyzer.session.details.presentation +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.analyzer.session.details.presentation.components.SessionDetailsCompareFabBar +import com.analyzer.session.details.presentation.components.SessionDetailsCompareSessionPickerDialog import com.analyzer.session.details.presentation.components.SessionDetailsHeader import com.analyzer.session.details.presentation.components.SessionDetailsLapTable +import com.analyzer.session.details.presentation.components.SessionDetailsShareResultsDialog import com.analyzer.session.details.presentation.components.SessionDetailsStatsRow +import com.analyzer.session.details.presentation.components.SessionDetailsWorkspaceFabBar import com.analyzer.session.details.presentation.model.LapStatus import com.analyzer.session.details.presentation.model.SessionDetailCompareLapUi import com.analyzer.session.details.presentation.model.SessionDetailFilterKind @@ -25,11 +34,16 @@ import com.analyzer.session.details.presentation.model.SessionDetailIntent import com.analyzer.session.details.presentation.model.SessionDetailState import com.analyzer.session.details.presentation.model.SessionDetailStatsUi import com.analyzer.session.details.presentation.model.SessionLapRowUi +import com.project.analyzer.chooser.FileChooserDialog +import com.project.analyzer.chooser.SelectionMode +import com.project.analyzer.chooser.rememberFileChooserState import com.project.analyzer.navigation.api.LocalNavigator import com.project.analyzer.navigation.api.Route import com.project.analyzer.theme.SimAnalyzerTheme import com.project.analyzer.ui.adaptive.ResponsiveGridMode import com.project.analyzer.ui.adaptive.ResponsiveScreen +import com.project.analyzer.ui.components.InfoBarSnackbarHost +import com.project.analyzer.ui.components.InfoBarSnackbarVisuals import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -39,35 +53,92 @@ internal fun SessionDetailsScreen(sessionId: Long) { val viewModel = metroViewModel() val navigator = LocalNavigator.current val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val exportDirectoryChooserState = rememberFileChooserState( + title = "Choose export folder", + selectionMode = SelectionMode.DIRECTORY, + onResult = { selectedPath -> + selectedPath?.let { path -> + viewModel.dispatch(SessionDetailIntent.ExportShareResultsToDirectory(path)) + } + }, + ) + val importCompareSessionChooserState = rememberFileChooserState( + title = "Choose compare session folder", + selectionMode = SelectionMode.DIRECTORY, + onResult = { selectedPath -> + selectedPath?.let { path -> + viewModel.dispatch(SessionDetailIntent.ImportCompareSession(path)) + } + }, + ) LaunchedEffect(viewModel, sessionId) { viewModel.dispatch(SessionDetailIntent.BindSession(sessionId)) } - SessionDetailsContent( - state = state, - onAnalysisClick = { - navigator.navigate(Route.SessionRoot.SessionAnalysis(sessionId = sessionId)) - }, - onCompareConfirm = { - val selection = state.selectedCompareLaps.toComparisonSelection() - if (selection.size == 2) { - val baseLap = selection[0] - val referenceLap = selection[1] - viewModel.dispatch(SessionDetailIntent.CancelCompareSelection) + LaunchedEffect(viewModel) { + viewModel.actions.collect { action -> + snackbarHostState.showSnackbar( + visuals = InfoBarSnackbarVisuals( + title = action.title, + message = action.message, + severity = action.severity, + duration = SnackbarDuration.Short, + ), + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + SessionDetailsContent( + state = state, + modifier = Modifier.fillMaxSize(), + onRequestShareExportDirectory = { exportDirectoryChooserState.show() }, + onRequestCompareImportDirectory = { importCompareSessionChooserState.show() }, + onAnalysisClick = { + navigator.navigate(Route.SessionRoot.SessionAnalysis(sessionId = sessionId)) + }, + onCompareConfirm = { + val selection = state.selectedCompareLaps.toComparisonSelection() + if (selection.size == 2) { + val baseLap = selection[0] + val referenceLap = selection[1] + viewModel.dispatch(SessionDetailIntent.CancelCompareSelection) + navigator.navigate( + Route.SessionRoot.SessionAnalysis( + sessionId = sessionId, + segmentId = baseLap.segmentId, + lapNumber = baseLap.lapNumber, + referenceSegmentId = referenceLap.segmentId, + referenceLapNumber = referenceLap.lapNumber, + ), + ) + } + }, + onCompareSessionSelect = { referenceSessionId -> + viewModel.dispatch(SessionDetailIntent.DismissCompareSessionPicker) navigator.navigate( Route.SessionRoot.SessionAnalysis( sessionId = sessionId, - segmentId = baseLap.segmentId, - lapNumber = baseLap.lapNumber, - referenceSegmentId = referenceLap.segmentId, - referenceLapNumber = referenceLap.lapNumber, + referenceSessionId = referenceSessionId, ), ) - } - }, - onIntent = viewModel::dispatch, - ) + }, + onIntent = viewModel::dispatch, + ) + + InfoBarSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 16.dp, vertical = 16.dp) + .widthIn(max = 560.dp), + ) + } + + FileChooserDialog(state = exportDirectoryChooserState) + FileChooserDialog(state = importCompareSessionChooserState) } @Composable @@ -76,76 +147,106 @@ internal fun SessionDetailsContent( modifier: Modifier = Modifier, onAnalysisClick: () -> Unit = {}, onCompareConfirm: () -> Unit = {}, + onCompareSessionSelect: (Long) -> Unit = {}, + onRequestShareExportDirectory: () -> Unit = {}, + onRequestCompareImportDirectory: () -> Unit = {}, onIntent: (SessionDetailIntent) -> Unit = {}, ) { - Scaffold( - modifier = modifier, - containerColor = SimAnalyzerTheme.material.background, - floatingActionButton = { - SessionDetailsCompareFabBar( - isCompareSelectionMode = state.isCompareSelectionMode, - selectedCompareLaps = state.selectedCompareLaps, - compareConfirmEnabled = state.compareConfirmEnabled, - onStartCompare = { - onIntent(SessionDetailIntent.StartCompareSelection) - }, - onCancelCompare = { - onIntent(SessionDetailIntent.CancelCompareSelection) - }, - onConfirmCompare = onCompareConfirm, - ) - }, - ) { innerPadding -> - ResponsiveScreen( - modifier = Modifier.padding(innerPadding), - contentPadding = PaddingValues( - start = 12.dp, - top = 0.dp, - end = 12.dp, - bottom = 104.dp, - ), - verticalSpacing = 10.dp, - gridMode = ResponsiveGridMode.Grid, - mediumColumns = 1, - expandedColumns = 1, - backgroundColor = SimAnalyzerTheme.material.background, - ) { - item(key = "session-details-stats", isContentFull = true) { - SessionDetailsStatsRow( - stats = state.stats, - modifier = Modifier.fillMaxWidth(), - ) - } - item(key = "session-details-header", isContentFull = true) { - SessionDetailsHeader( - header = state.header, - sortFilter = state.sortFilter, - showFilter = state.showFilter, - sessionTypeFilter = state.sessionTypeFilter, - modifier = Modifier.fillMaxWidth(), - onSortSelect = { onIntent(SessionDetailIntent.ChangeSort(it)) }, - onShowSelect = { onIntent(SessionDetailIntent.ChangeFilter(it)) }, - onSessionTypeSelect = { onIntent(SessionDetailIntent.ChangeSessionTypeFilter(it)) }, - onAnalysisClick = onAnalysisClick, - ) - } - item(key = "session-details-table", isContentFull = true) { - SessionDetailsLapTable( - state = state, - modifier = Modifier.fillMaxWidth(), - onPageChange = { onIntent(SessionDetailIntent.ChangePage(it)) }, - onSortChange = { onIntent(SessionDetailIntent.ChangeSort(it)) }, - onCompareToggle = { segmentId, lapNumber -> - onIntent( - SessionDetailIntent.ToggleCompareLap( - segmentId = segmentId, - lapNumber = lapNumber, - ), - ) + Box(modifier = modifier) { + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = SimAnalyzerTheme.material.background, + floatingActionButton = { + SessionDetailsWorkspaceFabBar( + isCompareSelectionMode = state.isCompareSelectionMode, + selectedCompareLaps = state.selectedCompareLaps, + compareConfirmEnabled = state.compareConfirmEnabled, + compareStartEnabled = !state.isLoading && state.visibleLaps.size >= 2, + actionsEnabled = !state.isLoading && state.error == null, + onStartCompare = { + onIntent(SessionDetailIntent.StartCompareSelection) + }, + onCancelCompare = { + onIntent(SessionDetailIntent.CancelCompareSelection) + }, + onConfirmCompare = onCompareConfirm, + onOpenCompareSessionPicker = { + onIntent(SessionDetailIntent.OpenCompareSessionPicker) + }, + onShareResults = { + onIntent(SessionDetailIntent.OpenShareResults) }, ) + }, + ) { innerPadding -> + ResponsiveScreen( + modifier = Modifier.padding(innerPadding), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + bottom = if (state.isCompareSelectionMode) 104.dp else 176.dp, + ), + verticalSpacing = 10.dp, + gridMode = ResponsiveGridMode.Grid, + mediumColumns = 1, + expandedColumns = 1, + backgroundColor = SimAnalyzerTheme.material.background, + ) { + item(key = "session-details-stats", isContentFull = true) { + SessionDetailsStatsRow( + stats = state.stats, + modifier = Modifier.fillMaxWidth(), + ) + } + item(key = "session-details-header", isContentFull = true) { + SessionDetailsHeader( + header = state.header, + sortFilter = state.sortFilter, + showFilter = state.showFilter, + sessionTypeFilter = state.sessionTypeFilter, + modifier = Modifier.fillMaxWidth(), + onSortSelect = { onIntent(SessionDetailIntent.ChangeSort(it)) }, + onShowSelect = { onIntent(SessionDetailIntent.ChangeFilter(it)) }, + onSessionTypeSelect = { onIntent(SessionDetailIntent.ChangeSessionTypeFilter(it)) }, + onAnalysisClick = onAnalysisClick, + ) + } + item(key = "session-details-table", isContentFull = true) { + SessionDetailsLapTable( + state = state, + modifier = Modifier.fillMaxWidth(), + onPageChange = { onIntent(SessionDetailIntent.ChangePage(it)) }, + onSortChange = { onIntent(SessionDetailIntent.ChangeSort(it)) }, + onCompareToggle = { segmentId, lapNumber -> + onIntent( + SessionDetailIntent.ToggleCompareLap( + segmentId = segmentId, + lapNumber = lapNumber, + ), + ) + }, + ) + } } } + + SessionDetailsCompareSessionPickerDialog( + picker = state.compareSessionPicker, + onDismiss = { onIntent(SessionDetailIntent.DismissCompareSessionPicker) }, + onSelectSession = onCompareSessionSelect, + onBrowseImportSession = onRequestCompareImportDirectory, + onImportSessionPath = { path -> + onIntent(SessionDetailIntent.ImportCompareSession(path)) + }, + ) + + SessionDetailsShareResultsDialog( + shareDialog = state.shareDialog, + onDismiss = { onIntent(SessionDetailIntent.DismissShareResults) }, + onCopySummary = { onIntent(SessionDetailIntent.CopyShareResults) }, + onExportReport = onRequestShareExportDirectory, + onOpenRawFiles = { onIntent(SessionDetailIntent.OpenShareSessionFiles) }, + ) } } diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareFabBar.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareFabBar.kt index ac8103b0..33eef980 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareFabBar.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareFabBar.kt @@ -8,40 +8,8 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Timeline -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.analyzer.session.details.presentation.model.SessionDetailCompareLapUi import com.project.analyzer.feature.screens.sessionDetails.Res.Res import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare @@ -49,29 +17,24 @@ import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_a import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_cancel_short import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_confirm import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_confirm_short -import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_progress -import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_ready -import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_select -import com.project.analyzer.theme.SimAnalyzerTheme import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import org.jetbrains.compose.resources.stringResource @Composable -internal fun SessionDetailsCompareFabBar( +internal fun SessionDetailsWorkspaceFabBar( isCompareSelectionMode: Boolean, selectedCompareLaps: ImmutableList, compareConfirmEnabled: Boolean, + compareStartEnabled: Boolean, + actionsEnabled: Boolean, modifier: Modifier = Modifier, onStartCompare: () -> Unit = {}, onCancelCompare: () -> Unit = {}, onConfirmCompare: () -> Unit = {}, + onOpenCompareSessionPicker: () -> Unit = {}, + onShareResults: () -> Unit = {}, ) { val compareFabDescription = stringResource(Res.string.session_details_action_compare) - val cancelDescription = stringResource(Res.string.session_details_action_compare_cancel) - val cancelLabel = stringResource(Res.string.session_details_action_compare_cancel_short) - val confirmDescription = stringResource(Res.string.session_details_action_compare_confirm) - val confirmLabel = stringResource(Res.string.session_details_action_compare_confirm_short) AnimatedContent( targetState = isCompareSelectionMode, @@ -93,249 +56,23 @@ internal fun SessionDetailsCompareFabBar( isCompareSelectionMode = true, selectedCompareLaps = selectedCompareLaps, ), - cancelLabel = cancelLabel, - confirmLabel = confirmLabel, - cancelDescription = cancelDescription, - confirmDescription = confirmDescription, + cancelLabel = stringResource(Res.string.session_details_action_compare_cancel_short), + confirmLabel = stringResource(Res.string.session_details_action_compare_confirm_short), + cancelDescription = stringResource(Res.string.session_details_action_compare_cancel), + confirmDescription = stringResource(Res.string.session_details_action_compare_confirm), onCancelCompare = onCancelCompare, onConfirmCompare = onConfirmCompare, ) } else { - ExtendedFloatingActionButton( - text = { - Text( - text = compareFabLabel( - isCompareSelectionMode = false, - selectedCompareLaps = selectedCompareLaps, - ), - ) - }, - icon = { - Icon( - imageVector = Icons.Filled.Timeline, - contentDescription = null, - ) - }, - onClick = onStartCompare, - modifier = Modifier.semantics { - contentDescription = compareFabDescription - }, - expanded = true, - containerColor = SimAnalyzerTheme.material.primaryContainer, - contentColor = SimAnalyzerTheme.material.onPrimaryContainer, + SessionDetailsWorkspaceSpeedDial( + selectedCompareLaps = selectedCompareLaps, + compareStartEnabled = compareStartEnabled, + actionsEnabled = actionsEnabled, + compareFabDescription = compareFabDescription, + onStartCompare = onStartCompare, + onOpenCompareSessionPicker = onOpenCompareSessionPicker, + onShareResults = onShareResults, ) } } } - -@Composable -private fun SessionDetailsCompareSelectionDock( - compareConfirmEnabled: Boolean, - title: String, - statusLabel: String, - cancelLabel: String, - confirmLabel: String, - cancelDescription: String, - confirmDescription: String, - onCancelCompare: () -> Unit, - onConfirmCompare: () -> Unit, -) { - Surface( - shape = SimAnalyzerTheme.corners.pill, - color = SimAnalyzerTheme.material.surface, - border = BorderStroke(1.dp, SimAnalyzerTheme.chrome.borderSubtle), - shadowElevation = 12.dp, - ) { - Row( - modifier = Modifier - .heightIn(min = 64.dp) - .padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - SessionDetailsCompareLead( - title = title, - statusLabel = statusLabel, - compareConfirmEnabled = compareConfirmEnabled, - ) - Spacer( - modifier = Modifier - .width(1.dp) - .height(30.dp) - .background(SimAnalyzerTheme.chrome.borderSubtle), - ) - SessionDetailsCompareActionChip( - icon = Icons.Filled.Close, - contentDescription = cancelDescription, - label = cancelLabel, - tint = SimAnalyzerTheme.extended.red, - backgroundColor = SimAnalyzerTheme.extended.red.copy(alpha = 0.12f), - borderColor = SimAnalyzerTheme.extended.red.copy(alpha = 0.24f), - onClick = onCancelCompare, - ) - SessionDetailsCompareActionChip( - icon = Icons.Filled.Check, - contentDescription = confirmDescription, - label = confirmLabel, - tint = if (compareConfirmEnabled) { - SimAnalyzerTheme.extended.lightGreen - } else { - SimAnalyzerTheme.material.onSurfaceVariant.copy(alpha = 0.72f) - }, - backgroundColor = if (compareConfirmEnabled) { - SimAnalyzerTheme.extended.lightGreen.copy(alpha = 0.14f) - } else { - SimAnalyzerTheme.material.surfaceVariant.copy(alpha = 0.8f) - }, - borderColor = if (compareConfirmEnabled) { - SimAnalyzerTheme.extended.lightGreen.copy(alpha = 0.28f) - } else { - SimAnalyzerTheme.chrome.borderSubtle - }, - enabled = compareConfirmEnabled, - onClick = onConfirmCompare, - ) - } - } -} - -@Composable -private fun SessionDetailsCompareLead(title: String, statusLabel: String, compareConfirmEnabled: Boolean) { - val iconContainerColor = when { - compareConfirmEnabled -> SimAnalyzerTheme.material.primaryContainer - else -> SimAnalyzerTheme.material.secondaryContainer - } - - Row( - modifier = Modifier.padding(start = 2.dp, end = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(iconContainerColor), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Filled.Timeline, - contentDescription = null, - tint = if (compareConfirmEnabled) { - SimAnalyzerTheme.material.onPrimaryContainer - } else { - SimAnalyzerTheme.material.onSecondaryContainer - }, - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = title, - style = SimAnalyzerTheme.typography.labelSmall, - color = SimAnalyzerTheme.material.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = statusLabel, - style = SimAnalyzerTheme.typography.titleSmall, - color = SimAnalyzerTheme.material.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -private fun SessionDetailsCompareActionChip( - icon: ImageVector, - contentDescription: String, - label: String, - tint: Color, - backgroundColor: Color, - borderColor: Color, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .clip(SimAnalyzerTheme.corners.pill) - .background(backgroundColor) - .then( - Modifier.border( - width = 1.dp, - color = borderColor, - shape = SimAnalyzerTheme.corners.pill, - ), - ) - .clickable(enabled = enabled, onClick = onClick) - .semantics { this.contentDescription = contentDescription } - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tint, - modifier = Modifier.size(16.dp), - ) - Text( - text = label, - color = tint, - style = SimAnalyzerTheme.typography.labelMedium, - ) - } -} - -@Composable -private fun compareFabLabel( - isCompareSelectionMode: Boolean, - selectedCompareLaps: ImmutableList, -): String = when { - !isCompareSelectionMode -> stringResource(Res.string.session_details_action_compare) - - selectedCompareLaps.size >= 2 -> stringResource(Res.string.session_details_action_compare_ready) - - selectedCompareLaps.isNotEmpty() -> stringResource( - Res.string.session_details_action_compare_progress, - selectedCompareLaps.size, - ) - - else -> stringResource(Res.string.session_details_action_compare_select) -} - -@Preview -@Composable -private fun SessionDetailsCompareFabBarPreview() { - SimAnalyzerTheme { - SessionDetailsCompareFabBar( - isCompareSelectionMode = true, - selectedCompareLaps = persistentListOf( - SessionDetailCompareLapUi( - segmentId = 1L, - lapNumber = 3, - lapLabel = "3", - sessionTypeLabel = "Race", - totalTimeMs = 95_212, - ), - ), - compareConfirmEnabled = false, - ) - } -} - -@Preview -@Composable -private fun SessionDetailsCompareFabBarIdlePreview() { - SimAnalyzerTheme { - SessionDetailsCompareFabBar( - isCompareSelectionMode = false, - selectedCompareLaps = persistentListOf(), - compareConfirmEnabled = false, - ) - } -} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareSelectionDock.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareSelectionDock.kt new file mode 100644 index 00000000..09f03857 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareSelectionDock.kt @@ -0,0 +1,222 @@ +package com.analyzer.session.details.presentation.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.analyzer.session.details.presentation.model.SessionDetailCompareLapUi +import com.project.analyzer.feature.screens.sessionDetails.Res.Res +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_progress +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_ready +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_compare_select +import com.project.analyzer.theme.SimAnalyzerTheme +import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SessionDetailsCompareSelectionDock( + compareConfirmEnabled: Boolean, + title: String, + statusLabel: String, + cancelLabel: String, + confirmLabel: String, + cancelDescription: String, + confirmDescription: String, + onCancelCompare: () -> Unit, + onConfirmCompare: () -> Unit, +) { + Surface( + shape = SimAnalyzerTheme.corners.pill, + color = SimAnalyzerTheme.material.surface, + border = BorderStroke(1.dp, SimAnalyzerTheme.chrome.borderSubtle), + shadowElevation = 12.dp, + ) { + Row( + modifier = Modifier + .heightIn(min = 64.dp) + .padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SessionDetailsCompareLead( + title = title, + statusLabel = statusLabel, + compareConfirmEnabled = compareConfirmEnabled, + ) + Spacer( + modifier = Modifier + .width(1.dp) + .height(30.dp) + .background(SimAnalyzerTheme.chrome.borderSubtle), + ) + SessionDetailsCompareActionChip( + icon = Icons.Filled.Close, + contentDescription = cancelDescription, + label = cancelLabel, + tint = SimAnalyzerTheme.extended.red, + backgroundColor = SimAnalyzerTheme.extended.red.copy(alpha = 0.12f), + borderColor = SimAnalyzerTheme.extended.red.copy(alpha = 0.24f), + onClick = onCancelCompare, + ) + SessionDetailsCompareActionChip( + icon = Icons.Filled.Check, + contentDescription = confirmDescription, + label = confirmLabel, + tint = if (compareConfirmEnabled) { + SimAnalyzerTheme.extended.lightGreen + } else { + SimAnalyzerTheme.material.onSurfaceVariant.copy(alpha = 0.72f) + }, + backgroundColor = if (compareConfirmEnabled) { + SimAnalyzerTheme.extended.lightGreen.copy(alpha = 0.14f) + } else { + SimAnalyzerTheme.material.surfaceVariant.copy(alpha = 0.8f) + }, + borderColor = if (compareConfirmEnabled) { + SimAnalyzerTheme.extended.lightGreen.copy(alpha = 0.28f) + } else { + SimAnalyzerTheme.chrome.borderSubtle + }, + enabled = compareConfirmEnabled, + onClick = onConfirmCompare, + ) + } + } +} + +@Composable +private fun SessionDetailsCompareLead(title: String, statusLabel: String, compareConfirmEnabled: Boolean,) { + val iconContainerColor = if (compareConfirmEnabled) { + SimAnalyzerTheme.material.primaryContainer + } else { + SimAnalyzerTheme.material.secondaryContainer + } + + Row( + modifier = Modifier.padding(start = 2.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(iconContainerColor), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Timeline, + contentDescription = null, + tint = if (compareConfirmEnabled) { + SimAnalyzerTheme.material.onPrimaryContainer + } else { + SimAnalyzerTheme.material.onSecondaryContainer + }, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = SimAnalyzerTheme.typography.labelSmall, + color = SimAnalyzerTheme.material.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = statusLabel, + style = SimAnalyzerTheme.typography.titleSmall, + color = SimAnalyzerTheme.material.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun SessionDetailsCompareActionChip( + icon: ImageVector, + contentDescription: String, + label: String, + tint: Color, + backgroundColor: Color, + borderColor: Color, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clip(SimAnalyzerTheme.corners.pill) + .background(backgroundColor) + .border( + width = 1.dp, + color = borderColor, + shape = SimAnalyzerTheme.corners.pill, + ) + .clickable(enabled = enabled, onClick = onClick) + .semantics { this.contentDescription = contentDescription } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp), + ) + Text( + text = label, + color = tint, + style = SimAnalyzerTheme.typography.labelMedium, + ) + } +} + +@Composable +internal fun compareFabLabel( + isCompareSelectionMode: Boolean, + selectedCompareLaps: ImmutableList, +): String = when { + !isCompareSelectionMode -> stringResource(Res.string.session_details_action_compare) + + selectedCompareLaps.size >= 2 -> stringResource(Res.string.session_details_action_compare_ready) + + selectedCompareLaps.isNotEmpty() -> stringResource( + Res.string.session_details_action_compare_progress, + selectedCompareLaps.size, + ) + + else -> stringResource(Res.string.session_details_action_compare_select) +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareSessionPickerDialog.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareSessionPickerDialog.kt new file mode 100644 index 00000000..9d97cdb0 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsCompareSessionPickerDialog.kt @@ -0,0 +1,327 @@ +package com.analyzer.session.details.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropTarget +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.DragData +import androidx.compose.ui.draganddrop.dragData +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.analyzer.session.details.presentation.model.SessionDetailCompareSessionCandidateUi +import com.analyzer.session.details.presentation.model.SessionDetailCompareSessionPickerUi +import com.project.analyzer.theme.SimAnalyzerTheme +import com.project.analyzer.ui.components.Button +import com.project.analyzer.ui.components.InfoDialog +import com.project.analyzer.ui.components.SimAnalyzerButtonSize +import com.project.analyzer.ui.components.SimAnalyzerButtonVariant +import java.io.File +import java.net.URI + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun SessionDetailsCompareSessionPickerDialog( + picker: SessionDetailCompareSessionPickerUi, + onDismiss: () -> Unit, + onSelectSession: (Long) -> Unit, + onBrowseImportSession: () -> Unit, + onImportSessionPath: (String) -> Unit, +) { + if (!picker.isVisible) return + + var dropActive by remember { mutableStateOf(false) } + val dropTarget = remember(onImportSessionPath) { + sessionImportDropTarget( + onDropStarted = { dropActive = true }, + onDropFinished = { dropActive = false }, + onImportSessionPath = onImportSessionPath, + ) + } + + InfoDialog( + title = picker.title, + message = "", + supportingText = picker.supportingText, + dismissButtonText = "Close", + onDismissRequest = onDismiss, + onDismissClick = onDismiss, + modifier = Modifier.widthIn(min = 460.dp, max = 620.dp), + ) { + SessionDetailsCompareImportZone( + isImporting = picker.isImporting, + isDropActive = dropActive, + onBrowseImportSession = onBrowseImportSession, + modifier = Modifier + .fillMaxWidth() + .dragAndDropTarget( + shouldStartDragAndDrop = { event -> + event.dragData() is DragData.FilesList + }, + target = dropTarget, + ), + ) + + picker.statusMessage?.takeIf(String::isNotBlank)?.let { message -> + Text( + text = message, + style = SimAnalyzerTheme.typography.bodySmall, + color = SimAnalyzerTheme.material.onPrimaryContainer, + modifier = Modifier + .fillMaxWidth() + .clip(SimAnalyzerTheme.corners.item) + .background(SimAnalyzerTheme.material.primaryContainer.copy(alpha = 0.92f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) + } + + Text( + text = "Compatible sessions", + style = SimAnalyzerTheme.typography.labelLarge, + color = SimAnalyzerTheme.material.onSurface, + ) + + when { + picker.isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = SimAnalyzerTheme.material.primary) + } + } + + picker.candidates.isEmpty() -> { + Text( + text = picker.emptyMessage ?: "No comparable sessions were found yet.", + style = SimAnalyzerTheme.typography.bodyLarge, + color = SimAnalyzerTheme.material.onSurfaceVariant, + ) + } + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items( + items = picker.candidates, + key = SessionDetailCompareSessionCandidateUi::sessionId, + ) { candidate -> + CompareSessionCandidateRow( + candidate = candidate, + onCompare = { onSelectSession(candidate.sessionId) }, + ) + } + } + } + } + } +} + +@Composable +private fun SessionDetailsCompareImportZone( + isImporting: Boolean, + isDropActive: Boolean, + onBrowseImportSession: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clip(SimAnalyzerTheme.corners.panel) + .border( + width = if (isDropActive) 2.dp else 1.dp, + color = if (isDropActive) { + SimAnalyzerTheme.material.primary + } else { + SimAnalyzerTheme.chrome.borderSubtle + }, + shape = SimAnalyzerTheme.corners.panel, + ), + color = if (isDropActive) { + SimAnalyzerTheme.material.primaryContainer.copy(alpha = 0.42f) + } else { + SimAnalyzerTheme.chrome.fillMuted + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = if (isDropActive) { + "Drop the session folder to add it" + } else { + "Drop a Sim Analyzer session folder here" + }, + style = SimAnalyzerTheme.typography.titleSmall, + color = SimAnalyzerTheme.material.onSurface, + ) + Text( + text = "Then press Compare on a compatible session below. Only the same game and track layout are accepted.", + style = SimAnalyzerTheme.typography.bodySmall, + color = SimAnalyzerTheme.material.onSurfaceVariant, + ) + Button( + text = if (isImporting) "Adding session..." else "Choose Folder", + onClick = onBrowseImportSession, + enabled = !isImporting, + variant = SimAnalyzerButtonVariant.Secondary, + size = SimAnalyzerButtonSize.Compact, + leadingIcon = Icons.Filled.FolderOpen, + ) + } + } +} + +@Composable +private fun CompareSessionCandidateRow(candidate: SessionDetailCompareSessionCandidateUi, onCompare: () -> Unit,) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(SimAnalyzerTheme.corners.panel), + color = SimAnalyzerTheme.material.surfaceVariant.copy(alpha = 0.34f), + tonalElevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = SimAnalyzerTheme.chrome.borderSubtle, + shape = SimAnalyzerTheme.corners.panel, + ) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = candidate.carLabel, + style = SimAnalyzerTheme.typography.titleSmall, + color = SimAnalyzerTheme.material.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${candidate.sessionTypeLabel} • ${candidate.dateLabel} • ${candidate.timeLabel}", + style = SimAnalyzerTheme.typography.bodySmall, + color = SimAnalyzerTheme.material.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + candidate.recommendationLabel?.let { label -> + Text( + text = label, + style = SimAnalyzerTheme.typography.labelSmall, + color = SimAnalyzerTheme.material.onPrimaryContainer, + modifier = Modifier + .clip(SimAnalyzerTheme.corners.pill) + .background(SimAnalyzerTheme.material.primaryContainer) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Best ${candidate.bestLapLabel} • ${candidate.lapsLabel} laps", + style = SimAnalyzerTheme.typography.labelMedium, + color = SimAnalyzerTheme.material.onSurface, + ) + Button( + text = "Compare", + onClick = onCompare, + variant = SimAnalyzerButtonVariant.Outline, + size = SimAnalyzerButtonSize.Compact, + leadingIcon = Icons.Filled.Timeline, + ) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +private fun sessionImportDropTarget( + onDropStarted: () -> Unit, + onDropFinished: () -> Unit, + onImportSessionPath: (String) -> Unit, +): DragAndDropTarget = object : DragAndDropTarget { + + override fun onStarted(event: DragAndDropEvent) { + onDropStarted() + } + + override fun onEntered(event: DragAndDropEvent) { + onDropStarted() + } + + override fun onExited(event: DragAndDropEvent) { + onDropFinished() + } + + override fun onEnded(event: DragAndDropEvent) { + onDropFinished() + } + + override fun onDrop(event: DragAndDropEvent): Boolean { + val files = event.dragData() as? DragData.FilesList ?: return false + val selectedPath = files.readFiles() + .mapNotNull(::toDroppedSessionPath) + .firstOrNull() + ?: return false + onImportSessionPath(selectedPath) + onDropFinished() + return true + } +} + +private fun toDroppedSessionPath(rawPath: String): String? = runCatching { + if (rawPath.startsWith("file:", ignoreCase = true)) { + File(URI(rawPath)).absolutePath + } else { + rawPath + } +}.getOrNull() diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsShareResultsDialog.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsShareResultsDialog.kt new file mode 100644 index 00000000..8c999a70 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsShareResultsDialog.kt @@ -0,0 +1,238 @@ +package com.analyzer.session.details.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.analyzer.session.details.presentation.model.SessionDetailShareDialogUi +import com.project.analyzer.feature.screens.sessionDetails.Res.Res +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_close +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_copy +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_copy_description +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_export +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_export_description +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_export_name +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_formats +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_preview +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_raw_files +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_share_raw_files_description +import com.project.analyzer.theme.SimAnalyzerTheme +import com.project.analyzer.ui.components.Button +import com.project.analyzer.ui.components.InfoDialog +import com.project.analyzer.ui.components.SimAnalyzerButtonSize +import com.project.analyzer.ui.components.SimAnalyzerButtonVariant +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SessionDetailsShareResultsDialog( + shareDialog: SessionDetailShareDialogUi, + onDismiss: () -> Unit, + onCopySummary: () -> Unit, + onExportReport: () -> Unit, + onOpenRawFiles: () -> Unit, +) { + if (!shareDialog.isVisible) return + + InfoDialog( + title = shareDialog.title, + message = shareDialog.supportingText, + onDismissRequest = onDismiss, + modifier = Modifier.width(640.dp), + ) { + SessionDetailsShareMetaRow( + label = stringResource(Res.string.session_details_share_export_name), + value = shareDialog.reportFileName, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(Res.string.session_details_share_formats), + style = SimAnalyzerTheme.typography.labelLarge, + color = SimAnalyzerTheme.material.onSurface, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + SessionDetailsShareActionCard( + title = stringResource(Res.string.session_details_share_copy), + description = stringResource(Res.string.session_details_share_copy_description), + buttonText = "Copy", + icon = Icons.Filled.ContentCopy, + variant = SimAnalyzerButtonVariant.Primary, + onClick = onCopySummary, + ) + SessionDetailsShareActionCard( + title = stringResource(Res.string.session_details_share_export), + description = stringResource(Res.string.session_details_share_export_description), + buttonText = "Save", + icon = Icons.Filled.Description, + variant = SimAnalyzerButtonVariant.Secondary, + onClick = onExportReport, + ) + SessionDetailsShareActionCard( + title = stringResource(Res.string.session_details_share_raw_files), + description = stringResource(Res.string.session_details_share_raw_files_description), + buttonText = "Open", + icon = Icons.Filled.FolderOpen, + variant = SimAnalyzerButtonVariant.Outline, + onClick = onOpenRawFiles, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(Res.string.session_details_share_preview), + style = SimAnalyzerTheme.typography.labelLarge, + color = SimAnalyzerTheme.material.onSurface, + ) + + SelectionContainer { + Text( + text = shareDialog.summaryText, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 260.dp) + .clip(SimAnalyzerTheme.corners.panel) + .background(SimAnalyzerTheme.chrome.fillMuted) + .border( + width = 1.dp, + color = SimAnalyzerTheme.chrome.borderSubtle, + shape = SimAnalyzerTheme.corners.panel, + ) + .verticalScroll(rememberScrollState()) + .padding(14.dp), + style = SimAnalyzerTheme.typography.bodyMedium, + color = SimAnalyzerTheme.material.onSurface, + ) + } + + Button( + text = stringResource(Res.string.session_details_share_close), + onClick = onDismiss, + variant = SimAnalyzerButtonVariant.Outline, + size = SimAnalyzerButtonSize.Compact, + modifier = Modifier.align(Alignment.End), + ) + } +} + +@Composable +private fun SessionDetailsShareActionCard( + title: String, + description: String, + buttonText: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + variant: SimAnalyzerButtonVariant, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(SimAnalyzerTheme.corners.panel) + .background(SimAnalyzerTheme.chrome.fillMuted) + .border( + width = 1.dp, + color = SimAnalyzerTheme.chrome.borderSubtle, + shape = SimAnalyzerTheme.corners.panel, + ) + .padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = SimAnalyzerTheme.typography.titleSmall, + color = SimAnalyzerTheme.material.onSurface, + ) + Text( + text = description, + style = SimAnalyzerTheme.typography.bodySmall, + color = SimAnalyzerTheme.material.onSurfaceVariant, + ) + } + Button( + text = buttonText, + onClick = onClick, + variant = variant, + size = SimAnalyzerButtonSize.Compact, + leadingIcon = icon, + ) + } +} + +@Composable +private fun SessionDetailsShareMetaRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(SimAnalyzerTheme.corners.item) + .background(SimAnalyzerTheme.chrome.fillMuted) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = SimAnalyzerTheme.typography.bodySmall, + color = SimAnalyzerTheme.material.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(SimAnalyzerTheme.material.secondary.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = SimAnalyzerTheme.material.secondary, + ) + } + Text( + text = value, + style = SimAnalyzerTheme.typography.labelMedium, + color = SimAnalyzerTheme.material.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsWorkspaceActionItem.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsWorkspaceActionItem.kt new file mode 100644 index 00000000..82ffc49b --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsWorkspaceActionItem.kt @@ -0,0 +1,14 @@ +package com.analyzer.session.details.presentation.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +internal data class SessionDetailsWorkspaceActionItem( + val label: String, + val contentDescription: String, + val icon: ImageVector, + val enabled: Boolean, + val containerColor: Color, + val contentColor: Color, + val onClick: () -> Unit, +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsWorkspaceSpeedDial.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsWorkspaceSpeedDial.kt new file mode 100644 index 00000000..e0293fd2 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/components/SessionDetailsWorkspaceSpeedDial.kt @@ -0,0 +1,286 @@ +package com.analyzer.session.details.presentation.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.analyzer.session.details.presentation.model.SessionDetailCompareLapUi +import com.project.analyzer.feature.screens.sessionDetails.Res.Res +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_add_session +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_more +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_more_close +import com.project.analyzer.feature.screens.sessionDetails.Res.session_details_action_share +import com.project.analyzer.theme.SimAnalyzerTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SessionDetailsWorkspaceSpeedDial( + selectedCompareLaps: ImmutableList, + compareStartEnabled: Boolean, + actionsEnabled: Boolean, + compareFabDescription: String, + onStartCompare: () -> Unit, + onOpenCompareSessionPicker: () -> Unit, + onShareResults: () -> Unit, +) { + var actionsExpanded by rememberSaveable { mutableStateOf(false) } + val openActionsLabel = stringResource(Res.string.session_details_action_more) + val closeActionsLabel = stringResource(Res.string.session_details_action_more_close) + val mainFabRotation by animateFloatAsState( + targetValue = if (actionsExpanded) 45f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "session-details-speed-dial-fab-rotation", + ) + val actions = rememberWorkspaceActions( + selectedCompareLaps = selectedCompareLaps, + compareStartEnabled = compareStartEnabled, + actionsEnabled = actionsEnabled, + compareFabDescription = compareFabDescription, + onStartCompare = onStartCompare, + onOpenCompareSessionPicker = onOpenCompareSessionPicker, + onShareResults = onShareResults, + ) + + LaunchedEffect(actionsEnabled) { + if (!actionsEnabled) actionsExpanded = false + } + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SessionDetailsWorkspaceActionItems( + actions = actions, + expanded = actionsExpanded, + onActionClick = { action -> + actionsExpanded = false + action.onClick() + }, + ) + SessionDetailsWorkspaceMainFab( + expanded = actionsExpanded, + enabled = actionsEnabled, + rotation = mainFabRotation, + openActionsLabel = openActionsLabel, + closeActionsLabel = closeActionsLabel, + onClick = { actionsExpanded = !actionsExpanded }, + ) + } +} + +@Composable +private fun rememberWorkspaceActions( + selectedCompareLaps: ImmutableList, + compareStartEnabled: Boolean, + actionsEnabled: Boolean, + compareFabDescription: String, + onStartCompare: () -> Unit, + onOpenCompareSessionPicker: () -> Unit, + onShareResults: () -> Unit, +): ImmutableList = persistentListOf( + SessionDetailsWorkspaceActionItem( + label = stringResource(Res.string.session_details_action_share), + contentDescription = stringResource(Res.string.session_details_action_share), + icon = Icons.Filled.Share, + enabled = actionsEnabled, + containerColor = SimAnalyzerTheme.material.tertiaryContainer, + contentColor = SimAnalyzerTheme.material.onTertiaryContainer, + onClick = onShareResults, + ), + SessionDetailsWorkspaceActionItem( + label = stringResource(Res.string.session_details_action_add_session), + contentDescription = stringResource(Res.string.session_details_action_add_session), + icon = Icons.Filled.Add, + enabled = actionsEnabled, + containerColor = SimAnalyzerTheme.material.secondaryContainer, + contentColor = SimAnalyzerTheme.material.onSecondaryContainer, + onClick = onOpenCompareSessionPicker, + ), + SessionDetailsWorkspaceActionItem( + label = compareFabLabel( + isCompareSelectionMode = false, + selectedCompareLaps = selectedCompareLaps, + ), + contentDescription = compareFabDescription, + icon = Icons.Filled.Timeline, + enabled = compareStartEnabled, + containerColor = SimAnalyzerTheme.material.primaryContainer, + contentColor = SimAnalyzerTheme.material.onPrimaryContainer, + onClick = onStartCompare, + ), +) + +@Composable +private fun SessionDetailsWorkspaceActionItems( + actions: ImmutableList, + expanded: Boolean, + onActionClick: (SessionDetailsWorkspaceActionItem) -> Unit, +) { + actions.asReversed().forEachIndexed { index, action -> + AnimatedVisibility( + visible = expanded, + enter = fadeIn( + animationSpec = tween( + durationMillis = 160, + delayMillis = index * 36, + easing = LinearOutSlowInEasing, + ), + ) + slideInVertically( + initialOffsetY = { it / 2 }, + animationSpec = tween( + durationMillis = 220, + delayMillis = index * 36, + easing = FastOutSlowInEasing, + ), + ) + scaleIn( + initialScale = 0.92f, + animationSpec = tween( + durationMillis = 220, + delayMillis = index * 36, + easing = FastOutSlowInEasing, + ), + ), + exit = fadeOut( + animationSpec = tween(durationMillis = 120), + ) + slideOutVertically( + targetOffsetY = { it / 3 }, + animationSpec = tween(durationMillis = 140), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(durationMillis = 140), + ), + label = "session-details-speed-dial-item-$index", + ) { + SessionDetailsWorkspaceActionRow( + action = action, + onClick = { onActionClick(action) }, + ) + } + } +} + +@Composable +private fun SessionDetailsWorkspaceMainFab( + expanded: Boolean, + enabled: Boolean, + rotation: Float, + openActionsLabel: String, + closeActionsLabel: String, + onClick: () -> Unit, +) { + FloatingActionButton( + onClick = { + if (enabled) onClick() + }, + modifier = Modifier.semantics { + contentDescription = if (expanded) closeActionsLabel else openActionsLabel + }, + containerColor = if (expanded) { + SimAnalyzerTheme.material.secondaryContainer + } else { + SimAnalyzerTheme.material.primaryContainer + }, + contentColor = if (expanded) { + SimAnalyzerTheme.material.onSecondaryContainer + } else { + SimAnalyzerTheme.material.onPrimaryContainer + }, + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = null, + modifier = Modifier.rotate(rotation), + ) + } +} + +@Composable +private fun SessionDetailsWorkspaceActionRow( + action: SessionDetailsWorkspaceActionItem, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + + Row( + modifier = Modifier + .defaultMinSize(minHeight = 40.dp) + .clickable( + enabled = action.enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .semantics(mergeDescendants = true) { + contentDescription = action.contentDescription + } + .alpha(if (action.enabled) 1f else 0.54f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = action.label, + style = SimAnalyzerTheme.typography.labelLarge, + color = SimAnalyzerTheme.material.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp), + ) + + Surface( + color = action.containerColor, + contentColor = action.contentColor, + shape = CircleShape, + shadowElevation = 8.dp, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = action.icon, + contentDescription = null, + ) + } + } + } +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailAction.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailAction.kt new file mode 100644 index 00000000..8033c48f --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailAction.kt @@ -0,0 +1,5 @@ +package com.analyzer.session.details.presentation.model + +import com.project.analyzer.ui.components.InfoBarSeverity + +internal data class SessionDetailAction(val title: String, val message: String, val severity: InfoBarSeverity,) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareLapUi.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareLapUi.kt new file mode 100644 index 00000000..7615de2a --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareLapUi.kt @@ -0,0 +1,9 @@ +package com.analyzer.session.details.presentation.model + +data class SessionDetailCompareLapUi( + val segmentId: Long, + val lapNumber: Int, + val lapLabel: String, + val sessionTypeLabel: String, + val totalTimeMs: Int?, +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareSessionCandidateUi.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareSessionCandidateUi.kt new file mode 100644 index 00000000..b697dc5f --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareSessionCandidateUi.kt @@ -0,0 +1,12 @@ +package com.analyzer.session.details.presentation.model + +data class SessionDetailCompareSessionCandidateUi( + val sessionId: Long, + val carLabel: String, + val sessionTypeLabel: String, + val dateLabel: String, + val timeLabel: String, + val bestLapLabel: String, + val lapsLabel: String, + val recommendationLabel: String? = null, +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareSessionPickerUi.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareSessionPickerUi.kt new file mode 100644 index 00000000..f2a74cc0 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailCompareSessionPickerUi.kt @@ -0,0 +1,15 @@ +package com.analyzer.session.details.presentation.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class SessionDetailCompareSessionPickerUi( + val isVisible: Boolean = false, + val isLoading: Boolean = false, + val isImporting: Boolean = false, + val title: String = "", + val supportingText: String = "", + val statusMessage: String? = null, + val emptyMessage: String? = null, + val candidates: ImmutableList = persistentListOf(), +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterKind.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterKind.kt new file mode 100644 index 00000000..674ccd41 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterKind.kt @@ -0,0 +1,7 @@ +package com.analyzer.session.details.presentation.model + +enum class SessionDetailFilterKind { + Sort, + Show, + SessionType, +} diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterOptionUi.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterOptionUi.kt new file mode 100644 index 00000000..c818f848 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterOptionUi.kt @@ -0,0 +1,3 @@ +package com.analyzer.session.details.presentation.model + +data class SessionDetailFilterOptionUi(val id: String, val label: String? = null) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterUiModel.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterUiModel.kt new file mode 100644 index 00000000..0a80cebd --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailFilterUiModel.kt @@ -0,0 +1,10 @@ +package com.analyzer.session.details.presentation.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class SessionDetailFilterUiModel( + val kind: SessionDetailFilterKind, + val selectedId: String, + val options: ImmutableList = persistentListOf(), +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailIntent.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailIntent.kt index 1399f4e4..f2b38711 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailIntent.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailIntent.kt @@ -9,5 +9,13 @@ sealed interface SessionDetailIntent { data class ChangePage(val page: Int) : SessionDetailIntent data object StartCompareSelection : SessionDetailIntent data object CancelCompareSelection : SessionDetailIntent + data object OpenCompareSessionPicker : SessionDetailIntent + data object DismissCompareSessionPicker : SessionDetailIntent + data class ImportCompareSession(val path: String) : SessionDetailIntent + data object OpenShareResults : SessionDetailIntent + data object DismissShareResults : SessionDetailIntent + data object CopyShareResults : SessionDetailIntent + data class ExportShareResultsToDirectory(val directoryPath: String) : SessionDetailIntent + data object OpenShareSessionFiles : SessionDetailIntent data class ToggleCompareLap(val segmentId: Long, val lapNumber: Int) : SessionDetailIntent } diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailShareDialogUi.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailShareDialogUi.kt new file mode 100644 index 00000000..4eca2b12 --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailShareDialogUi.kt @@ -0,0 +1,9 @@ +package com.analyzer.session.details.presentation.model + +data class SessionDetailShareDialogUi( + val isVisible: Boolean = false, + val title: String = "", + val supportingText: String = "", + val summaryText: String = "", + val reportFileName: String = "", +) diff --git a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailState.kt b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailState.kt index c4851109..aeeff1c0 100644 --- a/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailState.kt +++ b/feature/screens/sessionDetails/src/jvmMain/kotlin/com/analyzer/session/details/presentation/model/SessionDetailState.kt @@ -3,28 +3,6 @@ package com.analyzer.session.details.presentation.model import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -enum class SessionDetailFilterKind { - Sort, - Show, - SessionType, -} - -data class SessionDetailFilterOptionUi(val id: String, val label: String? = null) - -data class SessionDetailFilterUiModel( - val kind: SessionDetailFilterKind, - val selectedId: String, - val options: ImmutableList = persistentListOf(), -) - -data class SessionDetailCompareLapUi( - val segmentId: Long, - val lapNumber: Int, - val lapLabel: String, - val sessionTypeLabel: String, - val totalTimeMs: Int?, -) - data class SessionDetailState( val isLoading: Boolean = true, val error: String? = null, @@ -51,4 +29,6 @@ data class SessionDetailState( val isCompareSelectionMode: Boolean = false, val selectedCompareLaps: ImmutableList = persistentListOf(), val compareConfirmEnabled: Boolean = false, + val compareSessionPicker: SessionDetailCompareSessionPickerUi = SessionDetailCompareSessionPickerUi(), + val shareDialog: SessionDetailShareDialogUi = SessionDetailShareDialogUi(), ) diff --git a/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCaseTest.kt b/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCaseTest.kt new file mode 100644 index 00000000..c22c2e7b --- /dev/null +++ b/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/domain/usecase/SessionDetailCompareSuggestionsUseCaseTest.kt @@ -0,0 +1,129 @@ +package com.analyzer.session.details.domain.usecase + +import com.analyzer.session.data.model.RecordedSessionDetailPage +import com.analyzer.session.data.model.RecordedSessionListPage +import com.analyzer.session.data.model.RecordedSessionSummary +import com.analyzer.session.data.repository.RecordedSessionDetailRequest +import com.analyzer.session.data.repository.RecordedSessionListRequest +import com.analyzer.session.data.repository.RecordedSessionRepository +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SessionDetailCompareSuggestionsUseCaseTest { + + @Test + fun `use case keeps only same layout sessions and ranks same car first`() = kotlinx.coroutines.test.runTest { + val repository = FakeRecordedSessionRepository( + sessions = listOf( + session(sessionId = 42L, carId = 296, layoutId = "gp", startedAtMs = 5L), + session(sessionId = 77L, carId = 296, layoutId = "gp", startedAtMs = 4L), + session(sessionId = 81L, carId = 300, layoutId = "gp", startedAtMs = 6L), + session(sessionId = 82L, carId = 300, layoutId = "short", startedAtMs = 7L), + session(sessionId = 83L, carId = 300, layoutId = null, startedAtMs = 8L), + ), + ) + val useCase = SessionDetailCompareSuggestionsUseCaseImpl(repository) + + val result = useCase.loadSuggestions( + criteria = SessionDetailCompareCriteria( + currentSessionId = 42L, + gameId = "acc", + gameLabel = "ACC", + trackId = "monza", + layoutId = "gp", + trackLabel = "Monza GP", + preferredCarIdentityKey = "296", + ), + ) + + assertEquals("acc", repository.requests.single().gameId) + assertEquals("monza", repository.requests.single().trackId) + assertEquals(listOf(77L, 81L), result.candidates.map { it.sessionId }) + assertEquals("Same car", result.candidates.first().recommendationLabel) + assertEquals(2, result.excludedDifferentLayoutCount) + } + + @Test + fun `use case allows sessions without layout when both sides have none`() = kotlinx.coroutines.test.runTest { + val repository = FakeRecordedSessionRepository( + sessions = listOf( + session(sessionId = 11L, layoutId = null, carId = 201, startedAtMs = 1L), + session(sessionId = 12L, layoutId = null, carId = 202, startedAtMs = 2L), + ), + ) + val useCase = SessionDetailCompareSuggestionsUseCaseImpl(repository) + + val result = useCase.loadSuggestions( + criteria = SessionDetailCompareCriteria( + currentSessionId = 11L, + gameId = "acc", + gameLabel = "ACC", + trackId = "monza", + layoutId = null, + trackLabel = "Monza", + preferredCarIdentityKey = null, + ), + ) + + assertEquals(listOf(12L), result.candidates.map { it.sessionId }) + assertTrue(result.excludedDifferentLayoutCount == 0) + } +} + +private class FakeRecordedSessionRepository( + private val sessions: List, +) : RecordedSessionRepository { + + val requests = mutableListOf() + + override suspend fun loadSessionListPage( + request: RecordedSessionListRequest, + forceRefresh: Boolean, + ): RecordedSessionListPage { + requests += request + val filtered = sessions.filter { summary -> + (request.gameId == null || request.gameId == summary.gameId) && + (request.trackId == null || request.trackId == summary.trackId) + } + return RecordedSessionListPage( + items = filtered, + page = request.page, + pageCount = 1, + ) + } + + override suspend fun loadSessionDetailPage( + sessionId: Long, + request: RecordedSessionDetailRequest, + forceRefresh: Boolean, + ): RecordedSessionDetailPage? = null + + override suspend fun saveSession(sessionId: Long): Boolean = false + + override suspend fun deleteSession(sessionId: Long): Boolean = false +} + +private fun session( + sessionId: Long, + carId: Int?, + layoutId: String?, + startedAtMs: Long, +): RecordedSessionSummary = RecordedSessionSummary( + sessionId = sessionId, + startedAtMs = startedAtMs, + endedAtMs = null, + gameId = "acc", + sessionType = "race", + carModel = "bmw_m4_gt3", + carName = "BMW M4 GT3", + carId = carId, + trackId = "monza", + trackName = "Monza", + layoutId = layoutId, + lapCount = 10, + bestLapTimeMs = 98_100, + totalIncidents = 0, + distanceKm = 100.0, + isSaved = true, +) diff --git a/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/presentation/SessionDetailViewModelTest.kt b/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/presentation/SessionDetailViewModelTest.kt index 940d88aa..cfe32fbe 100644 --- a/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/presentation/SessionDetailViewModelTest.kt +++ b/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/presentation/SessionDetailViewModelTest.kt @@ -10,9 +10,19 @@ import com.analyzer.session.details.domain.model.SessionDetailPageResult import com.analyzer.session.details.domain.model.SessionDetailQuery import com.analyzer.session.details.domain.model.SessionLapDomainItem import com.analyzer.session.details.domain.model.SessionLapDomainStatus +import com.analyzer.session.details.domain.usecase.SessionDetailCompareCriteria +import com.analyzer.session.details.domain.usecase.SessionDetailCompareSuggestion +import com.analyzer.session.details.domain.usecase.SessionDetailCompareSuggestions +import com.analyzer.session.details.domain.usecase.SessionDetailCompareSuggestionsUseCase import com.analyzer.session.details.domain.usecase.SessionDetailDataUseCase +import com.analyzer.session.details.domain.usecase.SessionDetailImportCompareSessionResult +import com.analyzer.session.details.domain.usecase.SessionDetailImportCompareSessionUseCase +import com.analyzer.session.details.domain.usecase.SessionDetailShareResults +import com.analyzer.session.details.domain.usecase.SessionDetailShareResultsUseCase import com.analyzer.session.details.presentation.model.SessionDetailIntent +import kotlinx.coroutines.async import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -30,7 +40,7 @@ class SessionDetailViewModelTest { val dispatcher = StandardTestDispatcher(testScheduler) Dispatchers.setMain(dispatcher) try { - val viewModel = SessionDetailViewModel(FakeSessionDetailDataUseCase()) + val viewModel = buildViewModel() viewModel.dispatch(SessionDetailIntent.BindSession(sessionId = 42L)) advanceUntilIdle() @@ -63,7 +73,7 @@ class SessionDetailViewModelTest { val dispatcher = StandardTestDispatcher(testScheduler) Dispatchers.setMain(dispatcher) try { - val viewModel = SessionDetailViewModel(FakeSessionDetailDataUseCase()) + val viewModel = buildViewModel() viewModel.dispatch(SessionDetailIntent.BindSession(sessionId = 42L)) advanceUntilIdle() @@ -92,8 +102,103 @@ class SessionDetailViewModelTest { Dispatchers.resetMain() } } + + @Test + fun `compare session picker loads suggested sessions`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + try { + val compareSuggestionsUseCase = FakeSessionDetailCompareSuggestionsUseCase() + val viewModel = buildViewModel(compareSuggestionsUseCase = compareSuggestionsUseCase) + + viewModel.dispatch(SessionDetailIntent.BindSession(sessionId = 42L)) + advanceUntilIdle() + viewModel.dispatch(SessionDetailIntent.OpenCompareSessionPicker) + advanceUntilIdle() + + val picker = viewModel.state.value.compareSessionPicker + assertTrue(picker.isVisible) + assertFalse(picker.isLoading) + assertEquals(1, picker.candidates.size) + assertEquals(77L, picker.candidates.single().sessionId) + assertEquals("Monza GP", compareSuggestionsUseCase.lastCriteria?.trackLabel) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun `share dialog builds summary and copies it`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + try { + val shareResultsUseCase = FakeSessionDetailShareResultsUseCase() + val viewModel = buildViewModel(shareResultsUseCase = shareResultsUseCase) + + viewModel.dispatch(SessionDetailIntent.BindSession(sessionId = 42L)) + advanceUntilIdle() + viewModel.dispatch(SessionDetailIntent.OpenShareResults) + advanceUntilIdle() + + val shareDialog = viewModel.state.value.shareDialog + assertTrue(shareDialog.isVisible) + assertTrue(shareDialog.summaryText.contains("Monza GP")) + assertTrue(shareDialog.reportFileName.endsWith(".md")) + + val actionDeferred = async { viewModel.actions.first() } + viewModel.dispatch(SessionDetailIntent.CopyShareResults) + advanceUntilIdle() + + assertEquals(shareDialog.summaryText, shareResultsUseCase.lastCopiedSummary) + assertEquals("Summary copied", actionDeferred.await().title) + } finally { + Dispatchers.resetMain() + } + } + + @Test + fun `import compare session refreshes suggestions and highlights the added session`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + try { + val compareSuggestionsUseCase = FakeSessionDetailCompareSuggestionsUseCase() + val importCompareSessionUseCase = FakeSessionDetailImportCompareSessionUseCase() + val viewModel = buildViewModel( + compareSuggestionsUseCase = compareSuggestionsUseCase, + importCompareSessionUseCase = importCompareSessionUseCase, + ) + + viewModel.dispatch(SessionDetailIntent.BindSession(sessionId = 42L)) + advanceUntilIdle() + viewModel.dispatch(SessionDetailIntent.OpenCompareSessionPicker) + advanceUntilIdle() + + val actionDeferred = async { viewModel.actions.first() } + viewModel.dispatch(SessionDetailIntent.ImportCompareSession("C:/temp/session")) + advanceUntilIdle() + + assertEquals("C:/temp/session", importCompareSessionUseCase.lastPath) + assertEquals(listOf(false, true), compareSuggestionsUseCase.forceRefreshRequests) + assertEquals("Compare session added", actionDeferred.await().title) + assertEquals("Just added", viewModel.state.value.compareSessionPicker.candidates.single().recommendationLabel) + } finally { + Dispatchers.resetMain() + } + } } +private fun buildViewModel( + dataUseCase: SessionDetailDataUseCase = FakeSessionDetailDataUseCase(), + compareSuggestionsUseCase: SessionDetailCompareSuggestionsUseCase = FakeSessionDetailCompareSuggestionsUseCase(), + importCompareSessionUseCase: SessionDetailImportCompareSessionUseCase = FakeSessionDetailImportCompareSessionUseCase(), + shareResultsUseCase: SessionDetailShareResultsUseCase = FakeSessionDetailShareResultsUseCase(), +): SessionDetailViewModel = SessionDetailViewModel( + dataUseCase = dataUseCase, + compareSuggestionsUseCase = compareSuggestionsUseCase, + importCompareSessionUseCase = importCompareSessionUseCase, + shareResultsUseCase = shareResultsUseCase, +) + private class FakeSessionDetailDataUseCase : SessionDetailDataUseCase { override suspend fun loadPage( @@ -105,8 +210,12 @@ private class FakeSessionDetailDataUseCase : SessionDetailDataUseCase { page = SessionDetailPage( header = SessionDetailDomainHeader( carLabel = "BMW M4 GT3", - trackLabel = "Monza", + trackLabel = "Monza GP", sessionTypeLabel = "Race", + gameId = "acc", + trackId = "monza", + layoutId = "gp", + carModel = "bmw_m4_gt3", ), stats = SessionDetailDomainStats( bestLapLabel = "1:38.100", @@ -127,6 +236,83 @@ private class FakeSessionDetailDataUseCase : SessionDetailDataUseCase { ) } +private class FakeSessionDetailCompareSuggestionsUseCase : SessionDetailCompareSuggestionsUseCase { + + var lastCriteria: SessionDetailCompareCriteria? = null + val forceRefreshRequests = mutableListOf() + + override suspend fun loadSuggestions( + criteria: SessionDetailCompareCriteria, + forceRefresh: Boolean, + ): SessionDetailCompareSuggestions { + lastCriteria = criteria + forceRefreshRequests += forceRefresh + return SessionDetailCompareSuggestions( + candidates = listOf( + SessionDetailCompareSuggestion( + sessionId = 77L, + carLabel = "BMW M4 GT3", + sessionTypeLabel = "Race", + dateLabel = "Mar 20, 2026", + timeLabel = "20:40", + bestLapLabel = "1:38.100", + lapsLabel = "12", + recommendationLabel = "Same car", + ), + ), + ) + } +} + +private class FakeSessionDetailImportCompareSessionUseCase : SessionDetailImportCompareSessionUseCase { + + var lastCriteria: SessionDetailCompareCriteria? = null + var lastPath: String? = null + var result: SessionDetailImportCompareSessionResult = + SessionDetailImportCompareSessionResult.Imported( + sessionId = 77L, + destinationPath = "C:/recordings/77", + ) + + override suspend fun importSession( + criteria: SessionDetailCompareCriteria, + path: String, + ): SessionDetailImportCompareSessionResult { + lastCriteria = criteria + lastPath = path + return result + } +} + +private class FakeSessionDetailShareResultsUseCase : SessionDetailShareResultsUseCase { + + var lastCopiedSummary: String? = null + var lastExportDirectoryPath: String? = null + var lastReportFileName: String? = null + var lastOpenSessionId: Long? = null + + override suspend fun copySummary(summaryText: String): SessionDetailShareResults { + lastCopiedSummary = summaryText + return SessionDetailShareResults.CopiedSummary + } + + override suspend fun exportReport( + directoryPath: String, + reportFileName: String, + summaryText: String, + ): SessionDetailShareResults { + lastExportDirectoryPath = directoryPath + lastReportFileName = reportFileName + lastCopiedSummary = summaryText + return SessionDetailShareResults.ExportedReport("$directoryPath/$reportFileName") + } + + override suspend fun openSessionFiles(sessionId: Long): SessionDetailShareResults { + lastOpenSessionId = sessionId + return SessionDetailShareResults.OpenedSessionFiles + } +} + private fun lap( segmentId: Long, lapNumber: Int, diff --git a/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/presentation/SessionDetailsScreenTest.kt b/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/presentation/SessionDetailsScreenTest.kt index 963d0420..fb869d19 100644 --- a/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/presentation/SessionDetailsScreenTest.kt +++ b/feature/screens/sessionDetails/src/jvmTest/kotlin/com/analyzer/session/details/presentation/SessionDetailsScreenTest.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runDesktopComposeUiTest import com.analyzer.session.details.presentation.model.LapStatus import com.analyzer.session.details.presentation.model.SessionDetailCompareLapUi +import com.analyzer.session.details.presentation.model.SessionDetailCompareSessionCandidateUi +import com.analyzer.session.details.presentation.model.SessionDetailCompareSessionPickerUi import com.analyzer.session.details.presentation.model.SessionDetailFilterKind import com.analyzer.session.details.presentation.model.SessionDetailFilterOptionUi import com.analyzer.session.details.presentation.model.SessionDetailFilterUiModel @@ -93,13 +95,115 @@ class SessionDetailsScreenTest { } } - onNodeWithContentDescription("Compare Laps").performClick() + onNodeWithContentDescription("More session actions").performClick() + waitForIdle() + onNodeWithText("Compare Laps").performClick() waitForIdle() onNodeWithText("Select 2 Laps", useUnmergedTree = true).assertIsDisplayed() onNodeWithContentDescription("Cancel lap compare", useUnmergedTree = true).assertIsDisplayed() onNodeWithContentDescription("Open lap comparison", useUnmergedTree = true).assertIsDisplayed() } + + @Test + fun `session details opens compare session picker from fab`() = runDesktopComposeUiTest { + val state = SessionDetailsStateHolder(value = sampleSessionDetailState()) + + setContent { + SimAnalyzerTheme { + SessionDetailsContent( + state = state.value, + modifier = Modifier.uiTestTag(TestTags.SessionDetails), + onIntent = { intent -> + when (intent) { + SessionDetailIntent.OpenCompareSessionPicker -> { + state.value = state.value.copy( + compareSessionPicker = SessionDetailCompareSessionPickerUi( + isVisible = true, + title = "Compare on Monza GP", + supportingText = "Only ACC sessions from the same track layout are suggested.", + candidates = persistentListOf( + SessionDetailCompareSessionCandidateUi( + sessionId = 77L, + carLabel = "BMW M4 GT3", + sessionTypeLabel = "Race", + dateLabel = "Mar 20, 2026", + timeLabel = "20:40", + bestLapLabel = "1:38.100", + lapsLabel = "12", + recommendationLabel = "Same car", + ), + ), + ), + ) + } + + SessionDetailIntent.DismissCompareSessionPicker -> { + state.value = state.value.copy( + compareSessionPicker = SessionDetailCompareSessionPickerUi(), + ) + } + + else -> Unit + } + }, + ) + } + } + + onNodeWithContentDescription("More session actions").performClick() + waitForIdle() + onNodeWithText("Add Compare Session").performClick() + waitForIdle() + + onNodeWithText("Compare on Monza GP").assertIsDisplayed() + onNodeWithText("Same car").assertIsDisplayed() + onNodeWithText("Best 1:38.100 • 12 laps").assertIsDisplayed() + } + + @Test + fun `session details opens share dialog from fab`() = runDesktopComposeUiTest { + val state = SessionDetailsStateHolder(value = sampleSessionDetailState()) + + setContent { + SimAnalyzerTheme { + SessionDetailsContent( + state = state.value, + modifier = Modifier.uiTestTag(TestTags.SessionDetails), + onIntent = { intent -> + when (intent) { + SessionDetailIntent.OpenShareResults -> { + state.value = state.value.copy( + shareDialog = state.value.shareDialog.copy( + isVisible = true, + title = "Share Monza GP session", + supportingText = "Copy the summary or export a Markdown report.", + summaryText = "Sim Analyzer Session Summary\n\nTrack: Monza GP", + reportFileName = "simanalyzer_monza_gp_race.md", + ), + ) + } + + SessionDetailIntent.DismissShareResults -> { + state.value = state.value.copy(shareDialog = state.value.shareDialog.copy(isVisible = false)) + } + + else -> Unit + } + }, + ) + } + } + + onNodeWithContentDescription("More session actions").performClick() + waitForIdle() + onNodeWithText("Share Results").performClick() + waitForIdle() + + onNodeWithText("Share Monza GP session").assertIsDisplayed() + onNodeWithText("Copy Chat Summary").assertIsDisplayed() + onNodeWithText("Save Markdown Report").assertIsDisplayed() + } } private class SessionDetailsStateHolder(value: SessionDetailState) { @@ -148,6 +252,21 @@ private fun sampleSessionDetailState(): SessionDetailState = SessionDetailState( deltaIsPositive = false, status = LapStatus.BestLap, ), + SessionLapRowUi( + segmentId = 1L, + lapNumber = 2, + lapLabel = "2", + sessionTypeLabel = "Race", + totalTimeMs = 106890, + totalTime = "1:46.890", + s1 = "35.800", + s2 = "35.200", + s3 = "35.890", + incidents = "0", + delta = "+1.212", + deltaIsPositive = true, + status = LapStatus.Clean, + ), ), )