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,
+ ),
),
)