diff --git a/app/src/main/java/be/scri/ui/screens/about/AboutScreen.kt b/app/src/main/java/be/scri/ui/screens/about/AboutScreen.kt index efa44720..14a15cbf 100644 --- a/app/src/main/java/be/scri/ui/screens/about/AboutScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/about/AboutScreen.kt @@ -8,13 +8,26 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import be.scri.R import be.scri.helpers.AppFlavor import be.scri.helpers.FlavorProvider @@ -24,6 +37,7 @@ import be.scri.ui.common.components.ItemCardContainerWithTitle import be.scri.ui.screens.about.AboutUtil.getCommunityList import be.scri.ui.screens.about.AboutUtil.getFeedbackAndSupportList import be.scri.ui.screens.about.AboutUtil.getLegalListItems +import be.scri.ui.screens.tutorial.TutorialNavigator /** * The about page of the application with links to the community as well as sub pages for detailed descriptions. @@ -38,6 +52,15 @@ fun AboutScreen( context: Context, modifier: Modifier = Modifier, ) { + var showTutorial by remember { mutableStateOf(false) } + + if (showTutorial) { + TutorialNavigator( + onTutorialExit = { showTutorial = false }, + ) + return + } + val isConjugateApp = FlavorProvider.get() == AppFlavor.CONJUGATE val scrollState = rememberScrollState() @@ -81,6 +104,28 @@ fun AboutScreen( .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + // Tutorial button + Button( + onClick = { showTutorial = true }, + colors = + ButtonDefaults.buttonColors( + containerColor = Color(0xFFF5A623), + contentColor = Color.White, + ), + shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(52.dp), + ) { + Text( + text = "Start full tutorial", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + } + ItemCardContainerWithTitle( title = stringResource(R.string.i18n_app_about_community_title), cardItemsList = communityList, 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..90e160d3 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialHomeScreen.kt @@ -0,0 +1,242 @@ +// 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.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 + +/** + * Color constants matching the Scribe brand from the Figma designs. + */ +object TutorialColors { + val lightBackground = Color(0xFF6DAFCF) + val darkBackground = Color(0xFF1A2634) + val cardBackgroundLight = Color(0xFFFFFFFF) + val cardBackgroundDark = Color(0xFF2A3A4A) + val accentYellow = Color(0xFFF5A623) + val textPrimary = Color(0xFF1E1E1E) + val textPrimaryDark = Color(0xFFFFFFFF) + val textSecondary = Color(0xFF666666) + val textSecondaryDark = Color(0xFFAAAAAA) + val successGreen = Color(0xFF4CAF50) + val errorRed = Color(0xFFE53935) + val dividerLight = Color(0xFFE0E0E0) + val dividerDark = Color(0xFF3A4A5A) + val aboutBackground = Color(0xFFF5F5F5) + val aboutBackgroundDark = Color(0xFF1A1A2E) +} + +/** + * 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, +) + +/** + * 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 = if (isDarkTheme) TutorialColors.aboutBackgroundDark else TutorialColors.aboutBackground + val cardBackground = if (isDarkTheme) TutorialColors.cardBackgroundDark else TutorialColors.cardBackgroundLight + val textColor = if (isDarkTheme) TutorialColors.textPrimaryDark else TutorialColors.textPrimary + val secondaryTextColor = if (isDarkTheme) TutorialColors.textSecondaryDark else TutorialColors.textSecondary + val dividerColor = if (isDarkTheme) TutorialColors.dividerDark else TutorialColors.dividerLight + + 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 = TutorialColors.accentYellow, + modifier = Modifier.size(24.dp), + ) + Text( + text = "About", + color = TutorialColors.accentYellow, + 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 = TutorialColors.accentYellow, + contentColor = Color.White, + ), + 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..496bf11e --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialNavigator.kt @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.ui.screens.tutorial + +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 + +/** + * 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, + ) +} + +/** + * 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() + + 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..bc1c9a04 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialStepScreen.kt @@ -0,0 +1,344 @@ +// 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.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.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 + +/** + * 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 + val isScribeActive = isScribeKeyboardActive(context) + + if (!isScribeActive) { + WrongKeyboardScreen( + onBackPress = onBackPress, + onClosePress = onClosePress, + ) + return + } + + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) TutorialColors.darkBackground else TutorialColors.lightBackground + val cardBackground = if (isDarkTheme) TutorialColors.cardBackgroundDark else TutorialColors.cardBackgroundLight + val textColor = if (isDarkTheme) TutorialColors.textPrimaryDark else TutorialColors.textPrimary + + var userInput by remember { 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 = Color.White, + ) + Text( + text = "Quick tutorial", + color = Color.White, + fontSize = 14.sp, + ) + } + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Back", + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + } + IconButton(onClick = onClosePress) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close tutorial", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } + + // Chapter title + Text( + text = chapterTitle, + color = Color.White, + 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 = TutorialColors.accentYellow, + 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 = if (isDarkTheme) TutorialColors.textSecondaryDark else TutorialColors.textSecondary, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Text input field + HorizontalDivider( + color = if (isDarkTheme) TutorialColors.dividerDark else TutorialColors.dividerLight, + ) + + 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 = if (isDarkTheme) TutorialColors.dividerDark else TutorialColors.dividerLight, + ) + + // Validation feedback + when (validationState) { + InputValidationState.CORRECT -> { + if (step.requiresValidation) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = step.successMessage, + color = TutorialColors.successGreen, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + ) + } + } + InputValidationState.INCORRECT -> { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorText, + color = TutorialColors.errorRed, + 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 = TutorialColors.accentYellow, + contentColor = Color.White, + disabledContainerColor = TutorialColors.accentYellow.copy(alpha = 0.5f), + disabledContentColor = Color.White.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..7665f019 --- /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.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.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.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.Color +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 isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) TutorialColors.darkBackground else TutorialColors.lightBackground + val cardBackground = if (isDarkTheme) TutorialColors.cardBackgroundDark else TutorialColors.cardBackgroundLight + val textColor = if (isDarkTheme) TutorialColors.textPrimaryDark else TutorialColors.textPrimary + + 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 = Color.White, + modifier = Modifier.size(28.dp), + ) + } + IconButton(onClick = onClosePress) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close tutorial", + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + } + + // Title + Text( + text = "Non-Scribe keyboard", + color = Color.White, + 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 = if (isDarkTheme) TutorialColors.dividerDark else TutorialColors.dividerLight, + ) + + // 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 = if (isDarkTheme) TutorialColors.dividerDark else TutorialColors.dividerLight, + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + } +}