From 36d2b2dd287867cb8a9fea6436d53140d1cdab9d Mon Sep 17 00:00:00 2001 From: Mailos07 Date: Mon, 20 Apr 2026 14:45:50 -0400 Subject: [PATCH 1/3] initial commit --- .../be/scri/ui/screens/about/AboutScreen.kt | 45 +++ .../ui/screens/tutorial/TutorialHomeScreen.kt | 240 ++++++++++++ .../ui/screens/tutorial/TutorialNavigator.kt | 177 +++++++++ .../ui/screens/tutorial/TutorialStepScreen.kt | 343 ++++++++++++++++++ .../screens/tutorial/WrongKeyboardScreen.kt | 114 ++++++ 5 files changed, 919 insertions(+) create mode 100644 app/src/main/java/be/scri/ui/screens/tutorial/TutorialHomeScreen.kt create mode 100644 app/src/main/java/be/scri/ui/screens/tutorial/TutorialNavigator.kt create mode 100644 app/src/main/java/be/scri/ui/screens/tutorial/TutorialStepScreen.kt create mode 100644 app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt 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..c1492769 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..e9d6e656 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialHomeScreen.kt @@ -0,0 +1,240 @@ +// 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 onBackPressed Callback when the back button is pressed. + * @param onChapterSelected Callback when a specific chapter is tapped. + * @param onStartFullTutorial Callback when the "Start full tutorial" button is pressed. + */ +@Composable +fun TutorialHomeScreen( + onBackPressed: () -> Unit, + onChapterSelected: (Int) -> Unit, + onStartFullTutorial: () -> Unit, +) { + 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 { onBackPressed() }, + ) { + 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 { onChapterSelected(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..2c3be998 --- /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( + onBackPressed = onTutorialExit, + onChapterSelected = { 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, + onBackPressed = { + when { + currentStepIndex > 0 -> { + currentStepIndex-- + } + isFullTutorial && currentChapterIndex > 0 -> { + currentChapterIndex-- + val prevSteps = allChapters[currentChapterIndex].second + currentStepIndex = prevSteps.size - 1 + } + else -> { + currentScreen = "home" + } + } + }, + onClosePressed = { + currentScreen = "home" + }, + onNextPressed = { + 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..6298b632 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialStepScreen.kt @@ -0,0 +1,343 @@ +// 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 onBackPressed Callback when the back button is pressed. + * @param onClosePressed Callback when the close (X) button is pressed. + * @param onNextPressed 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, + onBackPressed: () -> Unit, + onClosePressed: () -> Unit, + onNextPressed: () -> Unit, + modifier: Modifier = Modifier, + isLastStep: Boolean = false, + showQuickTutorialHeader: Boolean = false, +) { + val context = LocalContext.current + val isScribeActive = isScribeKeyboardActive(context) + + if (!isScribeActive) { + WrongKeyboardScreen( + onBackPressed = onBackPressed, + onClosePressed = onClosePressed, + ) + 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 = onBackPressed) { + 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 = onClosePressed) { + 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 = onNextPressed, + 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..856b16e8 --- /dev/null +++ b/app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt @@ -0,0 +1,114 @@ +// 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.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.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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 + +/** + * 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. + * + * @param onBackPressed Callback when the back button is pressed. + * @param onClosePressed Callback when the close (X) button is pressed. + */ +@Composable +fun WrongKeyboardScreen( + onBackPressed: () -> Unit, + onClosePressed: () -> Unit, +) { + 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 + + 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 = onBackPressed) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Back", + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + IconButton(onClick = onClosePressed) { + 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.height(16.dp)) + + // Instruction card + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = cardBackground), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = "Press the 🌐 button to select a Scribe keyboard.", + color = textColor, + fontSize = 15.sp, + lineHeight = 22.sp, + modifier = Modifier.padding(16.dp), + ) + } + + Spacer(modifier = Modifier.weight(1f)) + } +} From 74ed098373995a8d5d29c1673667c2c3671695e6 Mon Sep 17 00:00:00 2001 From: Mailos07 Date: Thu, 23 Apr 2026 12:14:15 -0400 Subject: [PATCH 2/3] fix: rename lambda params to present tense and add modifier params --- .../be/scri/ui/screens/about/AboutScreen.kt | 2 +- .../ui/screens/tutorial/TutorialHomeScreen.kt | 16 +++++----- .../ui/screens/tutorial/TutorialNavigator.kt | 10 +++--- .../ui/screens/tutorial/TutorialStepScreen.kt | 31 ++++++++++--------- .../screens/tutorial/WrongKeyboardScreen.kt | 21 +++++++------ 5 files changed, 42 insertions(+), 38 deletions(-) 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 c1492769..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 @@ -56,7 +56,7 @@ fun AboutScreen( if (showTutorial) { TutorialNavigator( - onTutorialExit = { showTutorial = false } + onTutorialExit = { showTutorial = false }, ) return } 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 index e9d6e656..90e160d3 100644 --- a/app/src/main/java/be/scri/ui/screens/tutorial/TutorialHomeScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialHomeScreen.kt @@ -70,15 +70,17 @@ data class TutorialChapter( * Displays a list of tutorial chapters and a button to start the full tutorial. * This screen is accessible from the About tab. * - * @param onBackPressed Callback when the back button is pressed. - * @param onChapterSelected Callback when a specific chapter is tapped. + * @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( - onBackPressed: () -> Unit, - onChapterSelected: (Int) -> Unit, + onBackPress: () -> Unit, + onChapterSelect: (Int) -> Unit, onStartFullTutorial: () -> Unit, + modifier: Modifier = Modifier, ) { val isDarkTheme = isSystemInDarkTheme() val backgroundColor = if (isDarkTheme) TutorialColors.aboutBackgroundDark else TutorialColors.aboutBackground @@ -97,7 +99,7 @@ fun TutorialHomeScreen( Column( modifier = - Modifier + modifier .fillMaxSize() .background(backgroundColor) .padding(16.dp), @@ -105,7 +107,7 @@ fun TutorialHomeScreen( // Back button Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { onBackPressed() }, + modifier = Modifier.clickable { onBackPress() }, ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, @@ -186,7 +188,7 @@ fun TutorialHomeScreen( modifier = Modifier .fillMaxWidth() - .clickable { onChapterSelected(chapter.chapterIndex) } + .clickable { onChapterSelect(chapter.chapterIndex) } .padding(horizontal = 16.dp, vertical = 14.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, 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 index 2c3be998..496bf11e 100644 --- a/app/src/main/java/be/scri/ui/screens/tutorial/TutorialNavigator.kt +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialNavigator.kt @@ -111,8 +111,8 @@ fun TutorialNavigator(onTutorialExit: () -> Unit) { when (currentScreen) { "home" -> { TutorialHomeScreen( - onBackPressed = onTutorialExit, - onChapterSelected = { chapterIndex -> + onBackPress = onTutorialExit, + onChapterSelect = { chapterIndex -> currentChapterIndex = chapterIndex currentStepIndex = 0 isFullTutorial = false @@ -139,7 +139,7 @@ fun TutorialNavigator(onTutorialExit: () -> Unit) { step = step, isLastStep = isLastStep, showQuickTutorialHeader = !isFullTutorial && currentStepIndex == 0, - onBackPressed = { + onBackPress = { when { currentStepIndex > 0 -> { currentStepIndex-- @@ -154,10 +154,10 @@ fun TutorialNavigator(onTutorialExit: () -> Unit) { } } }, - onClosePressed = { + onClosePress = { currentScreen = "home" }, - onNextPressed = { + onNextPress = { when { !isLastStepInChapter -> { currentStepIndex++ 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 index 6298b632..bc1c9a04 100644 --- a/app/src/main/java/be/scri/ui/screens/tutorial/TutorialStepScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/tutorial/TutorialStepScreen.kt @@ -84,10 +84,11 @@ data class TutorialStep( * @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, - ) + val currentInputMethod = + Settings.Secure.getString( + context.contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD, + ) return currentInputMethod?.contains("be.scri") == true } @@ -102,9 +103,9 @@ fun isScribeKeyboardActive(context: Context): Boolean { * * @param chapterTitle The title of the current chapter (e.g., "Noun annotation"). * @param step The [TutorialStep] data for the current step. - * @param onBackPressed Callback when the back button is pressed. - * @param onClosePressed Callback when the close (X) button is pressed. - * @param onNextPressed Callback when the Next/Finish button is pressed. + * @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. @@ -113,9 +114,9 @@ fun isScribeKeyboardActive(context: Context): Boolean { fun TutorialStepScreen( chapterTitle: String, step: TutorialStep, - onBackPressed: () -> Unit, - onClosePressed: () -> Unit, - onNextPressed: () -> Unit, + onBackPress: () -> Unit, + onClosePress: () -> Unit, + onNextPress: () -> Unit, modifier: Modifier = Modifier, isLastStep: Boolean = false, showQuickTutorialHeader: Boolean = false, @@ -125,8 +126,8 @@ fun TutorialStepScreen( if (!isScribeActive) { WrongKeyboardScreen( - onBackPressed = onBackPressed, - onClosePressed = onClosePressed, + onBackPress = onBackPress, + onClosePress = onClosePress, ) return } @@ -168,7 +169,7 @@ fun TutorialStepScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = onBackPressed) { + IconButton(onClick = onBackPress) { if (showQuickTutorialHeader) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -191,7 +192,7 @@ fun TutorialStepScreen( ) } } - IconButton(onClick = onClosePressed) { + IconButton(onClick = onClosePress) { Icon( imageVector = Icons.Filled.Close, contentDescription = "Close tutorial", @@ -315,7 +316,7 @@ fun TutorialStepScreen( // Next / Finish button Button( - onClick = onNextPressed, + onClick = onNextPress, enabled = validationState == InputValidationState.CORRECT, colors = ButtonDefaults.buttonColors( 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 index 856b16e8..67d53c60 100644 --- a/app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt @@ -10,7 +10,6 @@ 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 @@ -34,13 +33,15 @@ 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. * - * @param onBackPressed Callback when the back button is pressed. - * @param onClosePressed Callback when the close (X) button is pressed. + * @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( - onBackPressed: () -> Unit, - onClosePressed: () -> Unit, + onBackPress: () -> Unit, + onClosePress: () -> Unit, + modifier: Modifier = Modifier, ) { val isDarkTheme = isSystemInDarkTheme() val backgroundColor = if (isDarkTheme) TutorialColors.darkBackground else TutorialColors.lightBackground @@ -49,7 +50,7 @@ fun WrongKeyboardScreen( Column( modifier = - Modifier + modifier .fillMaxSize() .background(backgroundColor), ) { @@ -62,7 +63,7 @@ fun WrongKeyboardScreen( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = onBackPressed) { + IconButton(onClick = onBackPress) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Back", @@ -70,7 +71,7 @@ fun WrongKeyboardScreen( modifier = Modifier.size(28.dp), ) } - IconButton(onClick = onClosePressed) { + IconButton(onClick = onClosePress) { Icon( imageVector = Icons.Filled.Close, contentDescription = "Close tutorial", @@ -89,7 +90,7 @@ fun WrongKeyboardScreen( modifier = Modifier.padding(horizontal = 20.dp), ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.padding(8.dp)) // Instruction card Card( @@ -101,7 +102,7 @@ fun WrongKeyboardScreen( .padding(horizontal = 20.dp), ) { Text( - text = "Press the 🌐 button to select a Scribe keyboard.", + text = "Press the \uD83C\uDF10 button to select a Scribe keyboard.", color = textColor, fontSize = 15.sp, lineHeight = 22.sp, From 5684b34e04c61a483ed09cc6db55bcadd2b608ff Mon Sep 17 00:00:00 2001 From: Mailos07 Date: Tue, 28 Apr 2026 15:25:25 -0400 Subject: [PATCH 3/3] fix: add focused input field to WrongKeyboardScreen so keyboard appears --- .../screens/tutorial/WrongKeyboardScreen.kt | 73 +++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) 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 index 67d53c60..7665f019 100644 --- a/app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/tutorial/WrongKeyboardScreen.kt @@ -13,18 +13,30 @@ 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 @@ -33,6 +45,11 @@ 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. @@ -48,6 +65,16 @@ fun WrongKeyboardScreen( 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 @@ -101,13 +128,47 @@ fun WrongKeyboardScreen( .fillMaxWidth() .padding(horizontal = 20.dp), ) { - Text( - text = "Press the \uD83C\uDF10 button to select a Scribe keyboard.", - color = textColor, - fontSize = 15.sp, - lineHeight = 22.sp, + 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))