implement statsGraph and refactor StatsScreen with drag-and-drop widget reordering
This commit is contained in:
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,14 +65,42 @@ 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),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
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)
|
.height(56.dp)
|
||||||
.background(
|
.background(
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
@@ -139,6 +172,7 @@ fun <T : TabItem> AppTabLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ThemePreviews
|
@ThemePreviews
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
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<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(
|
Box(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(vertical = 64.dp),
|
||||||
contentAlignment = Alignment.Center
|
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),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.animateContentSize(animationSpec = tween(300, easing = FastOutSlowInEasing))
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Stats Screen",
|
text = stringResource(widgetType.titleRes),
|
||||||
style = MaterialTheme.typography.headlineMedium
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user