diff --git a/app/src/main/java/be/scri/ui/screens/InstallationScreen.kt b/app/src/main/java/be/scri/ui/screens/InstallationScreen.kt index 93511c07..4bd6dc6b 100644 --- a/app/src/main/java/be/scri/ui/screens/InstallationScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/InstallationScreen.kt @@ -29,6 +29,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -49,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import be.scri.R import be.scri.ui.common.ScribeBaseScreen +import be.scri.ui.screens.tutorial.TutorialNavigator /** * The installation page of the application with details for installing Scribe keyboards and downloading data. @@ -61,6 +66,15 @@ fun InstallationScreen( onNavigateToDownloadData: () -> Unit, modifier: Modifier = Modifier, ) { + var showTutorial by remember { mutableStateOf(false) } + + if (showTutorial) { + TutorialNavigator( + onTutorialExit = { showTutorial = false }, + ) + return + } + val layoutDirection = LocalLayoutDirection.current val localConfiguration = LocalConfiguration.current val resource: Int = @@ -291,6 +305,7 @@ fun InstallationScreen( OutlinedButton( onClick = { + showTutorial = true }, modifier = Modifier diff --git a/app/src/main/java/be/scri/ui/screens/tutorial/TutorialChapter.kt b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialChapter.kt new file mode 100644 index 00000000..2a3ba295 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialChapter.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens.tutorial + +/** + * Represents a single tutorial chapter in the home screen. + * + * @property title The display name of the chapter. + * @property chapterIndex The index used to navigate to this chapter. + */ +data class TutorialChapter( + val title: String, + val chapterIndex: Int, +) diff --git a/app/src/main/java/be/scri/ui/screens/tutorial/TutorialContent.kt b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialContent.kt new file mode 100644 index 00000000..f55de975 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialContent.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens.tutorial + +/** + * Defines all tutorial chapters and their steps. + * Each chapter contains one or more interactive steps that guide the user + * through a specific Scribe feature. + */ +object TutorialContent { + /** + * Chapter 1: Noun Annotation. + * Teaches users about gender tags that appear when typing nouns. + */ + val nounAnnotationSteps = + listOf( + TutorialStep( + instruction = + "Write the word \"Vater\". Notice the word suggestions " + + "that appear on the keyboard's top bar.\n\n" + + "Then, press space. You will see the word's gender " + + "tag on the keyboard's top bar \u2013 in this case, \"M\" for Maskulin.", + expectedWord = "Vater", + ), + TutorialStep( + instruction = + "Now write the word \"Mutter\" and then press space. " + + "The gender tag will be \"F\", for Feminin.", + expectedWord = "Mutter", + ), + ) + + /** + * Chapter 2: Word Translation. + * Teaches users how to use the Translate command via the Scribe key. + */ + val wordTranslationSteps = + listOf( + TutorialStep( + instruction = + "Let's translate! Tap the \u27A1 Scribe key on the top-left " + + "corner of your keyboard, and select \u00DCbersetzen.\n\n" + + "Then write the word you want to translate, press \u25B6, " + + "and the translation will be returned to you.", + hint = "If your second language is not German, change the language in your keyboard.", + requiresValidation = false, + ), + ) + + val verbConjugationSteps = + listOf( + TutorialStep( + instruction = + "On to the verbs. Tap the \u27A1 Scribe key on the top-left " + + "corner of your keyboard, and select Konjugieren.\n\n" + + "Write the verb you want to conjugate, press \u25B6, and " + + "you will see a table with all the verb tenses. Select " + + "the one you need and it will be inserted!", + hint = "If your second language is not German, change the language in your keyboard.", + requiresValidation = false, + ), + ) + + val nounPluralsSteps = + listOf( + TutorialStep( + instruction = + "Finding the plural of a noun with Scribe is easy. Tap " + + "the \u27A1 Scribe key on the top-left corner of your " + + "keyboard, and select Plural.\n\n" + + "Then write the noun you want the plural for, press " + + "\u25B6, and the plural will be returned to you.", + hint = "If your second language is not German, change the language in your keyboard.", + requiresValidation = false, + ), + ) + + /** Returns all chapters as a list of pairs (title, steps). */ + fun getAllChapters(): List>> = + listOf( + "Noun annotation" to nounAnnotationSteps, + "Word translation" to wordTranslationSteps, + "Verb conjugation" to verbConjugationSteps, + "Noun plurals" to nounPluralsSteps, + ) +} diff --git a/app/src/main/java/be/scri/ui/screens/tutorial/TutorialHomeScreen.kt b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialHomeScreen.kt new file mode 100644 index 00000000..1ee40d7a --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialHomeScreen.kt @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens.tutorial + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The tutorial home screen (Screen 0.0 from Figma). + * Displays a list of tutorial chapters and a button to start the full tutorial. + * This screen is accessible from the About tab. + * + * @param onBackPress Callback when the back button is pressed. + * @param onChapterSelect Callback when a specific chapter is tapped. + * @param onStartFullTutorial Callback when the "Start full tutorial" button is pressed. + * @param modifier Modifier for this composable. + */ +@Composable +fun TutorialHomeScreen( + onBackPress: () -> Unit, + onChapterSelect: (Int) -> Unit, + onStartFullTutorial: () -> Unit, + modifier: Modifier = Modifier, +) { + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = MaterialTheme.colorScheme.background + val cardBackground = MaterialTheme.colorScheme.surface + val textColor = MaterialTheme.colorScheme.onSurface + val secondaryTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + val dividerColor = MaterialTheme.colorScheme.outlineVariant + + val chapters = + listOf( + TutorialChapter("Noun annotation", 0), + TutorialChapter("Word translation", 1), + TutorialChapter("Verb conjugation", 2), + TutorialChapter("Noun plurals", 3), + ) + + Column( + modifier = + modifier + .fillMaxSize() + .background(backgroundColor) + .padding(16.dp), + ) { + // Back button + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onBackPress() }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text( + text = "Home", + color = MaterialTheme.colorScheme.primary, + fontSize = 16.sp, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Info banner + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = cardBackground), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "\uD83D\uDCA1", + fontSize = 20.sp, + modifier = Modifier.padding(end = 12.dp), + ) + Text( + text = "Make sure you select the desired Scribe keyboard by pressing \uD83C\uDF10 when typing.", + color = textColor, + fontSize = 14.sp, + modifier = Modifier.weight(1f), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Intro text + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = cardBackground), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "This quick tutorial will show you how to use Scribe to support writing in your second language.", + color = textColor, + fontSize = 14.sp, + modifier = Modifier.padding(16.dp), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Tutorial chapters header + Text( + text = "Tutorial chapters", + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Chapter list + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = cardBackground), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + chapters.forEachIndexed { index, chapter -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onChapterSelect(chapter.chapterIndex) } + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = chapter.title, + color = textColor, + fontSize = 16.sp, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Go to ${chapter.title}", + tint = secondaryTextColor, + ) + } + if (index < chapters.size - 1) { + HorizontalDivider( + color = dividerColor, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Start full tutorial button + Button( + onClick = onStartFullTutorial, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = if (isDarkTheme) Color.White else Color.Black, + ), + shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text( + text = "Start full tutorial", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/be/scri/ui/screens/tutorial/TutorialNavigator.kt b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialNavigator.kt new file mode 100644 index 00000000..75d562db --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialNavigator.kt @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens.tutorial + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * The main tutorial navigation controller. + * Manages the flow between the tutorial home screen, individual chapters, and steps. + * Handles forward/backward navigation and tracks the user's current position. + * + * @param onTutorialExit Callback when the user exits the tutorial (back to About tab). + */ +@Composable +fun TutorialNavigator(onTutorialExit: () -> Unit) { + var currentScreen by remember { mutableStateOf("home") } + var currentChapterIndex by remember { mutableIntStateOf(0) } + var currentStepIndex by remember { mutableIntStateOf(0) } + var isFullTutorial by remember { mutableStateOf(false) } + + val allChapters = TutorialContent.getAllChapters() + + BackHandler { + if (currentScreen == "home") { + onTutorialExit() + } else { + when { + currentStepIndex > 0 -> { + currentStepIndex-- + } + isFullTutorial && currentChapterIndex > 0 -> { + currentChapterIndex-- + val prevSteps = allChapters[currentChapterIndex].second + currentStepIndex = prevSteps.size - 1 + } + else -> { + currentScreen = "home" + } + } + } + } + + when (currentScreen) { + "home" -> { + TutorialHomeScreen( + onBackPress = onTutorialExit, + onChapterSelect = { chapterIndex -> + currentChapterIndex = chapterIndex + currentStepIndex = 0 + isFullTutorial = false + currentScreen = "step" + }, + onStartFullTutorial = { + currentChapterIndex = 0 + currentStepIndex = 0 + isFullTutorial = true + currentScreen = "step" + }, + ) + } + "step" -> { + val (chapterTitle, steps) = allChapters[currentChapterIndex] + val step = steps[currentStepIndex] + + val isLastStepInChapter = currentStepIndex == steps.size - 1 + val isLastChapter = currentChapterIndex == allChapters.size - 1 + val isLastStep = isLastStepInChapter && (isLastChapter || !isFullTutorial) + + TutorialStepScreen( + chapterTitle = chapterTitle, + step = step, + isLastStep = isLastStep, + showQuickTutorialHeader = !isFullTutorial && currentStepIndex == 0, + onBackPress = { + when { + currentStepIndex > 0 -> { + currentStepIndex-- + } + isFullTutorial && currentChapterIndex > 0 -> { + currentChapterIndex-- + val prevSteps = allChapters[currentChapterIndex].second + currentStepIndex = prevSteps.size - 1 + } + else -> { + currentScreen = "home" + } + } + }, + onClosePress = { + currentScreen = "home" + }, + onNextPress = { + when { + !isLastStepInChapter -> { + currentStepIndex++ + } + isFullTutorial && !isLastChapter -> { + currentChapterIndex++ + currentStepIndex = 0 + } + else -> { + currentScreen = "home" + } + } + }, + ) + } + } +} diff --git a/app/src/main/java/be/scri/ui/screens/tutorial/TutorialStepScreen.kt b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialStepScreen.kt new file mode 100644 index 00000000..9c3a7b50 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialStepScreen.kt @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens.tutorial + +import android.content.Context +import android.provider.Settings +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.delay + +/** + * Represents the validation state of the user's input in a tutorial step. + */ +enum class InputValidationState { + /** No input yet. */ + EMPTY, + + /** User typed the correct word. */ + CORRECT, + + /** User typed the wrong word. */ + INCORRECT, +} + +/** + * A single step within a tutorial chapter. + * + * @property instruction The instructional text shown to the user. + * @property expectedWord The word the user needs to type to pass this step. + * @property hint An optional hint about switching keyboard language. + * @property successMessage The message shown when the user types correctly. + * @property errorMessage The message shown when the user types incorrectly. + * @property requiresValidation Whether this step requires the user to type a specific word. + */ +data class TutorialStep( + val instruction: String, + val expectedWord: String = "", + val hint: String = "If your second language is not German, change the language in your keyboard.", + val successMessage: String = "Great! Press Next to continue.", + val errorMessage: String = "", + val requiresValidation: Boolean = true, +) + +/** + * Checks whether the currently active keyboard is a Scribe keyboard. + * + * @param context The application context. + * @return true if the active input method belongs to the Scribe package, false otherwise. + */ +fun isScribeKeyboardActive(context: Context): Boolean { + val currentInputMethod = + Settings.Secure.getString( + context.contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD, + ) + return currentInputMethod?.contains("be.scri") == true +} + +/** + * The reusable tutorial step screen component (Screens 1.1-4.0 from Figma). + * This is the interactive lesson screen used by all tutorial chapters. + * It displays an instruction, a text input field, validates the user's input, + * and shows success/error feedback. + * + * If the user does not have a Scribe keyboard active, it shows the + * WrongKeyboardScreen instead, prompting them to switch. + * + * @param chapterTitle The title of the current chapter (e.g., "Noun annotation"). + * @param step The [TutorialStep] data for the current step. + * @param onBackPress Callback when the back button is pressed. + * @param onClosePress Callback when the close (X) button is pressed. + * @param onNextPress Callback when the Next/Finish button is pressed. + * @param modifier Modifier for this composable. + * @param isLastStep Whether this is the final step in the entire tutorial. + * @param showQuickTutorialHeader Whether to show "Quick tutorial" back link instead of back arrow. + */ +@Composable +fun TutorialStepScreen( + chapterTitle: String, + step: TutorialStep, + onBackPress: () -> Unit, + onClosePress: () -> Unit, + onNextPress: () -> Unit, + modifier: Modifier = Modifier, + isLastStep: Boolean = false, + showQuickTutorialHeader: Boolean = false, +) { + val context = LocalContext.current + var isScribeActive by remember { mutableStateOf(isScribeKeyboardActive(context)) } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + isScribeActive = isScribeKeyboardActive(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(Unit) { + while (true) { + delay(1000) + isScribeActive = isScribeKeyboardActive(context) + } + } + + if (!isScribeActive) { + WrongKeyboardScreen( + onBackPress = onBackPress, + onClosePress = onClosePress, + ) + return + } + + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = MaterialTheme.colorScheme.background + val cardBackground = MaterialTheme.colorScheme.surface + val textColor = MaterialTheme.colorScheme.onSurface + val textSecondaryColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + val dividerColor = MaterialTheme.colorScheme.outlineVariant + val primaryColor = MaterialTheme.colorScheme.primary + val headerColor = MaterialTheme.colorScheme.onBackground + val successColor = if (isDarkTheme) Color(0xFF08A045) else Color(0xFF9BC53D) + val errorColor = Color(0xFFE53935) + + var userInput by remember(step) { mutableStateOf("") } + + val validationState = + when { + !step.requiresValidation -> InputValidationState.CORRECT + userInput.isEmpty() -> InputValidationState.EMPTY + userInput.trim().equals(step.expectedWord, ignoreCase = false) -> InputValidationState.CORRECT + else -> InputValidationState.INCORRECT + } + + val errorText = + if (step.errorMessage.isNotEmpty()) { + step.errorMessage + } else { + "Not quite! Try writing ${step.expectedWord}." + } + + Column( + modifier = + modifier + .fillMaxSize() + .background(backgroundColor), + ) { + // Top navigation bar + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBackPress) { + if (showQuickTutorialHeader) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Back", + tint = headerColor, + ) + Text( + text = "Quick tutorial", + color = headerColor, + fontSize = 14.sp, + ) + } + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Back", + tint = headerColor, + modifier = Modifier.size(28.dp), + ) + } + } + IconButton(onClick = onClosePress) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close tutorial", + tint = headerColor, + modifier = Modifier.size(24.dp), + ) + } + } + + // Chapter title + Text( + text = chapterTitle, + color = headerColor, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Instruction card + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = cardBackground), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .border( + width = 2.dp, + color = primaryColor, + shape = RoundedCornerShape(12.dp), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + // Instruction text + Text( + text = step.instruction, + color = textColor, + fontSize = 14.sp, + lineHeight = 20.sp, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Language hint + Row( + verticalAlignment = Alignment.Top, + ) { + Text( + text = "\uD83C\uDF10 ", + fontSize = 14.sp, + ) + Text( + text = step.hint, + color = textSecondaryColor, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Text input field + HorizontalDivider( + color = dividerColor, + ) + + BasicTextField( + value = userInput, + onValueChange = { userInput = it }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + textStyle = + TextStyle( + color = textColor, + fontSize = 16.sp, + ), + cursorBrush = SolidColor(textColor), + singleLine = true, + ) + + HorizontalDivider( + color = dividerColor, + ) + + // Validation feedback + when (validationState) { + InputValidationState.CORRECT -> { + if (step.requiresValidation) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = step.successMessage, + color = successColor, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } + } + InputValidationState.INCORRECT -> { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorText, + color = errorColor, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } + InputValidationState.EMPTY -> { + // No feedback when empty. + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Next / Finish button + Button( + onClick = onNextPress, + enabled = validationState == InputValidationState.CORRECT, + colors = + ButtonDefaults.buttonColors( + containerColor = primaryColor, + contentColor = if (isDarkTheme) Color.White else Color.Black, + disabledContainerColor = primaryColor.copy(alpha = 0.5f), + disabledContentColor = (if (isDarkTheme) Color.White else Color.Black).copy(alpha = 0.5f), + ), + shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .height(52.dp), + ) { + Text( + text = if (isLastStep) "Finish tutorial" else "Next", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt b/app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt new file mode 100644 index 00000000..b9a5a6c9 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens.tutorial + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Screen displayed when the user has a non-Scribe keyboard active during the tutorial. + * Prompts the user to press the globe button to switch to a Scribe keyboard. + * + * The screen includes a focused text input field that automatically requests focus + * on launch, ensuring the system keyboard appears so the user can tap the globe icon + * to switch keyboards. Without this field, the keyboard would never appear and the + * user would be stuck on this screen. + * + * @param onBackPress Callback when the back button is pressed. + * @param onClosePress Callback when the close (X) button is pressed. + * @param modifier Modifier for this composable. + */ +@Composable +fun WrongKeyboardScreen( + onBackPress: () -> Unit, + onClosePress: () -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = MaterialTheme.colorScheme.background + val cardBackground = MaterialTheme.colorScheme.surface + val textColor = MaterialTheme.colorScheme.onSurface + val dividerColor = MaterialTheme.colorScheme.outlineVariant + val headerColor = MaterialTheme.colorScheme.onBackground + + var userInput by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + // Auto-focus the input field when the screen appears so the keyboard pops up. + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + + Column( + modifier = + modifier + .fillMaxSize() + .background(backgroundColor), + ) { + // Top navigation bar + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBackPress) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Back", + tint = headerColor, + modifier = Modifier.size(28.dp), + ) + } + IconButton(onClick = onClosePress) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close tutorial", + tint = headerColor, + modifier = Modifier.size(24.dp), + ) + } + } + + // Title + Text( + text = "Non-Scribe keyboard", + color = headerColor, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 20.dp), + ) + + Spacer(modifier = Modifier.padding(8.dp)) + + // Instruction card + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = cardBackground), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + // Instruction text + Text( + text = "Press the \uD83C\uDF10 button to select a Scribe keyboard.", + color = textColor, + fontSize = 15.sp, + lineHeight = 22.sp, + ) + + Spacer(modifier = Modifier.padding(8.dp)) + + HorizontalDivider( + color = dividerColor, + ) + + // Hidden input field that brings up the keyboard. + // The user types nothing here — it just exists to trigger the IME + // so the globe icon is accessible for switching keyboards. + BasicTextField( + value = userInput, + onValueChange = { userInput = it }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .focusRequester(focusRequester), + textStyle = + TextStyle( + color = textColor, + fontSize = 16.sp, + ), + cursorBrush = SolidColor(textColor), + singleLine = true, + ) + + HorizontalDivider( + color = dividerColor, + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + } +}