diff --git a/.gitignore b/.gitignore index c4647eb7..4abc1983 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ output-metadata.json +.kotlin/sessions/ + # OCR ONNX models app/src/main/assets/ocr/model*.onnx app/src/main/assets/ocr/model*.ort diff --git a/app/AGENTS.md b/app/AGENTS.md index 9aad028d..225273ff 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -22,7 +22,7 @@ xyz.sevive.arcaeaoffline/ │ └── screens//[/] ├── database/ app-private Room 3 databases: AppDatabase, OcrQueueDatabase ├── datastore/ DataStore stores and their repositories -├── data/ Paths, BuildFlavor, maintenance tasks, notification channels +├── data/ Paths, BuildFlavor, maintenance tasks, notification channels, ImportLog, etc. ├── jobs/ WorkManager workers ├── helpers/ validators, OCR and resource helpers, diagnostics, activity/context extensions ├── di/AppModule.kt the Koin module diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/data/imports/ImportLog.kt similarity index 86% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLog.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/data/imports/ImportLog.kt index 85fe1d6d..17f0e44d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLog.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/data/imports/ImportLog.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.manage +package xyz.sevive.arcaeaoffline.data.imports import androidx.annotation.PluralsRes import androidx.annotation.StringRes @@ -10,7 +10,7 @@ import kotlin.time.Clock import kotlin.time.Instant import kotlin.uuid.Uuid -internal sealed interface ImportLogEvent { +sealed interface ImportLogEvent { data class Plural( @PluralsRes val resId: Int, val quantity: Int, @@ -25,14 +25,14 @@ internal sealed interface ImportLogEvent { ) : ImportLogEvent } -internal data class ImportLogObject( +data class ImportLogObject( val uuid: Uuid = Uuid.generateV4(), val timestamp: Instant, val tag: String? = null, val event: ImportLogEvent, ) -internal class ImportLogManager { +class ImportLogManager { private val lock = Mutex() private val _logs = MutableStateFlow(emptyList()) val logs: StateFlow> = _logs diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportTaskQueue.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/data/imports/ImportTaskQueue.kt similarity index 98% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportTaskQueue.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/data/imports/ImportTaskQueue.kt index 1a90a7d1..86cdf6ff 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportTaskQueue.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/data/imports/ImportTaskQueue.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.manage +package xyz.sevive.arcaeaoffline.data.imports import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt index 1488aab0..a4b1fc8c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt @@ -21,6 +21,7 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.SongLocalizedReposito import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository import xyz.sevive.arcaeaoffline.core.di.coreModule import xyz.sevive.arcaeaoffline.core.search.ArcaeaCatalogStateHolder +import xyz.sevive.arcaeaoffline.data.imports.ImportLogManager import xyz.sevive.arcaeaoffline.database.AppDatabase import xyz.sevive.arcaeaoffline.database.OcrQueueDatabase import xyz.sevive.arcaeaoffline.database.daos.OcrHistoryDao @@ -47,7 +48,7 @@ import xyz.sevive.arcaeaoffline.ui.screens.database.DatabaseNavEntryViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.addplayresult.DatabaseAddPlayResultViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.bestlist.DatabaseBestListViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.deduplicator.DatabaseDeduplicatorViewModel -import xyz.sevive.arcaeaoffline.ui.screens.database.manage.DatabaseManageViewModel +import xyz.sevive.arcaeaoffline.ui.screens.database.playdata.DatabasePlayDataViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist.DatabasePlayResultListViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.r30list.DatabaseR30ListViewModel import xyz.sevive.arcaeaoffline.ui.screens.ocr.dependencies.OcrDependenciesScreenViewModel @@ -56,6 +57,7 @@ import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreenViewModel import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.preferences.OcrQueuePreferencesViewModel import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.staging.OcrQueueStagingViewModel import xyz.sevive.arcaeaoffline.ui.screens.overview.OverviewViewModel +import xyz.sevive.arcaeaoffline.ui.screens.resources.ResourcesViewModel import xyz.sevive.arcaeaoffline.ui.screens.settings.SettingsViewModel import xyz.sevive.arcaeaoffline.ui.screens.settings.unstablealert.SettingsUnstableAlertScreenViewModel import xyz.sevive.arcaeaoffline.ui.screens.utilities.UtilitiesChartRecommendScreenViewModel @@ -156,11 +158,13 @@ val appModule = single { create(::createArcaeaCatalogStateHolder) } single { create(::createDiagnosticsCollector) } + single() viewModel() viewModel() viewModel() - viewModel() + viewModel() + viewModel() viewModel() viewModel() viewModel() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/SubScreenContainer.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/SubScreenContainer.kt index ff841e83..ee0e260c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/SubScreenContainer.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/SubScreenContainer.kt @@ -110,6 +110,7 @@ fun SubScreenContainer( title: String, modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {}, content: @Composable () -> Unit, ) { @@ -121,6 +122,7 @@ fun SubScreenContainer( actions = actions, ) }, + floatingActionButton = floatingActionButton, snackbarHost = snackbarHost, content = content, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt index 59b80439..8ab49c0e 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt @@ -2,7 +2,6 @@ package xyz.sevive.arcaeaoffline.ui.components import android.content.res.Configuration import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith @@ -31,15 +30,11 @@ 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.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.materialkolor.hct.Hct import kotlinx.coroutines.flow.combine import org.koin.compose.koinInject import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass @@ -50,8 +45,6 @@ import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme -import xyz.sevive.arcaeaoffline.ui.theme.isSystemInDarkTheme -import xyz.sevive.arcaeaoffline.ui.theme.ratingClassColor import xyz.sevive.arcaeaoffline.ui.theme.spacing // Display data resolved as one atomic unit. Swapping the pair together is @@ -97,36 +90,10 @@ fun ArcaeaChartCard( var artistOverflowed by remember(difficultyWithSong) { mutableStateOf(false) } val canExpand = expanded || titleOverflowed || artistOverflowed - // TODO: change to local when theme settings added - val isDarkTheme = isSystemInDarkTheme() - val textTone = if (isDarkTheme) 80.0 else 32.0 - val accentColor by animateColorAsState( - targetValue = - ratingClassColor( - ArcaeaRatingClassDisplay.of(difficultyWithSong.ratingClass, difficultyWithSong.ratingClassAlias), - ), - label = "accentColor", - ) - val (backgroundBrush, accentTextColor) = - remember(accentColor, isDarkTheme) { - val hct = Hct.fromInt(accentColor.toArgb()) - if (isDarkTheme) { - val backgroundEnd = Color(hct.withTone(20.0).toInt()) - Brush.horizontalGradient( - 0.48f to backgroundEnd.copy(alpha = 0f), - 0.68f to backgroundEnd.copy(alpha = 0.12f), - 0.84f to backgroundEnd.copy(alpha = 0.45f), - 0.94f to backgroundEnd.copy(alpha = 0.78f), - 1.00f to backgroundEnd.copy(alpha = 0.90f), - ) to Color(hct.withTone(textTone).toInt()) - } else { - Brush.horizontalGradient( - 0.00f to accentColor.copy(alpha = 0f), - 0.40f to accentColor.copy(alpha = 0f), - 1.00f to accentColor.copy(alpha = 0.22f), - ) to Color(hct.withTone(textTone).toInt()) - } - } + val accent = + rememberRatingClassAccent( + ArcaeaRatingClassDisplay.of(difficultyWithSong.ratingClass, difficultyWithSong.ratingClassAlias), + ) val enabledCardColors = CardDefaults.cardColors() Card( @@ -143,7 +110,7 @@ fun ArcaeaChartCard( Row( // TODO: sync this to spacing...? Modifier - .background(backgroundBrush) + .background(accent.backgroundBrush) .padding(horizontal = MaterialTheme.spacing.pagePadding, vertical = MaterialTheme.spacing.cardPadding), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), verticalAlignment = Alignment.CenterVertically, @@ -187,12 +154,12 @@ fun ArcaeaChartCard( value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Medium, - color = accentTextColor, + color = accent.textColor, ) Text( ratingClass, style = MaterialTheme.typography.bodyMedium, - color = accentTextColor, + color = accent.textColor, ) } } @@ -203,7 +170,7 @@ fun ArcaeaChartCard( if (it) Icons.Default.UnfoldLess else Icons.Default.UnfoldMore, contentDescription = null, modifier = Modifier.size(20.dp), - tint = accentTextColor, + tint = accent.textColor, ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLogUi.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImportLogSheet.kt similarity index 83% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLogUi.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImportLogSheet.kt index f672a86b..9e142816 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/ImportLogUi.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ImportLogSheet.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.manage +package xyz.sevive.arcaeaoffline.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -41,6 +41,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import kotlinx.coroutines.launch +import xyz.sevive.arcaeaoffline.data.imports.ImportLogEvent +import xyz.sevive.arcaeaoffline.data.imports.ImportLogObject import xyz.sevive.arcaeaoffline.helpers.context.findActivity import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDate import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime @@ -52,15 +54,13 @@ import xyz.sevive.arcaeaoffline.ui.theme.spacing import kotlin.time.Instant import kotlin.uuid.Uuid -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable private fun ImportLogObjectUi( log: ImportLogObject, + widthSizeClass: WindowWidthSizeClass, modifier: Modifier = Modifier, ) { - val context = LocalContext.current val resources = LocalResources.current - val widthSizeClass = context.findActivity()?.let { calculateWindowSizeClass(it) }?.widthSizeClass ?: WindowWidthSizeClass.Compact val timestampText = remember(widthSizeClass) { @@ -102,13 +102,19 @@ private fun ImportLogObjectUi( } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class) @Composable internal fun ImportLogBottomSheet( onDismissRequest: () -> Unit, logs: List, ) { val coroutineScope = rememberCoroutineScope() + val widthSizeClass = + LocalContext.current + .findActivity() + ?.let { calculateWindowSizeClass(it) } + ?.widthSizeClass + ?: WindowWidthSizeClass.Compact val lazyColumnState = rememberLazyListState() val showScrollToTopButton by remember { @@ -150,6 +156,7 @@ internal fun ImportLogBottomSheet( items(logs, key = { it.uuid }) { ImportLogObjectUi( it, + widthSizeClass, Modifier .fillMaxWidth() .padding(vertical = MaterialTheme.spacing.sm) @@ -161,22 +168,34 @@ internal fun ImportLogBottomSheet( } } +private fun previewImportLogObject() = + ImportLogObject( + uuid = Uuid.generateV4(), + timestamp = Instant.fromEpochMilliseconds(0), + tag = "P-VIEW", + event = ImportLogEvent.Raw("Wow this is a log wow"), + ) + @Preview @Composable -private fun ImportLogObjectUiPreview() { - val log = - remember { - ImportLogObject( - uuid = Uuid.generateV4(), - timestamp = Instant.fromEpochMilliseconds(0), - tag = "P-VIEW", - event = ImportLogEvent.Raw("Wow this is a log wow"), - ) +private fun ImportLogObjectUiCompactPreview() { + val log = remember { previewImportLogObject() } + + ArcaeaOfflineTheme { + Card { + ImportLogObjectUi(log, WindowWidthSizeClass.Compact) } + } +} + +@Preview +@Composable +private fun ImportLogObjectUiMediumPreview() { + val log = remember { previewImportLogObject() } ArcaeaOfflineTheme { Card { - ImportLogObjectUi(log) + ImportLogObjectUi(log, WindowWidthSizeClass.Medium) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/LabeledCheckbox.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/LabeledCheckbox.kt new file mode 100644 index 00000000..9162fc0b --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/LabeledCheckbox.kt @@ -0,0 +1,44 @@ +package xyz.sevive.arcaeaoffline.ui.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import xyz.sevive.arcaeaoffline.ui.theme.spacing + +@Composable +fun LabeledCheckbox( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + label: String, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + + Row( + modifier.toggleable( + value = checked, + interactionSource = interactionSource, + indication = null, + role = Role.Checkbox, + onValueChange = onCheckedChange, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + interactionSource = interactionSource, + ) + Text(label) + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultEditorFields.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultEditorFields.kt index 8689dd64..6bb48cd3 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultEditorFields.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/PlayResultEditorFields.kt @@ -1,13 +1,14 @@ package xyz.sevive.arcaeaoffline.ui.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable @@ -29,6 +30,7 @@ import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier import xyz.sevive.arcaeaoffline.ui.common.datetimeeditor.NullableDateTimeEditor +import xyz.sevive.arcaeaoffline.ui.theme.spacing import kotlin.time.Clock import kotlin.time.Instant @@ -52,13 +54,12 @@ internal fun SetNullCheckbox( onIsNullChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = isNull, - onCheckedChange = onIsNullChange, - ) - Text(stringResource(R.string.play_result_editor_set_null)) - } + LabeledCheckbox( + checked = isNull, + onCheckedChange = onIsNullChange, + label = stringResource(R.string.play_result_editor_set_null), + modifier = modifier, + ) } @Composable @@ -75,7 +76,11 @@ internal fun GeneralNullableFieldWrapper( defaultValueConstructor = defaultValueConstructor, ) - Row(modifier, verticalAlignment = Alignment.CenterVertically) { + Row( + modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { valueComponent() SetNullCheckbox(isNull = value == null, onIsNullChange = { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/RatingClassAccent.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/RatingClassAccent.kt new file mode 100644 index 00000000..b3fe641b --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/RatingClassAccent.kt @@ -0,0 +1,62 @@ +package xyz.sevive.arcaeaoffline.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.materialkolor.hct.Hct +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClassDisplay +import xyz.sevive.arcaeaoffline.ui.theme.isSystemInDarkTheme +import xyz.sevive.arcaeaoffline.ui.theme.ratingClassColor + +/** + * A rating class tint for a surface: a gradient that fades the class color in from the + * right edge, and a text color legible on top of it. + */ +data class RatingClassAccent( + val backgroundBrush: Brush, + val textColor: Color, +) + +/** + * Resolves the [RatingClassAccent] of [display], animating the color when [display] changes. + */ +@Composable +fun rememberRatingClassAccent(display: ArcaeaRatingClassDisplay): RatingClassAccent { + // TODO: change to local when theme settings added + val isDarkTheme = isSystemInDarkTheme() + val textTone = if (isDarkTheme) 80.0 else 32.0 + val accentColor by animateColorAsState( + targetValue = ratingClassColor(display), + label = "accentColor", + ) + + return remember(accentColor, isDarkTheme) { + val hct = Hct.fromInt(accentColor.toArgb()) + val backgroundBrush = + if (isDarkTheme) { + val backgroundEnd = Color(hct.withTone(20.0).toInt()) + Brush.horizontalGradient( + 0.48f to backgroundEnd.copy(alpha = 0f), + 0.68f to backgroundEnd.copy(alpha = 0.12f), + 0.84f to backgroundEnd.copy(alpha = 0.45f), + 0.94f to backgroundEnd.copy(alpha = 0.78f), + 1.00f to backgroundEnd.copy(alpha = 0.90f), + ) + } else { + Brush.horizontalGradient( + 0.00f to accentColor.copy(alpha = 0f), + 0.40f to accentColor.copy(alpha = 0f), + 1.00f to accentColor.copy(alpha = 0.22f), + ) + } + + RatingClassAccent( + backgroundBrush = backgroundBrush, + textColor = Color(hct.withTone(textTone).toInt()), + ) + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt index 137db615..fde124f0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SettingsItems.kt @@ -255,6 +255,28 @@ data class SelectOption( val description: String? = null, ) +@Composable +fun RadioItem( + selected: Boolean, + onSelect: () -> Unit, + title: String, + modifier: Modifier = Modifier, + description: String? = null, +) { + BaseSettingsItem( + onClick = onSelect, + title = { Text(title) }, + description = description, + leadingSlot = { + RadioButton( + selected = selected, + onClick = null, + ) + }, + modifier = modifier, + ) +} + /** * A radio-button list of mutually exclusive options that renders one [BaseSettingsItem] * row per option without an outer container. @@ -268,16 +290,11 @@ fun SelectItem( ) { Column(modifier) { options.forEach { option -> - BaseSettingsItem( - onClick = { onSelect(option.value) }, - title = { Text(option.label) }, + RadioItem( + selected = option.value == selected, + onSelect = { onSelect(option.value) }, + title = option.label, description = option.description, - leadingSlot = { - RadioButton( - selected = option.value == selected, - onClick = null, - ) - }, ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/UiEvent.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/UiEvent.kt new file mode 100644 index 00000000..e186fd9a --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/UiEvent.kt @@ -0,0 +1,27 @@ +package xyz.sevive.arcaeaoffline.ui.helpers + +import android.content.res.Resources + +/** + * A one-shot UI message emitted by a ViewModel. + * + * TODO: combine with ImportLog? + */ +sealed class UiEvent { + data class StringRes( + @androidx.annotation.StringRes val resId: Int, + val formatArgs: List = emptyList(), + ) : UiEvent() + + data class PluralRes( + @androidx.annotation.PluralsRes val resId: Int, + val quantity: Int, + val formatArgs: List = emptyList(), + ) : UiEvent() + + fun asString(resources: Resources): String = + when (this) { + is StringRes -> resources.getString(resId, *formatArgs.toTypedArray()) + is PluralRes -> resources.getQuantityString(resId, quantity, *formatArgs.toTypedArray()) + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/UiEventChannel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/UiEventChannel.kt new file mode 100644 index 00000000..6fd34736 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/UiEventChannel.kt @@ -0,0 +1,14 @@ +package xyz.sevive.arcaeaoffline.ui.helpers + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow + +/** A one-shot [UiEvent] queue for a ViewModel. */ +class UiEventChannel { + private val channel = Channel(Channel.BUFFERED) + + val events: Flow = channel.receiveAsFlow() + + suspend fun send(event: UiEvent) = channel.send(event) +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Database.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Database.kt index 1777f178..513e8c26 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Database.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Database.kt @@ -9,7 +9,7 @@ enum class DatabaseSubScreen( val route: String, @StringRes val title: Int, ) { - Manage("$DATABASE_NAV_ROUTE_ROOT/manage", R.string.database_manage_title), + PlayData("$DATABASE_NAV_ROUTE_ROOT/play_data", R.string.database_play_data_title), AddPlayResult("$DATABASE_NAV_ROUTE_ROOT/add_play_result", R.string.database_add_play_result_title), ScoreList("$DATABASE_NAV_ROUTE_ROOT/score_list", R.string.database_play_result_list_title), BestList("$DATABASE_NAV_ROUTE_ROOT/best_list", R.string.database_best_list_title), diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Main.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Main.kt index 97894031..ed682b4c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Main.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Main.kt @@ -4,7 +4,7 @@ import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.HomeRepairService -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.MusicNote import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -13,9 +13,11 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.mainNavControllerNavigateToRoute import xyz.sevive.arcaeaoffline.ui.screens.database.DatabaseEntryScreen import xyz.sevive.arcaeaoffline.ui.screens.ocr.OcrEntryScreen import xyz.sevive.arcaeaoffline.ui.screens.overview.OverviewScreen +import xyz.sevive.arcaeaoffline.ui.screens.resources.ResourcesScreen import xyz.sevive.arcaeaoffline.ui.screens.settings.SettingsScreen import xyz.sevive.arcaeaoffline.ui.screens.utilities.UtilitiesEntryScreen @@ -29,28 +31,28 @@ enum class MainScreen( Overview( OVERVIEW_NAV_ROUTE_ROOT, { Icons.Filled.Dashboard }, - R.string.nav_overview, + R.string.nav_home, ), Database( DATABASE_NAV_ROUTE_ROOT, + { Icons.Filled.MusicNote }, + R.string.nav_plays, + ), + Resources( + RESOURCES_NAV_ROUTE_ROOT, { ImageVector.vectorResource(R.drawable.ic_database) }, - R.string.nav_database, + R.string.nav_resources, ), Utilities( UTILITIES_NAV_ROUTE_ROOT, { Icons.Filled.HomeRepairService }, - R.string.nav_utilities, + R.string.nav_tools, ), Ocr( OCR_NAV_ROUTE_ROOT, { ImageVector.vectorResource(R.drawable.ic_ocr) }, R.string.nav_ocr, ), - Settings( - SETTINGS_NAV_ROUTE_ROOT, - { Icons.Filled.Settings }, - R.string.nav_settings, - ), } @Composable @@ -64,23 +66,41 @@ fun MainNavigationGraph( modifier = modifier, ) { composable(MainScreen.Overview.route) { - OverviewScreen() + OverviewScreen( + onNavigateToSettings = { + mainNavController.navigate(SETTINGS_NAV_ROUTE_ROOT) { launchSingleTop = true } + }, + onNavigateToResources = { + mainNavControllerNavigateToRoute(mainNavController, MainScreen.Resources.route) + }, + ) } composable(MainScreen.Database.route) { DatabaseEntryScreen() } + composable(MainScreen.Resources.route) { + ResourcesScreen() + } + composable(MainScreen.Utilities.route) { UtilitiesEntryScreen() } composable(MainScreen.Ocr.route) { - OcrEntryScreen() + OcrEntryScreen( + onNavigateToResources = { + // A cross-tab jump from the OCR hints, this behavior keeps OCR page on the back stack. + mainNavController.navigate(MainScreen.Resources.route) { launchSingleTop = true } + }, + ) } - composable(MainScreen.Settings.route) { - SettingsScreen() + composable(SETTINGS_NAV_ROUTE_ROOT) { + SettingsScreen( + onNavigateBack = { mainNavController.popBackStack() }, + ) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Resources.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Resources.kt new file mode 100644 index 00000000..a664cc7f --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Resources.kt @@ -0,0 +1,3 @@ +package xyz.sevive.arcaeaoffline.ui.navigation + +const val RESOURCES_NAV_ROUTE_ROOT = "resources" diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseEntryScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseEntryScreen.kt index 1bb7da45..98f7a04d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseEntryScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseEntryScreen.kt @@ -6,7 +6,7 @@ import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen import xyz.sevive.arcaeaoffline.ui.screens.database.addplayresult.DatabaseAddPlayResultScreen import xyz.sevive.arcaeaoffline.ui.screens.database.bestlist.DatabaseBestListScreen import xyz.sevive.arcaeaoffline.ui.screens.database.deduplicator.DatabaseDeduplicatorScreen -import xyz.sevive.arcaeaoffline.ui.screens.database.manage.DatabaseManageScreen +import xyz.sevive.arcaeaoffline.ui.screens.database.playdata.DatabasePlayDataScreen import xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist.DatabasePlayResultListScreen import xyz.sevive.arcaeaoffline.ui.screens.database.r30list.DatabaseR30ListScreen @@ -16,7 +16,7 @@ fun DatabaseEntryScreen() = listPane = { DatabaseNavEntry() }, detailPane = { route -> when (route) { - DatabaseSubScreen.Manage.route -> DatabaseManageScreen() + DatabaseSubScreen.PlayData.route -> DatabasePlayDataScreen() DatabaseSubScreen.AddPlayResult.route -> DatabaseAddPlayResultScreen() DatabaseSubScreen.ScoreList.route -> DatabasePlayResultListScreen() DatabaseSubScreen.BestList.route -> DatabaseBestListScreen() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt index aaa1903a..80ec4a36 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntry.kt @@ -1,19 +1,21 @@ package xyz.sevive.arcaeaoffline.ui.screens.database +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.CopyAll import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Star import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -21,15 +23,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel +import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.ui.components.SelectItem +import xyz.sevive.arcaeaoffline.ui.components.SelectOption +import xyz.sevive.arcaeaoffline.ui.components.SettingsGroupHeader import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext import xyz.sevive.arcaeaoffline.ui.navigation.MainScreen import xyz.sevive.arcaeaoffline.ui.screens.NavEntryNavigateButton -import xyz.sevive.arcaeaoffline.ui.theme.spacing @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -38,7 +44,7 @@ fun DatabaseNavEntry( vm: DatabaseNavEntryViewModel = koinViewModel(), ) { val navContext = LocalListDetailNavigationContext.current - val statusUiState by vm.statusUiState.collectAsStateWithLifecycle() + val playResultCount by vm.playResultCount.collectAsStateWithLifecycle() val scoringMode by vm.scoringMode.collectAsStateWithLifecycle() Scaffold( @@ -47,79 +53,80 @@ fun DatabaseNavEntry( TopAppBar(title = { Text(stringResource(MainScreen.Database.title)) }) }, containerColor = Color.Transparent, - ) { - LazyColumn( + ) { paddingValues -> + Column( Modifier .fillMaxSize() - .consumeWindowInsets(it), - contentPadding = it, + .verticalScroll(rememberScrollState()) + .consumeWindowInsets(paddingValues) + .padding(paddingValues), ) { - item { - DatabaseStatus( - statusUiState, - Modifier - .fillMaxWidth() - .padding(horizontal = MaterialTheme.spacing.pagePadding), - ) - } + SettingsGroupHeader(stringResource(R.string.database_manage_scoring_mode_title)) - item { - NavEntryNavigateButton( - titleResId = DatabaseSubScreen.Manage.title, - icon = Icons.Default.Build, - ) { - navContext.navigateToDetail(DatabaseSubScreen.Manage.route) - } - } + SelectItem( + options = + listOf( + SelectOption( + value = ArcaeaScoringMode.B30_R10, + label = stringResource(R.string.database_manage_scoring_mode_b30_r10), + description = ArcaeaScoringMode.B30_R10.key.toString(), + ), + SelectOption( + value = ArcaeaScoringMode.B50, + label = stringResource(R.string.database_manage_scoring_mode_b50), + description = ArcaeaScoringMode.B50.key.toString(), + ), + ), + selected = scoringMode, + onSelect = vm::setScoringMode, + Modifier.fillMaxWidth(), + ) - item { - NavEntryNavigateButton( - titleResId = DatabaseSubScreen.AddPlayResult.title, - icon = Icons.Default.Add, - ) { - navContext.navigateToDetail(DatabaseSubScreen.AddPlayResult.route) - } - } + HorizontalDivider() - item { - NavEntryNavigateButton( - titleResId = DatabaseSubScreen.ScoreList.title, - icon = Icons.AutoMirrored.Default.List, - ) { - navContext.navigateToDetail(DatabaseSubScreen.ScoreList.route) - } + NavEntryNavigateButton( + title = stringResource(DatabaseSubScreen.ScoreList.title), + description = + pluralStringResource( + R.plurals.database_play_result_entries, + playResultCount, + playResultCount, + ), + icon = Icons.AutoMirrored.Default.List, + ) { + navContext.navigateToDetail(DatabaseSubScreen.ScoreList.route) } - // The best list screen applies the active scoring mode's rules - item { - NavEntryNavigateButton( - titleResId = DatabaseSubScreen.BestList.title, - icon = Icons.Default.Star, - ) { - navContext.navigateToDetail(DatabaseSubScreen.BestList.route) - } + NavEntryNavigateButton( + titleResId = DatabaseSubScreen.AddPlayResult.title, + icon = Icons.Default.Add, + ) { + navContext.navigateToDetail(DatabaseSubScreen.AddPlayResult.route) } - // The recent queue only exists under the B30 + R10 rules - if (scoringMode == ArcaeaScoringMode.B30_R10) { - item { - NavEntryNavigateButton( - titleResId = DatabaseSubScreen.R30.title, - icon = Icons.Default.History, - ) { - navContext.navigateToDetail(DatabaseSubScreen.R30.route) - } - } + NavEntryNavigateButton( + titleResId = DatabaseSubScreen.BestList.title, + icon = Icons.Default.Star, + ) { + navContext.navigateToDetail(DatabaseSubScreen.BestList.route) } - item { + // The recent queue only exists under the B30 + R10 rules + AnimatedVisibility(scoringMode == ArcaeaScoringMode.B30_R10) { NavEntryNavigateButton( - titleResId = DatabaseSubScreen.Deduplicator.title, - icon = Icons.Default.CopyAll, + titleResId = DatabaseSubScreen.R30.title, + icon = Icons.Default.History, ) { - navContext.navigateToDetail(DatabaseSubScreen.Deduplicator.route) + navContext.navigateToDetail(DatabaseSubScreen.R30.route) } } + + NavEntryNavigateButton( + titleResId = DatabaseSubScreen.PlayData.title, + icon = Icons.Default.Build, + ) { + navContext.navigateToDetail(DatabaseSubScreen.PlayData.route) + } } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntryViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntryViewModel.kt index 7eed4fc9..d456e298 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntryViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseNavEntryViewModel.kt @@ -2,82 +2,33 @@ package xyz.sevive.arcaeaoffline.ui.screens.database import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode -import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyLocalizedRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.MetaRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.PackLocalizedRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.PackRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.SongLocalizedRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository class DatabaseNavEntryViewModel( - metaRepo: MetaRepository, - propertyRepo: PropertyRepository, - packRepo: PackRepository, - songRepo: SongRepository, - difficultyRepo: DifficultyRepository, - chartInfoRepo: ChartInfoRepository, + private val propertyRepo: PropertyRepository, playResultRepo: PlayResultRepository, - packLocalizedRepo: PackLocalizedRepository, - songLocalizedRepo: SongLocalizedRepository, - difficultyLocalizedRepo: DifficultyLocalizedRepository, ) : ViewModel() { - class StatusUiState( - val databaseVersion: Int = 0, - val databaseSchemaVersion: Int = 0, - val packCount: Int = 0, - val songCount: Int = 0, - val difficultyCount: Int = 0, - val chartInfoCount: Int = 0, - val playResultCount: Int = 0, - val packLocalizedCount: Int = 0, - val songLocalizedCount: Int = 0, - val difficultyLocalizedCount: Int = 0, - val songDeletedInGameCount: Int = 0, - ) + private val logger = Logger.withTag(LOG_TAG) - val statusUiState = - combine( - propertyRepo.databaseVersion(), - packRepo.count(), - songRepo.count(), - difficultyRepo.count(), - chartInfoRepo.count(), - playResultRepo.count(), - packLocalizedRepo.count(), - songLocalizedRepo.count(), - difficultyLocalizedRepo.count(), - songRepo.countDeletedInGame(), - ) { flows -> - StatusUiState( - databaseVersion = flows[0] ?: 0, - databaseSchemaVersion = metaRepo.schemaVersion(), - packCount = flows[1] ?: 0, - songCount = flows[2] ?: 0, - difficultyCount = flows[3] ?: 0, - chartInfoCount = flows[4] ?: 0, - playResultCount = flows[5] ?: 0, - packLocalizedCount = flows[6] ?: 0, - songLocalizedCount = flows[7] ?: 0, - difficultyLocalizedCount = flows[8] ?: 0, - songDeletedInGameCount = flows[9] ?: 0, + val playResultCount: StateFlow = + playResultRepo + .count() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), + 0, ) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), - StatusUiState(), - ) companion object { private const val TIMEOUT_MILLIS = 1000L + private const val LOG_TAG = "DatabaseNavEntryVM" } val scoringMode: StateFlow = @@ -88,4 +39,11 @@ class DatabaseNavEntryViewModel( SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), ArcaeaScoringMode.B50, ) + + fun setScoringMode(mode: ArcaeaScoringMode) { + viewModelScope.launch { + runCatching { propertyRepo.setScoringMode(mode) } + .onFailure { logger.e(it) { "Failed to set scoring mode" } } + } + } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseStatus.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseStatus.kt deleted file mode 100644 index 27e7f43f..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/DatabaseStatus.kt +++ /dev/null @@ -1,152 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database - -import androidx.annotation.PluralsRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.filled.Translate -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.PreviewLightDark -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.ui.components.IconRow -import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme -import xyz.sevive.arcaeaoffline.ui.theme.spacing - -@Composable -private fun DatabaseStatusIconRow( - icon: ImageVector, - text: String, - localizedItemCount: Int? = null, - deletedItemCount: Int? = null, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - verticalAlignment = Alignment.Bottom, - ) { - IconRow { - Icon(icon, contentDescription = null) - Text(text) - } - - localizedItemCount?.let { - Text("·") - - IconRow { - Icon(Icons.Default.Translate, contentDescription = null) - Text(it.toString()) - } - } - - deletedItemCount?.let { - Text("·") - - IconRow { - Icon(Icons.Default.DeleteForever, contentDescription = null) - Text(it.toString()) - } - } - } -} - -@Composable -private fun DatabaseStatusLabel( - @PluralsRes labelId: Int, - count: Int, -) { - Text(text = pluralStringResource(id = labelId, count = count, count)) -} - -@Composable -fun DatabaseStatus( - uiState: DatabaseNavEntryViewModel.StatusUiState, - modifier: Modifier = Modifier, -) { - with(uiState) { - val databaseVersionText = - remember(uiState) { "v$databaseVersion, sv$databaseSchemaVersion" } - val packCountText = - pluralStringResource(R.plurals.database_pack_entries, packCount, packCount) - val songCountText = - pluralStringResource(R.plurals.database_song_entries, songCount, songCount) - val difficultyCountText = - pluralStringResource( - R.plurals.database_difficulty_entries, - difficultyCount, - difficultyCount, - ) - - Card(modifier) { - Column( - Modifier.padding(MaterialTheme.spacing.cardPadding), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - ) { - DatabaseStatusIconRow( - icon = ImageVector.vectorResource(R.drawable.ic_database), - text = databaseVersionText, - ) - - DatabaseStatusIconRow( - icon = ImageVector.vectorResource(R.drawable.ic_pack), - text = packCountText, - localizedItemCount = packLocalizedCount, - ) - - DatabaseStatusIconRow( - icon = Icons.Default.MusicNote, - text = songCountText, - localizedItemCount = songLocalizedCount, - deletedItemCount = songDeletedInGameCount, - ) - - DatabaseStatusIconRow( - icon = ImageVector.vectorResource(R.drawable.ic_rating_class), - text = difficultyCountText, - localizedItemCount = difficultyLocalizedCount, - ) - - DatabaseStatusLabel(R.plurals.database_chart_info_entries, chartInfoCount) - DatabaseStatusLabel(R.plurals.database_play_result_entries, playResultCount) - } - } - } -} - -@PreviewLightDark -@Composable -private fun DatabaseStatusPreview() { - ArcaeaOfflineTheme { - Surface { - DatabaseStatus( - DatabaseNavEntryViewModel.StatusUiState( - databaseVersion = 5, - databaseSchemaVersion = 10, - packCount = 12, - songCount = 50, - difficultyCount = 153, - chartInfoCount = 86, - playResultCount = 61, - packLocalizedCount = 12, - songLocalizedCount = 34, - difficultyLocalizedCount = 0, - ), - modifier = Modifier.fillMaxWidth(), - ) - } - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultChartAction.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultChartAction.kt index d971490c..c32462d3 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultChartAction.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultChartAction.kt @@ -27,12 +27,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartSelector import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface import xyz.sevive.arcaeaoffline.ui.components.IconRow -import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaChartDisplay import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.spacing @@ -54,7 +55,8 @@ private fun SelectChartDialog( @Composable internal fun DatabaseAddPlayResultChartAction( - difficulty: Difficulty?, + chart: DifficultyWithSong?, + chartInfo: ChartInfo?, onDifficultyChange: (Difficulty) -> Unit, modifier: Modifier = Modifier, ) { @@ -63,21 +65,19 @@ internal fun DatabaseAddPlayResultChartAction( if (showSelectChartDialog) { SelectChartDialog( onDismiss = { showSelectChartDialog = false }, - songId = difficulty?.songId, - ratingClass = difficulty?.ratingClass, + songId = chart?.songId, + ratingClass = chart?.ratingClass, onDifficultyChange = onDifficultyChange, ) } - val display by rememberArcaeaChartDisplay(difficulty?.songId, difficulty?.ratingClass) - Row(modifier, verticalAlignment = Alignment.Bottom) { Box(modifier = Modifier.weight(1f)) { - if (display != null) { + if (chart != null) { ArcaeaChartCard( - display!!.difficultyWithSong, + chart, Modifier.fillMaxWidth(), - chartInfo = display!!.chartInfo, + chartInfo = chartInfo, ) } else { Card( @@ -106,40 +106,30 @@ internal fun DatabaseAddPlayResultChartAction( @PreviewLightDark @Composable -private fun PlayResultActionPreview() { - val difficulty = - Difficulty( +private fun ChartActionPreview() { + val chart = + DifficultyWithSong( songId = "test", ratingClass = ArcaeaRatingClass.FUTURE, - ratingClassAlias = null, rating = 9, ratingPlus = true, - chartDesigner = null, - jacketDesigner = null, - audioOverride = false, - jacketOverride = false, - jacketNight = null, title = "Preview", artist = "Preview", - bg = null, - bgInverse = null, - bpm = null, - bpmBase = null, - version = null, - date = null, ) ArcaeaOfflineTheme { Surface { Column { DatabaseAddPlayResultChartAction( - difficulty = null, + chart = null, + chartInfo = null, onDifficultyChange = {}, modifier = Modifier.fillMaxWidth(), ) DatabaseAddPlayResultChartAction( - difficulty = difficulty, + chart = chart, + chartInfo = ChartInfo("test", ArcaeaRatingClass.FUTURE, constant = 98, notes = null), onDifficultyChange = {}, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultPlayResultAction.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultPlayResultAction.kt deleted file mode 100644 index 16a2169e..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultPlayResultAction.kt +++ /dev/null @@ -1,120 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.addplayresult - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.Card -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.minimumInteractiveComponentSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewLightDark -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass -import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning -import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard -import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog -import xyz.sevive.arcaeaoffline.ui.components.IconRow -import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme -import xyz.sevive.arcaeaoffline.ui.theme.spacing - -@Composable -internal fun DatabaseAddPlayResultPlayResultAction( - playResult: PlayResult?, - onPlayResultChange: (PlayResult) -> Unit, - warnings: List, - modifier: Modifier = Modifier, -) { - var showPlayResultEditorDialog by rememberSaveable { mutableStateOf(false) } - if (showPlayResultEditorDialog) { - playResult?.let { - ArcaeaPlayResultEditorDialog( - onDismiss = { showPlayResultEditorDialog = false }, - playResult = playResult, - onPlayResultChange = { onPlayResultChange(it) }, - ) - } - } - - Row(modifier, verticalAlignment = Alignment.Bottom) { - Box(Modifier.weight(1f)) { - if (playResult != null) { - Column(Modifier.fillMaxWidth()) { - ArcaeaPlayResultCard( - playResult = playResult, - onClick = { showPlayResultEditorDialog = true }, - warnings = warnings, - ) - } - } else { - Card(Modifier.fillMaxWidth()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) { - IconRow( - Modifier - .minimumInteractiveComponentSize() - .padding(MaterialTheme.spacing.cardPadding), - ) { - Icon(Icons.Default.Block, null) - Text(stringResource(R.string.database_add_play_result_select_chart_first)) - } - } - } - } - } - - IconButton( - onClick = { showPlayResultEditorDialog = true }, - enabled = playResult != null, - ) { - Icon(Icons.Default.Edit, contentDescription = null) - } - } -} - -@PreviewLightDark -@Composable -private fun PlayResultActionPreview() { - val playResult = - PlayResult( - songId = "test", - ratingClass = ArcaeaRatingClass.FUTURE, - score = 12345678, - ) - - ArcaeaOfflineTheme { - Surface { - Column { - DatabaseAddPlayResultPlayResultAction( - playResult = null, - onPlayResultChange = { }, - warnings = emptyList(), - modifier = Modifier.fillMaxWidth(), - ) - - DatabaseAddPlayResultPlayResultAction( - playResult = playResult, - onPlayResultChange = { }, - warnings = emptyList(), - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt index 19581585..982490c5 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultScreen.kt @@ -1,5 +1,8 @@ package xyz.sevive.arcaeaoffline.ui.screens.database.addplayresult +import androidx.compose.foundation.background +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.WindowInsets @@ -8,68 +11,235 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.CompositionLocalProvider +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.platform.LocalResources +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.core.database.entities.playRating +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha import xyz.sevive.arcaeaoffline.ui.SubScreenContainer -import xyz.sevive.arcaeaoffline.ui.components.IconRow +import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultValidatorWarningDetails +import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultValidatorWarningDetailsDialog +import xyz.sevive.arcaeaoffline.ui.components.LabeledCheckbox +import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorClearTypeField +import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorCommentField +import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorDateTimeField +import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorFarField +import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorLostField +import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorMaxRecallField +import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorModifierField +import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorPureField import xyz.sevive.arcaeaoffline.ui.components.SettingsGroupHeader +import xyz.sevive.arcaeaoffline.ui.components.TextItem +import xyz.sevive.arcaeaoffline.ui.components.arcaea.OutlinedArcaeaScoreTextField +import xyz.sevive.arcaeaoffline.ui.components.arcaea.rememberArcaeaScoreTextFieldState +import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton +import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton +import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen +import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme import xyz.sevive.arcaeaoffline.ui.theme.spacing @Composable internal fun BottomActionsBar( + warnings: List, + keepChart: Boolean, + onKeepChartChange: (Boolean) -> Unit, onReset: () -> Unit, onSave: () -> Unit, saveEnabled: Boolean, modifier: Modifier = Modifier, ) { - Row(modifier) { - OutlinedButton( - onClick = onReset, - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { - IconRow { - Icon(Icons.Default.RestartAlt, contentDescription = null) - Text(stringResource(R.string.general_reset)) + var showWarningsDialog by rememberSaveable { mutableStateOf(false) } + if (showWarningsDialog && warnings.isNotEmpty()) { + ArcaeaPlayResultValidatorWarningDetailsDialog( + onDismissRequest = { showWarningsDialog = false }, + warnings = warnings, + ) + } + LaunchedEffect(warnings) { if (warnings.isEmpty()) showWarningsDialog = false } + + Surface(modifier, color = BottomAppBarDefaults.containerColor) { + Column { + if (warnings.isNotEmpty()) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.extendedColorScheme.onWarningContainer, + ) { + TextItem( + title = pluralStringResource(R.plurals.play_result_validator_warning_count, warnings.size, warnings.size), + leadingIcon = Icons.Default.Warning, + leadingIconTint = MaterialTheme.extendedColorScheme.onWarningContainer, + onClick = { showWarningsDialog = true }, + modifier = Modifier.background(MaterialTheme.extendedColorScheme.warningContainer.copy(alpha = 0.5f)), + ) + } } - } - Spacer(Modifier.weight(1f)) + Row( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.pagePadding), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onReset, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.general_reset)) + } + + Spacer(Modifier.weight(1f)) + + LabeledCheckbox( + checked = keepChart, + onCheckedChange = onKeepChartChange, + label = stringResource(R.string.database_add_play_result_keep_chart), + ) + + Spacer(Modifier.width(MaterialTheme.spacing.sm)) - Button(onClick = onSave, enabled = saveEnabled) { - IconRow { - Icon(Icons.Default.Save, contentDescription = null) - Text(stringResource(R.string.general_save)) + Button(onClick = onSave, enabled = saveEnabled) { + Text(stringResource(R.string.general_save)) + } } } } } +@Composable +private fun SaveWithWarningsDialog( + warnings: List, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.database_add_play_result_save_with_warnings_title)) }, + text = { ArcaeaPlayResultValidatorWarningDetails(warnings) }, + confirmButton = { + DialogConfirmButton( + onClick = onConfirm, + customLabel = { Text(stringResource(R.string.general_save)) }, + ) + }, + dismissButton = { DialogDismissTextButton(onClick = onDismissRequest) }, + ) +} + +@Composable +private fun PlayRatingPreviewLine( + playResult: PlayResult, + scoringMode: ArcaeaScoringMode, + chartInfo: ChartInfo?, + modifier: Modifier = Modifier, +) { + val ratingText = + remember(playResult, scoringMode, chartInfo) { + ArcaeaFormatters.potentialToText(chartInfo?.let { playResult.playRating(it, scoringMode) }) + } + + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.arcaea_play_rating), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.secondaryItemAlpha(), + ) + + Text(ratingText, style = MaterialTheme.typography.titleMedium) + } +} + @Composable internal fun DatabaseAddPlayResultScreen( modifier: Modifier = Modifier, viewModel: DatabaseAddPlayResultViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val playResultEditEnabled by remember { derivedStateOf { uiState.playResult != null } } + + val playResult = uiState.playResult + val saveEnabled = playResult != null + + val scoreTextFieldState = rememberArcaeaScoreTextFieldState(playResult?.score ?: 0) + + LaunchedEffect(scoreTextFieldState.intValue) { + scoreTextFieldState.intValue?.let { score -> + playResult?.let { viewModel.setPlayResult(it.copy(score = score)) } + } + } + + LaunchedEffect(playResult?.score) { + val score = playResult?.score ?: 0 + if (scoreTextFieldState.intValue != score) { + scoreTextFieldState.updateValue(score) + } + } + + val resources = LocalResources.current + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(viewModel) { + viewModel.events.collect { event -> + snackbarHostState.showSnackbar(event.asString(resources), withDismissAction = true) + } + } + + var showSaveConfirmDialog by rememberSaveable { mutableStateOf(false) } + + fun requestSave() { + if (uiState.warnings.isEmpty()) { + viewModel.savePlayResult() + } else { + showSaveConfirmDialog = true + } + } + + if (showSaveConfirmDialog) { + SaveWithWarningsDialog( + warnings = uiState.warnings, + onConfirm = { + showSaveConfirmDialog = false + viewModel.savePlayResult() + }, + onDismissRequest = { showSaveConfirmDialog = false }, + ) + } SubScreenContainer( modifier = modifier, @@ -77,47 +247,93 @@ internal fun DatabaseAddPlayResultScreen( ) { Scaffold( contentWindowInsets = WindowInsets.safeDrawing, + snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { BottomActionsBar( - onReset = { viewModel.reset() }, - onSave = { viewModel.savePlayResult() }, - saveEnabled = playResultEditEnabled, - modifier = - Modifier - .fillMaxWidth() - .padding(MaterialTheme.spacing.pagePadding), + warnings = uiState.warnings, + keepChart = uiState.keepChartAfterSave, + onKeepChartChange = viewModel::setKeepChartAfterSave, + onReset = viewModel::reset, + onSave = ::requestSave, + saveEnabled = saveEnabled, + modifier = Modifier.fillMaxWidth(), ) }, ) { innerPadding -> - LazyColumn( + Column( Modifier .fillMaxSize() - .consumeWindowInsets(innerPadding), - contentPadding = innerPadding, + .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + .padding(bottom = MaterialTheme.spacing.pagePadding), ) { - item { - SettingsGroupHeader(stringResource(R.string.database_add_play_result_select_chart_header)) - } + SettingsGroupHeader(stringResource(R.string.database_add_play_result_select_chart_header)) - item { - DatabaseAddPlayResultChartAction( - difficulty = uiState.difficulty, - onDifficultyChange = { viewModel.setDifficulty(it) }, - modifier = Modifier.padding(horizontal = MaterialTheme.spacing.lg), - ) - } + DatabaseAddPlayResultChartAction( + chart = uiState.chart, + chartInfo = uiState.chartInfo, + onDifficultyChange = { viewModel.selectChart(it.songId, it.ratingClass) }, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.lg), + ) - item { - SettingsGroupHeader(stringResource(R.string.database_add_play_result_edit_play_result_header)) - } + SettingsGroupHeader(stringResource(R.string.database_add_play_result_edit_play_result_header)) - item { - DatabaseAddPlayResultPlayResultAction( - playResult = uiState.playResult, - onPlayResultChange = { viewModel.setPlayResult(it) }, - warnings = uiState.warnings, - modifier = Modifier.padding(horizontal = MaterialTheme.spacing.lg), - ) + Column( + Modifier.padding(horizontal = MaterialTheme.spacing.pagePadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + if (playResult == null) { + Text( + stringResource(R.string.database_add_play_result_select_chart_first), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.secondaryItemAlpha(), + ) + } else { + OutlinedArcaeaScoreTextField(scoreTextFieldState, Modifier.fillMaxWidth()) + + PlayRatingPreviewLine(playResult, uiState.scoringMode, uiState.chartInfo) + + PlayResultEditorPureField( + pure = playResult.pure, + onPureChange = { viewModel.setPlayResult(playResult.copy(pure = it)) }, + ) + + PlayResultEditorFarField( + far = playResult.far, + onFarChange = { viewModel.setPlayResult(playResult.copy(far = it)) }, + ) + + PlayResultEditorLostField( + lost = playResult.lost, + onLostChange = { viewModel.setPlayResult(playResult.copy(lost = it)) }, + ) + + PlayResultEditorMaxRecallField( + maxRecall = playResult.maxRecall, + onMaxRecallChange = { viewModel.setPlayResult(playResult.copy(maxRecall = it)) }, + ) + + PlayResultEditorClearTypeField( + clearType = playResult.clearType, + onClearTypeChange = { viewModel.setPlayResult(playResult.copy(clearType = it)) }, + ) + + PlayResultEditorModifierField( + arcaeaModifier = playResult.modifier, + onArcaeaModifierChange = { viewModel.setPlayResult(playResult.copy(modifier = it)) }, + ) + + PlayResultEditorDateTimeField( + instant = playResult.date, + onInstantChange = { viewModel.setPlayResult(playResult.copy(date = it)) }, + ) + + PlayResultEditorCommentField( + comment = playResult.comment, + onCommentChange = { viewModel.setPlayResult(playResult.copy(comment = it)) }, + ) + } } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultViewModel.kt index cd7c4649..5cba9fba 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/addplayresult/DatabaseAddPlayResultViewModel.kt @@ -3,61 +3,85 @@ package xyz.sevive.arcaeaoffline.ui.screens.database.addplayresult import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultClearType +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaPlayResultModifier +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode -import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.core.database.r30.ChartKey import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent +import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds +@OptIn(ExperimentalCoroutinesApi::class) class DatabaseAddPlayResultViewModel( + private val difficultyWithSongRepo: DifficultyWithSongRepository, private val chartInfoRepo: ChartInfoRepository, private val playResultRepo: PlayResultRepository, - private val propertyRepo: PropertyRepository, + propertyRepo: PropertyRepository, ) : ViewModel() { data class UiState( - val difficulty: Difficulty? = null, + val chart: DifficultyWithSong? = null, + val chartInfo: ChartInfo? = null, val playResult: PlayResult? = null, + val scoringMode: ArcaeaScoringMode = ArcaeaScoringMode.B50, val warnings: List = emptyList(), + val keepChartAfterSave: Boolean = false, ) - private val _difficulty = MutableStateFlow(null) - val difficulty = _difficulty.asStateFlow() + private val selectedChart = MutableStateFlow(null) + private val editingPlayResult = MutableStateFlow(null) + private val keepChartAfterSave = MutableStateFlow(false) - private val _playResult = MutableStateFlow(null) - val playResult = _playResult.asStateFlow() + private val eventChannel = UiEventChannel() + val events = eventChannel.events - private suspend fun getPlayResultWarnings( - playResult: PlayResult?, - scoringMode: ArcaeaScoringMode, - ): List { - if (playResult == null) return emptyList() + private val chart: Flow = + selectedChart.flatMapLatest { key -> + key?.let { difficultyWithSongRepo.find(it.songId, it.ratingClass) } ?: flowOf(null) + } - val chartInfo = chartInfoRepo.find(playResult).firstOrNull() - return ArcaeaPlayResultValidator.validate(playResult, chartInfo, scoringMode) - } + private val chartInfo: Flow = + selectedChart.flatMapLatest { key -> + key?.let { chartInfoRepo.find(it.songId, it.ratingClass) } ?: flowOf(null) + } - val uiState = + val uiState: StateFlow = combine( - difficulty, - playResult, + chart, + chartInfo, + editingPlayResult, propertyRepo.scoringMode(), - ) { difficulty, playResult, scoringMode -> + keepChartAfterSave, + ) { chart, chartInfo, playResult, scoringMode, keepChartAfterSave -> UiState( - difficulty = difficulty, + chart = chart, + chartInfo = chartInfo, playResult = playResult, - warnings = getPlayResultWarnings(playResult, scoringMode), + scoringMode = scoringMode, + warnings = playResult?.let { ArcaeaPlayResultValidator.validate(it, chartInfo, scoringMode) } ?: emptyList(), + keepChartAfterSave = keepChartAfterSave, ) }.stateIn( viewModelScope, @@ -65,41 +89,64 @@ class DatabaseAddPlayResultViewModel( UiState(), ) - fun setDifficulty(difficulty: Difficulty?) { - _difficulty.value = difficulty - initPlayResult() + private fun freshPlayResult(key: ChartKey) = + PlayResult( + songId = key.songId, + ratingClass = key.ratingClass, + score = 0, + pure = 0, + far = 0, + lost = 0, + date = Clock.System.now(), + maxRecall = 0, + modifier = ArcaeaPlayResultModifier.NORMAL, + clearType = ArcaeaPlayResultClearType.NORMAL_CLEAR, + comment = null, + ) + + fun selectChart( + songId: String, + ratingClass: ArcaeaRatingClass, + ) { + val key = ChartKey(songId, ratingClass) + if (selectedChart.value == key) return + + selectedChart.value = key + editingPlayResult.value = + editingPlayResult.value?.copy(songId = key.songId, ratingClass = key.ratingClass) + ?: freshPlayResult(key) } - private fun initPlayResult() { - setPlayResult( - _difficulty.value?.let { - _playResult.value?.copy(songId = it.songId, ratingClass = it.ratingClass) - ?: PlayResult(songId = it.songId, ratingClass = it.ratingClass, score = 0) - }, - ) + fun setPlayResult(playResult: PlayResult) { + editingPlayResult.value = playResult } - fun setPlayResult(playResult: PlayResult?) { - _playResult.value = playResult + fun setKeepChartAfterSave(keep: Boolean) { + keepChartAfterSave.value = keep } fun reset() { - _difficulty.value = null - _playResult.value = null + selectedChart.value = null + editingPlayResult.value = null } - private var savePlayResultJob: Job? = null + private var saveJob: Job? = null fun savePlayResult() { - savePlayResultJob?.cancel() - if (playResult.value == null) return + val playResult = editingPlayResult.value ?: return - savePlayResultJob = + saveJob?.cancel() + saveJob = viewModelScope.launch(Dispatchers.IO) { - playResult.value?.let { - playResultRepo.upsert(it) + playResultRepo.upsert(playResult) + + if (keepChartAfterSave.value) { + editingPlayResult.value = selectedChart.value?.let(::freshPlayResult) + } else { reset() } + + eventChannel.send(UiEvent.StringRes(R.string.database_add_play_result_saved)) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageDownload.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageDownload.kt deleted file mode 100644 index 5c592e55..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageDownload.kt +++ /dev/null @@ -1,50 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.manage - -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.api.DownloadableResource -import xyz.sevive.arcaeaoffline.core.api.RemoteResourcesInfoUiState -import xyz.sevive.arcaeaoffline.ui.components.resources.RemoteResourceDownloadItem - -@Composable -fun DatabaseManageDownload( - remoteResourcesInfoState: RemoteResourcesInfoUiState, - downloadingResources: Set, - downloadErrorTexts: Map, - onDownloadPacklist: () -> Unit, - onDownloadSonglist: () -> Unit, - onDownloadChartInfoDatabase: () -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier) { - RemoteResourceDownloadItem( - resource = DownloadableResource.PACKLIST, - infoState = remoteResourcesInfoState, - isDownloading = DownloadableResource.PACKLIST in downloadingResources, - downloadErrorText = downloadErrorTexts[DownloadableResource.PACKLIST], - title = stringResource(R.string.database_manage_download_packlist), - onDownload = onDownloadPacklist, - ) - - RemoteResourceDownloadItem( - resource = DownloadableResource.SONGLIST, - infoState = remoteResourcesInfoState, - isDownloading = DownloadableResource.SONGLIST in downloadingResources, - downloadErrorText = downloadErrorTexts[DownloadableResource.SONGLIST], - title = stringResource(R.string.database_manage_download_songlist), - onDownload = onDownloadSonglist, - ) - - RemoteResourceDownloadItem( - resource = DownloadableResource.CHART_INFO_DATABASE, - infoState = remoteResourcesInfoState, - isDownloading = DownloadableResource.CHART_INFO_DATABASE in downloadingResources, - downloadErrorText = downloadErrorTexts[DownloadableResource.CHART_INFO_DATABASE], - title = stringResource(R.string.database_manage_download_chart_info_database), - onDownload = onDownloadChartInfoDatabase, - ) - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageExport.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageExport.kt deleted file mode 100644 index ccfb208e..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageExport.kt +++ /dev/null @@ -1,39 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.manage - -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Column -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.ui.components.TextItem -import kotlin.time.Clock - -private class CreateJsonDocument : ActivityResultContracts.CreateDocument("application/json") - -@Composable -fun DatabaseManageExport( - onExportPlayResults: (Uri) -> Unit, - modifier: Modifier = Modifier, -) { - val exportPlayResultsHandler = - rememberLauncherForActivityResult(CreateJsonDocument()) { - it?.let(onExportPlayResults) - } - - Column(modifier) { - TextItem( - onClick = { - exportPlayResultsHandler.launch( - "arcaea-offline-data-exchange-${Clock.System.now().toEpochMilliseconds()}", - ) - }, - title = stringResource(R.string.database_manage_export_play_results), - leadingIcon = Icons.Default.UploadFile, - ) - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageImport.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageImport.kt deleted file mode 100644 index c216a632..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageImport.kt +++ /dev/null @@ -1,126 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.manage - -import android.net.Uri -import androidx.compose.foundation.layout.Column -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Android -import androidx.compose.material.icons.filled.DataObject -import androidx.compose.material.icons.filled.FileOpen -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.rememberFileChooserLauncher -import xyz.sevive.arcaeaoffline.ui.components.ArcaeaAppIcon -import xyz.sevive.arcaeaoffline.ui.components.TextItem - -@Composable -private fun joinImportEntries(entries: List): String = remember(entries) { entries.joinToString(" · ") } - -@Composable -fun DatabaseManageImport( - onImportPacklist: (Uri) -> Unit, - onImportSonglist: (Uri) -> Unit, - onImportArcaeaApk: (Uri) -> Unit, - canImportLists: Boolean, - onImportFromInstalledArcaea: () -> Unit, - onImportChartInfoDatabase: (Uri) -> Unit, - onImportSt3: (Uri) -> Unit, - modifier: Modifier = Modifier, -) { - val importPacklistLauncher = rememberFileChooserLauncher { it?.let(onImportPacklist) } - val importSonglistLauncher = rememberFileChooserLauncher { it?.let(onImportSonglist) } - val importArcaeaApkLauncher = rememberFileChooserLauncher { it?.let(onImportArcaeaApk) } - val importChartInfoDatabaseLauncher = - rememberFileChooserLauncher { it?.let(onImportChartInfoDatabase) } - val importSt3Launcher = rememberFileChooserLauncher { it?.let(onImportSt3) } - - val descPackEntries = stringResource(R.string.database_manage_import_pack_entries) - val descPackLocalizedEntries = - stringResource(R.string.database_manage_import_pack_localized_entries) - val descSongEntries = stringResource(R.string.database_manage_import_song_entries) - val descSongLocalizedEntries = - stringResource(R.string.database_manage_import_song_localized_entries) - val descDifficultyEntries = stringResource(R.string.database_manage_import_difficulty_entries) - val descDifficultyLocalizedEntries = - stringResource(R.string.database_manage_import_difficulty_localized_entries) - val descChartInfoEntries = stringResource(R.string.database_manage_import_chart_info_entries) - val descPlayResultEntries = stringResource(R.string.database_manage_import_play_result_entries) - - val descPacklist = joinImportEntries(listOf(descPackEntries, descPackLocalizedEntries)) - val descSonglist = - joinImportEntries( - listOf( - descSongEntries, - descSongLocalizedEntries, - descDifficultyEntries, - descDifficultyLocalizedEntries, - ), - ) - val descArcaeaApk = - joinImportEntries( - listOf( - descPackEntries, - descPackLocalizedEntries, - descSongEntries, - descSongLocalizedEntries, - descDifficultyEntries, - descDifficultyLocalizedEntries, - ), - ) - - Column(modifier) { - TextItem( - onClick = { importPacklistLauncher.launch("*/*") }, - title = stringResource(R.string.database_manage_import_packlist), - content = descPacklist, - leadingIcon = Icons.Default.DataObject, - ) - - TextItem( - onClick = { importSonglistLauncher.launch("*/*") }, - title = stringResource(R.string.database_manage_import_songlist), - content = descSonglist, - leadingIcon = Icons.Default.DataObject, - ) - - TextItem( - onClick = { importArcaeaApkLauncher.launch("*/*") }, - title = stringResource(R.string.database_manage_import_from_arcaea_apk), - content = descArcaeaApk, - leadingIcon = Icons.Default.Android, - ) - - if (canImportLists) { - TextItem( - onClick = onImportFromInstalledArcaea, - title = stringResource(R.string.database_manage_import_from_arcaea_installed), - content = descArcaeaApk, - leadingSlot = { ArcaeaAppIcon() }, - ) - } else { - TextItem( - title = stringResource(R.string.arcaea_button_resource_unavailable), - leadingSlot = { ArcaeaAppIcon(forceDisabled = true) }, - enabled = false, - ) - } - - TextItem( - onClick = { importChartInfoDatabaseLauncher.launch("*/*") }, - title = stringResource(R.string.database_manage_import_chart_info_database), - content = joinImportEntries(listOf(descChartInfoEntries)), - leadingIcon = ImageVector.vectorResource(R.drawable.ic_database), - ) - - TextItem( - onClick = { importSt3Launcher.launch("*/*") }, - title = stringResource(R.string.arcaea_st3), - content = joinImportEntries(listOf(descPlayResultEntries)), - leadingIcon = Icons.Default.FileOpen, - ) - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt deleted file mode 100644 index 9db3f0ac..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageScreen.kt +++ /dev/null @@ -1,186 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.manage - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.PendingActions -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.koin.compose.viewmodel.koinViewModel -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode -import xyz.sevive.arcaeaoffline.ui.SubScreenContainer -import xyz.sevive.arcaeaoffline.ui.components.IconRow -import xyz.sevive.arcaeaoffline.ui.components.SelectItem -import xyz.sevive.arcaeaoffline.ui.components.SelectOption -import xyz.sevive.arcaeaoffline.ui.components.SettingsGroupHeader -import xyz.sevive.arcaeaoffline.ui.components.TextItem - -@Composable -fun DatabaseManageScreen( - modifier: Modifier = Modifier, - viewModel: DatabaseManageViewModel = koinViewModel(), -) { - val context = LocalContext.current - - val canImportLists by viewModel.canImportLists.collectAsStateWithLifecycle() - - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - var showLogsSheet by rememberSaveable { mutableStateOf(false) } - if (showLogsSheet) { - ImportLogBottomSheet( - onDismissRequest = { showLogsSheet = false }, - logs = uiState.logs, - ) - } - - SubScreenContainer( - title = stringResource(R.string.database_manage_title), - actions = { - Box(contentAlignment = Alignment.Center) { - IconButton(onClick = { showLogsSheet = true }) { - Icon(Icons.Default.PendingActions, contentDescription = null) - } - - if (uiState.isWorking) { - CircularProgressIndicator() - } - } - }, - ) { - LazyColumn(modifier) { - item { - SettingsGroupHeader { - IconRow { - Icon(Icons.Default.Tune, contentDescription = null) - Text(stringResource(R.string.database_manage_scoring_mode_title)) - } - } - } - - item { - val scoringMode by viewModel.scoringMode.collectAsStateWithLifecycle() - - SelectItem( - options = - listOf( - SelectOption( - value = ArcaeaScoringMode.B30_R10, - label = stringResource(R.string.database_manage_scoring_mode_b30_r10), - description = ArcaeaScoringMode.B30_R10.key.toString(), - ), - SelectOption( - value = ArcaeaScoringMode.B50, - label = stringResource(R.string.database_manage_scoring_mode_b50), - description = ArcaeaScoringMode.B50.key.toString(), - ), - ), - selected = scoringMode, - onSelect = viewModel::setScoringMode, - Modifier.fillMaxWidth(), - ) - } - - item { - SettingsGroupHeader { - IconRow { - Icon(Icons.Default.Download, contentDescription = null) - Text(stringResource(R.string.database_manage_import_title)) - } - } - } - - item { - DatabaseManageImport( - onImportPacklist = { viewModel.importPacklist(it, context) }, - onImportSonglist = { viewModel.importSonglist(it, context) }, - onImportArcaeaApk = { viewModel.importArcaeaApkFromSelected(it, context) }, - canImportLists = canImportLists, - onImportFromInstalledArcaea = { viewModel.importArcaeaApkFromInstalled(context) }, - onImportChartInfoDatabase = { viewModel.importChartsInfoDatabase(it, context) }, - onImportSt3 = { viewModel.importSt3(it, context) }, - Modifier.fillMaxWidth(), - ) - } - - item { - SettingsGroupHeader { - IconRow { - Icon(Icons.Default.CloudDownload, contentDescription = null) - Text(stringResource(R.string.database_manage_download_title)) - } - } - } - - item { - val isFetchingRemoteInfo = uiState.remoteResourcesInfoState.isFetching - TextItem( - onClick = { viewModel.refreshRemoteResourcesInfo() }, - enabled = !isFetchingRemoteInfo, - leadingSlot = { - if (isFetchingRemoteInfo) { - CircularProgressIndicator(Modifier.size(24.dp)) - } else { - Icon( - Icons.Default.Refresh, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - } - }, - title = stringResource(R.string.database_manage_refresh_remote_info), - ) - } - - item { - DatabaseManageDownload( - remoteResourcesInfoState = uiState.remoteResourcesInfoState, - downloadingResources = uiState.downloadingResources, - downloadErrorTexts = uiState.downloadErrorTexts, - onDownloadPacklist = { viewModel.downloadPacklist() }, - onDownloadSonglist = { viewModel.downloadSonglist(context) }, - onDownloadChartInfoDatabase = { viewModel.downloadChartInfoDatabase(context) }, - Modifier.fillMaxWidth(), - ) - } - - item { - SettingsGroupHeader { - IconRow { - Icon(Icons.Default.Upload, contentDescription = null) - Text(stringResource(R.string.database_manage_export_title)) - } - } - } - - item { - DatabaseManageExport( - onExportPlayResults = { viewModel.exportPlayResults(it, context) }, - Modifier.fillMaxWidth(), - ) - } - } - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playdata/DatabasePlayDataScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playdata/DatabasePlayDataScreen.kt new file mode 100644 index 00000000..115da608 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playdata/DatabasePlayDataScreen.kt @@ -0,0 +1,127 @@ +package xyz.sevive.arcaeaoffline.ui.screens.database.playdata + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CopyAll +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.PendingActions +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.androidx.compose.koinViewModel +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.rememberFileChooserLauncher +import xyz.sevive.arcaeaoffline.ui.SubScreenContainer +import xyz.sevive.arcaeaoffline.ui.components.IconRow +import xyz.sevive.arcaeaoffline.ui.components.ImportLogBottomSheet +import xyz.sevive.arcaeaoffline.ui.components.SettingsGroupHeader +import xyz.sevive.arcaeaoffline.ui.components.TextItem +import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen +import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext +import xyz.sevive.arcaeaoffline.ui.screens.NavEntryNavigateButton +import kotlin.time.Clock + +private class CreateJsonDocument : ActivityResultContracts.CreateDocument("application/json") + +@Composable +fun DatabasePlayDataScreen( + modifier: Modifier = Modifier, + viewModel: DatabasePlayDataViewModel = koinViewModel(), +) { + val context = LocalContext.current + val navContext = LocalListDetailNavigationContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var showLogsSheet by rememberSaveable { mutableStateOf(false) } + if (showLogsSheet) { + ImportLogBottomSheet( + onDismissRequest = { showLogsSheet = false }, + logs = uiState.logs, + ) + } + + val importSt3Launcher = rememberFileChooserLauncher { it?.let { viewModel.importSt3(it, context) } } + val exportPlayResultsLauncher = + rememberLauncherForActivityResult(CreateJsonDocument()) { uri -> + uri?.let { viewModel.exportPlayResults(it, context) } + } + + SubScreenContainer( + modifier = modifier, + title = stringResource(R.string.database_play_data_title), + actions = { + Box(contentAlignment = Alignment.Center) { + IconButton(onClick = { showLogsSheet = true }) { + Icon(Icons.Default.PendingActions, contentDescription = null) + } + + if (uiState.isWorking) { + CircularProgressIndicator() + } + } + }, + ) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + SettingsGroupHeader { + IconRow { + Icon(Icons.Default.Download, contentDescription = null) + Text(stringResource(R.string.database_manage_import_title)) + } + } + + TextItem( + onClick = { importSt3Launcher.launch("*/*") }, + title = stringResource(R.string.arcaea_st3), + leadingIcon = Icons.Default.FileOpen, + ) + + SettingsGroupHeader { + IconRow { + Icon(Icons.Default.Upload, contentDescription = null) + Text(stringResource(R.string.database_manage_export_title)) + } + } + + TextItem( + onClick = { + exportPlayResultsLauncher.launch( + "arcaea-offline-data-exchange-${Clock.System.now().toEpochMilliseconds()}", + ) + }, + title = stringResource(R.string.database_manage_export_play_results), + leadingIcon = Icons.Default.UploadFile, + ) + + NavEntryNavigateButton( + titleResId = DatabaseSubScreen.Deduplicator.title, + icon = Icons.Default.CopyAll, + ) { + navContext.navigateToDetail(DatabaseSubScreen.Deduplicator.route) + } + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playdata/DatabasePlayDataViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playdata/DatabasePlayDataViewModel.kt new file mode 100644 index 00000000..aa974273 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playdata/DatabasePlayDataViewModel.kt @@ -0,0 +1,151 @@ +package xyz.sevive.arcaeaoffline.ui.screens.database.playdata + +import android.content.Context +import android.net.Uri +import android.text.format.Formatter +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.driver.bundled.SQLITE_OPEN_READONLY +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.io.files.SystemFileSystem +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.api.ArcaeaResourcesApiClient +import xyz.sevive.arcaeaoffline.core.database.externals.exporters.ArcaeaOfflineDEFv2Exporter +import xyz.sevive.arcaeaoffline.core.database.externals.importers.ArcaeaSt3PlayResultImporter +import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository +import xyz.sevive.arcaeaoffline.data.imports.ImportLogEvent +import xyz.sevive.arcaeaoffline.data.imports.ImportLogManager +import xyz.sevive.arcaeaoffline.data.imports.ImportLogObject +import xyz.sevive.arcaeaoffline.data.imports.ImportTaskQueue +import xyz.sevive.arcaeaoffline.helpers.context.copyToCache +import xyz.sevive.arcaeaoffline.helpers.context.uriSizeIfTooLarge +import java.io.OutputStream +import kotlin.time.Duration.Companion.seconds + +class DatabasePlayDataViewModel( + private val playResultRepo: PlayResultRepository, + private val importLogManager: ImportLogManager, +) : ViewModel() { + companion object { + private const val LOG_TAG = "DatabasePlayDataVM" + + private const val LOG_TAG_IMPORT_ST3 = "I-St3" + private const val LOG_TAG_EXPORT_PLAY_RESULTS = "E-PR" + } + + internal data class UiState( + val isWorking: Boolean = false, + val logs: List = emptyList(), + ) + + private val logger = Logger.withTag(LOG_TAG) + + private val taskQueue = + ImportTaskQueue( + parentScope = viewModelScope, + importLogManager = importLogManager, + logger = logger, + ) + + internal val uiState = + combine( + taskQueue.isWorking, + importLogManager.logs, + ) { isWorking, logs -> + UiState( + isWorking = isWorking, + logs = logs.sortedByDescending { it.timestamp }, + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + UiState(), + ) + + private suspend fun sendTask(action: suspend CoroutineScope.() -> Unit) { + taskQueue.send(action) + } + + /** + * Returns true when [uri] exceeds the import size cap, appending a log entry under [logTag]; + * the caller returns early on true. + */ + private suspend fun Context.importFileTooLarge( + uri: Uri, + logTag: String, + ): Boolean { + val actualSize = uriSizeIfTooLarge(uri) ?: return false + importLogManager.append( + logTag, + ImportLogEvent.Raw( + "input file too large, limit is ${Formatter.formatFileSize(this, ArcaeaResourcesApiClient.DEFAULT_MAX_RESOURCE_BYTES)} " + + "while input is ${Formatter.formatFileSize(this, actualSize)}!", + ), + ) + return true + } + + private suspend fun importSt3(conn: SQLiteConnection) { + val playResults = ArcaeaSt3PlayResultImporter.playResults(conn) + val affectedRows = + playResultRepo.upsertBatch(*playResults.toTypedArray()).size + + importLogManager.append( + LOG_TAG_IMPORT_ST3, + ImportLogEvent.Plural(R.plurals.database_play_results_imported, affectedRows), + ) + } + + fun importSt3( + fileUri: Uri, + context: Context, + ) { + viewModelScope.launch(Dispatchers.IO) { + sendTask { + if (context.importFileTooLarge(fileUri, LOG_TAG_IMPORT_ST3)) return@sendTask + + val dbCacheFile = context.copyToCache(fileUri, "st3-import-temp") ?: return@sendTask + + BundledSQLiteDriver() + .open(dbCacheFile.toString(), SQLITE_OPEN_READONLY) + .use { conn -> importSt3(conn) } + + SystemFileSystem.delete(dbCacheFile) + } + } + } + + private suspend fun exportPlayResults(outputStream: OutputStream) { + val playResults = playResultRepo.findAll().firstOrNull() ?: return + + ArcaeaOfflineDEFv2Exporter.playResultsRoot(playResults).let { + outputStream.write(ArcaeaOfflineDEFv2Exporter.playResults(it).toByteArray()) + importLogManager.append( + LOG_TAG_EXPORT_PLAY_RESULTS, + ImportLogEvent.Plural(R.plurals.database_play_results_exported, it.playResults.size), + ) + } + } + + fun exportPlayResults( + uri: Uri, + context: Context, + ) { + viewModelScope.launch(Dispatchers.IO) { + sendTask { + context.contentResolver.openOutputStream(uri)?.use { + exportPlayResults(it) + } + } + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListScreen.kt index 79fe94e5..9c8b0d5c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListScreen.kt @@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -27,7 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusTarget -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -37,6 +41,8 @@ import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.components.ArcaeaSearchTextField import xyz.sevive.arcaeaoffline.ui.components.LoadingOverlay +import xyz.sevive.arcaeaoffline.ui.navigation.DatabaseSubScreen +import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext import xyz.sevive.arcaeaoffline.ui.screens.EmptyScreen import xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist.components.DatabasePlayResultFilterChips import xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist.components.DatabasePlayResultFilterDialog @@ -68,12 +74,18 @@ private fun DatabasePlayResultSearchBar( @Composable fun DatabasePlayResultListScreen(viewModel: DatabasePlayResultListViewModel = koinViewModel()) { - val context = LocalContext.current - val uiState by viewModel.uiState.collectAsStateWithLifecycle() val catalog by viewModel.catalog.collectAsStateWithLifecycle() val selectedItemUuids by viewModel.selectedItemUuids.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + val resources = LocalResources.current + val navContext = LocalListDetailNavigationContext.current + + LaunchedEffect(viewModel) { + viewModel.events.collect { event -> + snackbarHostState.showSnackbar(event.asString(resources), withDismissAction = true) + } + } val listItems = uiState.listItems @@ -147,6 +159,11 @@ fun DatabasePlayResultListScreen(viewModel: DatabasePlayResultListViewModel = ko onClearSelectedItems = { viewModel.clearSelectedItems() }, ) }, + floatingActionButton = { + FloatingActionButton(onClick = { navContext.navigateToDetail(DatabaseSubScreen.AddPlayResult.route) }) { + Icon(Icons.Default.Add, contentDescription = stringResource(DatabaseSubScreen.AddPlayResult.title)) + } + }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { // The adaptive pane scaffold moves focus onto the destination pane when it changes, and @@ -220,7 +237,10 @@ fun DatabasePlayResultListScreen(viewModel: DatabasePlayResultListViewModel = ko ) { when { uiState.totalCount == 0 -> { - EmptyScreen(Modifier.fillMaxSize()) + EmptyScreen( + Modifier.fillMaxSize(), + text = stringResource(R.string.database_no_play_results), + ) } listItems.isEmpty() -> { @@ -248,13 +268,7 @@ fun DatabasePlayResultListScreen(viewModel: DatabasePlayResultListViewModel = ko DatabasePlayResultListItem( item = it, - onPlayResultChange = { newPlayResult -> - viewModel.updatePlayResult( - playResult = newPlayResult, - context = context, - snackbarHostState = snackbarHostState, - ) - }, + onPlayResultChange = viewModel::updatePlayResult, inSelectMode = isInSelectMode, selected = isSelected, onSelectedChange = { selected -> diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt index b781986a..220a1d49 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/playresultlist/DatabasePlayResultListViewModel.kt @@ -1,7 +1,5 @@ package xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist -import android.content.Context -import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -34,6 +32,8 @@ import xyz.sevive.arcaeaoffline.core.search.ArcaeaCatalog import xyz.sevive.arcaeaoffline.core.search.ArcaeaCatalogStateHolder import xyz.sevive.arcaeaoffline.core.search.SearchEngine import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder +import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent +import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.uuid.Uuid @@ -66,6 +66,9 @@ class DatabasePlayResultListViewModel( private val filter = MutableStateFlow(PlayResultFilter()) + private val eventChannel = UiEventChannel() + val events = eventChannel.events + /** * [filter] with the text query cleared. The list and facet computations * read the query only through [songSearchState], so this keeps a @@ -291,24 +294,15 @@ class DatabasePlayResultListViewModel( selectedItemUuids.value = emptySet() } - fun updatePlayResult( - playResult: PlayResult, - context: Context? = null, - snackbarHostState: SnackbarHostState? = null, - ) { + fun updatePlayResult(playResult: PlayResult) { viewModelScope.launch(Dispatchers.IO) { playResultRepo.upsert(playResult) - - if (context != null && snackbarHostState != null) { - snackbarHostState.showSnackbar( - message = - context.getString( - R.string.database_play_result_updated, - "(${playResult.songId}, ${playResult.uuid})", - ), - withDismissAction = true, - ) - } + eventChannel.send( + UiEvent.StringRes( + R.string.database_play_result_updated, + listOf("(${playResult.songId}, ${playResult.uuid})"), + ), + ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt index 8800bc8c..882adb2a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt @@ -8,13 +8,13 @@ import xyz.sevive.arcaeaoffline.ui.screens.ocr.performance.OcrPerformanceScreen import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreen @Composable -fun OcrEntryScreen() = +fun OcrEntryScreen(onNavigateToResources: () -> Unit) = AdaptiveEntryScreen( listPane = { OcrNavEntry() }, detailPane = { route -> when (route) { OcrSubScreen.Dependencies.route -> OcrDependenciesScreen() - OcrSubScreen.Queue.route -> OcrQueueScreen() + OcrSubScreen.Queue.route -> OcrQueueScreen(onNavigateToResources = onNavigateToResources) OcrSubScreen.Performance.route -> OcrPerformanceScreen() } }, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt index 66f7baee..6700382f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt @@ -5,7 +5,9 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Api +import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Queue import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.ExperimentalMaterial3Api @@ -20,6 +22,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.ImageHashesDatabaseStatusDetail +import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusViewer import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext @@ -28,6 +33,14 @@ import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen import xyz.sevive.arcaeaoffline.ui.screens.NavEntryNavigateButton import xyz.sevive.arcaeaoffline.ui.screens.ocr.dependencies.OcrDependenciesScreenViewModel +/** + * Whether the image hashes database needs setup: absent, failed to load, or holding no hashes. + * + * Null counts mean the detail has not loaded yet, not an empty database. + */ +private fun ImageHashesDatabaseStatusDetail.needsSetup(): Boolean = + absence || exception != null || jacketCount == 0 || partnerIconCount == 0 + @OptIn(ExperimentalMaterial3Api::class) @Composable fun OcrNavEntry(modifier: Modifier = Modifier) { @@ -53,6 +66,16 @@ fun OcrNavEntry(modifier: Modifier = Modifier) { Column { OcrDependencyImageHashesDatabaseStatusViewer(imageHashesDatabaseUiState) OcrDependencyCrnnModelStatusViewer(crnnModelUiState) + + if (imageHashesDatabaseUiState.statusDetail.needsSetup()) { + TextItem( + title = stringResource(R.string.ocr_guide_image_hashes_database_title), + content = stringResource(R.string.ocr_guide_image_hashes_database_content), + leadingIcon = Icons.Default.CloudDownload, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = { navContext.navigateToDetail(OcrSubScreen.Dependencies.route) }, + ) + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreen.kt index 597e4e74..5a339cdd 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreen.kt @@ -36,6 +36,7 @@ import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.dialogs.toAndroidUri +import kotlinx.coroutines.flow.merge import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.helpers.context.persistUriPermissions @@ -50,6 +51,7 @@ import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.staging.OcrQueueStagingView @OptIn(ExperimentalMaterial3Api::class) @Composable fun OcrQueueScreen( + onNavigateToResources: () -> Unit, screenViewModel: OcrQueueScreenViewModel = koinViewModel(), stagingViewModel: OcrQueueStagingViewModel = koinViewModel(), ) { @@ -63,6 +65,8 @@ fun OcrQueueScreen( val taskUiItems by screenViewModel.currentScreenUiItems.collectAsStateWithLifecycle() val isTaskUiItemsLoading by screenViewModel.isTaskUiItemsLoading.collectAsStateWithLifecycle() + val readinessSummary by screenViewModel.readinessSummary.collectAsStateWithLifecycle() + val category by screenViewModel.currentScreenCategory.collectAsStateWithLifecycle() val categoryBackButtonEnabled by remember { derivedStateOf { category != OcrQueueScreenCategory.NULL } @@ -71,9 +75,9 @@ fun OcrQueueScreen( val snackbarHostState = remember { SnackbarHostState() } val stagingUiState by stagingViewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(stagingViewModel, lifecycleOwner.lifecycle) { + LaunchedEffect(stagingViewModel, screenViewModel, lifecycleOwner.lifecycle) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - stagingViewModel.events.collect { event -> + merge(stagingViewModel.events, screenViewModel.events).collect { event -> val message = event.asString(resources) snackbarHostState.showSnackbar(message, withDismissAction = true) } @@ -172,6 +176,8 @@ fun OcrQueueScreen( taskCounts = queueTaskCounts, taskUiItems = taskUiItems, taskUiItemsLoading = isTaskUiItemsLoading, + readinessSummary = readinessSummary, + onNavigateToResources = onNavigateToResources, onSaveTask = { screenViewModel.saveTaskPlayResult(it) }, onDeleteTask = { screenViewModel.deleteTask(it) }, onEditChart = { id, c -> screenViewModel.modifyTaskChart(id, c) }, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenCategorySubScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenCategorySubScreen.kt index 6e2847e9..a0a7106c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenCategorySubScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenCategorySubScreen.kt @@ -10,9 +10,11 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.HourglassBottom import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.SaveAlt @@ -22,11 +24,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadiness +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadinessSummary import xyz.sevive.arcaeaoffline.ui.components.LoadingOverlay +import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.screens.EmptyScreen import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.tasklist.OcrQueueTaskList import xyz.sevive.arcaeaoffline.ui.theme.extendedColorScheme @@ -44,6 +50,8 @@ internal enum class OcrQueueScreenCategory { private fun NavigationSubScreen( onSwitchScreen: (OcrQueueScreenCategory) -> Unit, taskCounts: OcrQueueScreenViewModel.QueueTaskCounts, + readinessSummary: DatabaseReadinessSummary?, + onNavigateToResources: () -> Unit, onSaveAllTasks: () -> Unit, onStartSmartFix: () -> Unit, modifier: Modifier = Modifier, @@ -106,6 +114,41 @@ private fun NavigationSubScreen( tint = errorColor(), ) } + + readinessSummary?.let { summary -> + when (summary.readiness) { + DatabaseReadiness.EMPTY, DatabaseReadiness.SONGS_ONLY -> { + item { + TextItem( + title = stringResource(R.string.ocr_guide_database_title), + content = stringResource(R.string.ocr_guide_database_content), + leadingIcon = Icons.Default.CloudDownload, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = onNavigateToResources, + ) + } + } + + DatabaseReadiness.PARTIAL -> { + item { + TextItem( + title = + pluralStringResource( + R.plurals.resources_missing_chart_info, + summary.missingChartInfoCount, + summary.missingChartInfoCount, + ), + content = stringResource(R.string.ocr_guide_chart_info_partial_content), + leadingIcon = Icons.Default.CloudDownload, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = onNavigateToResources, + ) + } + } + + DatabaseReadiness.READY -> {} + } + } } } @@ -139,6 +182,8 @@ internal fun OcrQueueScreenCategorySubScreen( taskUiItems: List, taskUiItemsLoading: Boolean, taskCounts: OcrQueueScreenViewModel.QueueTaskCounts, + readinessSummary: DatabaseReadinessSummary?, + onNavigateToResources: () -> Unit, onSaveTask: (Long) -> Unit, onSaveAllTasks: () -> Unit, onStartSmartFix: () -> Unit, @@ -168,6 +213,8 @@ internal fun OcrQueueScreenCategorySubScreen( NavigationSubScreen( onSwitchScreen = { newCategory -> onCategoryChange(newCategory) }, taskCounts = taskCounts, + readinessSummary = readinessSummary, + onNavigateToResources = onNavigateToResources, onSaveAllTasks = onSaveAllTasks, onStartSmartFix = onStartSmartFix, modifier = modifier, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt index 4ca28470..e56e72b8 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/OcrQueueScreenViewModel.kt @@ -8,6 +8,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.workDataOf +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -19,10 +20,15 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.io.files.SystemFileSystem +import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadinessRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyWithSongRepository +import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepositoryImpl @@ -31,6 +37,8 @@ import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesSerializer import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.jobs.OcrQueueProcessingJob import xyz.sevive.arcaeaoffline.ui.helpers.UiDisplayChartCacheHolder +import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent +import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) @@ -38,6 +46,7 @@ class OcrQueueScreenViewModel( context: Context, private val difficultyWithSongRepo: DifficultyWithSongRepository, private val chartInfoRepo: ChartInfoRepository, + databaseReadinessRepo: DatabaseReadinessRepository, private val ocrQueueTaskRepo: OcrQueueTaskRepositoryImpl, preferencesRepository: OcrQueuePreferencesRepository, ) : ViewModel() { @@ -47,6 +56,9 @@ class OcrQueueScreenViewModel( private val workManager = WorkManager.getInstance(context.applicationContext) + private val eventChannel = UiEventChannel() + val events = eventChannel.events + private val ocrQueuePreferences = preferencesRepository.preferencesFlow.stateIn( viewModelScope, @@ -195,6 +207,14 @@ class OcrQueueScreenViewModel( it.isNotEmpty() && it[0].state == androidx.work.WorkInfo.State.RUNNING }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + /** Database readiness for the hint rows; null while the first load is in flight. */ + val readinessSummary = + databaseReadinessRepo.summary().stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + null, + ) + fun deleteTask(taskId: Long) { if (queueRunning.value) return @@ -252,20 +272,33 @@ class OcrQueueScreenViewModel( } fun startQueue(runMode: OcrQueueProcessingJob.RunMode = OcrQueueProcessingJob.RunMode.NORMAL) { - val workRequest = - OneTimeWorkRequestBuilder() - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .setInputData( - workDataOf( - OcrQueueProcessingJob.DATA_RUN_MODE to runMode.value, - OcrQueueProcessingJob.DATA_PARALLEL_COUNT to ocrQueuePreferences.value.parallelCount, - ), - ).build() - - workManager.enqueueUniqueWork( - OcrQueueProcessingJob.WORK_NAME, - ExistingWorkPolicy.KEEP, - workRequest, - ) + viewModelScope.launch { + // NORMAL and ALL needs the hashes database before processing any task + if (runMode != OcrQueueProcessingJob.RunMode.SMART_FIX && !imageHashesDatabaseFileExists()) { + eventChannel.send(UiEvent.StringRes(R.string.ocr_queue_event_ihdb_missing)) + return@launch + } + + val workRequest = + OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData( + workDataOf( + OcrQueueProcessingJob.DATA_RUN_MODE to runMode.value, + OcrQueueProcessingJob.DATA_PARALLEL_COUNT to ocrQueuePreferences.value.parallelCount, + ), + ).build() + + workManager.enqueueUniqueWork( + OcrQueueProcessingJob.WORK_NAME, + ExistingWorkPolicy.KEEP, + workRequest, + ) + } } + + private suspend fun imageHashesDatabaseFileExists(): Boolean = + withContext(Dispatchers.IO) { + SystemFileSystem.exists(OcrDependencyPaths().imageHashesDatabaseFile) + } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/staging/OcrQueueStagingViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/staging/OcrQueueStagingViewModel.kt index 4e67de4a..a2c2acfe 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/staging/OcrQueueStagingViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/staging/OcrQueueStagingViewModel.kt @@ -1,7 +1,6 @@ package xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.staging import android.content.Context -import android.content.res.Resources import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -15,14 +14,12 @@ import io.github.vinceglb.filekit.dialogs.toAndroidUri import io.github.vinceglb.filekit.name import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.R @@ -38,6 +35,8 @@ import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesSerializer import xyz.sevive.arcaeaoffline.helpers.fromWorkInfo import xyz.sevive.arcaeaoffline.jobs.OcrQueueProcessingJob import xyz.sevive.arcaeaoffline.jobs.OcrQueueStagingJob +import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent +import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds @@ -57,26 +56,6 @@ class OcrQueueStagingViewModel( OcrQueuePreferencesSerializer.defaultValue, ) - // TODO: combine with ImportLog? - sealed class UiEvent { - data class StringRes( - @androidx.annotation.StringRes val resId: Int, - val formatArgs: List = emptyList(), - ) : UiEvent() - - data class PluralRes( - @androidx.annotation.PluralsRes val resId: Int, - val quantity: Int, - val formatArgs: List = emptyList(), - ) : UiEvent() - - fun asString(resources: Resources): String = - when (this) { - is StringRes -> resources.getString(resId, *formatArgs.toTypedArray()) - is PluralRes -> resources.getQuantityString(resId, quantity, *formatArgs.toTypedArray()) - } - } - data class StagingItemCount( val checked: Int = 0, val total: Int = 0, @@ -89,8 +68,8 @@ class OcrQueueStagingViewModel( val stagingItemCount: StagingItemCount? = null, ) - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() + private val eventChannel = UiEventChannel() + val events = eventChannel.events private val stagingJobWorkInfoFlow = workManager.getWorkInfosForUniqueWorkFlow(OcrQueueStagingJob.WORK_NAME).map { @@ -109,7 +88,7 @@ class OcrQueueStagingViewModel( stagingJobWorkInfoFlow.distinctUntilChanged { old, new -> old?.state == new?.state }.collectLatest { workInfo -> if (workInfo == null || workInfo.state != WorkInfo.State.FAILED) return@collectLatest - _events.send( + eventChannel.send( UiEvent.StringRes( R.string.ocr_queue_event_staging_error, listOf(workInfo.outputData.getString(OcrQueueStagingJob.KEY_ERROR_MESSAGE) ?: "WorkInfo.State.FAILED"), @@ -163,7 +142,7 @@ class OcrQueueStagingViewModel( itemRepo.insertBatch(uris.associateWith { OcrQueueStagingUriType.FILE }, batchId) requestWork() - _events.send( + eventChannel.send( UiEvent.PluralRes(R.plurals.ocr_queue_event_files_staged, count, listOf(count)), ) } @@ -181,7 +160,7 @@ class OcrQueueStagingViewModel( itemRepo.insertBatch(mapOf(folder.toAndroidUri() to OcrQueueStagingUriType.FOLDER), batchId) requestWork() - _events.send( + eventChannel.send( UiEvent.StringRes(R.string.ocr_queue_event_folder_staged, listOf(folder.name)), ) } @@ -205,7 +184,7 @@ class OcrQueueStagingViewModel( fun deleteAll() { viewModelScope.launch { itemRepo.deleteAll() - _events.send(UiEvent.StringRes(R.string.ocr_queue_event_staging_cleared)) + eventChannel.send(UiEvent.StringRes(R.string.ocr_queue_event_staging_cleared)) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/ArcaeaPlayResultSummaryCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/ArcaeaPlayResultSummaryCard.kt new file mode 100644 index 00000000..8a9a1d1f --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/ArcaeaPlayResultSummaryCard.kt @@ -0,0 +1,124 @@ +package xyz.sevive.arcaeaoffline.ui.screens.overview + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClassDisplay +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.core.database.entities.playRating +import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime +import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaChartDisplay +import xyz.sevive.arcaeaoffline.ui.components.rememberRatingClassAccent +import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters +import xyz.sevive.arcaeaoffline.ui.theme.playResultGradeGradientBrush +import xyz.sevive.arcaeaoffline.ui.theme.spacing + +/** + * A single play result at a glance: chart, grade, score, play rating and date. + * + * Falls back to the raw song id and rating class when the chart is not in the database. + */ +@Composable +internal fun ArcaeaPlayResultSummaryCard( + playResult: PlayResult, + scoringMode: ArcaeaScoringMode, + modifier: Modifier = Modifier, +) { + val resources = LocalResources.current + val display by rememberArcaeaChartDisplay(playResult.songId, playResult.ratingClass) + + val title = display?.difficultyWithSong?.title ?: playResult.songId + val ratingClassDisplay = + display?.difficultyWithSong?.let { ArcaeaRatingClassDisplay.of(it.ratingClass, it.ratingClassAlias) } + ?: ArcaeaRatingClassDisplay.of(playResult.ratingClass) + val levelText = + display?.let { ArcaeaFormatters.ratingTextParts(it.difficultyWithSong, it.chartInfo?.constant ?: 0).value } + val difficultyText = listOfNotNull(ratingClassDisplay.name, levelText).joinToString(" ") + val accent = rememberRatingClassAccent(ratingClassDisplay) + + val gradeText = remember(playResult.score) { ArcaeaFormatters.scoreToLevelText(playResult.score) } + val scoreText = remember(playResult.score) { ArcaeaFormatters.score(playResult.score) } + val ratingText = + remember(display, playResult, scoringMode) { + ArcaeaFormatters.potentialToText(display?.chartInfo?.let { playResult.playRating(it, scoringMode) }) + } + val dateText = + remember(playResult.date) { + playResult.date?.formatAsLocalizedDateTime() ?: resources.getString(R.string.play_result_no_date) + } + + Card(modifier) { + Column( + Modifier + .background(accent.backgroundBrush) + .padding(horizontal = MaterialTheme.spacing.pagePadding, vertical = MaterialTheme.spacing.cardPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(MaterialTheme.spacing.sm)) + + Text( + difficultyText, + style = MaterialTheme.typography.labelLarge, + color = accent.textColor, + ) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + gradeText, + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + brush = playResultGradeGradientBrush(playResult.score), + ), + ) + + Text(scoreText, style = MaterialTheme.typography.titleMedium) + + Spacer(Modifier.weight(1f)) + + Text("PTT", fontWeight = FontWeight.Thin, style = MaterialTheme.typography.labelSmall) + + Text(ratingText, style = MaterialTheme.typography.titleMedium) + } + + Text( + dateText, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal), + modifier = Modifier.secondaryItemAlpha(), + ) + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewReadinessCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewReadinessCard.kt new file mode 100644 index 00000000..684e5390 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewReadinessCard.kt @@ -0,0 +1,106 @@ +package xyz.sevive.arcaeaoffline.ui.screens.overview + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.outlined.RadioButtonUnchecked +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadiness +import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime +import xyz.sevive.arcaeaoffline.ui.components.TextItem +import xyz.sevive.arcaeaoffline.ui.theme.spacing + +@Composable +private fun SetupStepItem( + title: String, + done: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TextItem( + title = title, + modifier = modifier, + leadingIcon = if (done) Icons.Default.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + leadingIconTint = if (done) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = onClick, + ) +} + +@Composable +internal fun OverviewReadinessCard( + uiState: OverviewViewModel.ReadinessUiState, + onNavigateToResources: () -> Unit, + modifier: Modifier = Modifier, +) { + val summary = uiState.summary + + Card(modifier) { + when (summary.readiness) { + DatabaseReadiness.EMPTY, DatabaseReadiness.SONGS_ONLY -> { + Text( + stringResource(R.string.home_setup_header), + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier.padding( + start = MaterialTheme.spacing.pagePadding, + end = MaterialTheme.spacing.pagePadding, + top = MaterialTheme.spacing.cardPadding, + ), + ) + + SetupStepItem( + title = stringResource(R.string.home_setup_step_songs), + done = summary.songCount > 0, + onClick = onNavigateToResources, + ) + + SetupStepItem( + title = stringResource(R.string.home_setup_step_chart_info), + done = summary.chartInfoCount > 0, + onClick = onNavigateToResources, + ) + } + + DatabaseReadiness.PARTIAL -> { + TextItem( + title = + pluralStringResource( + R.plurals.resources_missing_chart_info, + summary.missingChartInfoCount, + summary.missingChartInfoCount, + ), + content = stringResource(R.string.home_setup_update_chart_info), + leadingIcon = Icons.Default.CloudDownload, + trailingIcon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = onNavigateToResources, + ) + } + + DatabaseReadiness.READY -> { + val songCountText = + pluralStringResource(R.plurals.database_song_entries, summary.songCount, summary.songCount) + val chartInfoCountText = + pluralStringResource(R.plurals.database_chart_info_entries, summary.chartInfoCount, summary.chartInfoCount) + + TextItem( + title = "$songCountText · $chartInfoCountText", + content = + uiState.lastImportedAt?.let { + stringResource(R.string.resources_last_imported_at, it.formatAsLocalizedDateTime()) + }, + leadingIcon = Icons.Default.CheckCircle, + ) + } + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewScreen.kt index 2ec26cc4..c2f12ef2 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewScreen.kt @@ -1,32 +1,151 @@ package xyz.sevive.arcaeaoffline.ui.screens.overview -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowSizeClass import org.koin.compose.viewmodel.koinViewModel +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.ui.screens.EmptyScreen import xyz.sevive.arcaeaoffline.ui.theme.spacing +@OptIn(ExperimentalMaterial3Api::class) @Composable fun OverviewScreen( + onNavigateToSettings: () -> Unit, + onNavigateToResources: () -> Unit, modifier: Modifier = Modifier, viewModel: OverviewViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val readinessUiState by viewModel.readinessUiState.collectAsStateWithLifecycle() + val latestPlays by viewModel.latestPlays.collectAsStateWithLifecycle() + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.nav_home)) }, + actions = { + IconButton(onClick = onNavigateToSettings) { + Icon(Icons.Default.Settings, contentDescription = null) + } + }, + ) + }, + ) { padding -> + val twoPane = + currentWindowAdaptiveInfo() + .windowSizeClass + .isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) + + // Setup state and potential on one side, the activity feed on the other. + val statusPane: @Composable ColumnScope.() -> Unit = { + OverviewReadinessCard( + readinessUiState, + onNavigateToResources = onNavigateToResources, + Modifier.fillMaxWidth(), + ) - LazyColumn( - modifier - .safeDrawingPadding() - .fillMaxSize(), - contentPadding = PaddingValues(all = MaterialTheme.spacing.pagePadding), - ) { - item { OverviewPotentialCard(uiState) } + + val recentPane: @Composable ColumnScope.() -> Unit = { + Text( + stringResource(R.string.home_recent_header), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = MaterialTheme.spacing.sm), + ) + + if (latestPlays.isEmpty()) { + EmptyScreen( + Modifier.fillMaxWidth(), + text = stringResource(R.string.database_no_play_results), + ) + } else { + latestPlays.forEach { playResult -> + key(playResult.uuid) { + ArcaeaPlayResultSummaryCard( + playResult, + uiState.scoringMode, + Modifier.fillMaxWidth(), + ) + } + } + } + } + + if (twoPane) { + // Each pane scrolls on its own: the long activity feed never drags + // the short status pane along with it. + Row( + Modifier + .fillMaxSize() + .padding(padding), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.lg), + ) { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding( + start = MaterialTheme.spacing.pagePadding, + bottom = MaterialTheme.spacing.pagePadding, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + content = statusPane, + ) + + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding( + end = MaterialTheme.spacing.pagePadding, + bottom = MaterialTheme.spacing.pagePadding, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + content = recentPane, + ) + } + } else { + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding( + bottom = MaterialTheme.spacing.pagePadding, + start = MaterialTheme.spacing.pagePadding, + end = MaterialTheme.spacing.pagePadding, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + statusPane() + recentPane() + } + } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt index 8a104502..6b9d3931 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/overview/OverviewViewModel.kt @@ -3,19 +3,32 @@ package xyz.sevive.arcaeaoffline.ui.screens.overview import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadiness +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadinessRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadinessSummary +import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialGroups import xyz.sevive.arcaeaoffline.core.database.repositories.PotentialRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.helpers.calculatePotential import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant class OverviewViewModel( potentialRepository: PotentialRepository, propertyRepository: PropertyRepository, + readinessRepository: DatabaseReadinessRepository, + playResultRepository: PlayResultRepository, ) : ViewModel() { + companion object { + private const val LATEST_PLAYS_LIMIT = 5 + } + data class UiState( val isLoading: Boolean = true, val scoringMode: ArcaeaScoringMode = ArcaeaScoringMode.B50, @@ -23,6 +36,18 @@ class OverviewViewModel( val potential: Double? = null, ) + data class ReadinessUiState( + val summary: DatabaseReadinessSummary = + DatabaseReadinessSummary( + readiness = DatabaseReadiness.EMPTY, + songCount = 0, + chartInfoCount = 0, + missingChartInfoCount = 0, + ), + /** The later of the songs and chart info import timestamps. */ + val lastImportedAt: Instant? = null, + ) + val uiState = combine( propertyRepository.scoringMode(), @@ -39,4 +64,29 @@ class OverviewViewModel( SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), UiState(), ) + + val readinessUiState: StateFlow = + combine( + readinessRepository.summary(), + propertyRepository.songsLastImportedAt(), + propertyRepository.chartInfoLastImportedAt(), + ) { summary, songsLastImportedAt, chartInfoLastImportedAt -> + ReadinessUiState( + summary = summary, + lastImportedAt = listOfNotNull(songsLastImportedAt, chartInfoLastImportedAt).maxOrNull(), + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + ReadinessUiState(), + ) + + val latestPlays: StateFlow> = + playResultRepository + .findLatestPlays(LATEST_PLAYS_LIMIT) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + emptyList(), + ) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesScreen.kt new file mode 100644 index 00000000..0459cde4 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesScreen.kt @@ -0,0 +1,395 @@ +package xyz.sevive.arcaeaoffline.ui.screens.resources + +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.PendingActions +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowSizeClass +import org.koin.androidx.compose.koinViewModel +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.api.DownloadableResource +import xyz.sevive.arcaeaoffline.core.api.RemoteResourcesInfoUiState +import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDateTime +import xyz.sevive.arcaeaoffline.helpers.rememberFileChooserLauncher +import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import xyz.sevive.arcaeaoffline.ui.components.ArcaeaAppIcon +import xyz.sevive.arcaeaoffline.ui.components.ImportLogBottomSheet +import xyz.sevive.arcaeaoffline.ui.components.SelectItem +import xyz.sevive.arcaeaoffline.ui.components.SelectOption +import xyz.sevive.arcaeaoffline.ui.components.TextItem +import xyz.sevive.arcaeaoffline.ui.components.resources.RemoteResourceDownloadItem +import xyz.sevive.arcaeaoffline.ui.theme.spacing +import kotlin.time.Instant + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResourcesScreen( + modifier: Modifier = Modifier, + viewModel: ResourcesViewModel = koinViewModel(), +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val songsCardStatus by viewModel.songsCardStatus.collectAsStateWithLifecycle() + val chartInfoCardStatus by viewModel.chartInfoCardStatus.collectAsStateWithLifecycle() + val canImportLists by viewModel.canImportLists.collectAsStateWithLifecycle() + + var showLogsSheet by rememberSaveable { mutableStateOf(false) } + if (showLogsSheet) { + ImportLogBottomSheet( + onDismissRequest = { showLogsSheet = false }, + logs = uiState.logs, + ) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.nav_resources)) }, + actions = { + Box(contentAlignment = Alignment.Center) { + IconButton( + onClick = viewModel::refreshRemoteResourcesInfo, + enabled = !uiState.remoteResourcesInfoState.isFetching, + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + } + + if (uiState.remoteResourcesInfoState.isFetching) { + CircularProgressIndicator() + } + } + + Box(contentAlignment = Alignment.Center) { + IconButton(onClick = { showLogsSheet = true }) { + Icon(Icons.Default.PendingActions, contentDescription = null) + } + + if (uiState.isWorking) { + CircularProgressIndicator() + } + } + }, + ) + }, + ) { padding -> + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val columnCount = if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) 2 else 1 + + FlowRow( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding( + bottom = MaterialTheme.spacing.pagePadding, + start = MaterialTheme.spacing.pagePadding, + end = MaterialTheme.spacing.pagePadding, + ), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.lg), + maxItemsInEachRow = columnCount, + ) { + ResourcesSongsCard( + status = songsCardStatus, + remoteResourcesInfoState = uiState.remoteResourcesInfoState, + downloadingResources = uiState.downloadingResources, + downloadErrorTexts = uiState.downloadErrorTexts, + canImportLists = canImportLists, + onDownloadPacklist = viewModel::downloadPacklist, + onDownloadSonglist = { viewModel.downloadSonglist(context) }, + onImportPacklist = { viewModel.importPacklist(it, context) }, + onImportSonglist = { viewModel.importSonglist(it, context) }, + onImportArcaeaApk = { viewModel.importArcaeaApkFromSelected(it, context) }, + onImportFromInstalledArcaea = { viewModel.importArcaeaApkFromInstalled(context) }, + modifier = Modifier.weight(1f), + ) + + ResourcesChartInfoCard( + status = chartInfoCardStatus, + remoteResourcesInfoState = uiState.remoteResourcesInfoState, + downloadingResources = uiState.downloadingResources, + downloadErrorTexts = uiState.downloadErrorTexts, + onDownload = { viewModel.downloadChartInfoDatabase(context) }, + onImport = { viewModel.importChartsInfoDatabase(it, context) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +private enum class SongsSource { + ONLINE, + FILE, + INSTALLED, +} + +private enum class ChartInfoSource { + ONLINE, + FILE, +} + +@Composable +private fun LastImportedText( + label: String, + instant: Instant, + modifier: Modifier = Modifier, +) { + Text( + "$label · ${stringResource(R.string.resources_last_imported_at, instant.formatAsLocalizedDateTime())}", + style = MaterialTheme.typography.bodySmall, + modifier = modifier.secondaryItemAlpha(), + ) +} + +@Composable +private fun ResourcesSongsCard( + status: ResourcesViewModel.SongsCardStatus, + remoteResourcesInfoState: RemoteResourcesInfoUiState, + downloadingResources: Set, + downloadErrorTexts: Map, + canImportLists: Boolean, + onDownloadPacklist: () -> Unit, + onDownloadSonglist: () -> Unit, + onImportPacklist: (Uri) -> Unit, + onImportSonglist: (Uri) -> Unit, + onImportArcaeaApk: (Uri) -> Unit, + onImportFromInstalledArcaea: () -> Unit, + modifier: Modifier = Modifier, +) { + var source by rememberSaveable { mutableStateOf(SongsSource.ONLINE) } + val effectiveSource = if (source == SongsSource.INSTALLED && !canImportLists) SongsSource.ONLINE else source + val sourceOptions = + buildList { + add(SelectOption(value = SongsSource.ONLINE, label = stringResource(R.string.resources_source_online))) + add(SelectOption(value = SongsSource.FILE, label = stringResource(R.string.resources_source_file))) + if (canImportLists) { + add( + SelectOption( + value = SongsSource.INSTALLED, + label = stringResource(R.string.database_manage_import_from_arcaea_installed), + ), + ) + } + } + + val statusString = + listOf( + pluralStringResource(R.plurals.database_pack_entries, status.packCount, status.packCount), + pluralStringResource(R.plurals.database_song_entries, status.songCount, status.songCount), + pluralStringResource(R.plurals.database_difficulty_entries, status.difficultyCount, status.difficultyCount), + ).joinToString(" · ") + + val importPacklistLauncher = rememberFileChooserLauncher { it?.let(onImportPacklist) } + val importSonglistLauncher = rememberFileChooserLauncher { it?.let(onImportSonglist) } + val importArcaeaApkLauncher = rememberFileChooserLauncher { it?.let(onImportArcaeaApk) } + + Card(modifier.fillMaxWidth()) { + Column( + Modifier.padding( + horizontal = MaterialTheme.spacing.pagePadding, + vertical = MaterialTheme.spacing.cardPadding, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + Text( + stringResource(R.string.resources_songs_title), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + statusString, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.secondaryItemAlpha(), + ) + + status.packlistLastImportedAt?.let { LastImportedText(stringResource(R.string.database_manage_download_packlist), it) } + status.songsLastImportedAt?.let { LastImportedText(stringResource(R.string.database_manage_download_songlist), it) } + } + + HorizontalDivider() + + SelectItem( + options = sourceOptions, + selected = effectiveSource, + onSelect = { source = it }, + ) + + HorizontalDivider() + + when (effectiveSource) { + SongsSource.ONLINE -> { + RemoteResourceDownloadItem( + resource = DownloadableResource.PACKLIST, + infoState = remoteResourcesInfoState, + isDownloading = DownloadableResource.PACKLIST in downloadingResources, + downloadErrorText = downloadErrorTexts[DownloadableResource.PACKLIST], + title = stringResource(R.string.database_manage_download_packlist), + onDownload = onDownloadPacklist, + ) + + RemoteResourceDownloadItem( + resource = DownloadableResource.SONGLIST, + infoState = remoteResourcesInfoState, + isDownloading = DownloadableResource.SONGLIST in downloadingResources, + downloadErrorText = downloadErrorTexts[DownloadableResource.SONGLIST], + title = stringResource(R.string.database_manage_download_songlist), + onDownload = onDownloadSonglist, + ) + } + + SongsSource.FILE -> { + TextItem( + onClick = { importPacklistLauncher.launch("*/*") }, + title = stringResource(R.string.database_manage_import_packlist), + leadingIcon = Icons.Default.DataObject, + ) + + TextItem( + onClick = { importSonglistLauncher.launch("*/*") }, + title = stringResource(R.string.database_manage_import_songlist), + leadingIcon = Icons.Default.DataObject, + ) + + TextItem( + onClick = { importArcaeaApkLauncher.launch("*/*") }, + title = stringResource(R.string.database_manage_import_from_arcaea_apk), + leadingIcon = Icons.Default.Android, + ) + } + + SongsSource.INSTALLED -> { + TextItem( + onClick = onImportFromInstalledArcaea, + title = stringResource(R.string.database_manage_import_from_arcaea_installed), + leadingSlot = { ArcaeaAppIcon() }, + ) + } + } + } +} + +@Composable +private fun ResourcesChartInfoCard( + status: ResourcesViewModel.ChartInfoCardStatus, + remoteResourcesInfoState: RemoteResourcesInfoUiState, + downloadingResources: Set, + downloadErrorTexts: Map, + onDownload: () -> Unit, + onImport: (Uri) -> Unit, + modifier: Modifier = Modifier, +) { + var source by rememberSaveable { mutableStateOf(ChartInfoSource.ONLINE) } + + val importLauncher = rememberFileChooserLauncher { it?.let(onImport) } + val sourceOptions = + listOf( + SelectOption(value = ChartInfoSource.ONLINE, label = stringResource(R.string.resources_source_online)), + SelectOption(value = ChartInfoSource.FILE, label = stringResource(R.string.resources_source_file)), + ) + + Card(modifier.fillMaxWidth()) { + Column( + Modifier.padding( + horizontal = MaterialTheme.spacing.pagePadding, + vertical = MaterialTheme.spacing.cardPadding, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + Text( + stringResource(R.string.resources_chart_info_title), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + pluralStringResource(R.plurals.database_chart_info_entries, status.chartInfoCount, status.chartInfoCount), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.secondaryItemAlpha(), + ) + + if (status.missingChartInfoCount > 0) { + Text( + pluralStringResource( + R.plurals.resources_missing_chart_info, + status.missingChartInfoCount, + status.missingChartInfoCount, + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.secondaryItemAlpha(), + ) + } + status.chartInfoLastImportedAt?.let { + LastImportedText( + stringResource(R.string.database_manage_download_chart_info_database), + it, + ) + } + } + + HorizontalDivider() + + SelectItem( + options = sourceOptions, + selected = source, + onSelect = { source = it }, + ) + + HorizontalDivider() + + when (source) { + ChartInfoSource.ONLINE -> { + RemoteResourceDownloadItem( + resource = DownloadableResource.CHART_INFO_DATABASE, + infoState = remoteResourcesInfoState, + isDownloading = DownloadableResource.CHART_INFO_DATABASE in downloadingResources, + downloadErrorText = downloadErrorTexts[DownloadableResource.CHART_INFO_DATABASE], + title = stringResource(R.string.database_manage_download_chart_info_database), + onDownload = onDownload, + ) + } + + ChartInfoSource.FILE -> { + TextItem( + onClick = { importLauncher.launch("*/*") }, + title = stringResource(R.string.database_manage_import_chart_info_database), + leadingIcon = Icons.Default.FileOpen, + ) + } + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesViewModel.kt similarity index 86% rename from app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt rename to app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesViewModel.kt index 79ee8c20..8479197b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/database/manage/DatabaseManageViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/resources/ResourcesViewModel.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.ui.screens.database.manage +package xyz.sevive.arcaeaoffline.ui.screens.resources import android.content.Context import android.net.Uri @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -31,33 +30,35 @@ import xyz.sevive.arcaeaoffline.core.api.DownloadableResource import xyz.sevive.arcaeaoffline.core.api.RemoteResourcesInfoStateHolder import xyz.sevive.arcaeaoffline.core.api.RemoteResourcesInfoUiState import xyz.sevive.arcaeaoffline.core.api.throwableToErrorText -import xyz.sevive.arcaeaoffline.core.constants.ArcaeaScoringMode -import xyz.sevive.arcaeaoffline.core.database.externals.exporters.ArcaeaOfflineDEFv2Exporter import xyz.sevive.arcaeaoffline.core.database.externals.importers.ArcaeaPacklistImporter import xyz.sevive.arcaeaoffline.core.database.externals.importers.ArcaeaSonglistImporter -import xyz.sevive.arcaeaoffline.core.database.externals.importers.ArcaeaSt3PlayResultImporter import xyz.sevive.arcaeaoffline.core.database.externals.importers.ChartInfoDatabaseImporter import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadinessRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyLocalizedRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PackLocalizedRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PackRepository -import xyz.sevive.arcaeaoffline.core.database.repositories.PlayResultRepository import xyz.sevive.arcaeaoffline.core.database.repositories.PropertyRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongLocalizedRepository import xyz.sevive.arcaeaoffline.core.database.repositories.SongRepository +import xyz.sevive.arcaeaoffline.data.imports.ImportLogEvent +import xyz.sevive.arcaeaoffline.data.imports.ImportLogManager +import xyz.sevive.arcaeaoffline.data.imports.ImportLogObject +import xyz.sevive.arcaeaoffline.data.imports.ImportTaskQueue import xyz.sevive.arcaeaoffline.helpers.ArcaeaPackageHelper import xyz.sevive.arcaeaoffline.helpers.ArcaeaResourcesStateHolder import xyz.sevive.arcaeaoffline.helpers.context.copyToCache import xyz.sevive.arcaeaoffline.helpers.context.uriSizeIfTooLarge import java.io.FileInputStream import java.io.InputStream -import java.io.OutputStream import java.nio.charset.Charset import java.nio.charset.StandardCharsets +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant -class DatabaseManageViewModel( +class ResourcesViewModel( private val packRepo: PackRepository, private val packLocalizedRepo: PackLocalizedRepository, private val songRepo: SongRepository, @@ -65,28 +66,25 @@ class DatabaseManageViewModel( private val songLocalizedRepo: SongLocalizedRepository, private val difficultyLocalizedRepo: DifficultyLocalizedRepository, private val chartInfoRepo: ChartInfoRepository, - private val playResultRepo: PlayResultRepository, private val propertyRepo: PropertyRepository, + private val readinessRepo: DatabaseReadinessRepository, private val resourcesApiClient: ArcaeaResourcesApiClient, private val remoteResourcesInfoStateHolder: RemoteResourcesInfoStateHolder, + private val importLogManager: ImportLogManager, ) : ViewModel() { companion object { - private const val LOG_TAG = "DatabaseManageVM" + private const val LOG_TAG = "ResourcesVM" private const val LOG_TAG_IMPORT_PACKLIST = "I-Pklst" private const val LOG_TAG_IMPORT_SONGLIST = "I-Slst" private const val LOG_TAG_IMPORT_ARCAEA_APK = "I-ArcApk" private const val LOG_TAG_IMPORT_ARCAEA_INSTALLED = "I-ArcLocal" private const val LOG_TAG_IMPORT_CHART_INFO_DATABASE = "I-CIDb" - private const val LOG_TAG_IMPORT_ST3 = "I-St3" - private const val LOG_TAG_EXPORT_PLAY_RESULTS = "E-PR" private const val LOG_TAG_DOWNLOAD_PACKLIST = "D-Pklst" private const val LOG_TAG_DOWNLOAD_SONGLIST = "D-Slst" private const val LOG_TAG_DOWNLOAD_CHART_INFO_DATABASE = "D-CIDb" } - private val importLogManager = ImportLogManager() - internal data class UiState( val isWorking: Boolean = false, val logs: List = emptyList(), @@ -95,6 +93,20 @@ class DatabaseManageViewModel( val downloadErrorTexts: Map = emptyMap(), ) + data class SongsCardStatus( + val packCount: Int = 0, + val songCount: Int = 0, + val difficultyCount: Int = 0, + val packlistLastImportedAt: Instant? = null, + val songsLastImportedAt: Instant? = null, + ) + + data class ChartInfoCardStatus( + val chartInfoCount: Int = 0, + val missingChartInfoCount: Int = 0, + val chartInfoLastImportedAt: Instant? = null, + ) + private val logger = Logger.withTag(LOG_TAG) private val taskQueue = @@ -112,21 +124,7 @@ class DatabaseManageViewModel( fun refreshRemoteResourcesInfo() = remoteResourcesInfoStateHolder.refresh() - val scoringMode: StateFlow = - propertyRepo - .scoringMode() - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), - ArcaeaScoringMode.B50, - ) - - fun setScoringMode(mode: ArcaeaScoringMode) { - viewModelScope.launch { - runCatching { propertyRepo.setScoringMode(mode) } - .onFailure { logger.e(it) { "Failed to set scoring mode" } } - } - } + val canImportLists = ArcaeaResourcesStateHolder.canImportLists internal val uiState = combine( @@ -149,6 +147,34 @@ class DatabaseManageViewModel( UiState(), ) + val songsCardStatus: StateFlow = + combine( + packRepo.count(), + songRepo.count(), + difficultyRepo.count(), + propertyRepo.packlistLastImportedAt(), + propertyRepo.songsLastImportedAt(), + ) { packCount, songCount, difficultyCount, packlistLastImportedAt, songsLastImportedAt -> + SongsCardStatus(packCount, songCount, difficultyCount, packlistLastImportedAt, songsLastImportedAt) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + SongsCardStatus(), + ) + + val chartInfoCardStatus: StateFlow = + combine( + chartInfoRepo.count(), + readinessRepo.missingChartInfoCount(), + propertyRepo.chartInfoLastImportedAt(), + ) { chartInfoCount, missingChartInfoCount, chartInfoLastImportedAt -> + ChartInfoCardStatus(chartInfoCount, missingChartInfoCount, chartInfoLastImportedAt) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5.seconds.inWholeMilliseconds), + ChartInfoCardStatus(), + ) + private fun InputStream.readText(charset: Charset = StandardCharsets.UTF_8): String = bufferedReader(charset).use { it.readText() } private suspend fun sendTask(action: suspend CoroutineScope.() -> Unit) { @@ -193,6 +219,8 @@ class DatabaseManageViewModel( LOG_TAG_IMPORT_PACKLIST, ImportLogEvent.Plural(R.plurals.database_packs_localized_imported, packsLocalizedAffectedRows), ) + + propertyRepo.setPacklistLastImportedAt(Clock.System.now()) } fun importPacklist( @@ -270,6 +298,8 @@ class DatabaseManageViewModel( LOG_TAG_IMPORT_SONGLIST, ImportLogEvent.Plural(R.plurals.database_difficulties_localized_imported, difficultiesLocalizedAffectedRows), ) + + propertyRepo.setSongsLastImportedAt(Clock.System.now()) } fun importSonglist( @@ -332,8 +362,6 @@ class DatabaseManageViewModel( } } - val canImportLists = ArcaeaResourcesStateHolder.canImportLists - fun importArcaeaApkFromInstalled(context: Context) { viewModelScope.launch(Dispatchers.IO) { sendTask { @@ -373,6 +401,8 @@ class DatabaseManageViewModel( LOG_TAG_IMPORT_CHART_INFO_DATABASE, ImportLogEvent.Plural(R.plurals.database_chart_info_imported, affectedRows), ) + + propertyRepo.setChartInfoLastImportedAt(Clock.System.now()) } fun importChartsInfoDatabase( @@ -395,61 +425,6 @@ class DatabaseManageViewModel( } } - private suspend fun importSt3(conn: SQLiteConnection) { - val playResults = ArcaeaSt3PlayResultImporter.playResults(conn) - val affectedRows = - playResultRepo.upsertBatch(*playResults.toTypedArray()).size - - importLogManager.append( - LOG_TAG_IMPORT_ST3, - ImportLogEvent.Plural(R.plurals.database_play_results_imported, affectedRows), - ) - } - - fun importSt3( - fileUri: Uri, - context: Context, - ) { - viewModelScope.launch(Dispatchers.IO) { - sendTask { - if (context.importFileTooLarge(fileUri, LOG_TAG_IMPORT_ST3)) return@sendTask - - val dbCacheFile = context.copyToCache(fileUri, "st3-import-temp") ?: return@sendTask - - BundledSQLiteDriver() - .open(dbCacheFile.toString(), SQLITE_OPEN_READONLY) - .use { conn -> importSt3(conn) } - - SystemFileSystem.delete(dbCacheFile) - } - } - } - - private suspend fun exportPlayResults(outputStream: OutputStream) { - val playResults = playResultRepo.findAll().firstOrNull() ?: return - - ArcaeaOfflineDEFv2Exporter.playResultsRoot(playResults).let { - outputStream.write(ArcaeaOfflineDEFv2Exporter.playResults(it).toByteArray()) - importLogManager.append( - LOG_TAG_EXPORT_PLAY_RESULTS, - ImportLogEvent.Plural(R.plurals.database_play_results_exported, it.playResults.size), - ) - } - } - - fun exportPlayResults( - uri: Uri, - context: Context, - ) { - viewModelScope.launch(Dispatchers.IO) { - sendTask { - context.contentResolver.openOutputStream(uri)?.use { - exportPlayResults(it) - } - } - } - } - /** * Queues a download task: marks the resource busy from submission (so the item * shows "downloading" and stays disabled while queued or running), and logs diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsNavEntry.kt index 81d7066f..5878302b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsNavEntry.kt @@ -2,25 +2,37 @@ package xyz.sevive.arcaeaoffline.ui.screens.settings import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import xyz.sevive.arcaeaoffline.ui.navigation.MainScreen +import xyz.sevive.arcaeaoffline.R @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SettingsNavEntry( onNavigateToEmergencyModeActivity: () -> Unit, + onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier, topBar = { - TopAppBar(title = { Text(stringResource(MainScreen.Settings.title)) }) + TopAppBar( + title = { Text(stringResource(R.string.nav_settings)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.icon_desc_navigate_back)) + } + }, + ) }, ) { padding -> SettingsContent( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsScreen.kt index 8d9afcdc..d6540e06 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsScreen.kt @@ -13,7 +13,7 @@ import xyz.sevive.arcaeaoffline.ui.screens.settings.license.SettingsLicenseScree import xyz.sevive.arcaeaoffline.ui.screens.settings.unstablealert.SettingsUnstableAlertScreen @Composable -fun SettingsScreen() { +fun SettingsScreen(onNavigateBack: () -> Unit) { val context = LocalContext.current AdaptiveEntryScreen( @@ -22,6 +22,7 @@ fun SettingsScreen() { onNavigateToEmergencyModeActivity = { context.startActivity(Intent(context, EmergencyModeActivity::class.java)) }, + onNavigateBack = onNavigateBack, ) }, detailPane = { route -> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 15bd32f8..7383162b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -78,6 +78,11 @@ 图像哈希数据库 CRNN OCR 模型 + 获取图像哈希数据库 + OCR 依靠它识别截图中的曲目。 + 导入曲目与谱面定数 + OCR 靠它们显示曲目信息、给出校验警告。 + 这些谱面的校验警告可能不完整。 谱面 @@ -102,9 +107,10 @@ 留在 %s - 概览 - 数据库 - 小工具 + 主页 + 记录 + 资源 + 工具 OCR 设置 @@ -138,8 +144,6 @@ 数据库未初始化 - 管理 - 计分方式 B30 + R10 B50 @@ -171,14 +175,38 @@ 导出 游玩记录 + + 记录数据 + + + 曲包与曲目 + 谱面定数 + 在线下载 + 从文件导入 + 上次导入:%1$s + + %d 个谱面缺少定数 + + + + 开始使用 + 导入曲包与曲目 + 导入谱面定数 + 更新谱面定数 + 最近游玩 + 添加游玩记录 选择谱面…… 编辑游玩记录…… 点此选择谱面 请先选择谱面 + 仍有警告,确认保存? + 保留谱面 + 游玩记录已保存 + 暂无游玩记录 游玩记录列表 删除 %d 个游玩记录? @@ -322,6 +350,7 @@ 正在扫描 "%s" 暂存处理错误:%s + 图像哈希数据库缺失,无法启动 OCR 暂存区已清空 暂存 空 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4d2e775..595b439b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,11 @@ CC BY-SA 3.0 Image hashes database CRNN OCR model + Get the image hashes database + OCR uses it to identify songs in screenshots. + Import songs and chart info + OCR needs them to show song details and validate plays. + Warnings for those charts may be incomplete. Chart @@ -118,9 +123,10 @@ CC BY-SA 3.0 Stay in %s - Overview - Database - Utilities + Home + Plays + Resources + Tools OCR Settings @@ -159,8 +165,6 @@ CC BY-SA 3.0 Database not initialized - Manage - Scoring mode B30 + R10 B50 @@ -192,14 +196,39 @@ CC BY-SA 3.0 Export… Play Results + + Play Data + + + Packs & Songs + Chart Info + Download online + From file + Last imported: %1$s + + %d chart has no constant + %d charts have no constant + + + + Get started + Import packs & songs + Import chart info + Update chart info + Recent plays + Add a Play Result Select a chart… Then edit your play result… Click here to select a chart. Select a chart first. + Save with warnings? + Keep chart + Play result saved + No plays yet Play Results List Are you sure to delete %d play result? @@ -362,6 +391,7 @@ CC BY-SA 3.0 Scanning "%s" Error occurred in staging: %s + Cannot start the queue without image hashes database. Staging cleared Staging Empty diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/di/CoreModule.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/di/CoreModule.kt index e68b38bf..f51117ad 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/di/CoreModule.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/di/CoreModule.kt @@ -22,6 +22,8 @@ import xyz.sevive.arcaeaoffline.core.database.daos.SongLocalizedDao import xyz.sevive.arcaeaoffline.core.database.getArcaeaOfflineDatabase import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepository import xyz.sevive.arcaeaoffline.core.database.repositories.ChartInfoRepositoryImpl +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadinessRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.DatabaseReadinessRepositoryImpl import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyLocalizedRepository import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyLocalizedRepositoryImpl import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository @@ -112,4 +114,5 @@ val coreModule = single().bind(PlayResultBestRepository::class) single().bind(R30EntryRepository::class) single().bind(PotentialRepository::class) + single().bind(DatabaseReadinessRepository::class) } diff --git a/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/18.json b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/18.json new file mode 100644 index 00000000..1a67c1ab --- /dev/null +++ b/shared/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/18.json @@ -0,0 +1,720 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "5cfff91a072fbc2edae87fb82863d496", + "entities": [ + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "packs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `parent_pack_id` TEXT, `section` TEXT, `plus_character_id` INTEGER, `is_extend_pack` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "parentPackId", + "columnName": "parent_pack_id", + "affinity": "TEXT" + }, + { + "fieldPath": "section", + "columnName": "section", + "affinity": "TEXT" + }, + { + "fieldPath": "plusCharacterId", + "columnName": "plus_character_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isExtendPack", + "columnName": "is_extend_pack", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "packs_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lang` TEXT NOT NULL, `name` TEXT, `description` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `packs`(`id`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_packs_localized_id_lang", + "unique": true, + "columnNames": [ + "id", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_packs_localized_id_lang` ON `${TABLE_NAME}` (`id`, `lang`)" + }, + { + "name": "index_packs_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packs_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "packs", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idx` INTEGER NOT NULL, `id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `set` TEXT NOT NULL, `bpm` TEXT, `bpm_base` REAL, `audio_preview` INTEGER, `audio_preview_end` INTEGER, `side` INTEGER NOT NULL, `version` TEXT, `date` INTEGER, `bg` TEXT, `bg_inverse` TEXT, `bg_day` TEXT, `bg_night` TEXT, `source` TEXT, `source_copyright` TEXT, `deleted_in_game` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "idx", + "columnName": "idx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "set", + "columnName": "set", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "TEXT" + }, + { + "fieldPath": "bpmBase", + "columnName": "bpm_base", + "affinity": "REAL" + }, + { + "fieldPath": "audioPreview", + "columnName": "audio_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "audioPreviewEnd", + "columnName": "audio_preview_end", + "affinity": "INTEGER" + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + }, + { + "fieldPath": "bg", + "columnName": "bg", + "affinity": "TEXT" + }, + { + "fieldPath": "bgInverse", + "columnName": "bg_inverse", + "affinity": "TEXT" + }, + { + "fieldPath": "bgDay", + "columnName": "bg_day", + "affinity": "TEXT" + }, + { + "fieldPath": "bgNight", + "columnName": "bg_night", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceCopyright", + "columnName": "source_copyright", + "affinity": "TEXT" + }, + { + "fieldPath": "deletedInGame", + "columnName": "deleted_in_game", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "songs_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lang` TEXT NOT NULL, `title` TEXT, `source` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `songs`(`id`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_songs_localized_id_lang", + "unique": true, + "columnNames": [ + "id", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_songs_localized_id_lang` ON `${TABLE_NAME}` (`id`, `lang`)" + }, + { + "name": "index_songs_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "difficulties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `rating_class_alias` INTEGER, `rating` INTEGER NOT NULL, `rating_plus` INTEGER NOT NULL, `chart_designer` TEXT, `jacket_designer` TEXT, `audio_override` INTEGER NOT NULL, `jacket_override` INTEGER NOT NULL, `jacket_night` TEXT, `title` TEXT, `artist` TEXT, `bg` TEXT, `bg_inverse` TEXT, `bpm` TEXT, `bpm_base` REAL, `version` TEXT, `date` INTEGER, PRIMARY KEY(`song_id`, `rating_class`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingClassAlias", + "columnName": "rating_class_alias", + "affinity": "INTEGER" + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingPlus", + "columnName": "rating_plus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chartDesigner", + "columnName": "chart_designer", + "affinity": "TEXT" + }, + { + "fieldPath": "jacketDesigner", + "columnName": "jacket_designer", + "affinity": "TEXT" + }, + { + "fieldPath": "audioOverride", + "columnName": "audio_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "jacketOverride", + "columnName": "jacket_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "jacketNight", + "columnName": "jacket_night", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "bg", + "columnName": "bg", + "affinity": "TEXT" + }, + { + "fieldPath": "bgInverse", + "columnName": "bg_inverse", + "affinity": "TEXT" + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "TEXT" + }, + { + "fieldPath": "bpmBase", + "columnName": "bpm_base", + "affinity": "REAL" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + } + }, + { + "tableName": "difficulties_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `lang` TEXT NOT NULL, `title` TEXT, `artist` TEXT, PRIMARY KEY(`song_id`, `rating_class`), FOREIGN KEY(`song_id`, `rating_class`) REFERENCES `difficulties`(`song_id`, `rating_class`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + }, + "indices": [ + { + "name": "index_difficulties_localized_song_id_rating_class_lang", + "unique": true, + "columnNames": [ + "song_id", + "rating_class", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_difficulties_localized_song_id_rating_class_lang` ON `${TABLE_NAME}` (`song_id`, `rating_class`, `lang`)" + }, + { + "name": "index_difficulties_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_difficulties_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "difficulties", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "song_id", + "rating_class" + ], + "referencedColumns": [ + "song_id", + "rating_class" + ] + } + ] + }, + { + "tableName": "charts_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `constant` INTEGER NOT NULL, `notes` INTEGER, PRIMARY KEY(`song_id`, `rating_class`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constant", + "columnName": "constant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + } + }, + { + "tableName": "play_results", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uuid` BLOB NOT NULL, `song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `score` INTEGER NOT NULL, `pure` INTEGER, `far` INTEGER, `lost` INTEGER, `date` INTEGER, `max_recall` INTEGER, `modifier` INTEGER, `clear_type` INTEGER, `comment` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pure", + "columnName": "pure", + "affinity": "INTEGER" + }, + { + "fieldPath": "far", + "columnName": "far", + "affinity": "INTEGER" + }, + { + "fieldPath": "lost", + "columnName": "lost", + "affinity": "INTEGER" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxRecall", + "columnName": "max_recall", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifier", + "columnName": "modifier", + "affinity": "INTEGER" + }, + { + "fieldPath": "clearType", + "columnName": "clear_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_play_results_uuid", + "unique": true, + "columnNames": [ + "uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_play_results_uuid` ON `${TABLE_NAME}` (`uuid`)" + }, + { + "name": "index_play_results_song_id_rating_class", + "unique": false, + "columnNames": [ + "song_id", + "rating_class" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_results_song_id_rating_class` ON `${TABLE_NAME}` (`song_id`, `rating_class`)" + }, + { + "name": "index_play_results_date", + "unique": false, + "columnNames": [ + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_results_date` ON `${TABLE_NAME}` (`date`)" + } + ] + }, + { + "tableName": "r30_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uuid` BLOB NOT NULL, FOREIGN KEY(`uuid`) REFERENCES `play_results`(`uuid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_r30_entries_uuid", + "unique": false, + "columnNames": [ + "uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_r30_entries_uuid` ON `${TABLE_NAME}` (`uuid`)" + } + ], + "foreignKeys": [ + { + "table": "play_results", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5cfff91a072fbc2edae87fb82863d496')" + ] + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt index 4912c2a2..63ec0c54 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt @@ -68,8 +68,9 @@ import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 AutoMigration(from = 13, to = 14), AutoMigration(from = 14, to = 15), AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18), ], - version = 17, + version = 18, exportSchema = true, ) @ColumnTypeConverters( diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/DifficultyWithSongDao.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/DifficultyWithSongDao.kt index 2d2f2e5b..1cefe603 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/DifficultyWithSongDao.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/DifficultyWithSongDao.kt @@ -34,6 +34,30 @@ private const val DIFFICULTY_WITH_SONG_AND_INFO_QUERY = ORDER BY ci.constant """ +// ChartInfoDatabaseImporter maps source NULL constants to 0, so a present row can +// still carry no usable constant. Both queries count constant <= 0 as missing. +private const val DIFFICULTY_WITH_SONG_MISSING_INFO_QUERY = + """ + SELECT + d.song_id, d.rating_class, d.rating_class_alias, + d.rating, d.rating_plus, + COALESCE(d.title, s.title) AS title, + COALESCE(d.artist, s.artist) AS artist + FROM difficulties d + INNER JOIN songs s ON d.song_id = s.id + LEFT JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class + WHERE ci.song_id IS NULL OR ci.constant <= 0 + """ + +private const val MISSING_CHART_INFO_COUNT_QUERY = + """ + SELECT COUNT(*) + FROM difficulties d + INNER JOIN songs s ON d.song_id = s.id + LEFT JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class + WHERE ci.song_id IS NULL OR ci.constant <= 0 + """ + @Dao interface DifficultyWithSongDao { @Query("$DIFFICULTY_WITH_SONG_QUERY_BASE WHERE d.song_id = :songId AND d.rating_class = :ratingClass") @@ -59,4 +83,10 @@ interface DifficultyWithSongDao { // constant, which is meaningless without it. @Query(DIFFICULTY_WITH_SONG_AND_INFO_QUERY) fun findAllWithInfo(): Flow> + + @Query(DIFFICULTY_WITH_SONG_MISSING_INFO_QUERY) + fun findAllMissingInfo(): Flow> + + @Query(MISSING_CHART_INFO_COUNT_QUERY) + fun countMissingChartInfo(): Flow } diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt index c6cec9cd..21b9d135 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/PlayResultDao.kt @@ -38,6 +38,10 @@ interface PlayResultDao { @Query("SELECT COUNT(*) FROM play_results") fun count(): Flow + /** Rows without a date sort last. */ + @Query("SELECT * FROM play_results ORDER BY date DESC LIMIT :limit") + fun findLatestPlays(limit: Int): Flow> + @Upsert suspend fun upsert(item: PlayResult): Long diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt index 2485f38e..59c5d74a 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/PlayResult.kt @@ -20,6 +20,7 @@ import kotlin.uuid.Uuid indices = [ Index(value = ["uuid"], unique = true), Index(value = ["song_id", "rating_class"]), + Index(value = ["date"]), ], ) data class PlayResult( diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Property.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Property.kt index 010fbcc4..296850a4 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Property.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Property.kt @@ -13,5 +13,8 @@ data class Property( const val KEY_R30_LAST_UPDATED_AT = "r30_last_updated_at" const val KEY_R30_LAST_UPDATED_PLAY_RESULT_UUID = "r30_last_updated_uuid" const val KEY_SCORING_MODE = "scoring_mode" + const val KEY_PACKLIST_LAST_IMPORTED_AT = "packlist_last_imported_at" + const val KEY_SONGS_LAST_IMPORTED_AT = "songs_last_imported_at" + const val KEY_CHART_INFO_LAST_IMPORTED_AT = "chart_info_last_imported_at" } } diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DatabaseReadinessRepository.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DatabaseReadinessRepository.kt new file mode 100644 index 00000000..8762532a --- /dev/null +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DatabaseReadinessRepository.kt @@ -0,0 +1,66 @@ +package xyz.sevive.arcaeaoffline.core.database.repositories + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +enum class DatabaseReadiness { + /** No songs imported. */ + EMPTY, + + /** Songs imported, but no chart info at all. */ + SONGS_ONLY, + + /** Chart info imported, but some difficulties have no constant. */ + PARTIAL, + + /** Every difficulty has a chart info constant. */ + READY, +} + +data class DatabaseReadinessSummary( + val readiness: DatabaseReadiness, + val songCount: Int, + val chartInfoCount: Int, + val missingChartInfoCount: Int, +) + +interface DatabaseReadinessRepository { + fun readiness(): Flow + + fun missingChartInfoCount(): Flow + + fun summary(): Flow +} + +class DatabaseReadinessRepositoryImpl( + private val songRepo: SongRepository, + private val chartInfoRepo: ChartInfoRepository, + private val difficultyWithSongRepo: DifficultyWithSongRepository, +) : DatabaseReadinessRepository { + override fun missingChartInfoCount(): Flow = difficultyWithSongRepo.countMissingChartInfo() + + override fun summary(): Flow = + combine( + songRepo.count(), + chartInfoRepo.count(), + difficultyWithSongRepo.countMissingChartInfo(), + ) { songCount, chartInfoCount, missingCount -> + val readiness = + when { + songCount == 0 -> DatabaseReadiness.EMPTY + chartInfoCount == 0 -> DatabaseReadiness.SONGS_ONLY + missingCount > 0 -> DatabaseReadiness.PARTIAL + else -> DatabaseReadiness.READY + } + + DatabaseReadinessSummary( + readiness = readiness, + songCount = songCount, + chartInfoCount = chartInfoCount, + missingChartInfoCount = missingCount, + ) + } + + override fun readiness(): Flow = summary().map { it.readiness } +} diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DifficultyWithSongRepository.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DifficultyWithSongRepository.kt index 8ad23bb5..3987394b 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DifficultyWithSongRepository.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DifficultyWithSongRepository.kt @@ -22,6 +22,10 @@ interface DifficultyWithSongRepository { fun findAll(): Flow> fun findAllWithInfo(): Flow> + + fun findAllMissingInfo(): Flow> + + fun countMissingChartInfo(): Flow } class DifficultyWithSongRepositoryImpl( @@ -39,4 +43,8 @@ class DifficultyWithSongRepositoryImpl( override fun findAll(): Flow> = dao.findAll() override fun findAllWithInfo(): Flow> = dao.findAllWithInfo() + + override fun findAllMissingInfo(): Flow> = dao.findAllMissingInfo() + + override fun countMissingChartInfo(): Flow = dao.countMissingChartInfo() } diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt index 9a360340..8c4b6873 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PlayResultRepository.kt @@ -22,6 +22,8 @@ interface PlayResultRepository { fun count(): Flow + fun findLatestPlays(limit: Int): Flow> + suspend fun upsert(item: PlayResult): Long suspend fun upsertBatch(vararg items: PlayResult): List @@ -49,6 +51,8 @@ class PlayResultRepositoryImpl( override fun count(): Flow = dao.count() + override fun findLatestPlays(limit: Int): Flow> = dao.findLatestPlays(limit) + override suspend fun upsert(item: PlayResult) = dao.upsert(item) override suspend fun upsertBatch(vararg items: PlayResult) = dao.upsertBatch(*items) diff --git a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt index db404509..f7c7a08a 100644 --- a/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt +++ b/shared/src/commonMain/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/PropertyRepository.kt @@ -33,6 +33,18 @@ interface PropertyRepository { suspend fun r30LastUpdatedAt(): Instant? suspend fun setR30LastUpdatedAt(instant: Instant) + + fun packlistLastImportedAt(): Flow + + suspend fun setPacklistLastImportedAt(instant: Instant) + + fun songsLastImportedAt(): Flow + + suspend fun setSongsLastImportedAt(instant: Instant) + + fun chartInfoLastImportedAt(): Flow + + suspend fun setChartInfoLastImportedAt(instant: Instant) } class PropertyRepositoryImpl( @@ -77,4 +89,26 @@ class PropertyRepositoryImpl( override suspend fun setR30LastUpdatedAt(instant: Instant) { this.upsert(Property(Property.KEY_R30_LAST_UPDATED_AT, instant.toEpochMilliseconds().toString())) } + + private fun lastImportedAt(key: String): Flow = + this.find(key).map { it?.value?.toLongOrNull()?.let(Instant::fromEpochMilliseconds) } + + private suspend fun setLastImportedAt( + key: String, + instant: Instant, + ) { + this.upsert(Property(key, instant.toEpochMilliseconds().toString())) + } + + override fun packlistLastImportedAt(): Flow = lastImportedAt(Property.KEY_PACKLIST_LAST_IMPORTED_AT) + + override suspend fun setPacklistLastImportedAt(instant: Instant) = setLastImportedAt(Property.KEY_PACKLIST_LAST_IMPORTED_AT, instant) + + override fun songsLastImportedAt(): Flow = lastImportedAt(Property.KEY_SONGS_LAST_IMPORTED_AT) + + override suspend fun setSongsLastImportedAt(instant: Instant) = setLastImportedAt(Property.KEY_SONGS_LAST_IMPORTED_AT, instant) + + override fun chartInfoLastImportedAt(): Flow = lastImportedAt(Property.KEY_CHART_INFO_LAST_IMPORTED_AT) + + override suspend fun setChartInfoLastImportedAt(instant: Instant) = setLastImportedAt(Property.KEY_CHART_INFO_LAST_IMPORTED_AT, instant) } diff --git a/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/dao/DifficultyWithSongDaoTest.kt b/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/dao/DifficultyWithSongDaoTest.kt index 27c0ead8..70a1897b 100644 --- a/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/dao/DifficultyWithSongDaoTest.kt +++ b/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/dao/DifficultyWithSongDaoTest.kt @@ -179,4 +179,32 @@ class DifficultyWithSongDaoTest { // Null notes survive the join. assertNull(result.last().notes) } + + @Test + fun findAllMissingInfoTreatsZeroConstantAsMissing() = + runBlocking { + seed() + + // songb/FUTURE has no chart info row at all. + assertEquals( + setOf("songb" to ArcaeaRatingClass.FUTURE), + dao + .findAllMissingInfo() + .first() + .map { it.songId to it.ratingClass } + .toSet(), + ) + + // A row with constant = 0 (the importer maps source NULLs to 0) counts as missing too. + db.chartInfoDao().insert(ChartInfo("songb", ArcaeaRatingClass.FUTURE, constant = 0, notes = 800)) + + assertEquals( + setOf("songb" to ArcaeaRatingClass.FUTURE), + dao + .findAllMissingInfo() + .first() + .map { it.songId to it.ratingClass } + .toSet(), + ) + } } diff --git a/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/dao/PlayResultDaoTest.kt b/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/dao/PlayResultDaoTest.kt new file mode 100644 index 00000000..7e847ebd --- /dev/null +++ b/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/dao/PlayResultDaoTest.kt @@ -0,0 +1,89 @@ +package xyz.sevive.arcaeaoffline.core.dao + +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase +import xyz.sevive.arcaeaoffline.core.database.daos.PlayResultDao +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class PlayResultDaoTest { + private lateinit var db: ArcaeaOfflineDatabase + private lateinit var dao: PlayResultDao + + @BeforeTest + fun setUp() { + db = + Room + .inMemoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .build() + dao = db.playResultDao() + } + + @AfterTest + fun tearDown() { + db.close() + } + + private suspend fun seed() { + db.playResultDao().upsertBatch( + PlayResult( + songId = "songa", + ratingClass = ArcaeaRatingClass.FUTURE, + score = 9_900_000, + date = Instant.fromEpochMilliseconds(2000), + ), + PlayResult( + songId = "songa", + ratingClass = ArcaeaRatingClass.BEYOND, + score = 9_800_000, + date = Instant.fromEpochMilliseconds(3000), + ), + PlayResult( + songId = "songb", + ratingClass = ArcaeaRatingClass.PAST, + score = 9_700_000, + date = Instant.fromEpochMilliseconds(1000), + ), + ) + } + + @Test + fun findLatestPlaysOrdersByDateDescendingAndLimits() = + runBlocking { + seed() + + val result = dao.findLatestPlays(2).first() + + assertEquals( + listOf( + "songa" to ArcaeaRatingClass.BEYOND, + "songa" to ArcaeaRatingClass.FUTURE, + ), + result.map { it.songId to it.ratingClass }, + ) + } + + @Test + fun findLatestPlaysSortsNullDatesLast() = + runBlocking { + seed() + db.playResultDao().upsert( + PlayResult(songId = "songb", ratingClass = ArcaeaRatingClass.FUTURE, score = 9_600_000, date = null), + ) + + val result = dao.findLatestPlays(10).first() + + assertEquals(listOf(3000L, 2000L, 1000L, null), result.map { it.date?.toEpochMilliseconds() }) + assertNull(result.last().date) + } +} diff --git a/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DatabaseReadinessRepositoryTest.kt b/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DatabaseReadinessRepositoryTest.kt new file mode 100644 index 00000000..07a8ba79 --- /dev/null +++ b/shared/src/jvmTest/kotlin/xyz/sevive/arcaeaoffline/core/database/repositories/DatabaseReadinessRepositoryTest.kt @@ -0,0 +1,159 @@ +package xyz.sevive.arcaeaoffline.core.database.repositories + +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty +import xyz.sevive.arcaeaoffline.core.database.entities.Song +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class DatabaseReadinessRepositoryTest { + private lateinit var db: ArcaeaOfflineDatabase + private lateinit var repository: DatabaseReadinessRepository + + @BeforeTest + fun setUp() { + db = + Room + .inMemoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .build() + repository = + DatabaseReadinessRepositoryImpl( + songRepo = SongRepositoryImpl(db.songDao()), + chartInfoRepo = ChartInfoRepositoryImpl(db.chartInfoDao()), + difficultyWithSongRepo = DifficultyWithSongRepositoryImpl(db.difficultyWithSongDao()), + ) + } + + @AfterTest + fun tearDown() { + db.close() + } + + private fun song( + id: String, + idx: Int, + ) = Song( + idx = idx, + id = id, + title = id, + artist = "Artist", + set = "base", + side = 1, + ) + + private fun difficulty( + songId: String, + ratingClass: ArcaeaRatingClass, + ) = Difficulty( + songId = songId, + ratingClass = ratingClass, + ratingClassAlias = null, + rating = 90, + ratingPlus = false, + chartDesigner = null, + jacketDesigner = null, + audioOverride = false, + jacketOverride = false, + jacketNight = null, + title = null, + artist = null, + bg = null, + bgInverse = null, + bpm = null, + bpmBase = null, + version = null, + date = null, + ) + + // songb/FUTURE is left without chart info. + private suspend fun seedPartial() { + db.songDao().upsertBatch(song("songa", idx = 1), song("songb", idx = 2)) + db.difficultyDao().upsertBatch( + difficulty("songa", ArcaeaRatingClass.FUTURE), + difficulty("songa", ArcaeaRatingClass.BEYOND), + difficulty("songb", ArcaeaRatingClass.PAST), + difficulty("songb", ArcaeaRatingClass.FUTURE), + ) + db.chartInfoDao().insertBatch( + ChartInfo("songa", ArcaeaRatingClass.FUTURE, constant = 960, notes = 1200), + ChartInfo("songa", ArcaeaRatingClass.BEYOND, constant = 1080, notes = null), + ChartInfo("songb", ArcaeaRatingClass.PAST, constant = 410, notes = 500), + ) + } + + @Test + fun emptyWhenNoSongs() = + runBlocking { + assertEquals(DatabaseReadiness.EMPTY, repository.readiness().first()) + assertEquals(0, repository.missingChartInfoCount().first()) + } + + @Test + fun songsOnlyWhenNoChartInfo() = + runBlocking { + db.songDao().upsertBatch(song("songa", idx = 1)) + + assertEquals(DatabaseReadiness.SONGS_ONLY, repository.readiness().first()) + } + + @Test + fun partialWhenSomeDifficultiesLackChartInfo() = + runBlocking { + seedPartial() + + assertEquals(DatabaseReadiness.PARTIAL, repository.readiness().first()) + assertEquals(1, repository.missingChartInfoCount().first()) + } + + @Test + fun readyWhenEveryDifficultyHasChartInfo() = + runBlocking { + seedPartial() + db.chartInfoDao().insert( + ChartInfo("songb", ArcaeaRatingClass.FUTURE, constant = 700, notes = 800), + ) + + assertEquals(DatabaseReadiness.READY, repository.readiness().first()) + assertEquals(0, repository.missingChartInfoCount().first()) + } + + @Test + fun partialWhenChartInfoConstantIsZero() = + runBlocking { + seedPartial() + // Filling the only missing difficulty with constant = 0 must not reach READY. + db.chartInfoDao().insert( + ChartInfo("songb", ArcaeaRatingClass.FUTURE, constant = 0, notes = 800), + ) + + assertEquals(DatabaseReadiness.PARTIAL, repository.readiness().first()) + assertEquals(1, repository.missingChartInfoCount().first()) + } + + @Test + fun summaryCarriesReadinessAndCounts() = + runBlocking { + seedPartial() + + val summary = repository.summary().first() + + assertEquals( + DatabaseReadinessSummary( + readiness = DatabaseReadiness.PARTIAL, + songCount = 2, + chartInfoCount = 3, + missingChartInfoCount = 1, + ), + summary, + ) + } +}