diff --git a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt index 0668960d3..7954206ce 100644 --- a/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt +++ b/app/src/main/java/de/tum/informatics/www1/artemis/native_app/android/ui/MainActivity.kt @@ -40,9 +40,9 @@ import de.tum.informatics.www1.artemis.native_app.feature.courseregistration.cou import de.tum.informatics.www1.artemis.native_app.feature.courseregistration.navigateToCourseRegistration import de.tum.informatics.www1.artemis.native_app.feature.courseview.ui.course_overview.course import de.tum.informatics.www1.artemis.native_app.feature.courseview.ui.course_overview.navigateToCourse -import de.tum.informatics.www1.artemis.native_app.feature.dashboard.DashboardScreen -import de.tum.informatics.www1.artemis.native_app.feature.dashboard.dashboard -import de.tum.informatics.www1.artemis.native_app.feature.dashboard.navigateToDashboard +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.DashboardScreen +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.dashboard +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.navigateToDashboard import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewDestination import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewMode import de.tum.informatics.www1.artemis.native_app.feature.exerciseview.ExerciseViewUi diff --git a/feature/dashboard/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardScreenshots.kt b/feature/dashboard/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardScreenshots.kt index f5d28492f..8120ab925 100644 --- a/feature/dashboard/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardScreenshots.kt +++ b/feature/dashboard/src/debug/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardScreenshots.kt @@ -21,6 +21,8 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.LocalCourseImageProvid import de.tum.informatics.www1.artemis.native_app.core.ui.PlayStoreScreenshots import de.tum.informatics.www1.artemis.native_app.core.ui.ScreenshotFrame import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.DashboardService +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CourseOverviewViewModel +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CoursesOverview private const val IMAGE_MARS = "mars" private const val IMAGE_SATURN_5 = "saturn5" diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CoursesOverview.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CoursesOverview.kt deleted file mode 100644 index 10aefcb08..000000000 --- a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CoursesOverview.kt +++ /dev/null @@ -1,528 +0,0 @@ -package de.tum.informatics.www1.artemis.native_app.feature.dashboard - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Divider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.min -import androidx.compose.ui.unit.sp -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable -import de.tum.informatics.www1.artemis.native_app.core.model.Course -import de.tum.informatics.www1.artemis.native_app.core.model.CourseWithScore -import de.tum.informatics.www1.artemis.native_app.core.model.Dashboard -import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CompactCourseHeaderViewMode -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CompactCourseItemHeader -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseExerciseAndLectureCount -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseItemGrid -import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.ExpandedCourseItemHeader -import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.CoursePointsDecimalFormat -import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.BetaHintService -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import org.koin.androidx.compose.getViewModel -import org.koin.compose.koinInject -import java.text.DecimalFormat - -internal const val TEST_TAG_COURSE_LIST = "TEST_TAG_COURSE_LIST" - -internal fun testTagForCourse(courseId: Long) = "Course$courseId" - -@Serializable -data object DashboardScreen - -fun NavController.navigateToDashboard(builder: NavOptionsBuilder.() -> Unit) { - navigate(DashboardScreen, builder) -} - -fun NavGraphBuilder.dashboard( - onOpenSettings: () -> Unit, - onClickRegisterForCourse: () -> Unit, - onViewCourse: (courseId: Long) -> Unit -) { - composable { - CoursesOverview( - modifier = Modifier.fillMaxSize(), - viewModel = getViewModel(), - onOpenSettings = onOpenSettings, - onClickRegisterForCourse = onClickRegisterForCourse, - onViewCourse = onViewCourse - ) - } -} - -/** - * Displays the Course Overview screen. - * Uses Scaffold to display a Material Design TopAppBar. - */ -@Composable -internal fun CoursesOverview( - modifier: Modifier, - viewModel: CourseOverviewViewModel, - onOpenSettings: () -> Unit, - onClickRegisterForCourse: () -> Unit, - onViewCourse: (courseId: Long) -> Unit, - isBeta: Boolean = BuildConfig.isBeta, - betaHintService: BetaHintService = koinInject() -) { - val coursesDataState by viewModel.dashboard.collectAsState() - - //The course composable needs the serverUrl to build the correct url to fetch the course icon from. - val serverUrl by viewModel.serverUrl.collectAsState() - //The server wants an authorization token to send the course icon. - val authToken by viewModel.authToken.collectAsState() - - val topAppBarState = rememberTopAppBarState() - - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( - topAppBarState - ) - - val shouldDisplayBetaDialog by betaHintService.shouldShowBetaHint.collectAsState(initial = false) - var displayBetaDialog by rememberSaveable { mutableStateOf(false) } - - // Trigger the dialog if service sets value to true - LaunchedEffect(shouldDisplayBetaDialog) { - if (shouldDisplayBetaDialog) displayBetaDialog = true - } - - Scaffold( - modifier = modifier.then(Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)), - topBar = { - TopAppBar( - title = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - modifier = Modifier.weight(1f, fill = false), - text = stringResource(id = R.string.course_overview_title), - maxLines = 1 - ) - - if (isBeta) { - Text( - modifier = Modifier - .border( - 1.dp, - color = MaterialTheme.colorScheme.outline, - shape = RoundedCornerShape(percent = 50) - ) - .padding(horizontal = 8.dp), - text = stringResource(id = R.string.dashboard_title_beta), - color = MaterialTheme.colorScheme.outline, - maxLines = 1 - ) - } - } - }, - actions = { - IconButton(onClick = viewModel::requestReloadDashboard) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null - ) - } - - IconButton(onClick = onClickRegisterForCourse) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(id = R.string.course_overview_action_register) - ) - } - - IconButton(onClick = onOpenSettings) { - Icon(imageVector = Icons.Default.Settings, contentDescription = null) - } - }, - scrollBehavior = scrollBehavior - ) - } - ) { padding -> - BasicDataStateUi( - modifier = Modifier - .fillMaxSize() - .padding(top = padding.calculateTopPadding()) - .consumeWindowInsets(WindowInsets.systemBars), - dataState = coursesDataState, - loadingText = stringResource(id = R.string.courses_loading_loading), - failureText = stringResource(id = R.string.courses_loading_failure), - retryButtonText = stringResource(id = R.string.courses_loading_try_again), - onClickRetry = viewModel::requestReloadDashboard - ) { dashboard: Dashboard -> - if (dashboard.courses.isEmpty()) { - DashboardEmpty( - modifier = Modifier.fillMaxSize(), - onClickSignup = onClickRegisterForCourse - ) - } else { - CourseList( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp) - .testTag(TEST_TAG_COURSE_LIST), - courses = dashboard.courses, - serverUrl = serverUrl, - authorizationToken = authToken, - onClickOnCourse = { course -> onViewCourse(course.id ?: 0L) } - ) - } - } - } - - if (displayBetaDialog) { - val scope = rememberCoroutineScope() - - BetaHintDialog { dismissPermanently -> - if (dismissPermanently) { - scope.launch { - betaHintService.dismissBetaHintPermanently() - - displayBetaDialog = false - } - } else { - displayBetaDialog = false - } - } - } -} - -@Composable -private fun BetaHintDialog( - dismiss: (dismissPermanently: Boolean) -> Unit -) { - var isDismissPersistentlyChecked by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = { dismiss(false) }, - title = { Text(text = stringResource(id = R.string.dashboard_dialog_beta_title)) }, - text = { - Column( - modifier = Modifier, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(text = stringResource(id = R.string.dashboard_dialog_beta_message)) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - role = Role.Checkbox, - onClick = { isDismissPersistentlyChecked = !isDismissPersistentlyChecked } - ), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - modifier = Modifier, - checked = isDismissPersistentlyChecked, - onCheckedChange = { isDismissPersistentlyChecked = it } - ) - - Text(text = stringResource(id = R.string.dashboard_dialog_beta_do_not_show_again)) - } - } - }, - confirmButton = { - TextButton( - onClick = { dismiss(isDismissPersistentlyChecked) } - ) { - Text(text = stringResource(id = R.string.dashboard_dialog_beta_positive)) - } - } - ) -} - -/** - * Displays a lazy list of all the courses supplied. - */ -@Composable -private fun CourseList( - modifier: Modifier, - courses: List, - serverUrl: String, - authorizationToken: String, - onClickOnCourse: (Course) -> Unit -) { - CourseItemGrid( - modifier = modifier, - courses = courses, - ) { dashboardCourse, courseItemModifier, isCompact -> - CourseItem( - modifier = courseItemModifier.testTag(testTagForCourse(dashboardCourse.course.id!!)), - courseWithScore = dashboardCourse, - serverUrl = serverUrl, - authorizationToken = authorizationToken, - onClick = { onClickOnCourse(dashboardCourse.course) }, - isCompact = isCompact - ) - } -} - -/** - * Displays course icon, title and description in a Material Design Card. - */ -@Composable -fun CourseItem( - modifier: Modifier, - isCompact: Boolean, - courseWithScore: CourseWithScore, - serverUrl: String, - authorizationToken: String, - onClick: () -> Unit -) { - val currentPoints = courseWithScore.totalScores.studentScores.absoluteScore - val maxPoints = courseWithScore.totalScores.maxPoints - - val currentPointsFormatted = remember(currentPoints) { - CoursePointsDecimalFormat.format(currentPoints) - } - val maxPointsFormatted = remember(maxPoints) { - CoursePointsDecimalFormat.format(maxPoints) - } - - val progress = if (maxPoints == 0f) 0f else currentPoints / maxPoints - - val progressPercentFormatted = remember(progress) { - DecimalFormat.getPercentInstance().format(progress) - } - - if (isCompact) { - CompactCourseItemHeader( - modifier = modifier, - course = courseWithScore.course, - serverUrl = serverUrl, - authorizationToken = authorizationToken, - onClick = onClick, - compactCourseHeaderViewMode = CompactCourseHeaderViewMode.EXERCISE_AND_LECTURE_COUNT, - content = { - Divider() - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - LinearProgressIndicator( - modifier = Modifier.weight(1f), - progress = progress, - trackColor = MaterialTheme.colorScheme.onPrimary - ) - - CourseProgressText( - modifier = Modifier, - currentPointsFormatted = currentPointsFormatted, - maxPointsFormatted = maxPointsFormatted, - progressPercentFormatted = progressPercentFormatted - ) - } - } - ) - } else { - ExpandedCourseItemHeader( - modifier = modifier, - course = courseWithScore.course, - serverUrl = serverUrl, - authorizationToken = authorizationToken, - onClick = onClick, - content = { - Box( - modifier = Modifier - .weight(1f) - .aspectRatio(1f) - .align(Alignment.CenterHorizontally) - ) { - CircularCourseProgress( - modifier = Modifier - .fillMaxSize(0.8f) - .align(Alignment.Center), - progress = progress, - currentPointsFormatted = currentPointsFormatted, - maxPointsFormatted = maxPointsFormatted, - progressPercentFormatted = progressPercentFormatted - ) - } - - CourseExerciseAndLectureCount( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(vertical = 8.dp), - exerciseCount = courseWithScore.course.exercises.size, - lectureCount = courseWithScore.course.lectures.size, - textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), - alignment = Alignment.CenterHorizontally - ) - }, - rightHeaderContent = { } - ) - } -} - -@Composable -private fun CircularCourseProgress( - modifier: Modifier, - progress: Float, - currentPointsFormatted: String, - maxPointsFormatted: String, - progressPercentFormatted: String -) { - BoxWithConstraints(modifier = modifier) { - val progressBarWidthDp = min(24.dp, maxWidth * 0.1f) - val progressBarWidth = with(LocalDensity.current) { progressBarWidthDp.toPx() } - - Canvas( - modifier = Modifier - .fillMaxSize() - .padding(progressBarWidthDp) - ) { - drawArc( - color = Color.Green, - startAngle = 180f, - sweepAngle = 360f * progress, - useCenter = false, - style = Stroke(width = progressBarWidth) - ) - - drawArc( - color = Color.Red, - startAngle = 180f + 360f * progress, - sweepAngle = 360f * (1f - progress), - useCenter = false, - style = Stroke(width = progressBarWidth) - ) - } - - val (percentFontSize, ptsFontSize) = with(LocalDensity.current) { - val availableSpace = maxHeight - progressBarWidthDp * 2 - (availableSpace * 0.2f).toSp() to (availableSpace * 0.1f).toSp() - } - - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource( - id = R.string.course_overview_course_progress_percentage, - progressPercentFormatted - ), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - fontSize = percentFontSize, - fontWeight = FontWeight.Normal - ) - - Text( - text = stringResource( - id = R.string.course_overview_course_progress_pts, - currentPointsFormatted, - maxPointsFormatted - ), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - fontSize = ptsFontSize, - fontWeight = FontWeight.Bold - ) - } - } -} - -@Composable -private fun CourseProgressText( - modifier: Modifier, - currentPointsFormatted: String, - maxPointsFormatted: String, - progressPercentFormatted: String -) { - Text( - modifier = modifier, - text = stringResource( - id = R.string.course_overview_course_progress, - currentPointsFormatted, - maxPointsFormatted, - progressPercentFormatted - ), - fontSize = 14.sp - ) -} - -@Composable -private fun DashboardEmpty(modifier: Modifier, onClickSignup: () -> Unit) { - Box(modifier = modifier) { - Column( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth() - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.courses_empty_text), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Button(onClick = onClickSignup) { - Text(text = stringResource(id = R.string.courses_empty_register_now_button)) - } - } - } -} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/dashboard_module.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/dashboard_module.kt index 1a229c804..82e9061d2 100644 --- a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/dashboard_module.kt +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/dashboard_module.kt @@ -2,8 +2,11 @@ package de.tum.informatics.www1.artemis.native_app.feature.dashboard import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.BetaHintService import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.DashboardService +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.SurveyHintService import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl.BetaHintServiceImpl import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl.DashboardServiceImpl +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl.SurveyHintServiceImpl +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CourseOverviewViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -11,4 +14,5 @@ val dashboardModule = module { viewModel { CourseOverviewViewModel(get(), get(), get(), get()) } single { DashboardServiceImpl(get()) } single { BetaHintServiceImpl(get()) } + single { SurveyHintServiceImpl(get()) } } \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/SurveyHintService.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/SurveyHintService.kt new file mode 100644 index 000000000..ef8f2ebb4 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/SurveyHintService.kt @@ -0,0 +1,10 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.service + +import kotlinx.coroutines.flow.Flow + +interface SurveyHintService { + + val shouldShowSurveyHint: Flow + + suspend fun dismissSurveyHintPermanently() +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt index f812625c2..87935d6c9 100644 --- a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt @@ -1,11 +1,8 @@ package de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl import android.content.Context -import androidx.datastore.core.DataMigration -import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import de.tum.informatics.www1.artemis.native_app.feature.dashboard.BuildConfig import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.BetaHintService @@ -22,7 +19,11 @@ class BetaHintServiceImpl(private val context: Context) : BetaHintService { private val Context.storage by preferencesDataStore(DATA_STORE_KEY) - override val shouldShowBetaHint: Flow = context.storage.data.map { it[KEY_DISMISSED] ?: false }.map { !it } + private val isBetaHintDismissed: Flow = context.storage.data.map { it[KEY_DISMISSED] ?: false } + + override val shouldShowBetaHint: Flow = isBetaHintDismissed.map { dismissed -> + BuildConfig.isBeta && !dismissed + } override suspend fun dismissBetaHintPermanently() { context.storage.edit { data -> diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/SurveyHintServiceImpl.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/SurveyHintServiceImpl.kt new file mode 100644 index 000000000..07c9b9aa3 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/SurveyHintServiceImpl.kt @@ -0,0 +1,41 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.SurveyHintService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.LocalDate + + +private val SURVEY_START_DATE = LocalDate.of(2024, 11, 28) +private val SURVEY_END_DATE = LocalDate.of(2024, 12, 16) + +class SurveyHintServiceImpl( + private val context: Context, +) : SurveyHintService { + + private companion object { + private const val DATA_STORE_KEY = "survey_hint_store" + + private val KEY_SHOW_SURVEY = booleanPreferencesKey("showSurvey1") // Change this to "showSurvey2" for the second survey + } + + private val Context.storage by preferencesDataStore(DATA_STORE_KEY) + + override val shouldShowSurveyHint: Flow = context.storage.data + .map { it[KEY_SHOW_SURVEY] ?: isSurveyActive() } + + private fun isSurveyActive(): Boolean { + val currentDate = LocalDate.now() + return currentDate.isAfter(SURVEY_START_DATE) && currentDate.isBefore(SURVEY_END_DATE) + } + + override suspend fun dismissSurveyHintPermanently() { + context.storage.edit { data -> + data[KEY_SHOW_SURVEY] = false + } + } +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/BetaHintDialog.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/BetaHintDialog.kt new file mode 100644 index 000000000..af47ab7b7 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/BetaHintDialog.kt @@ -0,0 +1,79 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.R + +@Composable +fun BetaHintDialog( + dismiss: (dismissPermanently: Boolean) -> Unit +) { + var isDismissPersistentlyChecked by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { dismiss(false) }, + title = { Text(text = stringResource(id = R.string.dashboard_dialog_beta_title)) }, + text = { + Column( + modifier = Modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = stringResource(id = R.string.dashboard_dialog_beta_message)) + + DoNotShowAgainCheckBox( + isChecked = isDismissPersistentlyChecked, + onCheckedChange = { isDismissPersistentlyChecked = it } + ) + } + }, + confirmButton = { + TextButton( + onClick = { dismiss(isDismissPersistentlyChecked) } + ) { + Text(text = stringResource(id = R.string.dashboard_dialog_beta_positive)) + } + } + ) +} + +@Composable +fun DoNotShowAgainCheckBox( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + role = Role.Checkbox, + onClick = { onCheckedChange(!isChecked) } + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + modifier = Modifier, + checked = isChecked, + onCheckedChange = onCheckedChange + ) + + Text(text = stringResource(id = R.string.dashboard_dialog_beta_do_not_show_again)) + } +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseList.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseList.kt new file mode 100644 index 000000000..4314375fd --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseList.kt @@ -0,0 +1,256 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Divider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.sp +import de.tum.informatics.www1.artemis.native_app.core.model.Course +import de.tum.informatics.www1.artemis.native_app.core.model.CourseWithScore +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CompactCourseHeaderViewMode +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CompactCourseItemHeader +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseExerciseAndLectureCount +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseItemGrid +import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.ExpandedCourseItemHeader +import de.tum.informatics.www1.artemis.native_app.core.ui.exercise.CoursePointsDecimalFormat +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.R +import java.text.DecimalFormat + +/** + * Displays a lazy list of all the courses supplied. + */ +@Composable +fun CourseList( + modifier: Modifier, + courses: List, + serverUrl: String, + authorizationToken: String, + onClickOnCourse: (Course) -> Unit +) { + CourseItemGrid( + modifier = modifier, + courses = courses, + ) { dashboardCourse, courseItemModifier, isCompact -> + CourseItem( + modifier = courseItemModifier.testTag(testTagForCourse(dashboardCourse.course.id!!)), + courseWithScore = dashboardCourse, + serverUrl = serverUrl, + authorizationToken = authorizationToken, + onClick = { onClickOnCourse(dashboardCourse.course) }, + isCompact = isCompact + ) + } +} + +/** + * Displays course icon, title and description in a Material Design Card. + */ +@Composable +fun CourseItem( + modifier: Modifier, + isCompact: Boolean, + courseWithScore: CourseWithScore, + serverUrl: String, + authorizationToken: String, + onClick: () -> Unit +) { + val currentPoints = courseWithScore.totalScores.studentScores.absoluteScore + val maxPoints = courseWithScore.totalScores.maxPoints + + val currentPointsFormatted = remember(currentPoints) { + CoursePointsDecimalFormat.format(currentPoints) + } + val maxPointsFormatted = remember(maxPoints) { + CoursePointsDecimalFormat.format(maxPoints) + } + + val progress = if (maxPoints == 0f) 0f else currentPoints / maxPoints + + val progressPercentFormatted = remember(progress) { + DecimalFormat.getPercentInstance().format(progress) + } + + if (isCompact) { + CompactCourseItemHeader( + modifier = modifier, + course = courseWithScore.course, + serverUrl = serverUrl, + authorizationToken = authorizationToken, + onClick = onClick, + compactCourseHeaderViewMode = CompactCourseHeaderViewMode.EXERCISE_AND_LECTURE_COUNT, + content = { + Divider() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + modifier = Modifier.weight(1f), + progress = progress, + trackColor = MaterialTheme.colorScheme.onPrimary + ) + + CourseProgressText( + modifier = Modifier, + currentPointsFormatted = currentPointsFormatted, + maxPointsFormatted = maxPointsFormatted, + progressPercentFormatted = progressPercentFormatted + ) + } + } + ) + } else { + ExpandedCourseItemHeader( + modifier = modifier, + course = courseWithScore.course, + serverUrl = serverUrl, + authorizationToken = authorizationToken, + onClick = onClick, + content = { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .align(Alignment.CenterHorizontally) + ) { + CircularCourseProgress( + modifier = Modifier + .fillMaxSize(0.8f) + .align(Alignment.Center), + progress = progress, + currentPointsFormatted = currentPointsFormatted, + maxPointsFormatted = maxPointsFormatted, + progressPercentFormatted = progressPercentFormatted + ) + } + + CourseExerciseAndLectureCount( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp), + exerciseCount = courseWithScore.course.exercises.size, + lectureCount = courseWithScore.course.lectures.size, + textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center), + alignment = Alignment.CenterHorizontally + ) + }, + rightHeaderContent = { } + ) + } +} + + +@Composable +private fun CircularCourseProgress( + modifier: Modifier, + progress: Float, + currentPointsFormatted: String, + maxPointsFormatted: String, + progressPercentFormatted: String +) { + BoxWithConstraints(modifier = modifier) { + val progressBarWidthDp = min(24.dp, maxWidth * 0.1f) + val progressBarWidth = with(LocalDensity.current) { progressBarWidthDp.toPx() } + + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(progressBarWidthDp) + ) { + drawArc( + color = Color.Green, + startAngle = 180f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = progressBarWidth) + ) + + drawArc( + color = Color.Red, + startAngle = 180f + 360f * progress, + sweepAngle = 360f * (1f - progress), + useCenter = false, + style = Stroke(width = progressBarWidth) + ) + } + + val (percentFontSize, ptsFontSize) = with(LocalDensity.current) { + val availableSpace = maxHeight - progressBarWidthDp * 2 + (availableSpace * 0.2f).toSp() to (availableSpace * 0.1f).toSp() + } + + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource( + id = R.string.course_overview_course_progress_percentage, + progressPercentFormatted + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + fontSize = percentFontSize, + fontWeight = FontWeight.Normal + ) + + Text( + text = stringResource( + id = R.string.course_overview_course_progress_pts, + currentPointsFormatted, + maxPointsFormatted + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + fontSize = ptsFontSize, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun CourseProgressText( + modifier: Modifier, + currentPointsFormatted: String, + maxPointsFormatted: String, + progressPercentFormatted: String +) { + Text( + modifier = modifier, + text = stringResource( + id = R.string.course_overview_course_progress, + currentPointsFormatted, + maxPointsFormatted, + progressPercentFormatted + ), + fontSize = 14.sp + ) +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CourseOverviewViewModel.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseOverviewViewModel.kt similarity index 98% rename from feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CourseOverviewViewModel.kt rename to feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseOverviewViewModel.kt index 64997b67b..9d0948f6e 100644 --- a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/CourseOverviewViewModel.kt +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CourseOverviewViewModel.kt @@ -1,4 +1,4 @@ -package de.tum.informatics.www1.artemis.native_app.feature.dashboard +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -15,7 +15,6 @@ import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.Dash import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CoursesOverview.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CoursesOverview.kt new file mode 100644 index 000000000..8bd4bfa86 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/CoursesOverview.kt @@ -0,0 +1,255 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable +import de.tum.informatics.www1.artemis.native_app.core.model.Dashboard +import de.tum.informatics.www1.artemis.native_app.core.ui.common.BasicDataStateUi +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.BuildConfig +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.R +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.BetaHintService +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.SurveyHintService +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.getViewModel +import org.koin.compose.koinInject + +internal const val TEST_TAG_COURSE_LIST = "TEST_TAG_COURSE_LIST" + +internal fun testTagForCourse(courseId: Long) = "Course$courseId" + +@Serializable +data object DashboardScreen + +fun NavController.navigateToDashboard(builder: NavOptionsBuilder.() -> Unit) { + navigate(DashboardScreen, builder) +} + +fun NavGraphBuilder.dashboard( + onOpenSettings: () -> Unit, + onClickRegisterForCourse: () -> Unit, + onViewCourse: (courseId: Long) -> Unit +) { + composable { + CoursesOverview( + modifier = Modifier.fillMaxSize(), + viewModel = getViewModel(), + onOpenSettings = onOpenSettings, + onClickRegisterForCourse = onClickRegisterForCourse, + onViewCourse = onViewCourse + ) + } +} + +/** + * Displays the Course Overview screen. + * Uses Scaffold to display a Material Design TopAppBar. + */ +@Composable +internal fun CoursesOverview( + modifier: Modifier, + viewModel: CourseOverviewViewModel, + onOpenSettings: () -> Unit, + onClickRegisterForCourse: () -> Unit, + onViewCourse: (courseId: Long) -> Unit, + isBeta: Boolean = BuildConfig.isBeta, + betaHintService: BetaHintService = koinInject(), + surveyHintService: SurveyHintService = koinInject() +) { + val coursesDataState by viewModel.dashboard.collectAsState() + + //The course composable needs the serverUrl to build the correct url to fetch the course icon from. + val serverUrl by viewModel.serverUrl.collectAsState() + //The server wants an authorization token to send the course icon. + val authToken by viewModel.authToken.collectAsState() + + val topAppBarState = rememberTopAppBarState() + + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior( + topAppBarState + ) + + val shouldDisplayBetaDialog by betaHintService.shouldShowBetaHint.collectAsState(initial = false) + var displayBetaDialog by rememberSaveable { mutableStateOf(false) } + + // Trigger the dialog if service sets value to true + LaunchedEffect(shouldDisplayBetaDialog) { + if (shouldDisplayBetaDialog) displayBetaDialog = true + } + + Scaffold( + modifier = modifier.then(Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)), + topBar = { + TopAppBar( + title = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = stringResource(id = R.string.course_overview_title), + maxLines = 1 + ) + + if (isBeta) { + Text( + modifier = Modifier + .border( + 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(percent = 50) + ) + .padding(horizontal = 8.dp), + text = stringResource(id = R.string.dashboard_title_beta), + color = MaterialTheme.colorScheme.outline, + maxLines = 1 + ) + } + } + }, + actions = { + IconButton(onClick = viewModel::requestReloadDashboard) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null + ) + } + + IconButton(onClick = onClickRegisterForCourse) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.course_overview_action_register) + ) + } + + IconButton(onClick = onOpenSettings) { + Icon(imageVector = Icons.Default.Settings, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(top = padding.calculateTopPadding()) + .consumeWindowInsets(WindowInsets.systemBars) + ) { + SurveyHint( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + surveyHintService = surveyHintService + ) + + BasicDataStateUi( + modifier = Modifier.fillMaxSize(), + dataState = coursesDataState, + loadingText = stringResource(id = R.string.courses_loading_loading), + failureText = stringResource(id = R.string.courses_loading_failure), + retryButtonText = stringResource(id = R.string.courses_loading_try_again), + onClickRetry = viewModel::requestReloadDashboard + ) { dashboard: Dashboard -> + if (dashboard.courses.isEmpty()) { + DashboardEmpty( + modifier = Modifier.fillMaxSize(), + onClickSignup = onClickRegisterForCourse + ) + } else { + CourseList( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + .testTag(TEST_TAG_COURSE_LIST), + courses = dashboard.courses, + serverUrl = serverUrl, + authorizationToken = authToken, + onClickOnCourse = { course -> onViewCourse(course.id ?: 0L) } + ) + } + } + } + } + + if (displayBetaDialog) { + val scope = rememberCoroutineScope() + + BetaHintDialog { dismissPermanently -> + if (dismissPermanently) { + scope.launch { + betaHintService.dismissBetaHintPermanently() + + displayBetaDialog = false + } + } else { + displayBetaDialog = false + } + } + } +} + + + +@Composable +private fun DashboardEmpty(modifier: Modifier, onClickSignup: () -> Unit) { + Box(modifier = modifier) { + Column( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.courses_empty_text), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Button(onClick = onClickSignup) { + Text(text = stringResource(id = R.string.courses_empty_register_now_button)) + } + } + } +} \ No newline at end of file diff --git a/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/SurveyHint.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/SurveyHint.kt new file mode 100644 index 000000000..246f48113 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/ui/SurveyHint.kt @@ -0,0 +1,186 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.outlined.Feedback +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.R +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.SurveyHintService +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val SURVEY_URL = "https://survey.ase.in.tum.de/index.php/767298?lang=en" + +@Composable +fun SurveyHint( + modifier: Modifier = Modifier, + surveyHintService: SurveyHintService +) { + val shouldDisplaySurveyHint by surveyHintService.shouldShowSurveyHint.collectAsState(initial = false) + var displaySurveyHint by rememberSaveable { mutableStateOf(false) } + var showSurveyDialog by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(shouldDisplaySurveyHint) { + if (shouldDisplaySurveyHint) displaySurveyHint = true + } + + AnimatedVisibility(displaySurveyHint) { + SurveyHintImpl( + modifier = modifier, + onClick = { showSurveyDialog = true } + ) + } + + val uriHandler = LocalUriHandler.current + val scope = rememberCoroutineScope() + if (showSurveyDialog) { + SurveyDialog( + onClose = { participate -> + if (participate) { + scope.launch { + uriHandler.openUri(SURVEY_URL) + surveyHintService.dismissSurveyHintPermanently() + + delay(2000) // Wait for the survey to open before hiding the hint + displaySurveyHint = false + } + } + + showSurveyDialog = false + } + ) + } +} + +@Composable +private fun SurveyHintImpl( + modifier: Modifier, + onClick: () -> Unit +) { + Card( + modifier = modifier, + onClick = onClick + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.survey_hint_text), + ) + + Spacer(modifier = Modifier.weight(1f)) + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + + +@Composable +private fun SurveyDialog( + modifier: Modifier = Modifier, + onClose: (participate: Boolean) -> Unit +) { + + AlertDialog( + onDismissRequest = { onClose(false) }, + title = { Text(text = stringResource(R.string.survey_dialog_title)) }, + text = { + Column( + modifier = Modifier, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + Text(text = stringResource(R.string.survey_dialog_do_you_have_time)) + + Icon( + modifier = Modifier + .size(72.dp) + .align(Alignment.CenterHorizontally) + , + imageVector = Icons.Outlined.Feedback, + contentDescription = null, + ) + + Text(stringResource(R.string.survey_dialog_thank_you)) + } + }, + confirmButton = { + Button( + onClick = { onClose(true) } + ) { + Text(text = stringResource(R.string.survey_dialog_button_participate)) + + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp) + ) + } + }, + dismissButton = { + TextButton( + onClick = { onClose(false) } + ) { + Text(text = stringResource(R.string.survey_dialog_button_not_now)) + } + } + ) +} + +@Preview +@Composable +fun SurveyHintPreview() { + SurveyHintImpl( + modifier = Modifier, + onClick = {} + ) +} + +@Preview +@Composable +fun SurveyDialogPreview() { + SurveyDialog(onClose = {}) +} \ No newline at end of file diff --git a/feature/dashboard/src/main/res/values/survey_hint_strings.xml b/feature/dashboard/src/main/res/values/survey_hint_strings.xml new file mode 100644 index 000000000..b57eceac7 --- /dev/null +++ b/feature/dashboard/src/main/res/values/survey_hint_strings.xml @@ -0,0 +1,9 @@ + + + Survey available + We need your help! + Do you have 5–10 minutes to take a short survey and help us improve the app? + Thank you for your consideration! + Participate + Not now + \ No newline at end of file diff --git a/feature/dashboard/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardE2eTest.kt b/feature/dashboard/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardE2eTest.kt index c4bb4d51e..14580a78a 100644 --- a/feature/dashboard/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardE2eTest.kt +++ b/feature/dashboard/src/test/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/DashboardE2eTest.kt @@ -16,6 +16,10 @@ import de.tum.informatics.www1.artemis.native_app.core.test.BaseComposeTest import de.tum.informatics.www1.artemis.native_app.core.test.coreTestModules import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.DefaultTimeoutMillis import de.tum.informatics.www1.artemis.native_app.core.test.test_setup.course_creation.createCourse +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CourseOverviewViewModel +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.CoursesOverview +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.TEST_TAG_COURSE_LIST +import de.tum.informatics.www1.artemis.native_app.feature.dashboard.ui.testTagForCourse import de.tum.informatics.www1.artemis.native_app.feature.login.loginModule import de.tum.informatics.www1.artemis.native_app.feature.login.test.getAdminAccessToken import de.tum.informatics.www1.artemis.native_app.feature.login.test.performTestLogin