diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29a244f4f..01d90768b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,16 @@ android { versionNameSuffix = "-" + ProductFlavors.Dimensions.InstanceSelection.Flavors.Tum } + + findByName(ProductFlavors.Dimensions.ReleaseType.Flavors.Beta)?.apply { + versionNameSuffix = + "-beta" + } + + findByName(ProductFlavors.Dimensions.ReleaseType.Flavors.Production)?.apply { + versionNameSuffix = + "-prod" + } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 38810730c..666ca0d63 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -1,6 +1,5 @@ import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.variant.ApplicationAndroidComponentsExtension -import com.android.build.api.variant.LibraryAndroidComponentsExtension import commonConfiguration.configureJacoco import org.gradle.api.Plugin import org.gradle.api.Project @@ -20,7 +19,7 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - configureInstanceSelectionFlavor(this) + configureInstanceSelectionFlavors(this) defaultConfig.targetSdk = 33 } diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index ff9bab88e..ddc610388 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -1,21 +1,12 @@ @file:Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") import com.android.build.api.dsl.LibraryExtension -import com.android.build.api.variant.LibraryAndroidComponentsExtension -import com.android.build.gradle.internal.coverage.JacocoReportTask -import commonConfiguration.configureJacoco import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension -import org.gradle.api.tasks.testing.Test -import org.gradle.api.tasks.testing.logging.TestExceptionFormat -import org.gradle.api.tasks.testing.logging.TestLogEvent import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType -import org.gradle.kotlin.dsl.withType -import org.gradle.testing.jacoco.tasks.JacocoReport -import java.lang.Boolean import kotlin.Suppress import kotlin.apply import kotlin.with @@ -30,7 +21,7 @@ class AndroidFeatureConventionPlugin : Plugin { } extensions.configure { - configureInstanceSelectionFlavor(this) + configureInstanceSelectionFlavors(this) buildTypes { all { diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryInstanceSelectionFlavorConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryInstanceSelectionFlavorConventionPlugin.kt index 619daf053..9c763fc75 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryInstanceSelectionFlavorConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryInstanceSelectionFlavorConventionPlugin.kt @@ -7,7 +7,7 @@ class AndroidLibraryInstanceSelectionFlavorConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { extensions.configure { - configureInstanceSelectionFlavor(this) + configureInstanceSelectionFlavors(this) } } } diff --git a/build-logic/convention/src/main/kotlin/commonConfiguration/Jacoco.kt b/build-logic/convention/src/main/kotlin/commonConfiguration/Jacoco.kt index ef56a8e80..ba4ad0570 100644 --- a/build-logic/convention/src/main/kotlin/commonConfiguration/Jacoco.kt +++ b/build-logic/convention/src/main/kotlin/commonConfiguration/Jacoco.kt @@ -42,23 +42,29 @@ internal fun Project.configureJacoco( androidComponentsExtension.onVariants { variant -> val testTaskName = "test${variant.name.capitalize()}UnitTest" - val reportTask = tasks.register("jacoco${testTaskName.capitalize()}Report", JacocoReport::class) { - dependsOn(testTaskName) + val reportTask = + tasks.register("jacoco${testTaskName.capitalize()}Report", JacocoReport::class) { + dependsOn(testTaskName) - reports { - xml.required.set(true) - html.required.set(true) - } - - classDirectories.setFrom( - fileTree("$buildDir/tmp/kotlin-classes/${variant.name}") { - exclude(coverageExclusions) + reports { + xml.required.set(true) + html.required.set(true) } - ) - sourceDirectories.setFrom(files("$projectDir/src/main/java", "$projectDir/src/main/kotlin")) - executionData(tasks.getByName(testTaskName)) - } + classDirectories.setFrom( + fileTree("$buildDir/tmp/kotlin-classes/${variant.name}") { + exclude(coverageExclusions) + } + ) + + sourceDirectories.setFrom( + files( + "$projectDir/src/main/java", + "$projectDir/src/main/kotlin" + ) + ) + executionData(tasks.getByName(testTaskName)) + } jacocoTestReport.dependsOn(reportTask) } diff --git a/build-logic/convention/src/main/kotlin/commonConfiguration/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/commonConfiguration/KotlinAndroid.kt index d7d103b6e..4017d83a1 100644 --- a/build-logic/convention/src/main/kotlin/commonConfiguration/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/commonConfiguration/KotlinAndroid.kt @@ -1,8 +1,10 @@ @file:Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.ProductFlavor import com.android.build.api.variant.AndroidComponentsExtension import org.gradle.api.JavaVersion +import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.plugins.ExtensionAware @@ -26,12 +28,24 @@ object ProductFlavors { const val Tum = "tum" } } + + object ReleaseType { + const val Key = "release-type" + + object Flavors { + const val Beta = "beta" + + const val Production = "production" + } + } } object BuildConfigFields { const val HasInstanceRestriction = "hasInstanceRestriction" const val DefaultServerUrl = "defaultServerUrl" + + const val IsBeta = "isBeta" } } @@ -92,52 +106,90 @@ internal fun Project.configureKotlinAndroid( dependencies { add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get()) } + + configureReleaseTypeFlavors(commonExtension) +} + +internal fun Project.configureReleaseTypeFlavors( + commonExtension: CommonExtension<*, *, *, *, *>, +) { + commonExtension.apply { + flavorDimensions += ProductFlavors.Dimensions.ReleaseType.Key + + productFlavors { + createFlavor( + ProductFlavors.Dimensions.ReleaseType.Key, + ProductFlavors.Dimensions.ReleaseType.Flavors.Beta + ) { + buildConfigField("boolean", ProductFlavors.BuildConfigFields.IsBeta, "true") + } + + createFlavor( + ProductFlavors.Dimensions.ReleaseType.Key, + ProductFlavors.Dimensions.ReleaseType.Flavors.Production + ) { + buildConfigField("boolean", ProductFlavors.BuildConfigFields.IsBeta, "false") + } + } + } } -internal fun Project.configureInstanceSelectionFlavor( +internal fun Project.configureInstanceSelectionFlavors( commonExtension: CommonExtension<*, *, *, *, *>, ) { commonExtension.apply { flavorDimensions += ProductFlavors.Dimensions.InstanceSelection.Key productFlavors { - if (!Boolean.getBoolean("skip.flavor.${ProductFlavors.Dimensions.InstanceSelection.Flavors.FreeInstanceSelection}")) { - create(ProductFlavors.Dimensions.InstanceSelection.Flavors.FreeInstanceSelection) { - dimension = ProductFlavors.Dimensions.InstanceSelection.Key - - buildConfigField( - "boolean", - ProductFlavors.BuildConfigFields.HasInstanceRestriction, - "false" - ) - buildConfigField( - "String", - ProductFlavors.BuildConfigFields.DefaultServerUrl, - "\"\"" - ) - } + createFlavor( + ProductFlavors.Dimensions.InstanceSelection.Key, + ProductFlavors.Dimensions.InstanceSelection.Flavors.FreeInstanceSelection + ) { + buildConfigField( + "boolean", + ProductFlavors.BuildConfigFields.HasInstanceRestriction, + "false" + ) + buildConfigField( + "String", + ProductFlavors.BuildConfigFields.DefaultServerUrl, + "\"\"" + ) } - if (!Boolean.getBoolean("skip.flavor.${ProductFlavors.Dimensions.InstanceSelection.Flavors.Tum}")) { - create(ProductFlavors.Dimensions.InstanceSelection.Flavors.Tum) { - dimension = ProductFlavors.Dimensions.InstanceSelection.Key - - buildConfigField( - "boolean", - ProductFlavors.BuildConfigFields.HasInstanceRestriction, - "true" - ) - buildConfigField( - "String", - ProductFlavors.BuildConfigFields.DefaultServerUrl, - "\"https://artemis.cit.tum.de\"" - ) - } + createFlavor( + ProductFlavors.Dimensions.InstanceSelection.Key, + ProductFlavors.Dimensions.InstanceSelection.Flavors.Tum + ) { + buildConfigField( + "boolean", + ProductFlavors.BuildConfigFields.HasInstanceRestriction, + "true" + ) + buildConfigField( + "String", + ProductFlavors.BuildConfigFields.DefaultServerUrl, + "\"https://artemis.cit.tum.de\"" + ) } } } } +private fun NamedDomainObjectContainer.createFlavor( + dimensionKey: String, + name: String, + configure: ProductFlavor.() -> Unit +) { + if (!Boolean.getBoolean("skip.flavor.$name")) { + create(name) { + dimension = dimensionKey + + configure() + } + } +} + fun CommonExtension<*, *, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) { (this as ExtensionAware).extensions.configure("kotlinOptions", block) } diff --git a/feature/dashboard/build.gradle.kts b/feature/dashboard/build.gradle.kts index 654d7edce..f4e3c194a 100644 --- a/feature/dashboard/build.gradle.kts +++ b/feature/dashboard/build.gradle.kts @@ -13,6 +13,9 @@ dependencies { implementation(project(":core:data")) implementation(project(":core:device")) + implementation(libs.androidx.dataStore.core) + implementation(libs.androidx.dataStore.preferences) + implementation(libs.accompanist.swiperefresh) testImplementation(project(":feature:login")) testImplementation(project(":feature:login-test")) 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 index 4fe6c72ee..b86b3961c 100644 --- 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 @@ -1,20 +1,25 @@ package de.tum.informatics.www1.artemis.native_app.feature.dashboard import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border 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.FlowRow 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.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 @@ -22,13 +27,20 @@ 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.mutableStateListOf +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 @@ -49,6 +61,7 @@ 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.alert.TextAlertDialog 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 @@ -56,7 +69,10 @@ import de.tum.informatics.www1.artemis.native_app.core.ui.common.course.CourseEx 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 org.koin.androidx.compose.getViewModel +import org.koin.compose.koinInject import java.text.DecimalFormat const val DASHBOARD_DESTINATION = "dashboard" @@ -96,6 +112,8 @@ internal fun CoursesOverview( onClickRegisterForCourse: () -> Unit, onViewCourse: (courseId: Long) -> Unit ) { + val 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. @@ -109,11 +127,42 @@ internal fun CoursesOverview( 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 = { Text(text = stringResource(id = R.string.course_overview_title)) }, + 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 (BuildConfig.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( @@ -166,6 +215,63 @@ internal fun CoursesOverview( } } } + + 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(), + 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)) + } + } + ) } /** 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 199759855..1a229c804 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 @@ -1,6 +1,8 @@ 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.impl.BetaHintServiceImpl import de.tum.informatics.www1.artemis.native_app.feature.dashboard.service.impl.DashboardServiceImpl import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -8,4 +10,5 @@ import org.koin.dsl.module val dashboardModule = module { viewModel { CourseOverviewViewModel(get(), get(), get(), get()) } single { DashboardServiceImpl(get()) } + single { BetaHintServiceImpl(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/BetaHintService.kt b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/BetaHintService.kt new file mode 100644 index 000000000..9b95785d2 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/BetaHintService.kt @@ -0,0 +1,10 @@ +package de.tum.informatics.www1.artemis.native_app.feature.dashboard.service + +import kotlinx.coroutines.flow.Flow + +interface BetaHintService { + + val shouldShowBetaHint: Flow + + suspend fun dismissBetaHintPermanently() +} 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 new file mode 100644 index 000000000..f812625c2 --- /dev/null +++ b/feature/dashboard/src/main/kotlin/de/tum/informatics/www1/artemis/native_app/feature/dashboard/service/impl/BetaHintServiceImpl.kt @@ -0,0 +1,32 @@ +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 +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class BetaHintServiceImpl(private val context: Context) : BetaHintService { + + private companion object { + private const val DATA_STORE_KEY = "beta_hint_store" + + private val KEY_DISMISSED = booleanPreferencesKey("dismissed") + } + + private val Context.storage by preferencesDataStore(DATA_STORE_KEY) + + override val shouldShowBetaHint: Flow = context.storage.data.map { it[KEY_DISMISSED] ?: false }.map { !it } + + override suspend fun dismissBetaHintPermanently() { + context.storage.edit { data -> + data[KEY_DISMISSED] = true + } + } +} diff --git a/feature/dashboard/src/main/res/values/course_overview_ui_strings.xml b/feature/dashboard/src/main/res/values/course_overview_ui_strings.xml index 60a4cdd39..c2852ac11 100644 --- a/feature/dashboard/src/main/res/values/course_overview_ui_strings.xml +++ b/feature/dashboard/src/main/res/values/course_overview_ui_strings.xml @@ -15,4 +15,10 @@ You are not registered to a course yet. Do you want to sign up for one now? Sign up now - \ No newline at end of file + + Beta + Beta Version + You are using a beta version of Artemis Learning. It may not work as intended and you may start having issues at any point. Please go to settings/about to report any issues you may encounter. + Do not show again + Understood +