From eae37715cdb449a45284926ce9ea112b83918349 Mon Sep 17 00:00:00 2001 From: jonasgaudian <43753916+jonasgaudian@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:47:46 +0100 Subject: [PATCH] implement `statsGraph` and refactor `StatsScreen` with drag-and-drop widget reordering --- .../gaudian/translator/view/MainActivity.kt | 31 +- .../eu/gaudian/translator/view/Navigation.kt | 107 ++- .../view/composable/AppTabLayout.kt | 148 ++-- .../view/dictionary/MainDictionaryScreen.kt | 11 +- .../view/exercises/MainExerciseScreen.kt | 11 +- .../translator/view/settings/ApiKeyScreen.kt | 11 +- .../view/settings/MainSettingsScreen.kt | 11 +- .../translator/view/stats/StatsScreen.kt | 678 +++++++++++++++++- .../view/translation/LanguageSelectorBar.kt | 16 +- .../view/translation/MainTranslationScreen.kt | 10 + .../view/vocabulary/MainVocabularyScreen.kt | 9 + app/src/main/res/values/arrays.xml | 2 +- 12 files changed, 963 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/eu/gaudian/translator/view/MainActivity.kt b/app/src/main/java/eu/gaudian/translator/view/MainActivity.kt index b5c2628..7535089 100644 --- a/app/src/main/java/eu/gaudian/translator/view/MainActivity.kt +++ b/app/src/main/java/eu/gaudian/translator/view/MainActivity.kt @@ -253,7 +253,15 @@ fun TranslatorApp( val currentDestination = navBackStackEntry?.destination val selectedScreen = Screen.fromDestination(currentDestination) - @Suppress("HardCodedStringLiteral") val isBottomBarHidden = currentDestination?.route?.startsWith("vocabulary_exercise") == true + val isBottomBarHidden = currentDestination?.hierarchy?.any { destination -> + destination.route in setOf( + Screen.Translation.route, + Screen.Vocabulary.route, + Screen.Dictionary.route, + Screen.Exercises.route, + Screen.Settings.route + ) + } == true || currentDestination?.route == "start_exercise" val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false) BottomNavigationBar( @@ -262,6 +270,13 @@ fun TranslatorApp( showLabels = showBottomNavLabels, onItemSelected = { screen -> val inSameSection = currentDestination?.hierarchy?.any { it.route == screen.route } == true + val isMoreSection = screen in setOf( + Screen.Translation, + Screen.Vocabulary, + Screen.Dictionary, + Screen.Settings, + Screen.Exercises + ) // Always reset the selected section to its root and clear back stack between sections if (inSameSection) { @@ -274,6 +289,11 @@ fun TranslatorApp( launchSingleTop = true restoreState = false } + } else if (isMoreSection) { + navController.navigate(screen.route) { + launchSingleTop = true + restoreState = false + } } else { // Switching sections: clear entire back stack to start to avoid back navigation results navController.navigate(screen.route) { @@ -287,14 +307,7 @@ fun TranslatorApp( } }, onPlayClicked = { - navController.navigate("start_exercise") { - popUpTo(0) { - inclusive = true - saveState = false - } - launchSingleTop = true - restoreState = false - } + navController.navigate("start_exercise") } ) }, diff --git a/app/src/main/java/eu/gaudian/translator/view/Navigation.kt b/app/src/main/java/eu/gaudian/translator/view/Navigation.kt index aaeed76..dff415e 100644 --- a/app/src/main/java/eu/gaudian/translator/view/Navigation.kt +++ b/app/src/main/java/eu/gaudian/translator/view/Navigation.kt @@ -130,10 +130,6 @@ fun AppNavHost( HomeScreen(navController = navController) } - composable(Screen.Stats.route) { - StatsScreen(navController = navController) - } - composable(Screen.Library.route) { LibraryScreen(navController = navController) } @@ -144,6 +140,7 @@ fun AppNavHost( // Define all other navigation graphs at the same top level. homeGraph(navController) + statsGraph(navController) translationGraph(navController) dictionaryGraph(navController) vocabularyGraph(navController) @@ -163,6 +160,108 @@ fun NavGraphBuilder.homeGraph(navController: NavHostController) { } } +fun NavGraphBuilder.statsGraph( + navController: NavHostController, +) { + navigation( + startDestination = "main_stats", + route = Screen.Stats.route + ) { + composable("main_stats") { + StatsScreen(navController = navController) + } + composable("stats/vocabulary_sorting") { + VocabularySortingScreen( + navController = navController + ) + } + composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry -> + val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false + val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull() + VocabularyListScreen( + navController = navController, + showDueTodayOnly = showDueTodayOnly, + categoryId = categoryId, + onNavigateToItem = { item -> + navController.navigate("vocabulary_detail/${item.id}") + }, + onNavigateBack = { navController.popBackStack() }, + enableNavigationButtons = true + ) + } + composable("stats/language_progress") { + LanguageProgressScreen( + navController = navController + ) + } + composable("stats/vocabulary_heatmap") { + VocabularyHeatmapScreen( + navController = navController, + ) + } + composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry -> + val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false + val stageString = backStackEntry.arguments?.getString("stage") + val stage = stageString?.let { + if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it) + } + + VocabularyListScreen( + navController = navController, + showDueTodayOnly = showDueTodayOnly, + stage = stage, + onNavigateToItem = { item -> + navController.navigate("vocabulary_detail/${item.id}") + }, + onNavigateBack = { navController.popBackStack() }, + categoryId = 0, + enableNavigationButtons = true + ) + } + composable("stats/category_detail/{categoryId}") { backStackEntry -> + val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull() + + if (categoryId != null) { + CategoryDetailScreen( + categoryId = categoryId, + onBackClick = { navController.popBackStack() }, + onNavigateToItem = { item -> + navController.navigate("vocabulary_detail/${item.id}") + }, + navController = navController + ) + } + } + composable("stats/category_list_screen") { + CategoryListScreen( + onNavigateBack = { navController.popBackStack() }, + onCategoryClicked = { categoryId -> + navController.navigate("stats/category_detail/$categoryId") + } + ) + } + composable( + route = "stats/vocabulary_sorting?mode={mode}", + arguments = listOf( + navArgument("mode") { + type = NavType.StringType + nullable = true + } + ) + ) { backStackEntry -> + VocabularySortingScreen( + navController = navController, + initialFilterMode = backStackEntry.arguments?.getString("mode") + ) + } + composable("stats/no_grammar_items") { + NoGrammarItemsScreen( + navController = navController + ) + } + } +} + @OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) fun NavGraphBuilder.translationGraph(navController: NavHostController) { navigation( diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppTabLayout.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppTabLayout.kt index 5c78241..af6ebbb 100644 --- a/app/src/main/java/eu/gaudian/translator/view/composable/AppTabLayout.kt +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppTabLayout.kt @@ -20,9 +20,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +41,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.R /** * An interface that defines the required properties for any item @@ -60,21 +65,49 @@ fun AppTabLayout( tabs: List, selectedTab: T, onTabSelected: (T) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onNavigateBack: (() -> Unit)? = null ) { val selectedIndex = tabs.indexOf(selectedTab) - BoxWithConstraints( + Row( modifier = modifier .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 8.dp) - .height(56.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - shape = ComponentDefaults.CardShape - ) + .padding(vertical = 8.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { - val tabWidth = maxWidth / tabs.size + if (onNavigateBack != null) { + IconButton( + onClick = onNavigateBack, + modifier = Modifier + .padding(end = 8.dp) + .size(40.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = CircleShape + ), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = AppIcons.ArrowBack, + contentDescription = stringResource(R.string.cd_navigate_back) + ) + } + } + + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .height(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = ComponentDefaults.CardShape + ) + ) { + val tabWidth = maxWidth / tabs.size val indicatorOffset by animateDpAsState( targetValue = tabWidth * selectedIndex, @@ -82,58 +115,59 @@ fun AppTabLayout( label = "IndicatorOffset" ) - Box( - modifier = Modifier - .offset(x = indicatorOffset) - .width(tabWidth) - .fillMaxHeight() - .padding(4.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(12.dp) - ) - ) + Box( + modifier = Modifier + .offset(x = indicatorOffset) + .width(tabWidth) + .fillMaxHeight() + .padding(4.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp) + ) + ) - Row(modifier = Modifier.fillMaxWidth()) { - tabs.forEach { tab -> - val isSelected = tab == selectedTab - val contentColor by animateColorAsState( - targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, - animationSpec = spring(stiffness = Spring.StiffnessLow) - ) + Row(modifier = Modifier.fillMaxWidth()) { + tabs.forEach { tab -> + val isSelected = tab == selectedTab + val contentColor by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clickable( - onClick = { onTabSelected(tab) }, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ), - contentAlignment = Alignment.Center - ) { - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + onClick = { onTabSelected(tab) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center ) { - val context = LocalContext.current - val resolvedTitle = run { - val resId = context.resources.getIdentifier(tab.title, "string", context.packageName) - if (resId != 0) stringResource(resId) else tab.title + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + val context = LocalContext.current + val resolvedTitle = run { + val resId = context.resources.getIdentifier(tab.title, "string", context.packageName) + if (resId != 0) stringResource(resId) else tab.title + } + Icon( + modifier = Modifier.padding(4.dp), + imageVector = tab.icon, + contentDescription = resolvedTitle, + tint = contentColor + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = resolvedTitle, + color = contentColor, + ) } - Icon( - modifier = Modifier.padding(4.dp), - imageVector = tab.icon, - contentDescription = resolvedTitle, - tint = contentColor - ) - Spacer(modifier = Modifier.width(2.dp)) - Text( - text = resolvedTitle, - color = contentColor, - ) } } } diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreen.kt index 163bf76..26ebe63 100644 --- a/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreen.kt @@ -21,6 +21,7 @@ import eu.gaudian.translator.view.LocalConnectionConfigured import eu.gaudian.translator.view.NoConnectionScreen import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppTabLayout +import eu.gaudian.translator.view.composable.Screen import eu.gaudian.translator.view.composable.TabItem import eu.gaudian.translator.view.settings.SettingsRoutes import eu.gaudian.translator.viewmodel.CorrectionViewModel @@ -63,7 +64,15 @@ fun MainDictionaryScreen( AppTabLayout( tabs = dictionaryTabs, selectedTab = selectedTab, - onTabSelected = { selectedTab = it } + onTabSelected = { selectedTab = it }, + onNavigateBack = { + if (!navController.popBackStack()) { + navController.navigate(Screen.Home.route) { + launchSingleTop = true + restoreState = false + } + } + } ) when (selectedTab) { diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/MainExerciseScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/MainExerciseScreen.kt index 0bef304..6e2eb74 100644 --- a/app/src/main/java/eu/gaudian/translator/view/exercises/MainExerciseScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/MainExerciseScreen.kt @@ -38,6 +38,7 @@ import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppOutlinedCard import eu.gaudian.translator.view.composable.AppTabLayout import eu.gaudian.translator.view.composable.DialogButton +import eu.gaudian.translator.view.composable.Screen import eu.gaudian.translator.view.composable.TabItem import eu.gaudian.translator.viewmodel.AiGenerationState import eu.gaudian.translator.viewmodel.ExerciseViewModel @@ -76,7 +77,15 @@ fun MainExerciseScreen( AppTabLayout( tabs = ExerciseTab.entries, selectedTab = selectedTab, - onTabSelected = { selectedTab = it } + onTabSelected = { selectedTab = it }, + onNavigateBack = { + if (!navController.popBackStack()) { + navController.navigate(Screen.Home.route) { + launchSingleTop = true + restoreState = false + } + } + } ) Box(modifier = Modifier.weight(1f)) { diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt index 26d0c77..01c5c0f 100644 --- a/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt @@ -78,6 +78,7 @@ import eu.gaudian.translator.view.composable.AppTabLayout import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.ClickableText import eu.gaudian.translator.view.composable.PrimaryButton +import eu.gaudian.translator.view.composable.Screen import eu.gaudian.translator.view.composable.SecondaryButton import eu.gaudian.translator.view.composable.TabItem import eu.gaudian.translator.view.hints.HintDefinition @@ -133,7 +134,15 @@ fun ApiKeyScreen(navController: NavController) { AppTabLayout( tabs = apiTabs, selectedTab = selectedTab, - onTabSelected = { selectedTab = it } + onTabSelected = { selectedTab = it }, + onNavigateBack = { + if (!navController.popBackStack()) { + navController.navigate(Screen.Home.route) { + launchSingleTop = true + restoreState = false + } + } + } ) // Tab Content diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/MainSettingsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/MainSettingsScreen.kt index 4fa1a3e..989f406 100644 --- a/app/src/main/java/eu/gaudian/translator/view/settings/MainSettingsScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/settings/MainSettingsScreen.kt @@ -27,6 +27,7 @@ import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppOutlinedCard import eu.gaudian.translator.view.composable.AppScaffold import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.Screen private data class Setting(val titleRes: Int, val icon: ImageVector, val route: String) @@ -84,7 +85,15 @@ fun MainSettingsScreen( AppScaffold( topBar = { AppTopAppBar( - title =stringResource(R.string.title_settings) + title =stringResource(R.string.title_settings), + onNavigateBack = { + if (!navController.popBackStack()) { + navController.navigate(Screen.Home.route) { + launchSingleTop = true + restoreState = false + } + } + } ) } ) { paddingValues -> diff --git a/app/src/main/java/eu/gaudian/translator/view/stats/StatsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/stats/StatsScreen.kt index 957d323..c6482fb 100644 --- a/app/src/main/java/eu/gaudian/translator/view/stats/StatsScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/stats/StatsScreen.kt @@ -1,26 +1,692 @@ +@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead", "UnusedMaterial3ScaffoldPaddingParameter") + package eu.gaudian.translator.view.stats +import android.annotation.SuppressLint +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.model.WidgetType +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.view.dialogs.MissingLanguageDialog +import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget +import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget +import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget +import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget +import eu.gaudian.translator.view.vocabulary.widgets.ModernStartButtons +import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget +import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget +import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.ProgressViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +@SuppressLint("FrequentlyChangingValue") @Composable fun StatsScreen( navController: NavHostController, + onShowCustomExerciseDialog: () -> Unit = {}, + startDailyExercise: (Boolean) -> Unit = {}, + onNavigateToCategoryDetail: ((Int) -> Unit)? = null, + onNavigateToCategoryList: (() -> Unit)? = null, + onShowWordPairExerciseDialog: () -> Unit = {}, + onScroll: (Boolean) -> Unit = {}, modifier: Modifier = Modifier ) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity) + + var showMissingLanguageDialog by remember { mutableStateOf(false) } + var selectedMissingLanguageId by remember { mutableStateOf(null) } + val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity) + + val affectedItems by remember(selectedMissingLanguageId) { + selectedMissingLanguageId?.let { + vocabularyViewModel.getItemsForLanguage(it) + } ?: flowOf(emptyList()) + }.collectAsState(initial = emptyList()) + + if (showMissingLanguageDialog && selectedMissingLanguageId != null) { + MissingLanguageDialog( + showDialog = true, + missingLanguageId = selectedMissingLanguageId!!, + affectedItems = affectedItems, + onDismiss = { showMissingLanguageDialog = false }, + onDelete = { items -> + vocabularyViewModel.deleteVocabularyItemsById(items.map { it.id }) + showMissingLanguageDialog = false + }, + onReplace = { oldId, newId -> + vocabularyViewModel.replaceLanguageId(oldId, newId) + showMissingLanguageDialog = false + }, + onCreate = { newLanguage -> + languageViewModel.addCustomLanguage(newLanguage) + }, + languageViewModel = languageViewModel + ) + } + + val handleNavigateToCategoryDetail = onNavigateToCategoryDetail ?: { categoryId -> + navController.navigate("stats/category_detail/$categoryId") + } + val handleNavigateToCategoryList = onNavigateToCategoryList ?: { + navController.navigate("stats/category_list_screen") + } + + AppOutlinedCard(modifier = modifier) { + // We collect the order from DB initially + val initialWidgetOrder by settingsViewModel.widgetOrder.collectAsState(initial = null) + val collapsedWidgetIds by settingsViewModel.collapsedWidgetIds.collectAsState(initial = emptySet()) + val dashboardScrollState by settingsViewModel.dashboardScrollState.collectAsState() + val scope = rememberCoroutineScope() + + if (initialWidgetOrder == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 64.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + // BEST PRACTICE: Use a SnapshotStateList for immediate UI updates. + // We only initialize this once, so DB updates don't reset the list while dragging. + val orderedWidgets = remember { mutableStateListOf() } + + // Sync with DB only on first load + LaunchedEffect(initialWidgetOrder) { + if (orderedWidgets.isEmpty() && !initialWidgetOrder.isNullOrEmpty()) { + orderedWidgets.addAll(initialWidgetOrder!!) + } else if (orderedWidgets.isEmpty()) { + orderedWidgets.addAll(WidgetType.DEFAULT_ORDER) + } + } + + val lazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = dashboardScrollState.first, + initialFirstVisibleItemScrollOffset = dashboardScrollState.second + ) + + // Save scroll state + LaunchedEffect(lazyListState.firstVisibleItemIndex, lazyListState.firstVisibleItemScrollOffset) { + settingsViewModel.saveDashboardScrollState( + lazyListState.firstVisibleItemIndex, + lazyListState.firstVisibleItemScrollOffset + ) + } + + // Detect scroll and notify parent + LaunchedEffect(lazyListState.isScrollInProgress) { + onScroll(lazyListState.isScrollInProgress) + } + DisposableEffect(Unit) { + onDispose { + settingsViewModel.saveDashboardScrollState( + lazyListState.firstVisibleItemIndex, + lazyListState.firstVisibleItemScrollOffset + ) + } + } + + // --- Robust Drag and Drop State --- + val dragDropState = rememberDragDropState( + lazyListState = lazyListState, + onSwap = { fromIndex, toIndex -> + // Swap data immediately for responsiveness + orderedWidgets.apply { + add(toIndex, removeAt(fromIndex)) + } + }, + onDragEnd = { + // Persist to DB only when user drops + settingsViewModel.saveWidgetOrder(orderedWidgets.toList()) + } + ) + + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxSize() + .dragContainer(dragDropState), + contentPadding = PaddingValues(bottom = 160.dp) + ) { + itemsIndexed( + items = orderedWidgets, + key = { _, widget -> widget.id } + ) { index, widgetType -> + + val isDragging = index == dragDropState.draggingItemIndex + + // Calculate translation: distinct logic for dragged vs. stationary items + val translationY = if (isDragging) { + dragDropState.draggingItemOffset + } else { + 0f + } + + Box( + modifier = Modifier + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { + this.translationY = translationY + this.shadowElevation = if (isDragging) 8.dp.toPx() else 0f + this.scaleX = if (isDragging) 1.02f else 1f + this.scaleY = if (isDragging) 1.02f else 1f + } + // CRITICAL FIX: Only apply animation to items NOT being dragged. + // This prevents the "flicker" by stopping the layout animation + // from fighting your manual drag offset. + .then( + if (!isDragging) { + Modifier.animateItem( + placementSpec = spring( + stiffness = Spring.StiffnessLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ) + ) + } else { + Modifier + } + ) + ) { + WidgetContainer( + widgetType = widgetType, + isExpanded = widgetType.id !in collapsedWidgetIds, + onExpandedChange = { newExpandedState -> + scope.launch { + settingsViewModel.setWidgetExpandedState(widgetType.id, newExpandedState) + } + }, + onDragStart = { dragDropState.onDragStart(index) }, + onDrag = { dragAmount -> dragDropState.onDrag(dragAmount) }, + onDragEnd = { dragDropState.onDragEnd() }, + onDragCancel = { dragDropState.onDragInterrupted() }, + modifier = Modifier.fillMaxWidth() + ) { + LazyWidget( + widgetType = widgetType, + navController = navController, + vocabularyViewModel = vocabularyViewModel, + progressViewModel = progressViewModel, + onShowCustomExerciseDialog = onShowCustomExerciseDialog, + startDailyExercise = startDailyExercise, + onNavigateToCategoryDetail = handleNavigateToCategoryDetail, + onNavigateToCategoryList = handleNavigateToCategoryList, + onShowWordPairExerciseDialog = onShowWordPairExerciseDialog, + onMissingLanguage = { missingId -> + selectedMissingLanguageId = missingId + showMissingLanguageDialog = true + } + ) + } + } + } + } + } + } +} + +@Composable +private fun WidgetContainer( + widgetType: WidgetType, + isExpanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + content: @Composable () -> Unit +) { + AppCard( + modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), ) { - Text( - text = "Stats Screen", - style = MaterialTheme.typography.headlineMedium + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(300, easing = FastOutSlowInEasing)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(widgetType.titleRes), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + + IconButton(onClick = { onExpandedChange(!isExpanded) }) { + Icon( + imageVector = if (isExpanded) AppIcons.ArrowDropUp + else AppIcons.ArrowDropDown, + contentDescription = if (isExpanded) stringResource(R.string.text_collapse_widget) + else stringResource(R.string.text_expand_widget) + ) + } + + // Drag Handle with specific pointer input + Icon( + imageVector = AppIcons.DragHandle, + contentDescription = stringResource(R.string.text_drag_to_reorder), + tint = if (isExpanded) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(end = 8.dp, start = 8.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { _ -> onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() } + ) + } + ) + } + if (isExpanded) { + content() + } + } + } +} + +// -------------------------------------------------------------------------------- +// Fixed Drag and Drop Logic +// -------------------------------------------------------------------------------- + +@Composable +fun rememberDragDropState( + lazyListState: LazyListState, + onSwap: (Int, Int) -> Unit, + onDragEnd: () -> Unit +): DragDropState { + val scope = rememberCoroutineScope() + return remember(lazyListState, scope) { + DragDropState( + state = lazyListState, + onSwap = onSwap, + onDragFinished = onDragEnd, + scope = scope ) } } + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return this.pointerInput(dragDropState) { + // Just allows the modifier to exist in the chain, logic is in the handle + } +} + +class DragDropState( + private val state: LazyListState, + private val onSwap: (Int, Int) -> Unit, + private val onDragFinished: () -> Unit, + private val scope: CoroutineScope +) { + var draggingItemIndex by mutableIntStateOf(-1) + private set + + private val _draggingItemOffset = Animatable(0f) + val draggingItemOffset: Float + get() = _draggingItemOffset.value + + private val scrollChannel = Channel(Channel.CONFLATED) + + init { + scope.launch { + for (scrollAmount in scrollChannel) { + if (scrollAmount != 0f) { + state.scrollBy(scrollAmount) + checkSwap() + } + } + } + } + + fun onDragStart(index: Int) { + draggingItemIndex = index + scope.launch { _draggingItemOffset.snapTo(0f) } + } + + fun onDrag(dragAmount: Float) { + if (draggingItemIndex == -1) return + + scope.launch { + _draggingItemOffset.snapTo(_draggingItemOffset.value + dragAmount) + checkSwap() + checkOverscroll() + } + } + + private fun checkSwap() { + val draggedIndex = draggingItemIndex + if (draggedIndex == -1) return + + val visibleItems = state.layoutInfo.visibleItemsInfo + val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return + + // Calculate the visual center of the dragged item + val draggedCenter = draggedItemInfo.offset + (draggedItemInfo.size / 2) + _draggingItemOffset.value + + // Find a target to swap with + // FIX: We strictly check if we have crossed the CENTER of the target item. + // This acts as a hysteresis buffer to prevent flickering at the edges. + val targetItem = visibleItems.find { item -> + item.index != draggedIndex && + draggedCenter > item.offset && + draggedCenter < (item.offset + item.size) + } + + if (targetItem != null) { + // Extra Check: Ensure we have actually crossed the midpoint of the target + val targetCenter = itemCenter(targetItem.offset, targetItem.size) + val isAboveAndMovingDown = draggedIndex < targetItem.index && draggedCenter > targetCenter + val isBelowAndMovingUp = draggedIndex > targetItem.index && draggedCenter < targetCenter + + if (isAboveAndMovingDown || isBelowAndMovingUp) { + val targetIndex = targetItem.index + + // 1. Swap Data + onSwap(draggedIndex, targetIndex) + + // 2. Adjust Offset + // We calculate the physical distance the item moved in the layout (e.g. 150px). + // We subtract this from the current drag offset to keep the item visually stationary under the finger. + val layoutJumpDistance = (targetItem.offset - draggedItemInfo.offset).toFloat() + + scope.launch { + _draggingItemOffset.snapTo(_draggingItemOffset.value - layoutJumpDistance) + } + + // 3. Update Index + draggingItemIndex = targetIndex + } + } + } + + private fun itemCenter(offset: Int, size: Int): Float { + return offset + (size / 2f) + } + + private fun checkOverscroll() { + val draggedIndex = draggingItemIndex + if (draggedIndex == -1) { + scrollChannel.trySend(0f) + return + } + + val layoutInfo = state.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return + + val viewportStart = layoutInfo.viewportStartOffset + val viewportEnd = layoutInfo.viewportEndOffset + // Increased threshold slightly for smoother top-edge scrolling + val boundsStart = viewportStart + (viewportEnd * 0.15f) + val boundsEnd = viewportEnd - (viewportEnd * 0.15f) + + val itemTop = draggedItemInfo.offset + _draggingItemOffset.value + val itemBottom = itemTop + draggedItemInfo.size + + val scrollAmount = when { + itemTop < boundsStart -> -10f // Slower, more controlled scroll speed + itemBottom > boundsEnd -> 10f + else -> 0f + } + + scrollChannel.trySend(scrollAmount) + } + + fun onDragEnd() { + resetDrag() + onDragFinished() + } + + fun onDragInterrupted() { + resetDrag() + } + + private fun resetDrag() { + draggingItemIndex = -1 + scrollChannel.trySend(0f) + scope.launch { _draggingItemOffset.snapTo(0f) } + } +} + +// -------------------------------------------------------------------------------- +// Remainder of your existing components +// -------------------------------------------------------------------------------- + +@Composable +private fun LazyWidget( + widgetType: WidgetType, + navController: NavController, + vocabularyViewModel: VocabularyViewModel, + progressViewModel: ProgressViewModel, + onShowCustomExerciseDialog: () -> Unit, + startDailyExercise: (Boolean) -> Unit, + onNavigateToCategoryDetail: (Int) -> Unit, + onNavigateToCategoryList: () -> Unit, + onShowWordPairExerciseDialog: () -> Unit, + onMissingLanguage: (Int) -> Unit +) { + when (widgetType) { + WidgetType.StartButtons -> ModernStartButtons( + onCustomClick = onShowCustomExerciseDialog, + onDailyClick = { isSpelling -> + if (isSpelling) { + onShowWordPairExerciseDialog() + } else { + startDailyExercise(true) + Log.d("DailyExercise", "Starting daily exercise") + } + } + ) + + WidgetType.Status -> LazyStatusWidget( + vocabularyViewModel = vocabularyViewModel, + onNavigateToNew = { navController.navigate("stats/vocabulary_sorting?mode=NEW") }, + onNavigateToDuplicates = { navController.navigate("stats/vocabulary_sorting?mode=DUPLICATES") }, + onNavigateToFaulty = { navController.navigate("stats/vocabulary_sorting?mode=FAULTY") }, + onNavigateToNoGrammar = { navController.navigate("stats/no_grammar_items") }, + onNavigateToMissingLanguage = onMissingLanguage + ) + + else -> { + // Regular widgets that load immediately + when (widgetType) { + WidgetType.Streak -> StreakWidget( + streak = progressViewModel.streak.collectAsState(initial = 0).value, + lastSevenDays = progressViewModel.lastSevenDays.collectAsState().value, + dueTodayCount = progressViewModel.dueTodayCount.collectAsState().value, + wordsCompleted = progressViewModel.totalWordsCompleted.collectAsState().value, + onStatisticsClicked = { navController.navigate("stats/vocabulary_heatmap") } + ) + + WidgetType.WeeklyActivityChart -> WeeklyActivityChartWidget( + weeklyStats = progressViewModel.weeklyActivityStats.collectAsState().value + ) + + WidgetType.AllVocabulary -> AllVocabularyWidget( + vocabularyViewModel = vocabularyViewModel, + onOpenAllVocabulary = { navController.navigate("stats/vocabulary_list/false/null") }, + onStageClicked = { stage -> navController.navigate("stats/vocabulary_list/false/$stage") } + ) + + WidgetType.DueToday -> DueTodayWidget( + vocabularyViewModel = vocabularyViewModel, + onStageClicked = { stage -> navController.navigate("stats/vocabulary_list/false/$stage") } + ) + + WidgetType.CategoryProgress -> CategoryProgressWidget( + onCategoryClicked = { category -> + category?.let { onNavigateToCategoryDetail(it.id) } + }, + onViewAllClicked = onNavigateToCategoryList + ) + + WidgetType.Levels -> LevelWidget( + totalWords = vocabularyViewModel.vocabularyItems.collectAsState().value.size, + learnedWords = vocabularyViewModel.stageStats.collectAsState().value + .firstOrNull { it.stage == VocabularyStage.LEARNED }?.itemCount ?: 0, + onNavigateToProgress = { navController.navigate("stats/language_progress") } + ) + + } + } + } +} + +@Composable +private fun LazyStatusWidget( + vocabularyViewModel: VocabularyViewModel, + onNavigateToNew: () -> Unit, + onNavigateToDuplicates: () -> Unit, + onNavigateToFaulty: () -> Unit, + onNavigateToNoGrammar: () -> Unit, + onNavigateToMissingLanguage: (Int) -> Unit +) { + var isLoading by remember { mutableStateOf(true) } + + // Collect all flows asynchronously + val newItemsCount by vocabularyViewModel.newItemsCount.collectAsState() + val duplicateCount by vocabularyViewModel.duplicateItemsCount.collectAsState() + val faultyItemsCount by vocabularyViewModel.faultyItemsCount.collectAsState() + val itemsWithoutGrammarCount by vocabularyViewModel.itemsWithoutGrammarCount.collectAsState() + val missingLanguageInfo by vocabularyViewModel.missingLanguageInfo.collectAsState() + + LaunchedEffect( + newItemsCount, + duplicateCount, + faultyItemsCount, + itemsWithoutGrammarCount, + missingLanguageInfo + ) { + delay(100) + isLoading = false + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp)) + } + } else { + StatusWidget( + onNavigateToNew = onNavigateToNew, + onNavigateToDuplicates = onNavigateToDuplicates, + onNavigateToFaulty = onNavigateToFaulty, + onNavigateToNoGrammar = onNavigateToNoGrammar, + onNavigateToMissingLanguage = onNavigateToMissingLanguage + ) + } +} + +@Preview +@Composable +fun StatsScreenPreview() { + val navController = rememberNavController() + StatsScreen( + navController = navController, + onShowCustomExerciseDialog = {}, + onNavigateToCategoryDetail = {}, + startDailyExercise = {}, + onNavigateToCategoryList = {}, + onShowWordPairExerciseDialog = {}, + ) +} + +@Preview +@Composable +fun WidgetContainerPreview() { + WidgetContainer( + widgetType = WidgetType.Streak, + isExpanded = true, + onExpandedChange = {}, + onDragStart = { }, + onDrag = { }, + onDragEnd = { }, + onDragCancel = { } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text("Preview Content") + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/translation/LanguageSelectorBar.kt b/app/src/main/java/eu/gaudian/translator/view/translation/LanguageSelectorBar.kt index 4086ab8..ca5dd4c 100644 --- a/app/src/main/java/eu/gaudian/translator/view/translation/LanguageSelectorBar.kt +++ b/app/src/main/java/eu/gaudian/translator/view/translation/LanguageSelectorBar.kt @@ -67,9 +67,23 @@ fun ActionBar( } @Composable -fun TopBarActions(languageViewModel: LanguageViewModel, onSettingsClick: () -> Unit, hintContent: (@Composable () -> Unit)? = null) { +fun TopBarActions( + languageViewModel: LanguageViewModel, + onSettingsClick: () -> Unit, + onNavigateBack: (() -> Unit)? = null, + hintContent: (@Composable () -> Unit)? = null +) { ActionBar(modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)) { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = AppIcons.ArrowBack, + contentDescription = stringResource(R.string.cd_navigate_back) + ) + } + } + if (hintContent != null) { WithHint(hintContent = hintContent) { } diff --git a/app/src/main/java/eu/gaudian/translator/view/translation/MainTranslationScreen.kt b/app/src/main/java/eu/gaudian/translator/view/translation/MainTranslationScreen.kt index 61aa9dd..a8f02bb 100644 --- a/app/src/main/java/eu/gaudian/translator/view/translation/MainTranslationScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/translation/MainTranslationScreen.kt @@ -106,6 +106,14 @@ fun TranslationScreen( settingsViewModel = settingsViewModel, onHistoryClick = onHistoryClick, onSettingsClick = onSettingsClick, + onNavigateBack = { + if (!navController.popBackStack()) { + navController.navigate(eu.gaudian.translator.view.composable.Screen.Home.route) { + launchSingleTop = true + restoreState = false + } + } + }, context = context ) } @@ -119,6 +127,7 @@ private fun LoadedTranslationContent( settingsViewModel: SettingsViewModel, onHistoryClick: () -> Unit, onSettingsClick: () -> Unit, + onNavigateBack: () -> Unit, context: Context ) { val inputText by translationViewModel.inputText.collectAsState() @@ -167,6 +176,7 @@ private fun LoadedTranslationContent( TopBarActions( languageViewModel = languageViewModel, onSettingsClick = onSettingsClick, + onNavigateBack = onNavigateBack, hintContent = { HintDefinition.TRANSLATION.Render() } ) diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/MainVocabularyScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/MainVocabularyScreen.kt index 99e41af..c951851 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/MainVocabularyScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/MainVocabularyScreen.kt @@ -56,6 +56,7 @@ import eu.gaudian.translator.view.composable.AppOutlinedCard import eu.gaudian.translator.view.composable.AppSlider import eu.gaudian.translator.view.composable.AppTabLayout import eu.gaudian.translator.view.composable.OptionItemSwitch +import eu.gaudian.translator.view.composable.Screen import eu.gaudian.translator.view.composable.TabItem import eu.gaudian.translator.view.dialogs.StartExerciseDialog import eu.gaudian.translator.view.dialogs.VocabularyMenu @@ -329,6 +330,14 @@ fun MainVocabularyScreen( launchSingleTop = true restoreState = true } + }, + onNavigateBack = { + if (!navController.popBackStack()) { + navController.navigate(Screen.Home.route) { + launchSingleTop = true + restoreState = false + } + } } ) diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 71c83ff..632beb7 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -61,7 +61,7 @@ Version 0.3.0 \n• Enabled CSV Import for Vocabulary\n• Option to use a translation server for translations instead of AI models for some supported langugaes\n• UI bug fixes \n• Show word frequency \n• Performance optimizations \n• Improved translations (German and Portuguese) Version 0.4.0 \n• Added dictionary download (beta) \n• UI enhancements \n• Bugfixes \n• Re-designed vocabulary card with improved UI \n• More pre-configured providers \n• Improved performance - Version 0.5.0 \n• Reworked UI with new focus on Flashcards and Exercises \n• + Version 0.5.0 \n• Reworked UI with new focus on Flashcards and Exercises \n• Adding vocabulary is easier and more intuitive now