diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/MainActivity.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/MainActivity.kt index aed64a16..5f3f0236 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/MainActivity.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/MainActivity.kt @@ -15,6 +15,11 @@ import org.opencv.android.OpenCVLoader import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme class MainActivity : ComponentActivity() { + companion object { + /** Extra carrying the route a fresh session should open on, e.g. an OCR sub-screen route. */ + const val EXTRA_INITIAL_ROUTE = "xyz.sevive.arcaeaoffline.extra.INITIAL_ROUTE" + } + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -26,13 +31,15 @@ class MainActivity : ComponentActivity() { OpenCVLoader.initLocal() FileKit.init(this) + val initialRoute = intent?.getStringExtra(EXTRA_INITIAL_ROUTE) + setContent { ArcaeaOfflineTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - MainScreen() + MainScreen(initialNavigateRoute = initialRoute) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt index 0dcea2e5..2d2509a0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/MainScreen.kt @@ -6,7 +6,11 @@ import androidx.compose.material3.Text import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.Modifier import androidx.compose.ui.res.stringResource import androidx.navigation.NavController @@ -31,13 +35,43 @@ internal fun mainNavControllerNavigateToRoute( } @Composable -fun MainScreen(modifier: Modifier = Modifier) { +fun MainScreen( + modifier: Modifier = Modifier, + initialNavigateRoute: String? = null, +) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route val currentRouteRoot = currentRoute?.split("/") + // The intent extra survives activity recreation (rotation, process-death restore, etc.), + // so consuming the initial route to prevent unexpected extra navigations. + // A new jump recreates the activity via CLEAR_TOP, which starts with fresh state. + var initialRouteConsumed by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(initialNavigateRoute) { + val route = initialNavigateRoute ?: return@LaunchedEffect + if (initialRouteConsumed) return@LaunchedEffect + val tabRoute = route.substringBefore('/') + if (MainScreen.entries.none { it.route == tabRoute }) return@LaunchedEffect + + // Navigate with a fresh tab stack: + // 1. Omit restoreState: prevents restoring a stale nested/detail destination instead of this fresh entry. + // 2. popUpTo(startRoute, inclusive = true): replaces the initial root so pressing Back exits directly + // to the caller activity, while saving the start destination's state for subsequent tab switches. + val startRoute = navController.graph.startDestinationRoute ?: return@LaunchedEffect + navController.navigate(tabRoute) { + popUpTo(startRoute) { + saveState = true + inclusive = true + } + launchSingleTop = true + } + + initialRouteConsumed = true + } + val navigationSuiteColors = NavigationSuiteDefaults.colors( navigationBarContainerColor = MaterialTheme.colorScheme.inverseOnSurface, @@ -51,7 +85,7 @@ fun MainScreen(modifier: Modifier = Modifier) { icon = { Icon(it.icon(), contentDescription = null) }, label = { Text(stringResource(it.title)) }, alwaysShowLabel = true, - selected = if (currentRouteRoot != null) currentRouteRoot[0] == it.route else false, + selected = currentRouteRoot != null && currentRouteRoot[0] == it.route, onClick = { mainNavControllerNavigateToRoute(navController, it.route) }, ) } @@ -59,6 +93,6 @@ fun MainScreen(modifier: Modifier = Modifier) { modifier = modifier, navigationSuiteColors = navigationSuiteColors, ) { - MainNavigationGraph(navController) + MainNavigationGraph(navController, initialNavigateRoute) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt index f7d6e3e7..732fcfea 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/OcrFromShareActivity.kt @@ -1,21 +1,14 @@ package xyz.sevive.arcaeaoffline import android.content.Intent -import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.core.content.IntentCompat import io.github.vinceglb.filekit.FileKit -import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.init -import io.github.vinceglb.filekit.readBytes -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.koin.androidx.viewmodel.ext.android.viewModel import org.opencv.android.OpenCVLoader import xyz.sevive.arcaeaoffline.helpers.activity.getSourcePackageName @@ -26,7 +19,6 @@ import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme class OcrFromShareActivity : ComponentActivity() { private val viewModel by viewModel() - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -36,13 +28,10 @@ class OcrFromShareActivity : ComponentActivity() { setTitle(R.string.title_activity_ocr_from_share) - viewModel.setShareSourceApp(this.getSourcePackageName(), packageManager) + viewModel.setShareSourceApp(getSourcePackageName(), packageManager) setContent { - val windowSizeClass = calculateWindowSizeClass(this) - ArcaeaOfflineTheme { OcrFromShareScreen( - windowSizeClass, onReturnToShareApp = { finishAffinity() }, onStayInApp = { val intent = Intent(this@OcrFromShareActivity, MainActivity::class.java) @@ -59,7 +48,15 @@ class OcrFromShareActivity : ComponentActivity() { when (intent?.action) { Intent.ACTION_SEND -> { if (intent.type?.startsWith("image/") == true) { - handleSingleImageOcr() + // fix 'getParcelableExtra(String!): T?' is deprecated. Deprecated in Java + // https://stackoverflow.com/a/75824124/16484891 + // CC BY-SA 4.0 + IntentCompat + .getParcelableExtra( + intent, + Intent.EXTRA_STREAM, + Uri::class.java, + )?.let(viewModel::onImageShared) } } @@ -73,34 +70,4 @@ class OcrFromShareActivity : ComponentActivity() { else -> {} } } - - private fun handleSingleImageOcr() { - // fix 'getParcelableExtra(String!): T?' is deprecated. Deprecated in Java - // https://stackoverflow.com/a/75824124/16484891 - // CC BY-SA 4.0 - val uri = - IntentCompat.getParcelableExtra( - intent, - Intent.EXTRA_STREAM, - Uri::class.java, - ) ?: return - - // get an input stream from the Uri - // TODO: use input stream - val imageBytes = - runCatching { - runBlocking { PlatformFile(uri).readBytes() } - }.getOrElse { - viewModel.setException(Exception("Error reading image", it)) - return - } - - val imgBitmap = BitmapFactory.decodeStream(imageBytes.inputStream()) - viewModel.setBitmap(imgBitmap) - runBlocking { - launch { - viewModel.startOcr(uri, this@OcrFromShareActivity) - } - } - } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt index 1f73291a..b7269bb9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt @@ -119,3 +119,10 @@ data class CrnnModelStatusDetail( } } } + +/** + * 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. + */ +fun ImageHashesDatabaseStatusDetail.needsSetup(): Boolean = absence || exception != null || jacketCount == 0 || partnerIconCount == 0 diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveBitmapToGallery.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveBitmapToGallery.kt deleted file mode 100644 index 0e7b0ba5..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveBitmapToGallery.kt +++ /dev/null @@ -1,108 +0,0 @@ -package xyz.sevive.arcaeaoffline.permissions.storage - -import android.Manifest -import android.content.ContentValues -import android.content.Context -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import androidx.core.content.ContextCompat -import co.touchlab.kermit.Logger -import java.io.File -import java.io.FileOutputStream -import kotlin.time.Clock - -private fun checkSaveToGalleryPermission(context: Context): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return true - - val checkPermission = - ContextCompat.checkSelfPermission( - context, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ) - return checkPermission == PackageManager.PERMISSION_GRANTED -} - -class SaveBitmapToGallery( - private val bitmap: Bitmap, -) { - companion object { - const val DIRECTORY = "Arcaea Offline" - const val TAG = "SaveBitmapToGallery" - - fun checkPermission(context: Context): Boolean = checkSaveToGalleryPermission(context) - } - - private val logger = Logger.withTag(TAG) - - /** - * https://stackoverflow.com/a/73554532/16484891 - * - * CC BY-SA 4.0 - * @see link - */ - private fun saveBitmap( - context: Context, - filename: String, - compressFormat: Bitmap.CompressFormat, - quality: Int, - ): Boolean { - logger.i { "Saving $filename to gallery" } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - if (!checkPermission(context)) return false - - val parentDir = - File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), - DIRECTORY, - ) - - if (!parentDir.exists()) { - logger.i { "Creating parent dir, success: ${parentDir.mkdirs()}" } - } - FileOutputStream(File(parentDir, filename)).use { - bitmap.compress(compressFormat, quality, it) - } - } else { - val values = - ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, filename) - // put(MediaStore.MediaColumns.MIME_TYPE, mimeType) - put( - MediaStore.MediaColumns.RELATIVE_PATH, - "${Environment.DIRECTORY_PICTURES}/$DIRECTORY", - ) - put(MediaStore.MediaColumns.IS_PENDING, 1) - } - - val resolver = context.contentResolver - val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) - uri - ?.let { resolver.openOutputStream(it) } - ?.use { bitmap.compress(compressFormat, quality, it) } - - values.clear() - values.put(MediaStore.MediaColumns.IS_PENDING, 0) - uri?.also { resolver.update(it, values, null, null) } - } - - return true - } - - private fun getDefaultFileBaseName(): String = "arcaea-offline-${Clock.System.now().toEpochMilliseconds()}" - - fun saveJpg( - context: Context, - fileBaseName: String = getDefaultFileBaseName(), - quality: Int = 100, - ) = saveBitmap(context, "$fileBaseName.jpg", Bitmap.CompressFormat.JPEG, quality) - - fun savePng( - context: Context, - fileBaseName: String = getDefaultFileBaseName(), - quality: Int = 100, - ) = saveBitmap(context, "$fileBaseName.png", Bitmap.CompressFormat.PNG, quality) -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt new file mode 100644 index 00000000..a98c4847 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/permissions/storage/SaveImageToGallery.kt @@ -0,0 +1,145 @@ +package xyz.sevive.arcaeaoffline.permissions.storage + +import android.Manifest +import android.content.ContentValues +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.ContextCompat +import co.touchlab.kermit.Logger +import java.io.File +import java.io.IOException + +/** Writes image bytes into the public Pictures gallery. */ +object SaveImageToGallery { + const val DIRECTORY = "Arcaea Offline" + const val LOG_TAG = "SaveImageToGallery" + + private val logger = Logger.withTag(LOG_TAG) + + fun checkPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return true + + val checkPermission = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) + return checkPermission == PackageManager.PERMISSION_GRANTED + } + + /** + * https://stackoverflow.com/a/73554532/16484891 + * + * CC BY-SA 4.0 + * @see link + * @param dateTakenMillis The image's logical date in epoch millis, for gallery apps use it for + * timeline ordering on API 29+. + * @param mimeType Override MediaStore's mime type prediction + * @throws IOException If writing the image fails, including MediaStore rejecting the entry. + * @throws SecurityException On API < 29 when [checkPermission] is false. + */ + fun saveImageBytes( + context: Context, + fileName: String, + bytes: ByteArray, + dateTakenMillis: Long, + mimeType: String, + ) { + logger.i { "Saving $fileName to gallery" } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + saveImageBytesPreQ(context, fileName, bytes) + } else { + saveImageBytesQPlus(context, fileName, bytes, dateTakenMillis, mimeType) + } + } + + private fun saveImageBytesPreQ( + context: Context, + fileName: String, + bytes: ByteArray, + ) { + if (!checkPermission(context)) { + throw SecurityException("WRITE_EXTERNAL_STORAGE permission is not granted") + } + + val parentDir = + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + DIRECTORY, + ) + + if (!parentDir.exists()) { + logger.i { "Creating parent dir ${parentDir.path}, success: ${parentDir.mkdirs()}" } + } + + // Q+ MediaProvider renames duplicate display names itself. + // Older API would silently overwrite, so pick a free name instead. + var target = File(parentDir, fileName) + if (target.exists()) { + val dot = fileName.lastIndexOf('.') + val base = if (dot < 0) fileName else fileName.substring(0, dot) + val ext = if (dot < 0) "" else fileName.substring(dot) + + var index = 1 + while (target.exists()) { + target = File(parentDir, "$base ($index)$ext") + index++ + } + } + try { + target.outputStream().use { it.write(bytes) } + } catch (e: Exception) { + // A failed save must not leave a partial file behind + target.delete() + throw e + } + } + + private fun saveImageBytesQPlus( + context: Context, + fileName: String, + bytes: ByteArray, + dateTakenMillis: Long, + mimeType: String, + ) { + val values = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + put( + MediaStore.MediaColumns.RELATIVE_PATH, + "${Environment.DIRECTORY_PICTURES}/$DIRECTORY", + ) + put(MediaStore.Images.Media.DATE_TAKEN, dateTakenMillis) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + val resolver = context.contentResolver + val uri = + resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("Failed to create a MediaStore entry for $fileName") + + try { + val outputStream = + resolver.openOutputStream(uri) + ?: throw IOException("Failed to open the MediaStore entry for writing") + + outputStream.use { it.write(bytes) } + + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + if (resolver.update(uri, values, null, null) == 0) { + throw IOException("Failed to finalize the MediaStore entry for $fileName") + } + } catch (e: Exception) { + // A failed save must not leave the IS_PENDING placeholder behind + runCatching { resolver.delete(uri, null, null) } + .onFailure { logger.w(it) { "Failed to remove the pending MediaStore entry" } } + throw e + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/AdaptiveEntryScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/AdaptiveEntryScreen.kt index ddf4ad54..6c506bc9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/AdaptiveEntryScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/AdaptiveEntryScreen.kt @@ -23,6 +23,9 @@ import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext * * [listPane] and [detailPane] can read navigation operations * from [LocalListDetailNavigationContext] without threading parameters. + * + * [initialDetailRoute] seeds the detail pane for a fresh composition (e.g. deep links); + * `null` starts on the list pane alone. */ @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable @@ -30,10 +33,11 @@ fun AdaptiveEntryScreen( listPane: @Composable () -> Unit, detailPane: @Composable (route: String) -> Unit, extraPane: (@Composable (route: String) -> Unit)? = null, + initialDetailRoute: String? = null, ) { val navigator = rememberListDetailPaneScaffoldNavigator() val coroutineScope = rememberCoroutineScope() - var detailPaneRoute by rememberSaveable { mutableStateOf(null) } + var detailPaneRoute by rememberSaveable { mutableStateOf(initialDetailRoute) } var extraPaneRoute by rememberSaveable { mutableStateOf(null) } LaunchedEffect(Unit) { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/AppIconLabelButton.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/AppIconLabelButton.kt deleted file mode 100644 index bf2cad45..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/AppIconLabelButton.kt +++ /dev/null @@ -1,56 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import xyz.sevive.arcaeaoffline.ui.theme.spacing - -@Composable -internal fun AppIconLabelButton( - onClick: () -> Unit, - appIcon: @Composable () -> Unit, - appLabel: @Composable () -> Unit, - modifier: Modifier = Modifier, - actionText: (@Composable () -> Unit)? = null, - colors: ButtonColors = ButtonDefaults.buttonColors(), - trailingIcon: @Composable () -> Unit = { Icon(Icons.AutoMirrored.Default.ArrowForward, null) }, -) { - Button( - onClick = onClick, - modifier = modifier, - colors = colors, - shape = RoundedCornerShape(20.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - appIcon() - - Column( - Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.iconTextGap), - ) { - appLabel() - - if (actionText != null) { - actionText() - } - } - - trailingIcon() - } - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt index 2977aee9..3607905f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareActions.kt @@ -1,108 +1,147 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare -import android.Manifest -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive -import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.launch +import androidx.compose.ui.tooling.preview.Preview import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.permissions.storage.SaveBitmapToGallery -import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog -import xyz.sevive.arcaeaoffline.ui.components.IconRow -import xyz.sevive.arcaeaoffline.ui.theme.spacing +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorScoreIsZeroWarning +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.ui.components.SaveWithWarningsDialog +import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @Composable -internal fun OcrFromShareActions(ocrFromShareViewModel: OcrFromShareViewModel) { - val coroutineScope = rememberCoroutineScope() +internal fun OcrFromShareActions( + hasPlayResult: Boolean, + playResultSaveState: OcrFromShareViewModel.PlayResultSaveState, + warnings: List, + onSavePlayResult: () -> Unit, + onCachePlayResult: () -> Unit, +) { + val playResultSaved = playResultSaveState.saved + val playResultCached = playResultSaveState.cached + val canSavePlayResult = hasPlayResult && !playResultSaved && !playResultCached - val context = LocalContext.current - - val handleSavePicture = { ocrFromShareViewModel.saveImage(context) } - val requestWriteStoragePermissionLauncher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { if (it) handleSavePicture() } - - var showSavePicturePermissionRequiredDialog by rememberSaveable { mutableStateOf(false) } - if (showSavePicturePermissionRequiredDialog) { - PermissionRequiredDialog( - onDismiss = { showSavePicturePermissionRequiredDialog = false }, + var showSaveWithWarningsDialog by rememberSaveable { mutableStateOf(false) } + if (showSaveWithWarningsDialog) { + SaveWithWarningsDialog( + warnings = warnings, onConfirm = { - requestWriteStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - showSavePicturePermissionRequiredDialog = false + showSaveWithWarningsDialog = false + onSavePlayResult() }, - functionName = stringResource(R.string.permission_function_save_image), - permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + onDismissRequest = { showSaveWithWarningsDialog = false }, ) } - val score by ocrFromShareViewModel.playResult.collectAsStateWithLifecycle() - val scoreSaved by ocrFromShareViewModel.scoreSaved.collectAsStateWithLifecycle() - val imageSaved by ocrFromShareViewModel.imageSaved.collectAsStateWithLifecycle() - val scoreCached by ocrFromShareViewModel.scoreCached.collectAsStateWithLifecycle() - - Column { - Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { - Button( - onClick = { coroutineScope.launch { ocrFromShareViewModel.saveScore() } }, - enabled = score != null && !scoreSaved && !scoreCached, - ) { - IconRow { - Icon(Icons.Default.Save, null) - Text(stringResource(R.string.general_save)) - } + // The whole row morphs into a single saved segment once the play result is saved. + AnimatedContent(playResultSaved) { saved -> + if (saved) { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + // Selected and disabled renders through the disabled-active colors + // The alpha-faded defaults would wash the primary out + SegmentedButton( + selected = true, + onClick = {}, + enabled = false, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 1), + colors = + SegmentedButtonDefaults.colors( + disabledActiveContainerColor = Color.Transparent, + disabledActiveBorderColor = MaterialTheme.colorScheme.primary, + disabledActiveContentColor = MaterialTheme.colorScheme.primary, + ), + label = { Text(stringResource(R.string.ocr_from_share_play_result_saved)) }, + ) } + } else { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SegmentedButton( + selected = false, + onClick = { + if (warnings.isEmpty()) { + onSavePlayResult() + } else { + showSaveWithWarningsDialog = true + } + }, + enabled = canSavePlayResult, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + colors = + SegmentedButtonDefaults.colors( + inactiveContainerColor = MaterialTheme.colorScheme.primary, + inactiveContentColor = MaterialTheme.colorScheme.onPrimary, + ), + icon = { Icon(Icons.Default.Save, null) }, + label = { Text(stringResource(R.string.general_save)) }, + ) - OutlinedButton( - onClick = { coroutineScope.launch { ocrFromShareViewModel.cacheScore(context) } }, - enabled = score != null && !scoreSaved && !scoreCached, - ) { - IconRow { - Icon(Icons.Default.Archive, null) - Text(stringResource(R.string.ocr_from_share_cache_score_button)) - } + SegmentedButton( + selected = false, + onClick = onCachePlayResult, + enabled = canSavePlayResult, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + icon = { Icon(Icons.Default.Archive, null) }, + label = { Text(stringResource(R.string.ocr_from_share_cache_play_result_button)) }, + ) } } + } +} - OutlinedButton( - onClick = { - if (SaveBitmapToGallery.checkPermission(context)) { - handleSavePicture() - } else { - showSavePicturePermissionRequiredDialog = true - } - }, - enabled = !imageSaved, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.secondary, - ), - ) { - IconRow { - Icon(Icons.Default.FileDownload, null) - Text(stringResource(R.string.ocr_from_share_save_image_button)) - } +@Preview(name = "Actions", showBackground = true) +@Composable +private fun PreviewActions() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareActions( + hasPlayResult = true, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = false, + cached = false, + ), + warnings = listOf(ArcaeaPlayResultValidatorScoreIsZeroWarning), + onSavePlayResult = {}, + onCachePlayResult = {}, + ) + } + } +} + +@Preview(name = "Actions saved", showBackground = true) +@Composable +private fun PreviewActionsSaved() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareActions( + hasPlayResult = true, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = true, + cached = false, + ), + warnings = emptyList(), + onSavePlayResult = {}, + onCachePlayResult = {}, + ) } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt index 23276b97..ac25a496 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt @@ -1,18 +1,134 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +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.Check +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +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.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatus +import xyz.sevive.arcaeaoffline.helpers.needsSetup +import xyz.sevive.arcaeaoffline.ui.components.BaseSettingsItem +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.theme.spacing + +private fun OcrFromShareViewModel.OcrDependencyViewersUiState.allDependenciesReady(): Boolean = + imageHashesDatabase.statusDetail.status() == OcrDependencyStatus.OK && + crnnModel.statusDetail.status() == OcrDependencyStatus.OK @Composable internal fun OcrFromShareOcrDependencyStatusCard( - uiState: OcrFromShareViewModel.OcrDependencyViewersUiState, + uiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, + onNavigateToDependencies: () -> Unit, modifier: Modifier = Modifier, ) { - Card(modifier) { - OcrDependencyImageHashesDatabaseStatusViewer(uiState = uiState.imageHashesDatabase) - OcrDependencyCrnnModelStatusViewer(uiState = uiState.crnnModel) + val allReady = uiState?.allDependenciesReady() ?: false + var expanded by rememberSaveable(allReady) { mutableStateOf(!allReady) } + + // Smooths the loading-to-loaded swap and any later content size change. + Card(modifier.animateContentSize()) { + OcrFromShareOcrDependencyStatusCardHeader( + uiState = uiState, + expanded = expanded, + onClick = uiState?.let { { expanded = !expanded } }, + ) + + uiState?.let { + AnimatedVisibility(visible = expanded) { + Column { + OcrDependencyImageHashesDatabaseStatusViewer(uiState = it.imageHashesDatabase) + OcrDependencyCrnnModelStatusViewer(uiState = it.crnnModel) + + if (it.imageHashesDatabase.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 = onNavigateToDependencies, + ) + } + } + } + } } } + +@Composable +private fun OcrFromShareOcrDependencyStatusCardHeader( + uiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, + expanded: Boolean, + onClick: (() -> Unit)?, +) { + val allReady = uiState?.allDependenciesReady() ?: false + val chevronRotation by animateFloatAsState( + targetValue = if (expanded) 0f else -90f, + label = "DependencyCardChevronRotation", + ) + + BaseSettingsItem( + title = { Text(stringResource(R.string.ocr_dependencies_title)) }, + onClick = onClick, + leadingSlot = { + Icon( + Icons.Default.Api, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingSlot = { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (uiState == null) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + } else if (allReady) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + + Icon( + Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.graphicsLayer { rotationZ = chevronRotation }, + ) + } + }, + ) +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt index ff1a122f..61dfd2e2 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrResult.kt @@ -1,14 +1,29 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -16,52 +31,251 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp 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.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong +import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.ui.components.ArcaeaChartSelector import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultCard import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultEditorDialog -import xyz.sevive.arcaeaoffline.ui.components.rememberArcaeaChartDisplay +import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface +import xyz.sevive.arcaeaoffline.ui.components.IconRow +import xyz.sevive.arcaeaoffline.ui.helpers.DifficultyWithSongDisplay +import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme +import xyz.sevive.arcaeaoffline.ui.theme.spacing +import kotlin.time.Instant @Composable -internal fun OcrFromShareOcrResult(viewModel: OcrFromShareViewModel) { - val score by viewModel.playResult.collectAsStateWithLifecycle() - val exception by viewModel.exception.collectAsStateWithLifecycle() - val display by rememberArcaeaChartDisplay(score?.songId, score?.ratingClass) +internal fun OcrFromShareOcrResult( + playResult: PlayResult?, + onSetPlayResult: (PlayResult) -> Unit, + display: DifficultyWithSongDisplay?, + warnings: List, + playResultSaveState: OcrFromShareViewModel.PlayResultSaveState, + exception: Exception?, + onRetryOcr: () -> Unit, +) { + val playResultSaved = playResultSaveState.saved + val playResultCached = playResultSaveState.cached + var showPlayResultEditorDialog by rememberSaveable { mutableStateOf(false) } - var showScoreEditorDialog by rememberSaveable { mutableStateOf(false) } - - if (showScoreEditorDialog && score != null) { + if (showPlayResultEditorDialog && playResult != null) { ArcaeaPlayResultEditorDialog( - onDismiss = { showScoreEditorDialog = false }, - playResult = score!!, - onPlayResultChange = { viewModel.setScore(it) }, + onDismiss = { showPlayResultEditorDialog = false }, + playResult = playResult, + onPlayResultChange = onSetPlayResult, ) } - val scoreSaved by viewModel.scoreSaved.collectAsStateWithLifecycle() - val scoreCached by viewModel.scoreCached.collectAsStateWithLifecycle() + var showChartSelector by rememberSaveable { mutableStateOf(false) } - if (score != null) { - Row(verticalAlignment = Alignment.Bottom) { - ArcaeaPlayResultCard( - playResult = score!!, - modifier = Modifier.weight(1f), - difficultyWithSong = display?.difficultyWithSong, - chartInfo = display?.chartInfo, + if (showChartSelector && playResult != null) { + BasicAlertDialogSurface(onDismissRequest = { showChartSelector = false }) { + ArcaeaChartSelector( + songId = playResult.songId, + ratingClass = playResult.ratingClass, + onDifficultyChange = { + onSetPlayResult(playResult.copy(songId = it.songId, ratingClass = it.ratingClass)) + // do not close dialog here since the selector will fire its initial selection + // when the dialog opens + }, ) + } + } + + // Size changes between the waiting, error, and result branches animate instead of jumping. + Box(Modifier.animateContentSize()) { + if (playResult != null) { + Row(verticalAlignment = Alignment.Bottom) { + ArcaeaPlayResultCard( + playResult = playResult, + modifier = Modifier.weight(1f), + difficultyWithSong = display?.difficultyWithSong, + chartInfo = display?.chartInfo, + warnings = warnings, + ) + + AnimatedVisibility(visible = !playResultSaved && !playResultCached) { + Box(Modifier.wrapContentSize(Alignment.TopEnd)) { + var showEditMenu by rememberSaveable { mutableStateOf(false) } + IconButton(onClick = { showEditMenu = true }) { + Icon(Icons.Default.Edit, contentDescription = null) + } - AnimatedVisibility(visible = !scoreSaved && !scoreCached) { - IconButton(onClick = { showScoreEditorDialog = true }) { - Icon( - Icons.Default.Edit, - contentDescription = stringResource(R.string.general_edit), + DropdownMenu(expanded = showEditMenu, onDismissRequest = { showEditMenu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.arcaea_play_result)) }, + onClick = { + showPlayResultEditorDialog = true + showEditMenu = false + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.arcaea_chart)) }, + onClick = { + showChartSelector = true + showEditMenu = false + }, + ) + } + } + } + } + } else if (exception != null) { + Column { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.error, + ) { + Text( + stringResource(R.string.ocr_from_share_error), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), ) + Text(exception.toString()) + } + + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + + FilledTonalButton(onClick = onRetryOcr) { + IconRow { + Icon(Icons.Default.Refresh, contentDescription = null) + Text(stringResource(R.string.ocr_from_share_retry_ocr)) + } } } + } else { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xxs)) { + Text(stringResource(R.string.ocr_from_share_waiting_result)) + LinearProgressIndicator(gapSize = 0.dp) + } + } + } +} + +private fun previewPlayResult(): PlayResult = + PlayResult( + songId = "song_id", + ratingClass = ArcaeaRatingClass.PRESENT, + score = 10_001_230, + pure = 1284, + far = 0, + lost = 0, + maxRecall = 1234, + date = Instant.fromEpochSeconds(616), + clearType = ArcaeaPlayResultClearType.FULL_RECALL, + modifier = ArcaeaPlayResultModifier.HARD, + ) + +private fun previewDisplay(): DifficultyWithSongDisplay = + DifficultyWithSongDisplay( + difficultyWithSong = + DifficultyWithSong( + songId = "song_id", + ratingClass = ArcaeaRatingClass.PRESENT, + rating = 12, + ratingPlus = true, + title = "Test if Y", + artist = "void mian();", + ), + chartInfo = + ChartInfo( + songId = "song_id", + ratingClass = ArcaeaRatingClass.PRESENT, + constant = 129, + notes = 1234, + ), + ) + +@Preview(name = "OCR success", showBackground = true) +@Composable +private fun PreviewOcrResultWithScore() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareOcrResult( + playResult = previewPlayResult(), + display = previewDisplay(), + warnings = emptyList(), + exception = null, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = false, + cached = false, + ), + onSetPlayResult = {}, + onRetryOcr = {}, + ) + } + } +} + +@Preview(name = "OCR success & saved", showBackground = true) +@Composable +private fun PreviewOcrResultScoreSaved() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareOcrResult( + playResult = previewPlayResult(), + display = previewDisplay(), + warnings = emptyList(), + exception = null, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = true, + cached = false, + ), + onSetPlayResult = {}, + onRetryOcr = {}, + ) + } + } +} + +@Preview(name = "OCR error", showBackground = true, heightDp = 200) +@Composable +private fun PreviewOcrResultError() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareOcrResult( + playResult = null, + display = null, + warnings = emptyList(), + exception = IllegalStateException("Wow error"), + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = false, + cached = false, + ), + onSetPlayResult = {}, + onRetryOcr = {}, + ) + } + } +} + +@Preview(name = "OCR waiting", showBackground = true, heightDp = 120) +@Composable +private fun PreviewOcrResultWaiting() { + ArcaeaOfflineTheme { + Surface { + OcrFromShareOcrResult( + playResult = null, + display = null, + warnings = emptyList(), + exception = null, + playResultSaveState = + OcrFromShareViewModel.PlayResultSaveState( + saved = false, + cached = false, + ), + onSetPlayResult = {}, + onRetryOcr = {}, + ) } - } else if (exception != null) { - Text(exception.toString(), color = MaterialTheme.colorScheme.error) - } else { - Text(stringResource(R.string.ocr_from_share_waiting_result)) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt index c107cca6..1e43a819 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareScreen.kt @@ -1,111 +1,154 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare +import android.Manifest +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio 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.layout.size -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.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.outlined.BrokenImage +import androidx.compose.material.icons.outlined.Image import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.WindowSizeClass -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +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.BiasAlignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.PainterState +import com.github.panpf.sketch.rememberAsyncImageState +import xyz.sevive.arcaeaoffline.MainActivity import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.SECONDARY_ALPHA +import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery +import xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare.OcrFromShareViewModel.OcrResultState +import xyz.sevive.arcaeaoffline.ui.common.PermissionRequiredDialog +import xyz.sevive.arcaeaoffline.ui.components.IconRow +import xyz.sevive.arcaeaoffline.ui.components.ImagePreviewDialogFullscreen +import xyz.sevive.arcaeaoffline.ui.components.TextItem +import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen import xyz.sevive.arcaeaoffline.ui.theme.spacing @Composable -internal fun OcrFromShareReturnToShareAppButton( +private fun OcrFromShareReturnToShareAppButton( + shareSource: OcrFromShareViewModel.ShareSourceUiState, onReturnToShareApp: () -> Unit, - ocrFromShareViewModel: OcrFromShareViewModel, modifier: Modifier = Modifier, ) { - val appName by ocrFromShareViewModel.shareSourceAppName.collectAsStateWithLifecycle() - val appIcon by ocrFromShareViewModel.shareSourceAppIcon.collectAsStateWithLifecycle() - val iconSize = Icons.Default.Apps.defaultHeight - AppIconLabelButton( - onClick = { onReturnToShareApp() }, - appIcon = { - if (appIcon != null) { - Image(appIcon!!, null, Modifier.size(iconSize)) - } else { - Icon(Icons.Default.Apps, null, Modifier.size(iconSize)) - } + TextItem( + onClick = onReturnToShareApp, + title = shareSource.name ?: stringResource(R.string.ocr_from_share_return_to_share_source), + content = if (shareSource.name != null) stringResource(R.string.ocr_from_share_return_to_share_source) else null, + leadingSlot = { + shareSource.icon?.let { + Image(it, null, Modifier.size(iconSize)) + } ?: Icon(Icons.Default.Apps, null, Modifier.size(iconSize)) }, - appLabel = { - if (appName != null) { - Text(appName!!) - } + trailingSlot = { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }, - actionText = { Text(stringResource(R.string.ocr_from_share_return_to_share_source)) }, modifier = modifier, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.primary, - ), ) } @Composable -internal fun OcrFromShareStayInAppButton( +private fun OcrFromShareStayInAppButton( onStayInApp: () -> Unit, modifier: Modifier = Modifier, ) { - AppIconLabelButton( - onClick = { onStayInApp() }, - appIcon = { - Icon(Icons.Default.Dashboard, null) - }, - appLabel = { - Text( - String.format( - stringResource(R.string.ocr_from_share_stay_in_app), - stringResource(R.string.app_name), - ), - ) - }, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + TextItem( + onClick = onStayInApp, + title = + String.format( + stringResource(R.string.ocr_from_share_stay_in_app), + stringResource(R.string.app_name), ), + leadingSlot = { Icon(Icons.Default.Home, null) }, + trailingSlot = { + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) + }, modifier = modifier, ) } @Composable -internal fun OcrFromShareScreenContentMedium( +private fun OcrFromShareScreenExpanded( + sharedImageUri: Uri?, + imageSaved: Boolean, + onSaveImage: () -> Unit, + dependencyUiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, + shareSource: OcrFromShareViewModel.ShareSourceUiState, onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, - viewModel: OcrFromShareViewModel, + onNavigateToDependencies: () -> Unit, + snackbarHost: @Composable () -> Unit, + resultSection: @Composable ColumnScope.() -> Unit, + actionsSection: @Composable ColumnScope.() -> Unit, ) { - val context = LocalContext.current - - val imageBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() - val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { viewModel.reloadOcrDependencyViewersUiState(context) } - - Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> + // Top bar removed since it takes a certain amount of height but contains no useful information + // Especially useless on landscape phones + Scaffold(snackbarHost = snackbarHost) { innerPadding -> Row( Modifier .fillMaxSize() @@ -114,116 +157,368 @@ internal fun OcrFromShareScreenContentMedium( .padding(MaterialTheme.spacing.pagePadding), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.pagePadding), ) { - Column( + OcrFromShareImage( + sharedImageUri, Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - ) { - OcrFromShareOcrDependencyStatusCard(ocrDependencyViewersUiState) - - if (imageBitmap != null) { - Image(imageBitmap!!, null) - } - } + contentScale = ContentScale.Crop, + // Bias the crop window left for max recall to be visible + // Right side contains nearly useless partner image for OCR data correction + alignment = BiasAlignment(-0.42f, 0f), + imageSaved = imageSaved, + onSaveImage = onSaveImage, + ) - LazyColumn( - Modifier.weight(1f), + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { - item { - OcrFromShareOcrResult(viewModel) - } + OcrFromShareOcrDependencyStatusCard(dependencyUiState, onNavigateToDependencies) - item { - OcrFromShareActions(viewModel) - } + resultSection() - item { - OcrFromShareReturnToShareAppButton(onReturnToShareApp, viewModel) - } + actionsSection() - item { - OcrFromShareStayInAppButton(onStayInApp) - } + OcrFromShareReturnToShareAppButton(shareSource, onReturnToShareApp) + + OcrFromShareStayInAppButton(onStayInApp) } } } } @Composable -fun OcrFromShareScreenCompact( +private fun OcrFromShareScreenCompact( + sharedImageUri: Uri?, + imageSaved: Boolean, + onSaveImage: () -> Unit, + dependencyUiState: OcrFromShareViewModel.OcrDependencyViewersUiState?, + shareSource: OcrFromShareViewModel.ShareSourceUiState, onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, - viewModel: OcrFromShareViewModel, + onNavigateToDependencies: () -> Unit, + snackbarHost: @Composable () -> Unit, + resultSection: @Composable ColumnScope.() -> Unit, + actionsSection: @Composable ColumnScope.() -> Unit, ) { - val context = LocalContext.current - - val imageBitmap by viewModel.imageBitmap.collectAsStateWithLifecycle() - val ocrDependencyViewersUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { viewModel.reloadOcrDependencyViewersUiState(context) } - - Scaffold(topBar = { OcrFromShareTopBar() }) { innerPadding -> - LazyColumn( + Scaffold( + topBar = { OcrFromShareTopBar() }, + snackbarHost = snackbarHost, + ) { innerPadding -> + Column( modifier = Modifier .fillMaxSize() - .consumeWindowInsets(innerPadding), - contentPadding = - PaddingValues( - top = innerPadding.calculateTopPadding() + MaterialTheme.spacing.pagePadding, - bottom = innerPadding.calculateBottomPadding() + MaterialTheme.spacing.pagePadding, + .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) + .padding( + top = innerPadding.calculateTopPadding() + MaterialTheme.spacing.pagePadding, + bottom = innerPadding.calculateBottomPadding() + MaterialTheme.spacing.pagePadding, + ), + ) { + Column( + Modifier.padding( start = MaterialTheme.spacing.pagePadding, end = MaterialTheme.spacing.pagePadding, ), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - ) { - item { - OcrFromShareOcrDependencyStatusCard(ocrDependencyViewersUiState) - } + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + OcrFromShareOcrDependencyStatusCard(dependencyUiState, onNavigateToDependencies) - if (imageBitmap != null) { - item { - Image(imageBitmap!!, null) - } - } + OcrFromShareImage( + sharedImageUri, + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f), + imageSaved = imageSaved, + onSaveImage = onSaveImage, + ) - item { - OcrFromShareOcrResult(viewModel) - } + resultSection() - item { - OcrFromShareActions(viewModel) + actionsSection() } - item { - OcrFromShareReturnToShareAppButton(onReturnToShareApp, viewModel) - } + OcrFromShareReturnToShareAppButton(shareSource, onReturnToShareApp) - item { - OcrFromShareStayInAppButton(onStayInApp) - } + OcrFromShareStayInAppButton(onStayInApp) } } } @Composable fun OcrFromShareScreen( - windowSizeClass: WindowSizeClass, onReturnToShareApp: () -> Unit, onStayInApp: () -> Unit, viewModel: OcrFromShareViewModel, ) { - if (windowSizeClass.widthSizeClass >= WindowWidthSizeClass.Medium) { - OcrFromShareScreenContentMedium( + val context = LocalContext.current + val res = LocalResources.current + + val navigateToDependencies = { + // User should expect returning to this activity after setting up, + // so do not finish activity here. + // CLEAR_TOP tells the running instance to recreate and re-read the + // route extra for jumping. + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(MainActivity.EXTRA_INITIAL_ROUTE, OcrSubScreen.Dependencies.route) + } + context.startActivity(intent) + } + + val shareRequest by viewModel.shareRequest.collectAsStateWithLifecycle() + val sharedImageUri = shareRequest?.uri + val dependencyUiState by viewModel.ocrDependencyViewersUiState.collectAsStateWithLifecycle() + val shareSource by viewModel.shareSourceUiState.collectAsStateWithLifecycle() + val resultState by viewModel.ocrResultState.collectAsStateWithLifecycle() + val playResultSaveState by viewModel.playResultSaveState.collectAsStateWithLifecycle() + val imageSaved by viewModel.imageSaved.collectAsStateWithLifecycle() + val warnings by viewModel.warnings.collectAsStateWithLifecycle() + val display by viewModel.display.collectAsStateWithLifecycle() + + val resultSection: @Composable ColumnScope.() -> Unit = + remember { + movableContentOf { + OcrFromShareOcrResult( + playResult = (resultState as? OcrResultState.Done)?.playResult, + onSetPlayResult = { viewModel.setPlayResult(it) }, + display = display, + warnings = warnings, + playResultSaveState = playResultSaveState, + exception = (resultState as? OcrResultState.Failed)?.exception, + onRetryOcr = { viewModel.retryOcr() }, + ) + } + } + + val handleSaveImage = { viewModel.saveImage() } + val requestWriteStoragePermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { if (it) handleSaveImage() } + + var showSavePicturePermissionRequiredDialog by rememberSaveable { mutableStateOf(false) } + if (showSavePicturePermissionRequiredDialog) { + PermissionRequiredDialog( + onDismiss = { showSavePicturePermissionRequiredDialog = false }, + onConfirm = { + requestWriteStoragePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + showSavePicturePermissionRequiredDialog = false + }, + functionName = stringResource(R.string.permission_function_save_image), + permissions = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + ) + } + + val onSaveImage = { + if (SaveImageToGallery.checkPermission(context)) { + handleSaveImage() + } else { + showSavePicturePermissionRequiredDialog = true + } + } + + val actionsSection: @Composable ColumnScope.() -> Unit = + remember { + movableContentOf { + OcrFromShareActions( + hasPlayResult = resultState is OcrResultState.Done, + playResultSaveState = playResultSaveState, + warnings = warnings, + onSavePlayResult = { viewModel.savePlayResult() }, + onCachePlayResult = { + // TODO: viewModel.cachePlayResult(context) + Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show() + }, + ) + } + } + + // Reload on every resume to possibly reflect the latest status + // e.g. returning from the app's dependency setup + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.reloadOcrDependencyViewersUiState() + } + } + + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(viewModel, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + snackbarHostState.showSnackbar(event.asString(res), withDismissAction = true) + } + } + } + + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + if (landscape) { + OcrFromShareScreenExpanded( + sharedImageUri = sharedImageUri, + imageSaved = imageSaved, + onSaveImage = onSaveImage, + dependencyUiState = dependencyUiState, + shareSource = shareSource, onReturnToShareApp = onReturnToShareApp, onStayInApp = onStayInApp, - viewModel = viewModel, + onNavigateToDependencies = navigateToDependencies, + snackbarHost = { SnackbarHost(snackbarHostState) }, + resultSection = resultSection, + actionsSection = actionsSection, ) } else { OcrFromShareScreenCompact( + sharedImageUri = sharedImageUri, + imageSaved = imageSaved, + onSaveImage = onSaveImage, + dependencyUiState = dependencyUiState, + shareSource = shareSource, onReturnToShareApp = onReturnToShareApp, onStayInApp = onStayInApp, - viewModel = viewModel, + onNavigateToDependencies = navigateToDependencies, + snackbarHost = { SnackbarHost(snackbarHostState) }, + resultSection = resultSection, + actionsSection = actionsSection, ) } } + +@Composable +private fun OcrFromShareImage( + uri: Uri?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + alignment: Alignment = Alignment.Center, + imageSaved: Boolean, + onSaveImage: () -> Unit, +) { + var showImagePreview by rememberSaveable { mutableStateOf(false) } + val imageState = rememberAsyncImageState() + + if (showImagePreview) { + uri?.let { + ImagePreviewDialogFullscreen( + onDismissRequest = { showImagePreview = false }, + uri = it, + ) + } + } + + // Image tap and the fullscreen button share an interaction source + // The press ripple draws on the button rather than the whole image + val imageInteractionSource = remember { MutableInteractionSource() } + + Box( + modifier.clickable( + interactionSource = imageInteractionSource, + indication = null, + enabled = uri != null, + ) { showImagePreview = true }, + ) { + if (uri == null) { + ImagePlaceholder(Icons.Outlined.Image, LocalContentColor.current.copy(alpha = SECONDARY_ALPHA)) + } else { + val painterState = imageState.painterState + + if (painterState == null || painterState is PainterState.Loading) { + ImageSkeleton(Modifier.fillMaxSize()) + } + + // TODO: this does not actually invoke, sketch or our issue? + if (painterState is PainterState.Error) { + ImagePlaceholder(Icons.Outlined.BrokenImage, MaterialTheme.colorScheme.error) + } + + AsyncImage( + uri = uri.toString(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + state = imageState, + contentScale = contentScale, + alignment = alignment, + ) + + Column( + Modifier + .align(Alignment.BottomStart) + .padding(MaterialTheme.spacing.sm), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xxs), + ) { + FilledTonalButton( + onClick = { showImagePreview = true }, + interactionSource = imageInteractionSource, + ) { + IconRow { + Icon(Icons.Default.Fullscreen, null) + Text(stringResource(R.string.ocr_from_share_fullscreen)) + } + } + + FilledTonalButton( + onClick = onSaveImage, + enabled = !imageSaved, + // Disabled colors are explicit since the defaults are alpha-faded and the + // image would show through the button + colors = + ButtonDefaults.filledTonalButtonColors( + disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer, + disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + AnimatedContent(imageSaved) { saved -> + if (saved) { + IconRow { + Icon(Icons.Default.Check, null) + Text(stringResource(R.string.ocr_from_share_image_saved)) + } + } else { + IconRow { + Icon(Icons.Default.FileDownload, null) + Text(stringResource(R.string.ocr_from_share_save_image_button)) + } + } + } + } + } + } + } +} + +/** Solid placeholder with a centered icon, shown while the uri is absent or its image failed to load. */ +@Composable +private fun ImagePlaceholder( + icon: ImageVector, + tint: Color, + modifier: Modifier = Modifier, +) { + Box( + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, Modifier.size(48.dp), tint = tint) + } +} + +@Composable +private fun ImageSkeleton(modifier: Modifier = Modifier) { + val alpha by + rememberInfiniteTransition(label = "ImageSkeleton") + .animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "ImageSkeletonAlpha", + ) + + Box(modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = alpha))) +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 37cb76e7..d5e7f08d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -2,7 +2,6 @@ package xyz.sevive.arcaeaoffline.ui.activities.ocrfromshare import android.content.Context import android.content.pm.PackageManager -import android.graphics.Bitmap import android.net.Uri import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -10,56 +9,127 @@ import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import de.stefan_oltmann.kim.Kim +import de.stefan_oltmann.kim.common.ImageWriteException +import de.stefan_oltmann.kim.input.ByteArrayByteReader +import de.stefan_oltmann.kim.model.MediaFormat +import de.stefan_oltmann.kim.model.MetadataUpdate import io.github.vinceglb.filekit.utils.div +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime import kotlinx.io.asOutputStream import kotlinx.io.buffered import kotlinx.io.files.SystemFileSystem +import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult +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.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper import xyz.sevive.arcaeaoffline.data.OcrPaths import xyz.sevive.arcaeaoffline.database.entities.OcrHistory import xyz.sevive.arcaeaoffline.database.repositories.OcrHistoryRepository +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.DeviceOcrHelper import xyz.sevive.arcaeaoffline.helpers.OcrDependencyLoader import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatusBuilder -import xyz.sevive.arcaeaoffline.permissions.storage.SaveBitmapToGallery +import xyz.sevive.arcaeaoffline.permissions.storage.SaveImageToGallery import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusUiState import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusUiState +import xyz.sevive.arcaeaoffline.ui.helpers.DifficultyWithSongDisplay +import xyz.sevive.arcaeaoffline.ui.helpers.UiEvent +import xyz.sevive.arcaeaoffline.ui.helpers.UiEventChannel +import java.io.IOException +import kotlin.concurrent.Volatile import kotlin.time.Clock +import kotlin.time.Instant class OcrFromShareViewModel( + private val appContext: Context, private val playResultRepo: PlayResultRepository, private val ocrHistoryRepo: OcrHistoryRepository, + private val difficultyWithSongRepo: DifficultyWithSongRepository, + private val chartInfoRepo: ChartInfoRepository, ) : ViewModel() { class OcrDependencyViewersUiState( val imageHashesDatabase: OcrDependencyImageHashesDatabaseStatusUiState = OcrDependencyImageHashesDatabaseStatusUiState(), val crnnModel: OcrDependencyCrnnModelStatusUiState = OcrDependencyCrnnModelStatusUiState(), ) + /** Recognition outcome: waiting while OCR runs, failed with its error, or done with the result. */ + sealed interface OcrResultState { + data object Waiting : OcrResultState + + data class Failed( + val exception: Exception, + ) : OcrResultState + + data class Done( + val playResult: PlayResult, + ) : OcrResultState + } + + data class ShareSourceUiState( + val name: String? = null, + val icon: ImageBitmap? = null, + ) + + data class PlayResultSaveState( + val saved: Boolean = false, + val cached: Boolean = false, + ) + + /** The share as delivered by the Activity, captured once at [onImageShared] and reused by retries. */ + data class ShareRequest( + val uri: Uri, + val sharedAt: Instant, + ) + + /** The image this share session is about, captured once at [onImageShared]. */ + @Suppress("ArrayInDataClass") + data class SharedImage( + val uri: Uri, + val sharedAt: Instant, + val sourceTakenAt: Instant?, + val bytes: ByteArray, + ) + companion object { const val LOG_TAG = "OcrFromShareViewModel" } private val logger = Logger.withTag(LOG_TAG) - private val _ocrDependencyViewersUiState = MutableStateFlow(OcrDependencyViewersUiState()) + private val eventChannel = UiEventChannel() + val events = eventChannel.events + + private val _ocrDependencyViewersUiState = MutableStateFlow(null) val ocrDependencyViewersUiState = _ocrDependencyViewersUiState.asStateFlow() - fun reloadOcrDependencyViewersUiState(context: Context) { + init { + reloadOcrDependencyViewersUiState() + } + + fun reloadOcrDependencyViewersUiState() { viewModelScope.launch(Dispatchers.IO) { val imageHashesDatabase = OcrDependencyStatusBuilder.imageHashesDatabase() - val crnnModel = OcrDependencyStatusBuilder.crnnModel(context) + val crnnModel = OcrDependencyStatusBuilder.crnnModel(appContext) _ocrDependencyViewersUiState.value = OcrDependencyViewersUiState( @@ -69,167 +139,290 @@ class OcrFromShareViewModel( } } - private val bitmap = MutableStateFlow(null) - val imageBitmap = - bitmap.map { it?.asImageBitmap() }.stateIn( - viewModelScope, - SharingStarted.Lazily, - null, - ) - - fun setBitmap(newBitmap: Bitmap) { - bitmap.value = newBitmap - } - private val shareSourceAppPackageName = MutableStateFlow(null) - private val _shareSourceAppName = MutableStateFlow(null) - val shareSourceAppName = _shareSourceAppName.asStateFlow() - - private val _shareSourceAppIcon = MutableStateFlow(null) - val shareSourceAppIcon = _shareSourceAppIcon.asStateFlow() + private val _shareSourceUiState = MutableStateFlow(ShareSourceUiState()) + val shareSourceUiState = _shareSourceUiState.asStateFlow() fun setShareSourceApp( packageName: String?, packageManager: PackageManager, ) { if (packageName == null) { + logger.w { "Share app packageName is null" } shareSourceAppPackageName.value = null - _shareSourceAppName.value = null - _shareSourceAppIcon.value = null + _shareSourceUiState.value = ShareSourceUiState() return } shareSourceAppPackageName.value = packageName - _shareSourceAppName.value = - packageName.let { - try { - val info = packageManager.getApplicationInfo(it, 0) - packageManager.getApplicationLabel(info).toString() - } catch (e: PackageManager.NameNotFoundException) { - it + _shareSourceUiState.value = + ShareSourceUiState( + name = + packageName.let { + try { + val info = packageManager.getApplicationInfo(it, 0) + packageManager.getApplicationLabel(info).toString() + } catch (e: PackageManager.NameNotFoundException) { + logger.w(e) { "Failed to resolve share app name" } + it + } + }, + icon = + packageName.let { + try { + packageManager.getApplicationIcon(it).toBitmap().asImageBitmap() + } catch (e: PackageManager.NameNotFoundException) { + logger.w(e) { "Failed to resolve share app icon" } + null + } + }, + ) + } + + private val _ocrResultState = MutableStateFlow(OcrResultState.Waiting) + val ocrResultState = _ocrResultState.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val display = + _ocrResultState + .flatMapLatest { state -> + val playResult = (state as? OcrResultState.Done)?.playResult + if (playResult == null) { + flowOf(null) + } else { + combine( + difficultyWithSongRepo.find(playResult.songId, playResult.ratingClass), + chartInfoRepo.find(playResult.songId, playResult.ratingClass), + ) { dws, info -> + dws?.let { DifficultyWithSongDisplay(it, info) } + } } - } - _shareSourceAppIcon.value = - packageName.let { - try { - packageManager.getApplicationIcon(it).toBitmap().asImageBitmap() - } catch (e: PackageManager.NameNotFoundException) { - null + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + @OptIn(ExperimentalCoroutinesApi::class) + val warnings = + _ocrResultState + .flatMapLatest { state -> + val playResult = (state as? OcrResultState.Done)?.playResult + if (playResult == null) { + flowOf(emptyList()) + } else { + display.map { d -> + ArcaeaPlayResultValidator.validate(playResult = playResult, chartInfo = d?.chartInfo) + } } - } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + private var sharedImage: SharedImage? = null + + private val _shareRequest = MutableStateFlow(null) + val shareRequest = _shareRequest.asStateFlow() + + /** Single entry for Activity */ + fun onImageShared(uri: Uri) { + // The Activity re-delivers the share intent on recreation, so holding a flag here + // to prevent dropping the user's edits and the saved state. + if (_shareRequest.value?.uri == uri) return + + val request = ShareRequest(uri, Clock.System.now()) + _shareRequest.value = request + viewModelScope.launch { readImageAndStartOcr(request) } } - private val _playResult = MutableStateFlow(null) - val playResult = _playResult.asStateFlow() + private suspend fun readImageAndStartOcr(request: ShareRequest) { + _ocrResultState.value = OcrResultState.Waiting + + try { + withContext(Dispatchers.IO) { + val bytes = + appContext.contentResolver.openInputStream(request.uri)?.use { it.readBytes() } + ?: throw IOException("Cannot open shared image stream") + + val sourceTakenAt = + runCatching { + DeviceOcrHelper.readImageDateFromExif(ByteArrayByteReader(bytes)) + }.getOrNull() + + sharedImage = SharedImage(request.uri, request.sharedAt, sourceTakenAt, bytes) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _ocrResultState.value = OcrResultState.Failed(Exception("Error reading image", e)) + return + } - private val _exception = MutableStateFlow(null) - val exception = _exception.asStateFlow() + startOcr(request.uri) + } - fun setScore(playResult: PlayResult?) { - _playResult.value = playResult + fun retryOcr() { + val request = _shareRequest.value ?: return + viewModelScope.launch { readImageAndStartOcr(request) } } - fun setException(e: Exception) { - _exception.value = e + fun setPlayResult(playResult: PlayResult) { + _ocrResultState.value = OcrResultState.Done(playResult) } - private val _scoreSaved = MutableStateFlow(false) - val scoreSaved = _scoreSaved.asStateFlow() + private val _playResultSaveState = MutableStateFlow(PlayResultSaveState()) + val playResultSaveState = _playResultSaveState.asStateFlow() + + @Volatile + private var playResultSaveInFlight = false - suspend fun saveScore() { - if (playResult.value != null) { - playResultRepo.upsert(playResult.value!!) - _scoreSaved.value = true + fun savePlayResult() { + if (playResultSaveInFlight || playResultSaveState.value.saved) return + val playResult = (_ocrResultState.value as? OcrResultState.Done)?.playResult ?: return + playResultSaveInFlight = true + + viewModelScope.launch { + try { + playResultRepo.upsert(playResult) + _playResultSaveState.value = _playResultSaveState.value.copy(saved = true) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Error occurred while saving play result" } + eventChannel.send( + UiEvent.StringRes(R.string.ocr_from_share_play_result_save_failed, listOf(e.toString())), + ) + } finally { + playResultSaveInFlight = false + } } } private val _imageSaved = MutableStateFlow(false) val imageSaved = _imageSaved.asStateFlow() - fun saveImage(context: Context) { - val bitmap = bitmap.value - if (bitmap != null && !imageSaved.value) { - _imageSaved.value = - SaveBitmapToGallery(bitmap).saveJpg( - context, - "ocr-from-share-${Clock.System.now().toEpochMilliseconds()}", + @Volatile + private var imageSaveInFlight = false + + fun saveImage() { + val shared = sharedImage ?: return + if (imageSaveInFlight || imageSaved.value) return + imageSaveInFlight = true + + viewModelScope.launch(Dispatchers.IO) { + try { + val dateTaken = shared.sourceTakenAt ?: shared.sharedAt + val fileName = + "ocr-from-share-" + + imageFileDateFormat.format(dateTaken.toLocalDateTime(TimeZone.currentSystemDefault())) + + "." + imageFileExtension(shared.bytes) + + val bytes = + if (shared.sourceTakenAt != null) { + shared.bytes + } else { + try { + Kim.update( + shared.bytes, + MetadataUpdate.TakenDate(shared.sharedAt.toEpochMilliseconds()), + ) + } catch (e: ImageWriteException) { + logger.w(e) { "Writing EXIF taken date failed, saving without it" } + shared.bytes + } + } + + SaveImageToGallery.saveImageBytes( + appContext, + fileName, + bytes, + dateTaken.toEpochMilliseconds(), + imageFileMimeType(shared.bytes), + ) + _imageSaved.value = true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "Error occurred while saving image" } + eventChannel.send( + UiEvent.StringRes(R.string.ocr_from_share_image_save_failed, listOf(e.toString())), ) + } finally { + imageSaveInFlight = false + } } } - private val _scoreCached = MutableStateFlow(false) - val scoreCached = _scoreCached.asStateFlow() - - suspend fun cacheScore(context: Context) { - val score = playResult.value - if (score != null) { - val ocrHistory = - OcrHistory.fromArcaeaScore( - playResult = score, - sourcePackageName = shareSourceAppPackageName.value, - storeDate = Clock.System.now(), - ) - val id = ocrHistoryRepo.insert(ocrHistory) - - val bitmap = bitmap.value - if (bitmap != null) { - val parentDir = OcrPaths().fromShareImageCacheDir - if (!SystemFileSystem.exists(parentDir)) { - SystemFileSystem.createDirectories(parentDir) - logger.i { "Created image cache dir" } - } - - val cacheImagePath = parentDir / "$id.jpg" - SystemFileSystem.sink(cacheImagePath).buffered().use { sink -> - bitmap.compress(Bitmap.CompressFormat.JPEG, 100, sink.asOutputStream()) - } + @Suppress("unused") + suspend fun cachePlayResult() { + val playResult = (_ocrResultState.value as? OcrResultState.Done)?.playResult ?: return + val ocrHistory = + OcrHistory.fromArcaeaScore( + playResult = playResult, + sourcePackageName = shareSourceAppPackageName.value, + storeDate = Clock.System.now(), + ) + val id = ocrHistoryRepo.insert(ocrHistory) + + sharedImage?.let { shared -> + val parentDir = OcrPaths().fromShareImageCacheDir + if (!SystemFileSystem.exists(parentDir)) { + SystemFileSystem.createDirectories(parentDir) + logger.i { "Created image cache dir" } } - _scoreCached.value = true + val cacheImagePath = parentDir / "$id.${imageFileExtension(shared.bytes)}" + SystemFileSystem.sink(cacheImagePath).buffered().use { it.asOutputStream().write(shared.bytes) } } - } - suspend fun startOcr( - imageUri: Uri, - context: Context, - ) { - logger.i { "OCR from share request: $imageUri" } + _playResultSaveState.value = _playResultSaveState.value.copy(cached = true) + } - val ortSession = DeviceOcrOnnxHelper.createOrtSession(context) + private suspend fun startOcr(uri: Uri) { + logger.i { "OCR from share request: $uri" } withContext(Dispatchers.IO) { try { - val imageHashesSQLiteDatabase = - OcrDependencyLoader.imageHashesSQLiteDatabase() - - val ocrResult = - imageHashesSQLiteDatabase.use { sqliteDb -> - val imageHashesDatabase = ImageHashesDatabase(sqliteDb) - DeviceOcrHelper.ocrImage( - imageUri, - imageHashesDatabase, - ortSession = ortSession, + DeviceOcrOnnxHelper.createOrtSession(appContext).use { ortSession -> + val imageHashesSQLiteDatabase = + OcrDependencyLoader.imageHashesSQLiteDatabase() + + val ocrResult = + imageHashesSQLiteDatabase.use { sqliteDb -> + val imageHashesDatabase = ImageHashesDatabase(sqliteDb) + DeviceOcrHelper.ocrImage( + uri, + imageHashesDatabase, + ortSession = ortSession, + ) + } + val playResult = + DeviceOcrHelper.ocrResultToPlayResult( + uri, + appContext, + ocrResult, + fallbackDate = Clock.System.now(), ) - } - val playResult = - DeviceOcrHelper.ocrResultToPlayResult( - imageUri, - context, - ocrResult, - fallbackDate = Clock.System.now(), - ) - - _playResult.value = playResult - _exception.value = null + + _ocrResultState.value = OcrResultState.Done(playResult) + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - _playResult.value = null - _exception.value = e + _ocrResultState.value = OcrResultState.Failed(e) logger.e(e) { "Error occurred during OCR" } - } finally { - ortSession.close() } } } } + +private val imageFileDateFormat = + LocalDateTime.Format { + year() + monthNumber() + day() + char('_') + hour() + minute() + second() + } + +private fun imageFileExtension(bytes: ByteArray): String = MediaFormat.detect(bytes)?.fileNameExtensions?.firstOrNull() ?: "jpg" + +private fun imageFileMimeType(bytes: ByteArray): String = MediaFormat.detect(bytes)?.mimeType ?: "image/jpeg" 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 8ab49c0e..268d80c0 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 @@ -44,19 +44,10 @@ import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong 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.helpers.DifficultyWithSongDisplay import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.spacing -// Display data resolved as one atomic unit. Swapping the pair together is -// what keeps the card consistent (title/level text and constant change in -// the same frame) and lets the card's AnimatedContent keep its old -> new -// transition: the previous pair is held while the next one is resolving, -// instead of dropping the card to its placeholder. -data class DifficultyWithSongDisplay( - val difficultyWithSong: DifficultyWithSong, - val chartInfo: ChartInfo?, -) - @Composable internal fun rememberArcaeaChartDisplay( songId: String?, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt new file mode 100644 index 00000000..8818b3eb --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/SaveWithWarningsDialog.kt @@ -0,0 +1,31 @@ +package xyz.sevive.arcaeaoffline.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidatorWarning +import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton +import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton + +/** Asks for confirmation before saving a play result that raised validator warnings. */ +@Composable +fun SaveWithWarningsDialog( + warnings: List, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.play_result_save_with_warnings_title)) }, + text = { ArcaeaPlayResultValidatorWarningDetails(warnings) }, + confirmButton = { + DialogConfirmButton( + onClick = onConfirm, + customLabel = { Text(stringResource(R.string.general_save)) }, + ) + }, + dismissButton = { DialogDismissTextButton(onClick = onDismissRequest) }, + ) +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/DifficultyWithSongDisplay.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/DifficultyWithSongDisplay.kt new file mode 100644 index 00000000..f8975521 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/DifficultyWithSongDisplay.kt @@ -0,0 +1,18 @@ +package xyz.sevive.arcaeaoffline.ui.helpers + +import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo +import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyWithSong + +/** + * Display data for one chart, resolved as one atomic unit. + * + * The chart card relies on the pair swapping together: title/level text and constant stay + * consistent within one frame, and the previous pair is held while the next one is resolving, + * so the card cross-fades instead of dropping to its placeholder. + * + * [chartInfo] is null when the chart is absent from the local chart database. + */ +data class DifficultyWithSongDisplay( + val difficultyWithSong: DifficultyWithSong, + val chartInfo: ChartInfo?, +) 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 ed682b4c..1c5cdfb8 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 @@ -58,6 +58,7 @@ enum class MainScreen( @Composable fun MainNavigationGraph( mainNavController: NavHostController, + initialNavigateRoute: String? = null, modifier: Modifier = Modifier, ) { NavHost( @@ -90,6 +91,7 @@ fun MainNavigationGraph( composable(MainScreen.Ocr.route) { OcrEntryScreen( + initialDetailRoute = initialNavigateRoute?.takeIf { it.startsWith(OCR_NAV_ROUTE_ROOT) }, 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 } 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 982490c5..c3b1f32b 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 @@ -16,7 +16,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons 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 @@ -51,7 +50,6 @@ 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.ArcaeaPlayResultValidatorWarningDetails import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPlayResultValidatorWarningDetailsDialog import xyz.sevive.arcaeaoffline.ui.components.LabeledCheckbox import xyz.sevive.arcaeaoffline.ui.components.PlayResultEditorClearTypeField @@ -62,12 +60,11 @@ 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.SaveWithWarningsDialog 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 @@ -139,26 +136,6 @@ internal fun BottomActionsBar( } } -@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, 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 882adb2a..9f3632b4 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,14 +8,17 @@ import xyz.sevive.arcaeaoffline.ui.screens.ocr.performance.OcrPerformanceScreen import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreen @Composable -fun OcrEntryScreen(onNavigateToResources: () -> Unit) = - AdaptiveEntryScreen( - listPane = { OcrNavEntry() }, - detailPane = { route -> - when (route) { - OcrSubScreen.Dependencies.route -> OcrDependenciesScreen() - OcrSubScreen.Queue.route -> OcrQueueScreen(onNavigateToResources = onNavigateToResources) - OcrSubScreen.Performance.route -> OcrPerformanceScreen() - } - }, - ) +fun OcrEntryScreen( + initialDetailRoute: String? = null, + onNavigateToResources: () -> Unit, +) = AdaptiveEntryScreen( + initialDetailRoute = initialDetailRoute, + listPane = { OcrNavEntry() }, + detailPane = { route -> + when (route) { + OcrSubScreen.Dependencies.route -> OcrDependenciesScreen() + 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 6700382f..c1a5596a 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 @@ -23,7 +23,7 @@ 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.helpers.needsSetup import xyz.sevive.arcaeaoffline.ui.components.TextItem import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusViewer @@ -33,14 +33,6 @@ 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) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7383162b..67779483 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -100,9 +100,15 @@ 等待 OCR 结果…… + 全屏 保存图片 - 暂存 - 成绩已保存 + 图片已保存 + 图片保存失败 (%s) + 暂存 + 记录已保存 + 记录保存失败 (%s) + OCR 出错 + 重新识别 返回上一应用 留在 %s @@ -201,7 +207,7 @@ 编辑游玩记录…… 点此选择谱面 请先选择谱面 - 仍有警告,确认保存? + 仍有警告,确认保存? 保留谱面 游玩记录已保存 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 595b439b..ff82281e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,9 +116,15 @@ CC BY-SA 3.0 Waiting for OCR… + Fullscreen Save Image - Cache - Score saved + Image Saved + Failed to save image (%s) + Cache + Play Saved + Failed to save play result (%s) + OCR Error + Retry OCR Return to previous app Stay in %s @@ -223,7 +229,7 @@ CC BY-SA 3.0 Then edit your play result… Click here to select a chart. Select a chart first. - Save with warnings? + Save with warnings? Keep chart Play result saved diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5cb2d3b..9047e3cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,8 @@ apache-commons-compress = "1.28.0" io-sentry-sentryAndroid = "8.17.0" markdown-renderer = "0.41.0" -panpf-sketch = "4.5.0-beta05" -panpf-zoomimage = "1.5.0-beta02" +panpf-sketch = "4.6.0" +panpf-zoomimage = "1.6.0" github-cheonjaeung-gridlayout = "2.7.4" aboutlibraries = "14.2.1" kermit = "2.1.0"