implement statsGraph and refactor StatsScreen with drag-and-drop widget reordering

This commit is contained in:
jonasgaudian
2026-02-16 17:47:46 +01:00
parent 6c669ac310
commit eae37715cd
12 changed files with 963 additions and 82 deletions

View File

@@ -253,7 +253,15 @@ fun TranslatorApp(
val currentDestination = navBackStackEntry?.destination val currentDestination = navBackStackEntry?.destination
val selectedScreen = Screen.fromDestination(currentDestination) 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) val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
BottomNavigationBar( BottomNavigationBar(
@@ -262,6 +270,13 @@ fun TranslatorApp(
showLabels = showBottomNavLabels, showLabels = showBottomNavLabels,
onItemSelected = { screen -> onItemSelected = { screen ->
val inSameSection = currentDestination?.hierarchy?.any { it.route == screen.route } == true 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 // Always reset the selected section to its root and clear back stack between sections
if (inSameSection) { if (inSameSection) {
@@ -274,6 +289,11 @@ fun TranslatorApp(
launchSingleTop = true launchSingleTop = true
restoreState = false restoreState = false
} }
} else if (isMoreSection) {
navController.navigate(screen.route) {
launchSingleTop = true
restoreState = false
}
} else { } else {
// Switching sections: clear entire back stack to start to avoid back navigation results // Switching sections: clear entire back stack to start to avoid back navigation results
navController.navigate(screen.route) { navController.navigate(screen.route) {
@@ -287,14 +307,7 @@ fun TranslatorApp(
} }
}, },
onPlayClicked = { onPlayClicked = {
navController.navigate("start_exercise") { navController.navigate("start_exercise")
popUpTo(0) {
inclusive = true
saveState = false
}
launchSingleTop = true
restoreState = false
}
} }
) )
}, },

View File

@@ -130,10 +130,6 @@ fun AppNavHost(
HomeScreen(navController = navController) HomeScreen(navController = navController)
} }
composable(Screen.Stats.route) {
StatsScreen(navController = navController)
}
composable(Screen.Library.route) { composable(Screen.Library.route) {
LibraryScreen(navController = navController) LibraryScreen(navController = navController)
} }
@@ -144,6 +140,7 @@ fun AppNavHost(
// Define all other navigation graphs at the same top level. // Define all other navigation graphs at the same top level.
homeGraph(navController) homeGraph(navController)
statsGraph(navController)
translationGraph(navController) translationGraph(navController)
dictionaryGraph(navController) dictionaryGraph(navController)
vocabularyGraph(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) @OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
fun NavGraphBuilder.translationGraph(navController: NavHostController) { fun NavGraphBuilder.translationGraph(navController: NavHostController) {
navigation( navigation(

View File

@@ -20,9 +20,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.R
/** /**
* An interface that defines the required properties for any item * An interface that defines the required properties for any item
@@ -60,21 +65,49 @@ fun <T : TabItem> AppTabLayout(
tabs: List<T>, tabs: List<T>,
selectedTab: T, selectedTab: T,
onTabSelected: (T) -> Unit, onTabSelected: (T) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onNavigateBack: (() -> Unit)? = null
) { ) {
val selectedIndex = tabs.indexOf(selectedTab) val selectedIndex = tabs.indexOf(selectedTab)
BoxWithConstraints( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 8.dp) .padding(vertical = 8.dp, horizontal = 8.dp),
.height(56.dp) verticalAlignment = Alignment.CenterVertically
.background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = ComponentDefaults.CardShape
)
) { ) {
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( val indicatorOffset by animateDpAsState(
targetValue = tabWidth * selectedIndex, targetValue = tabWidth * selectedIndex,
@@ -82,58 +115,59 @@ fun <T : TabItem> AppTabLayout(
label = "IndicatorOffset" label = "IndicatorOffset"
) )
Box( Box(
modifier = Modifier modifier = Modifier
.offset(x = indicatorOffset) .offset(x = indicatorOffset)
.width(tabWidth) .width(tabWidth)
.fillMaxHeight() .fillMaxHeight()
.padding(4.dp) .padding(4.dp)
.background( .background(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) )
) )
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
tabs.forEach { tab -> tabs.forEach { tab ->
val isSelected = tab == selectedTab val isSelected = tab == selectedTab
val contentColor by animateColorAsState( val contentColor by animateColorAsState(
targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
animationSpec = spring(stiffness = Spring.StiffnessLow) animationSpec = spring(stiffness = Spring.StiffnessLow)
) )
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.clickable( .clickable(
onClick = { onTabSelected(tab) }, onClick = { onTabSelected(tab) },
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) { ) {
val context = LocalContext.current
val resolvedTitle = run { Row(
val resId = context.resources.getIdentifier(tab.title, "string", context.packageName) verticalAlignment = Alignment.CenterVertically,
if (resId != 0) stringResource(resId) else tab.title 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,
)
} }
} }
} }

View File

@@ -21,6 +21,7 @@ import eu.gaudian.translator.view.LocalConnectionConfigured
import eu.gaudian.translator.view.NoConnectionScreen import eu.gaudian.translator.view.NoConnectionScreen
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppTabLayout 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.composable.TabItem
import eu.gaudian.translator.view.settings.SettingsRoutes import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.viewmodel.CorrectionViewModel import eu.gaudian.translator.viewmodel.CorrectionViewModel
@@ -63,7 +64,15 @@ fun MainDictionaryScreen(
AppTabLayout( AppTabLayout(
tabs = dictionaryTabs, tabs = dictionaryTabs,
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it } onTabSelected = { selectedTab = it },
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
}
) )
when (selectedTab) { when (selectedTab) {

View File

@@ -38,6 +38,7 @@ import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedCard import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.composable.AppTabLayout import eu.gaudian.translator.view.composable.AppTabLayout
import eu.gaudian.translator.view.composable.DialogButton 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.view.composable.TabItem
import eu.gaudian.translator.viewmodel.AiGenerationState import eu.gaudian.translator.viewmodel.AiGenerationState
import eu.gaudian.translator.viewmodel.ExerciseViewModel import eu.gaudian.translator.viewmodel.ExerciseViewModel
@@ -76,7 +77,15 @@ fun MainExerciseScreen(
AppTabLayout( AppTabLayout(
tabs = ExerciseTab.entries, tabs = ExerciseTab.entries,
selectedTab = selectedTab, 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)) { Box(modifier = Modifier.weight(1f)) {

View File

@@ -78,6 +78,7 @@ import eu.gaudian.translator.view.composable.AppTabLayout
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.ClickableText import eu.gaudian.translator.view.composable.ClickableText
import eu.gaudian.translator.view.composable.PrimaryButton 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.SecondaryButton
import eu.gaudian.translator.view.composable.TabItem import eu.gaudian.translator.view.composable.TabItem
import eu.gaudian.translator.view.hints.HintDefinition import eu.gaudian.translator.view.hints.HintDefinition
@@ -133,7 +134,15 @@ fun ApiKeyScreen(navController: NavController) {
AppTabLayout( AppTabLayout(
tabs = apiTabs, tabs = apiTabs,
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it } onTabSelected = { selectedTab = it },
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
}
) )
// Tab Content // Tab Content

View File

@@ -27,6 +27,7 @@ import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedCard import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.composable.AppScaffold import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar 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) private data class Setting(val titleRes: Int, val icon: ImageVector, val route: String)
@@ -84,7 +85,15 @@ fun MainSettingsScreen(
AppScaffold( AppScaffold(
topBar = { topBar = {
AppTopAppBar( 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 -> ) { paddingValues ->

View File

@@ -1,26 +1,692 @@
@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead", "UnusedMaterial3ScaffoldPaddingParameter")
package eu.gaudian.translator.view.stats 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.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.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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.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 @Composable
fun StatsScreen( fun StatsScreen(
navController: NavHostController, navController: NavHostController,
onShowCustomExerciseDialog: () -> Unit = {},
startDailyExercise: (Boolean) -> Unit = {},
onNavigateToCategoryDetail: ((Int) -> Unit)? = null,
onNavigateToCategoryList: (() -> Unit)? = null,
onShowWordPairExerciseDialog: () -> Unit = {},
onScroll: (Boolean) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Box( val activity = LocalContext.current.findActivity()
modifier = modifier.fillMaxSize(), val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
contentAlignment = Alignment.Center val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity)
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
var showMissingLanguageDialog by remember { mutableStateOf(false) }
var selectedMissingLanguageId by remember { mutableStateOf<Int?>(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<WidgetType>() }
// 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( Column(
text = "Stats Screen", modifier = Modifier
style = MaterialTheme.typography.headlineMedium .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<Float>(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")
}
}
}

View File

@@ -67,9 +67,23 @@ fun ActionBar(
} }
@Composable @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)) { 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) { if (hintContent != null) {
WithHint(hintContent = hintContent) { WithHint(hintContent = hintContent) {
} }

View File

@@ -106,6 +106,14 @@ fun TranslationScreen(
settingsViewModel = settingsViewModel, settingsViewModel = settingsViewModel,
onHistoryClick = onHistoryClick, onHistoryClick = onHistoryClick,
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(eu.gaudian.translator.view.composable.Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
},
context = context context = context
) )
} }
@@ -119,6 +127,7 @@ private fun LoadedTranslationContent(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
onHistoryClick: () -> Unit, onHistoryClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onNavigateBack: () -> Unit,
context: Context context: Context
) { ) {
val inputText by translationViewModel.inputText.collectAsState() val inputText by translationViewModel.inputText.collectAsState()
@@ -167,6 +176,7 @@ private fun LoadedTranslationContent(
TopBarActions( TopBarActions(
languageViewModel = languageViewModel, languageViewModel = languageViewModel,
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onNavigateBack = onNavigateBack,
hintContent = { HintDefinition.TRANSLATION.Render() } hintContent = { HintDefinition.TRANSLATION.Render() }
) )

View File

@@ -56,6 +56,7 @@ import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.composable.AppSlider import eu.gaudian.translator.view.composable.AppSlider
import eu.gaudian.translator.view.composable.AppTabLayout import eu.gaudian.translator.view.composable.AppTabLayout
import eu.gaudian.translator.view.composable.OptionItemSwitch 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.composable.TabItem
import eu.gaudian.translator.view.dialogs.StartExerciseDialog import eu.gaudian.translator.view.dialogs.StartExerciseDialog
import eu.gaudian.translator.view.dialogs.VocabularyMenu import eu.gaudian.translator.view.dialogs.VocabularyMenu
@@ -329,6 +330,14 @@ fun MainVocabularyScreen(
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
} }
},
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
} }
) )

View File

@@ -61,7 +61,7 @@
<string-array name="changelog_entries"> <string-array name="changelog_entries">
<item>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)</item> <item>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)</item>
<item>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</item> <item>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</item>
<item>Version 0.5.0 \n• Reworked UI with new focus on Flashcards and Exercises \n• </item> <item>Version 0.5.0 \n• Reworked UI with new focus on Flashcards and Exercises \n• Adding vocabulary is easier and more intuitive now </item>
<item> </item> <item> </item>
</string-array> </string-array>