diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..4f1665a --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Studeez \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml index fb7f4a8..b589d56 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index a9f4e52..a2d7c21 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,5 +1,6 @@ + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..ff9696e --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index bdd9278..8978d23 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,6 @@ - - + diff --git a/app/build.gradle b/app/build.gradle index 52f7cb8..68d4e47 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,6 +8,10 @@ plugins { // Protobuf id 'com.google.protobuf' version '0.8.17' + + // Firebase + id 'com.google.gms.google-services' + id 'com.google.firebase.crashlytics' } android { @@ -62,6 +66,7 @@ dependencies { implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" implementation 'androidx.compose.material:material:1.2.0' + debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" // ViewModel @@ -93,6 +98,9 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' + // Coroutine testing + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' + // Mocking testImplementation 'org.mockito.kotlin:mockito-kotlin:3.2.0' @@ -108,14 +116,13 @@ dependencies { debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" //Firebase -// implementation platform('com.google.firebase:firebase-bom:30.4.1') -// implementation 'com.google.firebase:firebase-crashlytics-ktx' -// implementation 'com.google.firebase:firebase-analytics-ktx' -// implementation 'com.google.firebase:firebase-auth-ktx' -// implementation 'com.google.firebase:firebase-firestore-ktx' -// implementation 'com.google.firebase:firebase-perf-ktx' -// implementation 'com.google.firebase:firebase-config-ktx' - + implementation platform('com.google.firebase:firebase-bom:31.3.0') + implementation 'com.google.firebase:firebase-crashlytics-ktx' + implementation 'com.google.firebase:firebase-analytics-ktx' + implementation 'com.google.firebase:firebase-auth-ktx' + implementation 'com.google.firebase:firebase-firestore-ktx' + implementation 'com.google.firebase:firebase-perf-ktx' + implementation 'com.google.firebase:firebase-config-ktx' } // Allow references to generate code diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..d0e5703 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,39 @@ +{ + "project_info": { + "project_number": "604222599611", + "project_id": "studeez-5f8d1", + "storage_bucket": "studeez-5f8d1.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:604222599611:android:1e6fe76fce9862b610949e", + "android_client_info": { + "package_name": "be.ugent.sel.studeez" + } + }, + "oauth_client": [ + { + "client_id": "604222599611-1s7ojfmep7u0kohismntl9a5vffflgjd.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAzSB7P73KPOXFw2kjA1Hm0_mOwxVS5xhE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "604222599611-1s7ojfmep7u0kohismntl9a5vffflgjd.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 307071e..4426f42 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> diff --git a/app/src/main/java/be/ugent/sel/studeez/StudeezApp.kt b/app/src/main/java/be/ugent/sel/studeez/StudeezApp.kt new file mode 100644 index 0000000..c3d1634 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/StudeezApp.kt @@ -0,0 +1,62 @@ +package be.ugent.sel.studeez + +import android.content.res.Resources +import androidx.compose.foundation.layout.padding +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import be.ugent.sel.studeez.common.snackbar.SnackbarManager +import be.ugent.sel.studeez.navigation.StudeezNavGraph +import be.ugent.sel.studeez.ui.theme.StudeezTheme +import kotlinx.coroutines.CoroutineScope + +@Composable +fun StudeezApp() { + StudeezTheme { + Surface(color = MaterialTheme.colors.background) { + val appState = rememberAppState() + + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = it, + modifier = Modifier.padding(8.dp), + snackbar = { snackbarData -> + Snackbar(snackbarData, contentColor = MaterialTheme.colors.onPrimary) + } + ) + }, + scaffoldState = appState.scaffoldState + ) { innerPaddingModifier -> + StudeezNavGraph(appState, Modifier.padding(innerPaddingModifier)) + } + } + } +} + +@Composable +fun rememberAppState( + scaffoldState: ScaffoldState = rememberScaffoldState(), + navController: NavHostController = rememberNavController(), + snackbarManager: SnackbarManager = SnackbarManager, + resources: Resources = resources(), + coroutineScope: CoroutineScope = rememberCoroutineScope() +) = + remember(scaffoldState, navController, snackbarManager, resources, coroutineScope) { + StudeezAppstate(scaffoldState, navController, snackbarManager, resources, coroutineScope) + } + +@Composable +@ReadOnlyComposable +fun resources(): Resources { + LocalConfiguration.current + return LocalContext.current.resources +} diff --git a/app/src/main/java/be/ugent/sel/studeez/StudeezAppstate.kt b/app/src/main/java/be/ugent/sel/studeez/StudeezAppstate.kt new file mode 100644 index 0000000..cc28cd3 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/StudeezAppstate.kt @@ -0,0 +1,51 @@ +package be.ugent.sel.studeez + +import android.content.res.Resources +import androidx.compose.material.ScaffoldState +import androidx.compose.runtime.Stable +import androidx.navigation.NavHostController +import be.ugent.sel.studeez.common.snackbar.SnackbarManager +import be.ugent.sel.studeez.common.snackbar.SnackbarMessage.Companion.toMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch + +@Stable +class StudeezAppstate( + val scaffoldState: ScaffoldState, + val navController: NavHostController, + private val snackbarManager: SnackbarManager, + private val resources: Resources, + coroutineScope: CoroutineScope +) { + init { + coroutineScope.launch { + snackbarManager.snackbarMessages.filterNotNull().collect { snackbarMessage -> + val text = snackbarMessage.toMessage(resources) + scaffoldState.snackbarHostState.showSnackbar(text) + } + } + } + + fun popUp() { + navController.popBackStack() + } + + fun navigate(route: String) { + navController.navigate(route) { launchSingleTop = true } + } + + fun navigateAndPopUp(route: String, popUp: String) { + navController.navigate(route) { + launchSingleTop = true + popUpTo(popUp) { inclusive = true } + } + } + + fun clearAndNavigate(route: String) { + navController.navigate(route) { + launchSingleTop = true + popUpTo(0) { inclusive = true } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/StudeezHiltApp.kt b/app/src/main/java/be/ugent/sel/studeez/StudeezHiltApp.kt new file mode 100644 index 0000000..5db407f --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/StudeezHiltApp.kt @@ -0,0 +1,7 @@ +package be.ugent.sel.studeez + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class StudeezHiltApp : Application() diff --git a/app/src/main/java/be/ugent/sel/studeez/MainActivity.kt b/app/src/main/java/be/ugent/sel/studeez/activities/MainActivity.kt similarity index 64% rename from app/src/main/java/be/ugent/sel/studeez/MainActivity.kt rename to app/src/main/java/be/ugent/sel/studeez/activities/MainActivity.kt index 3c0deee..b020cc8 100644 --- a/app/src/main/java/be/ugent/sel/studeez/MainActivity.kt +++ b/app/src/main/java/be/ugent/sel/studeez/activities/MainActivity.kt @@ -1,4 +1,4 @@ -package be.ugent.sel.studeez +package be.ugent.sel.studeez.activities import android.os.Bundle import androidx.activity.ComponentActivity @@ -10,8 +10,17 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.lifecycleScope +import be.ugent.sel.studeez.StudeezApp +import be.ugent.sel.studeez.screens.session.InvisibleSessionManager import be.ugent.sel.studeez.ui.theme.StudeezTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +var onTimerInvisible: Job? = null + +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -22,11 +31,23 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background ) { - Greeting("Android") + StudeezApp() } } } } + + override fun onStop() { + onTimerInvisible = lifecycleScope.launch { + InvisibleSessionManager.updateTimer() + } + super.onStop() + } + + override fun onStart() { + onTimerInvisible?.cancel() + super.onStart() + } } @Composable diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/ButtonComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/ButtonComposable.kt new file mode 100644 index 0000000..c96994d --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/ButtonComposable.kt @@ -0,0 +1,188 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.ugent.sel.studeez.common.ext.basicButton +import be.ugent.sel.studeez.common.ext.card +import be.ugent.sel.studeez.common.ext.defaultButtonShape +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun BasicTextButton(@StringRes text: Int, modifier: Modifier, action: () -> Unit) { + TextButton( + onClick = action, + modifier = modifier + ) { + Text( + text = stringResource(text) + ) + } +} + +@Composable +fun BasicButton( + @StringRes text: Int, + modifier: Modifier = Modifier, + colors: ButtonColors = ButtonDefaults.buttonColors(), + border: BorderStroke? = null, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier, + shape = defaultButtonShape(), + colors = colors, + border = border, + enabled = enabled, + ) { + Text( + text = stringResource(text), + fontSize = 16.sp + ) + } +} + +@Preview +@Composable +fun BasicButtonPreview() { + BasicButton(text = AppText.add_timer, modifier = Modifier.basicButton()) {} +} + +@Composable +fun StealthButton( + @StringRes text: Int, + modifier: Modifier = Modifier.card(), + enabled: Boolean = true, + onClick: () -> Unit, +) { + //val clickablemodifier = if (disabled) Modifier.clickable(indication = null) else modifier + val borderColor = if (enabled) MaterialTheme.colors.primary + else MaterialTheme.colors.onSurface.copy(alpha = 0.3f) + BasicButton( + text = text, + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.surface, + contentColor = borderColor + ), + border = BorderStroke(2.dp, borderColor) + ) +} + +@Preview +@Composable +fun StealthButtonCardPreview() { + StealthButton(text = AppText.edit) { + + } +} + + +@Composable +fun DeleteButton( + @StringRes text: Int, + onClick: () -> Unit, +) { + BasicButton( + text = text, + modifier = Modifier.basicButton(), + onClick = onClick, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.error, + contentColor = MaterialTheme.colors.onSurface, + ), + ) +} + +@Preview +@Composable +fun DeleteButtonPreview() { + DeleteButton(text = AppText.delete_subject) {} +} + +@Composable +fun DialogConfirmButton(@StringRes text: Int, action: () -> Unit) { + Button( + onClick = action + ) { + Text(text = stringResource(text)) + } +} + +@Composable +fun DialogCancelButton(@StringRes text: Int, action: () -> Unit) { + Button( + onClick = action, + colors = + ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.onPrimary, + contentColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(text)) + } +} + +@Composable +fun NewTaskSubjectButton( + onClick: () -> Unit, + @StringRes text: Int, +) { + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .padding(10.dp, 5.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent, + contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + ), + shape = RoundedCornerShape(2.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.4f)), + elevation = null, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(id = text)) + Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(id = text)) + } + } +} + +@Preview +@Composable +fun NewTaskButtonPreview() { + NewTaskSubjectButton(onClick = {}, text = AppText.new_task) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/DrawerScreenComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/DrawerScreenComposable.kt new file mode 100644 index 0000000..b0b1829 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/DrawerScreenComposable.kt @@ -0,0 +1,64 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.common.composable.drawer.Drawer +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.ui.theme.StudeezTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun DrawerScreenTemplate( + title: String, + drawerActions: DrawerActions, + barAction: @Composable RowScope.() -> Unit = {}, + content: @Composable (PaddingValues) -> Unit +) { + val scaffoldState: ScaffoldState = rememberScaffoldState() + val coroutineScope: CoroutineScope = rememberCoroutineScope() + + Scaffold( + scaffoldState = scaffoldState, + + topBar = { TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = { + coroutineScope.launch { scaffoldState.drawerState.open() } + }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = resources().getString(AppText.menu) + ) + } + }, + actions = barAction + )}, + + drawerContent = { + Drawer(drawerActions) + } + ) { + content(it) + } +} + +@Preview +@Composable +fun DrawerScreenPreview() { + StudeezTheme { DrawerScreenTemplate( + title = "Drawer screen preview", + drawerActions =DrawerActions({}, {}, {}, {}, {}) + ) { + Text(text = "Preview content") + } } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/FloatingActionButtonComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/FloatingActionButtonComposable.kt new file mode 100644 index 0000000..ea2b52d --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/FloatingActionButtonComposable.kt @@ -0,0 +1,145 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.* +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.ui.theme.StudeezTheme +import be.ugent.sel.studeez.R.string as AppText + +const val TRANSITION = "transition" +val HEIGHT_DIFFERENCE = 30.dp + +data class AddButtonActions( + val onTaskClick: () -> Unit, + val onFriendClick: () -> Unit, + val onSessionClick: () -> Unit +) + +@Composable +fun AddButton( + addButtonActions: AddButtonActions +) { + var isExpanded by remember { mutableStateOf(false) } + + // Rotate the button when expanded, normal when collapsed. + val transition = updateTransition(targetState = isExpanded, label = TRANSITION) + val rotate by transition.animateFloat(label = TRANSITION) { expanded -> if (expanded) 315f else 0f } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Box { + // Show minis when expanded. + if (isExpanded) { + ExpandedAddButton( + addButtonActions = addButtonActions + ) + } + } + + // The base add button + FloatingActionButton( + onClick = { + // Toggle expanded/collapsed. + isExpanded = !isExpanded + }, + modifier = Modifier.padding(bottom = if (isExpanded) 78.dp else 0.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "fab", + modifier = Modifier.rotate(rotate) // The rotation + ) + } + } +} + +@Composable +fun ExpandedAddButton( + addButtonActions: AddButtonActions +) { + Row { + ExpandedEntry( + name = AppText.task, + imageVector = Icons.Default.Check, + onClick = addButtonActions.onTaskClick, + modifier = Modifier.padding(36.dp, HEIGHT_DIFFERENCE, 36.dp, 0.dp) + ) + + ExpandedEntry( + name = AppText.friend, + imageVector = Icons.Default.Person, + onClick = addButtonActions.onFriendClick + ) + + ExpandedEntry( + name = AppText.session, + imageVector = Icons.Default.DateRange, + onClick = addButtonActions.onSessionClick, + modifier = Modifier.padding(36.dp, HEIGHT_DIFFERENCE, 36.dp, 0.dp) + ) + } +} + +@Composable +fun ExpandedEntry( + name: Int, + imageVector: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = onClick, + modifier = modifier + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = imageVector, + contentDescription = resources().getString(name), + // TODO Dark overlay + // tint = colors.surface + ) + Text( + text = resources().getString(name), + // TODO Dark overlay + // color = colors.surface + ) + } + + } + +} + +@Preview +@Composable +fun AddButtonPreview() { + StudeezTheme { AddButton( + addButtonActions = AddButtonActions({}, {}, {}) + )} +} + +@Preview +@Composable +fun ExpandedAddButtonPreview() { + StudeezTheme { ExpandedAddButton ( + addButtonActions = AddButtonActions({}, {}, {}) + ) } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/FormComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/FormComposable.kt new file mode 100644 index 0000000..1fbcfb2 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/FormComposable.kt @@ -0,0 +1,22 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun FormComposable( + title: String, + popUp: () -> Unit, + content: @Composable () -> Unit, +) { + SecondaryScreenTemplate(title = title, popUp = popUp) { + Box( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/ImageComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/ImageComposable.kt new file mode 100644 index 0000000..39e7272 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/ImageComposable.kt @@ -0,0 +1,39 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp + +@Composable +fun ImageBackgroundButton( + paint: Painter, + str: String, + background2: Color, + setBackground1: (Color) -> Unit, + setBackground2: (Color) -> Unit +) { + Image( + painter = paint, + str, + modifier = Modifier + .clickable { + if (background2 == Color.Transparent) { + setBackground1(Color.LightGray) + setBackground2(Color.Transparent) + } else { + setBackground2(Color.Transparent) + } + } + .border( + width = 2.dp, + color = background2, + shape = RoundedCornerShape(16.dp) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/PrimaryScreenComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/PrimaryScreenComposable.kt new file mode 100644 index 0000000..0b3ee6e --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/PrimaryScreenComposable.kt @@ -0,0 +1,88 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Menu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.drawer.Drawer +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.common.composable.navbar.NavigationBar +import be.ugent.sel.studeez.common.composable.navbar.NavigationBarActions +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.ui.theme.StudeezTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun PrimaryScreenTemplate( + title: String, + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions, + barAction: @Composable RowScope.() -> Unit = {}, + content: @Composable (PaddingValues) -> Unit +) { + val scaffoldState: ScaffoldState = rememberScaffoldState() + val coroutineScope: CoroutineScope = rememberCoroutineScope() + + Scaffold( + scaffoldState = scaffoldState, + + topBar = { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = { + coroutineScope.launch { scaffoldState.drawerState.open() } + }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = resources().getString(R.string.menu) + ) + } + }, + actions = barAction + ) + }, + + drawerContent = { + Drawer(drawerActions) + }, + + bottomBar = { NavigationBar(navigationBarActions) }, + floatingActionButtonPosition = FabPosition.Center, + isFloatingActionButtonDocked = true, + floatingActionButton = { AddButton(AddButtonActions( + onTaskClick = navigationBarActions.onAddTaskClick, + onFriendClick = navigationBarActions.onAddFriendClick, + onSessionClick = navigationBarActions.onAddSessionClick + )) } + ) { + content(it) + } +} + +@Preview +@Composable +fun PrimaryScreenPreview() { + StudeezTheme { + PrimaryScreenTemplate( + "Preview screen", + DrawerActions({}, {}, {}, {}, {}), + NavigationBarActions({ false }, {}, {}, {}, {}, {}, {}, {}), + { + IconButton(onClick = { /*TODO*/ }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit" + ) + } + }, + ) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/SecondaryScreenComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/SecondaryScreenComposable.kt new file mode 100644 index 0000000..5999072 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/SecondaryScreenComposable.kt @@ -0,0 +1,48 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.ui.theme.StudeezTheme + +@Composable +// Does not contain floatingActionButton and bottom bar, used in all the other screens +fun SecondaryScreenTemplate( + title: String, + popUp: () -> Unit, + barAction: @Composable RowScope.() -> Unit = {}, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + // Everything at the top of the screen + topBar = { TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = { popUp() }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = resources().getString(R.string.go_back) + ) + } + }, + actions = barAction + ) }, + ) { paddingValues -> + content(paddingValues) + } +} + +@Preview +@Composable +fun SecondaryScreenToolbarPreview() { + StudeezTheme { SecondaryScreenTemplate( + "Preview screen", + {} + ) {} } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/SimpleScreenComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/SimpleScreenComposable.kt new file mode 100644 index 0000000..0e3c684 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/SimpleScreenComposable.kt @@ -0,0 +1,16 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable + +@Composable +fun SimpleScreenTemplate( + title: String, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( topBar = { TopAppBar ( title = { Text(text = title) } ) } + ) { paddingValues -> content(paddingValues) } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/TextComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/TextComposable.kt new file mode 100644 index 0000000..25fa3c4 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/TextComposable.kt @@ -0,0 +1,39 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun Headline( + text: String +) { + Row ( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = text, + fontSize = 34.sp + ) + } +} + +@Composable +fun DateText(date: String) { + Text( + text = date, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + modifier = Modifier.padding(horizontal = 10.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/TextFieldComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/TextFieldComposable.kt new file mode 100644 index 0000000..aadcee3 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/TextFieldComposable.kt @@ -0,0 +1,221 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.common.ext.fieldModifier +import be.ugent.sel.studeez.resources +import kotlin.math.sin +import be.ugent.sel.studeez.R.drawable as AppIcon +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun BasicField( + @StringRes text: Int, + value: String, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + singleLine = true, + modifier = modifier, + value = value, + onValueChange = { onNewValue(it) }, + placeholder = { Text(stringResource(text)) } + ) +} + +@Composable +fun LabelledInputField( + value: String, + onNewValue: (String) -> Unit, + @StringRes label: Int, + singleLine: Boolean = false +) { + OutlinedTextField( + value = value, + singleLine = singleLine, + onValueChange = onNewValue, + label = { Text(text = stringResource(id = label)) }, + modifier = Modifier.fieldModifier() + ) +} + +@Composable +fun UsernameField( + value: String, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + singleLine = true, + modifier = modifier, + value = value, + onValueChange = { onNewValue(it) }, + placeholder = { Text(stringResource(AppText.username)) }, + leadingIcon = { Icon(imageVector = Icons.Default.Person, contentDescription = "Username") } + ) +} + +@Composable +fun EmailField( + value: String, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + singleLine = true, + modifier = modifier, + value = value, + onValueChange = { onNewValue(it) }, + placeholder = { Text(stringResource(AppText.email)) }, + leadingIcon = { Icon(imageVector = Icons.Default.Email, contentDescription = "Email") } + ) +} + +@Composable +fun LabeledNumberInputField( + value: Int, + onNewValue: (Int) -> Unit, + @StringRes label: Int, + singleLine: Boolean = false +) { + var number by remember { mutableStateOf(value) } + OutlinedTextField( + value = number.toString(), + singleLine = singleLine, + label = { Text(resources().getString(label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + onValueChange = {typedInt -> + val isNumber = typedInt.matches(Regex("[1-9]+\\d*]")) + if (isNumber) { + number = typedInt.toInt() + onNewValue(typedInt.toInt()) + } + } + ) +} + +@Composable +fun LabeledErrorTextField( + modifier: Modifier = Modifier, + initialValue: String, + @StringRes label: Int, + singleLine: Boolean = false, + errorText: Int, + keyboardType: KeyboardType, + predicate: (String) -> Boolean, + onNewCorrectValue: (String) -> Unit +) { + var value by remember { + mutableStateOf(initialValue) + } + + var isValid by remember { + mutableStateOf(predicate(value)) + } + + Column { + OutlinedTextField( + modifier = modifier.fieldModifier(), + value = value, + onValueChange = { newText -> + value = newText + isValid = predicate(value) + if (isValid) { + onNewCorrectValue(newText) + } + }, + singleLine = singleLine, + label = { Text(text = stringResource(id = label)) }, + isError = !isValid, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Done + ) + ) + + if (!isValid) { + Text( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(id = errorText), + color = MaterialTheme.colors.error + ) + } + } +} + + + + @Preview(showBackground = true) + @Composable + fun IntInputPreview() { + LabeledNumberInputField(value = 1, onNewValue = {}, label = AppText.email) + } + +@Composable +fun PasswordField( + value: String, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier +) { + PasswordField(value, AppText.password, onNewValue, modifier) +} + +@Composable +fun RepeatPasswordField( + value: String, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier +) { + PasswordField(value, AppText.repeat_password, onNewValue, modifier) +} + +@Composable +private fun PasswordField( + value: String, + @StringRes placeholder: Int, + onNewValue: (String) -> Unit, + modifier: Modifier = Modifier +) { + var isVisible by remember { mutableStateOf(false) } + + val icon = + if (isVisible) painterResource(AppIcon.ic_visibility_on) + else painterResource(AppIcon.ic_visibility_off) + + val visualTransformation = + if (isVisible) VisualTransformation.None else PasswordVisualTransformation() + + OutlinedTextField( + modifier = modifier, + value = value, + onValueChange = { onNewValue(it) }, + placeholder = { Text(text = stringResource(placeholder)) }, + leadingIcon = { Icon(imageVector = Icons.Default.Lock, contentDescription = "Lock") }, + trailingIcon = { + IconButton(onClick = { isVisible = !isVisible }) { + Icon(painter = icon, contentDescription = "Visibility") + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = visualTransformation + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/TimePickerButtonComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/TimePickerButtonComposable.kt new file mode 100644 index 0000000..c5e75cc --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/TimePickerButtonComposable.kt @@ -0,0 +1,115 @@ +package be.ugent.sel.studeez.common.composable + +import android.app.TimePickerDialog +import android.app.TimePickerDialog.OnTimeSetListener +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.ext.fieldModifier +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import be.ugent.sel.studeez.ui.theme.StudeezTheme + +@Composable +fun TimePickerCard( + @StringRes text: Int, + initialSeconds: Int, + onTimeChosen: (Int) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .fieldModifier(), + elevation = 10.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .fieldModifier(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = text), + fontWeight = FontWeight.Medium + ) + + TimePickerButton( + initialSeconds = initialSeconds, + onTimeChosen = onTimeChosen + ) + } + } +} + +@Composable +fun TimePickerButton( + initialSeconds: Int, + modifier: Modifier = Modifier, + colors: ButtonColors = ButtonDefaults.buttonColors(), + border: BorderStroke? = null, + onTimeChosen: (Int) -> Unit +) { + val context = LocalContext.current + val timeState: MutableState = remember { + mutableStateOf(initialSeconds) + } + + Button( + onClick = { pickDuration(context, onTimeChosen, timeState) }, + modifier = modifier, + shape = RoundedCornerShape(20.dp), + colors = colors, + border = border + ) { + Text(text = HoursMinutesSeconds(timeState.value).toString()) + } +} + +private fun pickDuration(context: Context, onTimeChosen: (Int) -> Unit, timeState: MutableState) { + val listener = OnTimeSetListener { _, hour, minute -> + timeState.value = HoursMinutesSeconds(hour, minute, 0).getTotalSeconds() + onTimeChosen(timeState.value) + } + val hms = HoursMinutesSeconds(timeState.value) + val mTimePickerDialog = TimePickerDialog( + context, + listener, + hms.hours, + hms.minutes, + true + ) + mTimePickerDialog.show() +} + +@Preview +@Composable +fun TimePickerButtonPreview() { + StudeezTheme { + TimePickerButton(initialSeconds = 5 * 60 + 12, onTimeChosen = {}) + } +} + +@Preview +@Composable +fun TimePickerCardPreview() { + StudeezTheme { + TimePickerCard(text = R.string.studyTime, initialSeconds = 5 * 60 + 12, onTimeChosen = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/TimerEntry.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/TimerEntry.kt new file mode 100644 index 0000000..7dc105b --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/TimerEntry.kt @@ -0,0 +1,73 @@ +package be.ugent.sel.studeez.common.composable + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.data.local.models.timer_info.CustomTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo + +@Composable +fun TimerEntry( + timerInfo: TimerInfo, + rightButton: @Composable () -> Unit = {}, + leftButton: @Composable () -> Unit = {} +) { + Row( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.weight(1f) + ) { + Box(modifier = Modifier.align(alignment = Alignment.CenterVertically)) { + leftButton() + } + + Column( + Modifier.padding( + horizontal = 20.dp, + vertical = 11.dp + ) + ) { + Text( + text = timerInfo.name, + fontWeight = FontWeight.Medium, + fontSize = 20.sp + ) + Text( + text = timerInfo.description, fontWeight = FontWeight.Light, fontSize = 14.sp + ) + } + } + + Box(modifier = Modifier.align(alignment = Alignment.CenterVertically)) { + rightButton() + } + } +} + +@Preview +@Composable +fun TimerEntryPreview() { + val timerInfo = CustomTimerInfo( + "my preview timer", "This is the description of the timer", 60 + ) + TimerEntry(timerInfo = timerInfo) { + StealthButton(text = R.string.edit) {} + } +} + +@Preview +@Composable +fun TimerDefaultEntryPreview() { + val timerInfo = CustomTimerInfo( + "Default preview timer", "This is the description of the timer", 60 + ) + TimerEntry(timerInfo = timerInfo) {} +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/drawer/DrawerComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/drawer/DrawerComposable.kt new file mode 100644 index 0000000..2d4eab3 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/drawer/DrawerComposable.kt @@ -0,0 +1,129 @@ +package be.ugent.sel.studeez.common.composable.drawer + +import android.content.Context +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Info +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.ui.theme.StudeezTheme + +data class DrawerActions( + val onHomeButtonClick: () -> Unit, + val onTimersClick: () -> Unit, + val onSettingsClick: () -> Unit, + val onLogoutClick: () -> Unit, + val onAboutClick: (Context) -> Unit, +) + +fun getDrawerActions( + drawerViewModel: DrawerViewModel, + open: (String) -> Unit, + openAndPopUp: (String, String) -> Unit, +): DrawerActions { + return DrawerActions( + onHomeButtonClick = { drawerViewModel.onHomeButtonClick(open) }, + onTimersClick = { drawerViewModel.onTimersClick(open) }, + onSettingsClick = { drawerViewModel.onSettingsClick(open) }, + onLogoutClick = { drawerViewModel.onLogoutClick(openAndPopUp) }, + onAboutClick = { context -> drawerViewModel.onAboutClick(open, context = context) }, + ) +} + +@Composable +fun Drawer( + drawerActions: DrawerActions, +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + DrawerEntry( + icon = Icons.Default.Home, + text = resources().getString(R.string.home), + onClick = drawerActions.onHomeButtonClick, + ) + DrawerEntry( + icon = ImageVector.vectorResource(id = R.drawable.ic_timer), + text = resources().getString(R.string.timers), + onClick = drawerActions.onTimersClick, + ) + DrawerEntry( + icon = Icons.Default.Settings, + text = resources().getString(R.string.settings), + onClick = drawerActions.onSettingsClick, + ) + DrawerEntry( + icon = ImageVector.vectorResource(id = R.drawable.ic_logout), + text = resources().getString(R.string.log_out), + onClick = drawerActions.onLogoutClick, + ) + } + + val context = LocalContext.current + DrawerEntry( + icon = Icons.Outlined.Info, + text = resources().getString(R.string.about), + onClick = { drawerActions.onAboutClick(context) }, + ) + } +} + +@Composable +fun DrawerEntry( + icon: ImageVector, + text: String, + onClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .clickable(onClick = onClick) + .fillMaxWidth() + ) { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth(0.15f) + ) { + Icon(imageVector = icon, contentDescription = text) + } + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth(0.85f) + ) { + Text(text = text) + } + } +} + +@Preview +@Composable +fun DrawerPreview() { + val drawerActions = DrawerActions({}, {}, {}, {}, {}) + StudeezTheme { + Drawer(drawerActions) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/drawer/DrawerViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/drawer/DrawerViewModel.kt new file mode 100644 index 0000000..e55c342 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/drawer/DrawerViewModel.kt @@ -0,0 +1,48 @@ +package be.ugent.sel.studeez.common.composable.drawer + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.navigation.StudeezDestinations.HOME_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.LOGIN_SCREEN +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +const val REPO_URL: String = "https://github.ugent.be/SELab1/project2023-groep14/" + +@HiltViewModel +class DrawerViewModel @Inject constructor( + private val accountDAO: AccountDAO, + logService: LogService +) : StudeezViewModel(logService) { + + fun onHomeButtonClick(open: (String) -> Unit) { + open(HOME_SCREEN) + } + + fun onTimersClick(openAndPopup: (String) -> Unit) { + openAndPopup(StudeezDestinations.TIMER_SCREEN) + } + + fun onSettingsClick(open: (String) -> Unit) { + open(StudeezDestinations.SETTINGS_SCREEN) + } + + fun onLogoutClick(openAndPopUp: (String, String) -> Unit) { + launchCatching { + accountDAO.signOut() + openAndPopUp(LOGIN_SCREEN, HOME_SCREEN) + } + } + + fun onAboutClick(open: (String) -> Unit, context: Context) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(REPO_URL)) + context.startActivity(intent) + } +} diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/Feed.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/Feed.kt new file mode 100644 index 0000000..54be2ea --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/Feed.kt @@ -0,0 +1,160 @@ +package be.ugent.sel.studeez.common.composable.feed + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.ugent.sel.studeez.common.composable.BasicTextButton +import be.ugent.sel.studeez.common.composable.DateText +import be.ugent.sel.studeez.common.composable.Headline +import be.ugent.sel.studeez.common.ext.textButton +import be.ugent.sel.studeez.data.local.models.FeedEntry +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun Feed( + uiState: FeedUiState, + continueTask: (String, String) -> Unit, + onEmptyFeedHelp: () -> Unit +) { + when (uiState) { + FeedUiState.Loading -> LoadingFeed() + is FeedUiState.Succes -> LoadedFeed( + uiState = uiState, + continueTask = continueTask, + onEmptyFeedHelp = onEmptyFeedHelp + ) + } +} + +@Composable +fun LoadedFeed( + uiState: FeedUiState.Succes, + continueTask: (String, String) -> Unit, + onEmptyFeedHelp: () -> Unit, +) { + if (uiState.feedEntries.isEmpty()) EmptyFeed(onEmptyFeedHelp) + else FeedWithElements(uiState = uiState, continueTask = continueTask) +} + +@Composable +fun LoadingFeed() { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator(color = MaterialTheme.colors.onBackground) + } +} + +@Composable +fun FeedWithElements( + uiState: FeedUiState.Succes, + continueTask: (String, String) -> Unit, +) { + val feedEntries = uiState.feedEntries + LazyColumn { + items(feedEntries.toList()) { (date, feedEntries) -> + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val totalDayStudyTime: Int = feedEntries.sumOf { it.totalStudyTime } + DateText(date = date) + Text( + text = "${HoursMinutesSeconds(totalDayStudyTime)}", + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + } + feedEntries.forEach { feedEntry -> + FeedEntry(feedEntry = feedEntry) { + continueTask(feedEntry.subjectId, feedEntry.taskId) + } + } + Spacer(modifier = Modifier.height(20.dp)) + } + } +} + +@Composable +fun EmptyFeed(onEmptyFeedHelp: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Headline(text = stringResource(id = AppText.your_feed)) + + BasicTextButton( + AppText.empty_feed_help_text, + Modifier.textButton(), + action = onEmptyFeedHelp, + ) + } + } +} + +@Preview +@Composable +fun FeedLoadingPreview() { + Feed( + uiState = FeedUiState.Loading, + continueTask = { _, _ -> run {} }, {} + ) +} + +@Preview +@Composable +fun FeedPreview() { + Feed( + uiState = FeedUiState.Succes( + mapOf( + "08 May 2023" to listOf( + FeedEntry( + argb_color = 0xFFFFD200, + subJectName = "Test Subject", + taskName = "Test Task", + totalStudyTime = 600, + ), + FeedEntry( + argb_color = 0xFFFFD200, + subJectName = "Test Subject", + taskName = "Test Task", + totalStudyTime = 20, + ), + ), + "09 May 2023" to listOf( + FeedEntry( + argb_color = 0xFFFD1200, + subJectName = "Test Subject", + taskName = "Test Task", + ), + FeedEntry( + argb_color = 0xFFFFD200, + subJectName = "Test Subject", + taskName = "Test Task", + ), + ) + ) + ), + continueTask = { _, _ -> run {} }, {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedEntry.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedEntry.kt new file mode 100644 index 0000000..ff950d6 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedEntry.kt @@ -0,0 +1,116 @@ +package be.ugent.sel.studeez.common.composable.feed + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.common.composable.StealthButton +import be.ugent.sel.studeez.data.local.models.FeedEntry +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun FeedEntry( + feedEntry: FeedEntry, + continueWithTask: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(start = 10.dp) + .weight(11f) + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(Color(feedEntry.argb_color)), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + Text( + text = feedEntry.subJectName, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = feedEntry.taskName, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + Text(text = HoursMinutesSeconds(feedEntry.totalStudyTime).toString()) + } + } + val buttonText: Int = + if (feedEntry.isArchived) AppText.deleted else AppText.continue_task + StealthButton( + text = buttonText, + enabled = !feedEntry.isArchived, + modifier = Modifier + .padding(start = 10.dp, end = 5.dp) + .weight(6f) + ) { + if (!feedEntry.isArchived) { + continueWithTask() + } + } + + } + } +} + +@Preview +@Composable +fun FeedEntryPreview() { + FeedEntry( + continueWithTask = {}, + feedEntry = FeedEntry( + argb_color = 0xFFFFD200, + subJectName = "Test Subject", + taskName = "Test Task", + totalStudyTime = 20, + ) + ) +} + +@Preview +@Composable +fun FeedEntryOverflowPreview() { + FeedEntry( + continueWithTask = {}, + feedEntry = FeedEntry( + argb_color = 0xFFFFD200, + subJectName = "Test Subject", + taskName = "Test Taskkkkkkkkkkkkkkkkkkkkkkkkk", + totalStudyTime = 20, + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedUiState.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedUiState.kt new file mode 100644 index 0000000..1b938ca --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedUiState.kt @@ -0,0 +1,8 @@ +package be.ugent.sel.studeez.common.composable.feed + +import be.ugent.sel.studeez.data.local.models.FeedEntry + +sealed interface FeedUiState { + object Loading : FeedUiState + data class Succes(val feedEntries: Map>) : FeedUiState +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedViewModel.kt new file mode 100644 index 0000000..b5e2b1a --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/feed/FeedViewModel.kt @@ -0,0 +1,45 @@ +package be.ugent.sel.studeez.common.composable.feed + +import androidx.lifecycle.viewModelScope +import be.ugent.sel.studeez.data.SelectedTask +import be.ugent.sel.studeez.domain.FeedDAO +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.TaskDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FeedViewModel @Inject constructor( + feedDAO: FeedDAO, + private val taskDAO: TaskDAO, + private val selectedTask: SelectedTask, + logService: LogService +) : StudeezViewModel(logService) { + + val uiState: StateFlow = feedDAO.getFeedEntries() + .map { FeedUiState.Succes(it) } + .stateIn( + scope = viewModelScope, + initialValue = FeedUiState.Loading, + started = SharingStarted.Eagerly, + ) + + fun continueTask(open: (String) -> Unit, subjectId: String, taskId: String) { + viewModelScope.launch { + val task = taskDAO.getTask(subjectId, taskId) + selectedTask.set(task) + open(StudeezDestinations.TIMER_SELECTION_SCREEN) + } + } + + fun onEmptyFeedHelp(open: (String) -> Unit) { + open(StudeezDestinations.ADD_SUBJECT_FORM) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/NavigationBarComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/NavigationBarComposable.kt new file mode 100644 index 0000000..c4d6e33 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/NavigationBarComposable.kt @@ -0,0 +1,132 @@ +package be.ugent.sel.studeez.common.composable.navbar + +import androidx.compose.material.BottomNavigation +import androidx.compose.material.BottomNavigationItem +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.DateRange +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.navigation.StudeezDestinations.HOME_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.PROFILE_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.SESSIONS_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.SUBJECT_SCREEN +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.ui.theme.StudeezTheme +import be.ugent.sel.studeez.R.string as AppText + +data class NavigationBarActions( + val isSelectedTab: (String) -> Boolean, + + val onHomeClick: () -> Unit, + val onTasksClick: () -> Unit, + val onSessionsClick: () -> Unit, + val onProfileClick: () -> Unit, + + // AddButton + val onAddTaskClick: () -> Unit, + val onAddFriendClick: () -> Unit, + val onAddSessionClick: () -> Unit +) + +fun getNavigationBarActions( + navigationBarViewModel: NavigationBarViewModel, + open: (String) -> Unit, + getCurrentScreen: () -> String? +): NavigationBarActions { + return NavigationBarActions( + isSelectedTab = { screen -> + screen == getCurrentScreen() + }, + onHomeClick = { + navigationBarViewModel.onHomeClick(open) + }, + onTasksClick = { + navigationBarViewModel.onTasksClick(open) + }, + onSessionsClick = { + navigationBarViewModel.onSessionsClick(open) + }, + onProfileClick = { + navigationBarViewModel.onProfileClick(open) + }, + + onAddTaskClick = { + navigationBarViewModel.onAddTaskClick(open) + }, + onAddFriendClick = { + navigationBarViewModel.onAddFriendClick(open) + }, + onAddSessionClick = { + navigationBarViewModel.onAddSessionClick(open) + } + ) +} + +@Composable +fun NavigationBar( + navigationBarActions: NavigationBarActions +) { + BottomNavigation( + elevation = 10.dp + ) { + BottomNavigationItem( + icon = { Icon(imageVector = Icons.Default.List, resources().getString(AppText.home)) }, + label = { Text(text = resources().getString(AppText.home)) }, + selected = navigationBarActions.isSelectedTab(HOME_SCREEN), + onClick = navigationBarActions.onHomeClick + ) + + BottomNavigationItem( + icon = { + Icon( + imageVector = Icons.Default.Check, resources().getString(AppText.tasks) + ) + }, + label = { Text(text = resources().getString(AppText.tasks)) }, + selected = navigationBarActions.isSelectedTab(SUBJECT_SCREEN), + onClick = navigationBarActions.onTasksClick + ) + + // Hack to space the entries in the navigation bar, make space for fab + BottomNavigationItem(icon = {}, onClick = {}, selected = false, enabled = false) + + BottomNavigationItem( + icon = { + Icon( + imageVector = Icons.Outlined.DateRange, resources().getString(AppText.sessions) + ) + }, + label = { Text(text = resources().getString(AppText.sessions)) }, + selected = navigationBarActions.isSelectedTab(SESSIONS_SCREEN), + onClick = navigationBarActions.onSessionsClick + ) + + BottomNavigationItem( + icon = { + Icon( + imageVector = Icons.Default.Person, resources().getString(AppText.profile) + ) + }, + label = { Text(text = resources().getString(AppText.profile)) }, + selected = navigationBarActions.isSelectedTab(PROFILE_SCREEN), + onClick = navigationBarActions.onProfileClick + ) + + } +} + +@Preview(showBackground = true) +@Composable +fun NavigationBarPreview() { + StudeezTheme { + NavigationBar( + navigationBarActions = NavigationBarActions({ false }, {}, {}, {}, {}, {}, {}, {}), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/NavigationBarViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/NavigationBarViewModel.kt new file mode 100644 index 0000000..d2f4f09 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/NavigationBarViewModel.kt @@ -0,0 +1,49 @@ +package be.ugent.sel.studeez.common.composable.navbar + +import be.ugent.sel.studeez.common.snackbar.SnackbarManager +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.navigation.StudeezDestinations.HOME_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.PROFILE_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.SELECT_SUBJECT +import be.ugent.sel.studeez.navigation.StudeezDestinations.SESSIONS_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.SUBJECT_SCREEN +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import be.ugent.sel.studeez.R.string as AppText + +@HiltViewModel +class NavigationBarViewModel @Inject constructor( + logService: LogService +) : StudeezViewModel(logService) { + + fun onHomeClick(open: (String) -> Unit) { + open(HOME_SCREEN) + } + + fun onTasksClick(open: (String) -> Unit) { + open(SUBJECT_SCREEN) + } + + fun onSessionsClick(open: (String) -> Unit) { + open(SESSIONS_SCREEN) + } + + fun onProfileClick(open: (String) -> Unit) { + open(PROFILE_SCREEN) + } + + fun onAddTaskClick(open: (String) -> Unit) { + open(SELECT_SUBJECT) + } + + fun onAddFriendClick(open: (String) -> Unit) { + // TODO open(SEARCH_FRIENDS_SCREEN) + SnackbarManager.showMessage(AppText.add_friend_not_possible_yet) // TODO Remove + } + + fun onAddSessionClick(open: (String) -> Unit) { + // TODO open(CREATE_SESSION_SCREEN) + SnackbarManager.showMessage(AppText.create_session_not_possible_yet) // TODO Remove + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/TimePickerComposable.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/TimePickerComposable.kt new file mode 100644 index 0000000..3a59519 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/navbar/TimePickerComposable.kt @@ -0,0 +1,24 @@ +package be.ugent.sel.studeez.common.composable.navbar + +import android.app.TimePickerDialog +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun BasicTimePicker( + onHoursChange: (Int) -> Unit, + onMinutesChange: (Int) -> Unit, + Hours: Int, + Minutes: Int, +): TimePickerDialog { + return TimePickerDialog( + LocalContext.current, + { _, mHour: Int, mMinute: Int -> + onHoursChange(mHour) + onMinutesChange(mMinute) + }, + Hours, + Minutes, + true + ) +} diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/tasks/SubjectEntry.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/tasks/SubjectEntry.kt new file mode 100644 index 0000000..63d4fbe --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/tasks/SubjectEntry.kt @@ -0,0 +1,129 @@ +package be.ugent.sel.studeez.common.composable.tasks + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.List +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.common.composable.StealthButton +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun SubjectEntry( + subject: Subject, + getTaskCount: () -> Flow, + getCompletedTaskCount: () -> Flow, + getStudyTime: () -> Flow, + selectButton: @Composable (RowScope) -> Unit, +) { + val studytime by getStudyTime().collectAsState(initial = 0) + val taskCount by getTaskCount().collectAsState(initial = 0) + val completedTaskCount by getCompletedTaskCount().collectAsState(initial = 0) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(start = 10.dp) + .weight(3f) + ) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(Color(subject.argb_color)), + ) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + Text( + text = subject.name, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = HoursMinutesSeconds(studytime).toString(), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + Icon( + imageVector = Icons.Default.List, + contentDescription = stringResource(id = AppText.tasks) + ) + Text(text = "${completedTaskCount}/${taskCount}") + } + } + } + } + selectButton(this) + } + } +} + +@Preview +@Composable +fun SubjectEntryPreview() { + SubjectEntry( + subject = Subject( + name = "Test Subject", + argb_color = 0xFFFFD200, + ), + getTaskCount = { flowOf() }, + getCompletedTaskCount = { flowOf() }, + getStudyTime = { flowOf() }, + ) { + StealthButton( + text = AppText.view_tasks, + modifier = Modifier + .padding(start = 10.dp, end = 5.dp) + ) {} + } +} + +@Preview +@Composable +fun OverflowSubjectEntryPreview() { + SubjectEntry( + subject = Subject( + name = "Testttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + argb_color = 0xFFFFD200, + ), + getTaskCount = { flowOf() }, + getCompletedTaskCount = { flowOf() }, + getStudyTime = { flowOf() }, + ) {} +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/composable/tasks/TaskEntry.kt b/app/src/main/java/be/ugent/sel/studeez/common/composable/tasks/TaskEntry.kt new file mode 100644 index 0000000..35e7a44 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/composable/tasks/TaskEntry.kt @@ -0,0 +1,131 @@ +package be.ugent.sel.studeez.common.composable.tasks + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.StealthButton +import be.ugent.sel.studeez.data.local.models.task.Task +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import be.ugent.sel.studeez.resources + +@Composable +fun TaskEntry( + task: Task, + onCheckTask: (Boolean) -> Unit, + onArchiveTask: () -> Unit, + onStartTask: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + val color = if (task.completed) Color.Gray else MaterialTheme.colors.onSurface + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(start = 10.dp) + .weight(22f), + ) { + Checkbox( + checked = task.completed, + onCheckedChange = onCheckTask, + colors = CheckboxDefaults.colors( + checkedColor = Color.Gray, + uncheckedColor = MaterialTheme.colors.onSurface, + ) + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = task.name, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + color = color, + modifier = Modifier.weight(13f), + ) + Text( + text = "${HoursMinutesSeconds(task.time)}", + color = color, + modifier = Modifier.weight(7f) + ) + + } + } + Box(modifier = Modifier.weight(7f)) { + if (task.completed) { + IconButton( + onClick = onArchiveTask, + modifier = Modifier + .padding(start = 20.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = resources().getString(R.string.delete_task), + ) + } + } else { + StealthButton( + text = R.string.start, + modifier = Modifier + .padding(end = 5.dp), + ) { + onStartTask() + } + } + } + } + } +} + +@Preview +@Composable +fun TaskEntryPreview() { + TaskEntry( + task = Task( + name = "Test Task", + completed = false, + ), + {}, {}, {} + ) +} + +@Preview +@Composable +fun CompletedTaskEntryPreview() { + TaskEntry( + task = Task( + name = "Test Task", + completed = true, + ), + {}, {}, {}, + ) +} + +@Preview +@Composable +fun OverflowTaskEntryPreview() { + TaskEntry( + task = Task( + name = "Test Taskkkkkkkkkkkkkkkkkkkkkkkkkkk", + completed = false, + ), + {}, {}, {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/ext/ColorExt.kt b/app/src/main/java/be/ugent/sel/studeez/common/ext/ColorExt.kt new file mode 100644 index 0000000..87ce226 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/ext/ColorExt.kt @@ -0,0 +1,10 @@ +package be.ugent.sel.studeez.common.ext + +import androidx.compose.ui.graphics.Color +import kotlin.random.Random + +fun Color.Companion.generateRandomArgb(): Long { + val random = Random + val mask: Long = (0x000000FFL shl random.nextInt(0, 3)).inv() + return random.nextLong(0xFF000000L, 0xFFFFFFFFL) and mask +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/ext/ModifierExt.kt b/app/src/main/java/be/ugent/sel/studeez/common/ext/ModifierExt.kt new file mode 100644 index 0000000..66ade69 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/ext/ModifierExt.kt @@ -0,0 +1,46 @@ +package be.ugent.sel.studeez.common.ext + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.unit.dp + +fun Modifier.textButton(): Modifier { + return this.fillMaxWidth().padding(16.dp, 8.dp, 16.dp, 0.dp) +} + +fun Modifier.basicButton(): Modifier { + return this.fillMaxWidth().padding(16.dp, 8.dp) +} + +fun Modifier.card(): Modifier { + return this.padding(16.dp, 0.dp, 16.dp, 8.dp) +} + +fun Modifier.contextMenu(): Modifier { + return this.wrapContentWidth() +} + +fun Modifier.dropdownSelector(): Modifier { + return this.fillMaxWidth() +} + +fun Modifier.fieldModifier(): Modifier { + return this.fillMaxWidth().padding(16.dp, 4.dp) +} + +fun Modifier.toolbarActions(): Modifier { + return this.wrapContentSize(Alignment.TopEnd) +} + +fun Modifier.spacer(): Modifier { + return this.fillMaxWidth().padding(12.dp) +} + +fun Modifier.smallSpacer(): Modifier { + return this.fillMaxWidth().height(8.dp) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/ext/ShapeExt.kt b/app/src/main/java/be/ugent/sel/studeez/common/ext/ShapeExt.kt new file mode 100644 index 0000000..2114a74 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/ext/ShapeExt.kt @@ -0,0 +1,8 @@ +package be.ugent.sel.studeez.common.ext + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +fun defaultButtonShape(): RoundedCornerShape { + return RoundedCornerShape(20.dp) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/ext/StringExt.kt b/app/src/main/java/be/ugent/sel/studeez/common/ext/StringExt.kt new file mode 100644 index 0000000..02af993 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/ext/StringExt.kt @@ -0,0 +1,25 @@ +package be.ugent.sel.studeez.common.ext + +import android.util.Patterns +import java.util.regex.Pattern + +private const val MIN_PASS_LENGTH = 6 +private const val PASS_PATTERN = "^(?=.*\\d)(?=.*[a-z])(?=.*[A-Z])(?=\\S+$).{4,}$" + +fun String.isValidEmail(): Boolean { + return this.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(this).matches() +} + +fun String.isValidPassword(): Boolean { + return this.isNotBlank() && + this.length >= MIN_PASS_LENGTH && + Pattern.compile(PASS_PATTERN).matcher(this).matches() +} + +fun String.passwordMatches(repeated: String): Boolean { + return this == repeated +} + +fun String.idFromParameter(): String { + return this.substring(1, this.length - 1) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/snackbar/SnackBarManager.kt b/app/src/main/java/be/ugent/sel/studeez/common/snackbar/SnackBarManager.kt new file mode 100644 index 0000000..ad743d5 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/snackbar/SnackBarManager.kt @@ -0,0 +1,20 @@ +package be.ugent.sel.studeez.common.snackbar + +import androidx.annotation.StringRes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object SnackbarManager { + private val messages: MutableStateFlow = MutableStateFlow(null) + val snackbarMessages: StateFlow + get() = messages.asStateFlow() + + fun showMessage(@StringRes message: Int) { + messages.value = SnackbarMessage.ResourceSnackbar(message) + } + + fun showMessage(message: SnackbarMessage) { + messages.value = message + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/common/snackbar/SnackBarMessage.kt b/app/src/main/java/be/ugent/sel/studeez/common/snackbar/SnackBarMessage.kt new file mode 100644 index 0000000..495ceb1 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/common/snackbar/SnackBarMessage.kt @@ -0,0 +1,25 @@ +package be.ugent.sel.studeez.common.snackbar + +import android.content.res.Resources +import androidx.annotation.StringRes +import be.ugent.sel.studeez.R.string as AppText + +sealed class SnackbarMessage { + class StringSnackbar(val message: String) : SnackbarMessage() + class ResourceSnackbar(@StringRes val message: Int) : SnackbarMessage() + + companion object { + fun SnackbarMessage.toMessage(resources: Resources): String { + return when (this) { + is StringSnackbar -> this.message + is ResourceSnackbar -> resources.getString(this.message) + } + } + + fun Throwable.toSnackbarMessage(): SnackbarMessage { + val message = this.message.orEmpty() + return if (message.isNotBlank()) StringSnackbar(message) + else ResourceSnackbar(AppText.generic_error) + } + } +} diff --git a/app/src/main/java/be/ugent/sel/studeez/data/SelectedState.kt b/app/src/main/java/be/ugent/sel/studeez/data/SelectedState.kt new file mode 100644 index 0000000..c52939f --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/SelectedState.kt @@ -0,0 +1,45 @@ +package be.ugent.sel.studeez.data + +import be.ugent.sel.studeez.data.local.models.SessionReport +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.data.local.models.task.Task +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Used to cummunicate between viewmodels. + */ +abstract class SelectedState { + abstract var value: T + operator fun invoke() = value + fun set(newValue: T) { + this.value = newValue + } +} + +@Singleton +class SelectedSessionReport @Inject constructor() : SelectedState() { + override lateinit var value: SessionReport +} + +@Singleton +class SelectedTask @Inject constructor() : SelectedState() { + override lateinit var value: Task +} + +@Singleton +class SelectedTimer @Inject constructor() : SelectedState() { + override lateinit var value: FunctionalTimer +} + +@Singleton +class SelectedSubject @Inject constructor() : SelectedState() { + override lateinit var value: Subject +} + +@Singleton +class SelectedTimerInfo @Inject constructor() : SelectedState() { + override lateinit var value: TimerInfo +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/db/.keep b/app/src/main/java/be/ugent/sel/studeez/data/local/db/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/FeedEntry.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/FeedEntry.kt new file mode 100644 index 0000000..8733c48 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/FeedEntry.kt @@ -0,0 +1,14 @@ +package be.ugent.sel.studeez.data.local.models + +import com.google.firebase.Timestamp + +data class FeedEntry( + val argb_color: Long = 0, + val subJectName: String = "", + val taskName: String = "", + val taskId: String = "", // Name of task is not unique + val subjectId: String = "", + val totalStudyTime: Int = 0, + val endTime: Timestamp = Timestamp(0, 0), + val isArchived: Boolean = false +) diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/SessionReport.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/SessionReport.kt new file mode 100644 index 0000000..5835538 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/SessionReport.kt @@ -0,0 +1,12 @@ +package be.ugent.sel.studeez.data.local.models + +import com.google.firebase.Timestamp +import com.google.firebase.firestore.DocumentId + +data class SessionReport( + @DocumentId val id: String = "", + val studyTime: Int = 0, + val endTime: Timestamp = Timestamp(0, 0), + val taskId: String = "", + val subjectId: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/User.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/User.kt new file mode 100644 index 0000000..2fba2ce --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/User.kt @@ -0,0 +1,3 @@ +package be.ugent.sel.studeez.data.local.models + +data class User(val id: String = "") diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/task/Subject.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/task/Subject.kt new file mode 100644 index 0000000..261f3e0 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/task/Subject.kt @@ -0,0 +1,18 @@ +package be.ugent.sel.studeez.data.local.models.task + +import com.google.firebase.firestore.DocumentId +import com.google.firebase.firestore.Exclude + +data class Subject( + @DocumentId val id: String = "", + val name: String = "", + val argb_color: Long = 0, + var archived: Boolean = false, +) + +object SubjectDocument { + const val id = "id" + const val name = "name" + const val archived = "archived" + const val argb_color = "argb_color" +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/task/Task.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/task/Task.kt new file mode 100644 index 0000000..ff2748d --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/task/Task.kt @@ -0,0 +1,21 @@ +package be.ugent.sel.studeez.data.local.models.task + +import com.google.firebase.firestore.DocumentId + +data class Task( + @DocumentId val id: String = "", + val name: String = "", + var completed: Boolean = false, + val time: Int = 0, + val subjectId: String = "", + var archived: Boolean = false, +) + +object TaskDocument { + const val id = "id" + const val name = "name" + const val completed = "completed" + const val time = "time" + const val subjectId = "subjectId" + const val archived = "archived" +} diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalCustomTimer.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalCustomTimer.kt new file mode 100644 index 0000000..7bc51f8 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalCustomTimer.kt @@ -0,0 +1,24 @@ +package be.ugent.sel.studeez.data.local.models.timer_functional + +class FunctionalCustomTimer(studyTime: Int) : FunctionalTimer(studyTime) { + + override fun tick() { + if (!hasEnded()) { + time-- + totalStudyTime++ + } + } + + override fun hasEnded(): Boolean { + return time.time == 0 + } + + override fun hasCurrentCountdownEnded(): Boolean { + return hasEnded() + } + + override fun accept(visitor: FunctionalTimerVisitor): T { + return visitor.visitFunctionalCustomTimer(this) + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalEndlessTimer.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalEndlessTimer.kt new file mode 100644 index 0000000..51ee182 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalEndlessTimer.kt @@ -0,0 +1,21 @@ +package be.ugent.sel.studeez.data.local.models.timer_functional + +class FunctionalEndlessTimer : FunctionalTimer(0) { + + override fun hasEnded(): Boolean { + return false + } + + override fun hasCurrentCountdownEnded(): Boolean { + return false + } + + override fun tick() { + time++ + totalStudyTime++ + } + + override fun accept(visitor: FunctionalTimerVisitor): T { + return visitor.visitFunctionalEndlessTimer(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalPomodoroTimer.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalPomodoroTimer.kt new file mode 100644 index 0000000..f5237d6 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalPomodoroTimer.kt @@ -0,0 +1,47 @@ +package be.ugent.sel.studeez.data.local.models.timer_functional + +class FunctionalPomodoroTimer( + private var studyTime: Int, + private var breakTime: Int, + val repeats: Int +) : FunctionalTimer(studyTime) { + + var breaksRemaining = repeats - 1 + var isInBreak = false + + override fun tick() { + if (hasEnded()) { + return + } + if (hasCurrentCountdownEnded()) { + if (isInBreak) { + breaksRemaining-- + time.time = studyTime + } else { + time.time = breakTime + } + isInBreak = !isInBreak + } + time-- + + if (!isInBreak) { + totalStudyTime++ + } + } + + override fun hasEnded(): Boolean { + return !hasBreaksRemaining() && hasCurrentCountdownEnded() + } + + private fun hasBreaksRemaining(): Boolean { + return breaksRemaining > 0 + } + + override fun hasCurrentCountdownEnded(): Boolean { + return time.time == 0 + } + + override fun accept(visitor: FunctionalTimerVisitor): T { + return visitor.visitFunctionalBreakTimer(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalTimer.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalTimer.kt new file mode 100644 index 0000000..656f52f --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalTimer.kt @@ -0,0 +1,31 @@ +package be.ugent.sel.studeez.data.local.models.timer_functional + +import be.ugent.sel.studeez.data.local.models.SessionReport +import com.google.firebase.Timestamp +import com.google.firebase.firestore.DocumentReference + +abstract class FunctionalTimer(initialValue: Int) { + var time: Time = Time(initialValue) + var totalStudyTime: Int = 0 + + fun getHoursMinutesSeconds(): HoursMinutesSeconds { + return time.getAsHMS() + } + + abstract fun tick() + + abstract fun hasEnded(): Boolean + + abstract fun hasCurrentCountdownEnded(): Boolean + + fun getSessionReport(subjectId: String, taskId: String): SessionReport { + return SessionReport( + studyTime = totalStudyTime, + endTime = Timestamp.now(), + taskId = taskId, + subjectId = subjectId + ) + } + + abstract fun accept(visitor: FunctionalTimerVisitor): T +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalTimerVisitor.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalTimerVisitor.kt new file mode 100644 index 0000000..3acc805 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/FunctionalTimerVisitor.kt @@ -0,0 +1,11 @@ +package be.ugent.sel.studeez.data.local.models.timer_functional + +interface FunctionalTimerVisitor { + + fun visitFunctionalCustomTimer(functionalCustomTimer: FunctionalCustomTimer): T + + fun visitFunctionalEndlessTimer(functionalEndlessTimer: FunctionalEndlessTimer): T + + fun visitFunctionalBreakTimer(functionalPomodoroTimer: FunctionalPomodoroTimer): T + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/HoursMinutesSeconds.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/HoursMinutesSeconds.kt new file mode 100644 index 0000000..edccbd0 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/HoursMinutesSeconds.kt @@ -0,0 +1,21 @@ +package be.ugent.sel.studeez.data.local.models.timer_functional + +data class HoursMinutesSeconds(val hours: Int, val minutes: Int, val seconds: Int) { + + constructor(sec: Int): this( + hours = sec / (60 * 60), + minutes = (sec / (60)) % 60, + seconds = sec % 60, + ) + + fun getTotalSeconds(): Int { + return (hours * 60 * 60) + (minutes * 60) + seconds + } + + override fun toString(): String { + val hoursString = hours.toString().padStart(2, '0') + val minutesString = minutes.toString().padStart(2, '0') + val secondsString = seconds.toString().padStart(2, '0') + return "$hoursString:$minutesString:$secondsString" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/Time.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/Time.kt new file mode 100644 index 0000000..e37b374 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_functional/Time.kt @@ -0,0 +1,13 @@ +package be.ugent.sel.studeez.data.local.models.timer_functional + +class Time(var time: Int) { + operator fun invoke() = time + + operator fun inc(): Time = Time(time + 1) + + operator fun dec(): Time = Time(time - 1) + + fun getAsHMS(): HoursMinutesSeconds { + return HoursMinutesSeconds(time) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/CustomTimerInfo.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/CustomTimerInfo.kt new file mode 100644 index 0000000..d88e39f --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/CustomTimerInfo.kt @@ -0,0 +1,30 @@ +package be.ugent.sel.studeez.data.local.models.timer_info + +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer + +class CustomTimerInfo( + name: String, + description: String, + var studyTime: Int, + id: String = "" +): TimerInfo(id, name, description) { + + override fun getFunctionalTimer(): FunctionalTimer { + return FunctionalCustomTimer(studyTime) + } + + override fun asJson() : Map { + return mapOf( + "type" to "custom", + "name" to name, + "description" to description, + "studyTime" to studyTime, + ) + } + + override fun accept(visitor: TimerInfoVisitor): T { + return visitor.visitCustomTimerInfo(this) + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/EndlessTimerInfo.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/EndlessTimerInfo.kt new file mode 100644 index 0000000..45f7fd7 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/EndlessTimerInfo.kt @@ -0,0 +1,29 @@ +package be.ugent.sel.studeez.data.local.models.timer_info + +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer + +class EndlessTimerInfo( + name: String, + description: String, + id: String = "" +): TimerInfo(id, name, description) { + + + override fun getFunctionalTimer(): FunctionalTimer { + return FunctionalEndlessTimer() + } + + override fun asJson() : Map { + return mapOf( + "type" to "endless", + "name" to name, + "description" to description + ) + } + + override fun accept(visitor: TimerInfoVisitor): T { + return visitor.visitEndlessTimerInfo(this) + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/PomodoroTimerInfo.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/PomodoroTimerInfo.kt new file mode 100644 index 0000000..7316630 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/PomodoroTimerInfo.kt @@ -0,0 +1,36 @@ +package be.ugent.sel.studeez.data.local.models.timer_info + +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimerVisitor + +class PomodoroTimerInfo( + name: String, + description: String, + var studyTime: Int, + var breakTime: Int, + var repeats: Int, + id: String = "" +): TimerInfo(id, name, description) { + + + override fun getFunctionalTimer(): FunctionalTimer { + return FunctionalPomodoroTimer(studyTime, breakTime, repeats) + } + + override fun asJson() : Map { + return mapOf( + "type" to "break", + "name" to name, + "description" to description, + "studyTime" to studyTime, + "breakTime" to breakTime, + "repeats" to repeats, + ) + } + + override fun accept(visitor: TimerInfoVisitor): T { + return visitor.visitBreakTimerInfo(this) + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerInfo.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerInfo.kt new file mode 100644 index 0000000..e4deded --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerInfo.kt @@ -0,0 +1,27 @@ +package be.ugent.sel.studeez.data.local.models.timer_info + +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer + +/** + * Deze klasse stelt de de info van een timer weer. Elke timer heeft een id, naam en descriptie + */ +abstract class TimerInfo( + val id: String, + var name: String, + var description: String +) { + + /** + * Geef de functionele timer terug die kan gebruikt worden tijden een sessie. + */ + abstract fun getFunctionalTimer(): FunctionalTimer + + /** + * Geef deze timer weer als json. Wordt gebruikt om terug op te slaan in de databank. + * TODO implementaties hebben nog hardgecodeerde strings. + */ + abstract fun asJson(): Map + abstract fun accept(visitor: TimerInfoVisitor): T + +} + diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerInfoVisitor.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerInfoVisitor.kt new file mode 100644 index 0000000..e331c8d --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerInfoVisitor.kt @@ -0,0 +1,11 @@ +package be.ugent.sel.studeez.data.local.models.timer_info + +interface TimerInfoVisitor { + + fun visitCustomTimerInfo(customTimerInfo: CustomTimerInfo): T + + fun visitEndlessTimerInfo(endlessTimerInfo: EndlessTimerInfo): T + + fun visitBreakTimerInfo(pomodoroTimerInfo: PomodoroTimerInfo): T + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerJson.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerJson.kt new file mode 100644 index 0000000..37a7b9f --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerJson.kt @@ -0,0 +1,16 @@ +package be.ugent.sel.studeez.data.local.models.timer_info + +import com.google.firebase.firestore.DocumentId + +/** + * Timers uit de databank (remote config en firestore) worden als eerste stap omgezet naar dit type. + */ +data class TimerJson( + val type: String = "", + val name: String = "", + val description: String = "", + val studyTime: Int = 0, + val breakTime: Int = 0, + val repeats: Int = 0, + @DocumentId val id: String = "" +) diff --git a/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerType.kt b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerType.kt new file mode 100644 index 0000000..20fb36d --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/data/local/models/timer_info/TimerType.kt @@ -0,0 +1,7 @@ +package be.ugent.sel.studeez.data.local.models.timer_info + +enum class TimerType { + BREAK, + ENDLESS, + CUSTOM +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/data/remote/.keep b/app/src/main/java/be/ugent/sel/studeez/data/remote/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/be/ugent/sel/studeez/di/DatabaseModule.kt b/app/src/main/java/be/ugent/sel/studeez/di/DatabaseModule.kt new file mode 100644 index 0000000..4c5fea1 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/di/DatabaseModule.kt @@ -0,0 +1,39 @@ +package be.ugent.sel.studeez.di + +import be.ugent.sel.studeez.domain.* +import be.ugent.sel.studeez.domain.implementation.* +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class DatabaseModule { + @Binds + abstract fun provideAccountDAO(impl: FirebaseAccountDAO): AccountDAO + + @Binds + abstract fun provideUserDAO(impl: FirebaseUserDAO): UserDAO + + @Binds + abstract fun provideTimerDAO(impl: FirebaseTimerDAO): TimerDAO + + @Binds + abstract fun provideLogService(impl: LogServiceImpl): LogService + + @Binds + abstract fun provideConfigurationService(impl: FirebaseConfigurationService): ConfigurationService + + @Binds + abstract fun provideSessionDAO(impl: FireBaseSessionDAO): SessionDAO + + @Binds + abstract fun provideSubjectDAO(impl: FireBaseSubjectDAO): SubjectDAO + + @Binds + abstract fun provideTaskDAO(impl: FireBaseTaskDAO): TaskDAO + + @Binds + abstract fun provideFeedDAO(impl: FirebaseFeedDAO): FeedDAO +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/di/FireBaseModule.kt b/app/src/main/java/be/ugent/sel/studeez/di/FireBaseModule.kt new file mode 100644 index 0000000..e8510f3 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/di/FireBaseModule.kt @@ -0,0 +1,21 @@ +package be.ugent.sel.studeez.di + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.ktx.auth +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object FirebaseModule { + @Provides + fun auth(): FirebaseAuth = Firebase.auth + + @Provides + fun firestore(): FirebaseFirestore = Firebase.firestore +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/AccountDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/AccountDAO.kt new file mode 100644 index 0000000..c813ec6 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/AccountDAO.kt @@ -0,0 +1,34 @@ +/* +Copyright 2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + */ + +package be.ugent.sel.studeez.domain + +import be.ugent.sel.studeez.data.local.models.User +import kotlinx.coroutines.flow.Flow + +interface AccountDAO { + val currentUserId: String + val hasUser: Boolean + + val currentUser: Flow + + suspend fun signInWithEmailAndPassword(email: String, password: String) + suspend fun sendRecoveryEmail(email: String) + suspend fun signUpWithEmailAndPassword(email: String, password: String) + suspend fun deleteAccount() + + suspend fun signOut() +} diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/ConfigurationService.kt b/app/src/main/java/be/ugent/sel/studeez/domain/ConfigurationService.kt new file mode 100644 index 0000000..26aeba5 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/ConfigurationService.kt @@ -0,0 +1,11 @@ +package be.ugent.sel.studeez.domain + +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo + +interface ConfigurationService { + + suspend fun fetchConfiguration(): Boolean + + fun getDefaultTimers(): List + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/FeedDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/FeedDAO.kt new file mode 100644 index 0000000..2d91781 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/FeedDAO.kt @@ -0,0 +1,10 @@ +package be.ugent.sel.studeez.domain + +import be.ugent.sel.studeez.data.local.models.FeedEntry +import kotlinx.coroutines.flow.Flow + +interface FeedDAO { + + fun getFeedEntries(): Flow>> + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/LogService.kt b/app/src/main/java/be/ugent/sel/studeez/domain/LogService.kt new file mode 100644 index 0000000..c5b7087 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/LogService.kt @@ -0,0 +1,5 @@ +package be.ugent.sel.studeez.domain + +interface LogService { + fun logNonFatalCrash(throwable: Throwable) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/SessionDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/SessionDAO.kt new file mode 100644 index 0000000..77087d2 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/SessionDAO.kt @@ -0,0 +1,15 @@ +package be.ugent.sel.studeez.domain + +import be.ugent.sel.studeez.data.local.models.SessionReport +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import kotlinx.coroutines.flow.Flow + +interface SessionDAO { + + fun getSessions(): Flow> + + fun saveSession(newSessionReport: SessionReport) + + fun deleteSession(newTimer: TimerInfo) + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/SubjectDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/SubjectDAO.kt new file mode 100644 index 0000000..d887ef5 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/SubjectDAO.kt @@ -0,0 +1,23 @@ +package be.ugent.sel.studeez.domain + +import be.ugent.sel.studeez.data.local.models.task.Subject +import kotlinx.coroutines.flow.Flow + +interface SubjectDAO { + + fun getSubjects(): Flow> + + fun saveSubject(newSubject: Subject) + + fun deleteSubject(oldSubject: Subject) + + fun updateSubject(newSubject: Subject) + + suspend fun archiveSubject(subject: Subject) + + fun getTaskCount(subject: Subject): Flow + fun getCompletedTaskCount(subject: Subject): Flow + fun getStudyTime(subject: Subject): Flow + + suspend fun getSubject(subjectId: String): Subject? +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/TaskDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/TaskDAO.kt new file mode 100644 index 0000000..8a2dd41 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/TaskDAO.kt @@ -0,0 +1,18 @@ +package be.ugent.sel.studeez.domain + +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.data.local.models.task.Task +import kotlinx.coroutines.flow.Flow + +interface TaskDAO { + + fun getTasks(subject: Subject): Flow> + + fun saveTask(newTask: Task) + + fun updateTask(newTask: Task) + + fun deleteTask(oldTask: Task) + + suspend fun getTask(subjectId: String, taskId: String): Task +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/TimerDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/TimerDAO.kt new file mode 100644 index 0000000..ab3edcb --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/TimerDAO.kt @@ -0,0 +1,19 @@ +package be.ugent.sel.studeez.domain + +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.TimerJson +import kotlinx.coroutines.flow.Flow + +interface TimerDAO { + + fun getUserTimers(): Flow> + + fun getAllTimers(): Flow> + + fun saveTimer(newTimer: TimerInfo) + + fun updateTimer(timerInfo: TimerInfo) + + fun deleteTimer(timerInfo: TimerInfo) + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/UserDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/UserDAO.kt new file mode 100644 index 0000000..b96cf17 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/UserDAO.kt @@ -0,0 +1,13 @@ +package be.ugent.sel.studeez.domain + +interface UserDAO { + + suspend fun getUsername(): String? + suspend fun save(newUsername: String) + + /** + * Delete all references to this user in the database. Similar to the deleteCascade in + * relational databases. + */ + suspend fun deleteUserReferences() +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseCollections.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseCollections.kt new file mode 100644 index 0000000..78867c9 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseCollections.kt @@ -0,0 +1,9 @@ +package be.ugent.sel.studeez.domain.implementation + +object FireBaseCollections { + const val SESSION_COLLECTION = "sessions" + const val USER_COLLECTION = "users" + const val TIMER_COLLECTION = "timers" + const val SUBJECT_COLLECTION = "subjects" + const val TASK_COLLECTION = "tasks" +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseSessionDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseSessionDAO.kt new file mode 100644 index 0000000..a818236 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseSessionDAO.kt @@ -0,0 +1,37 @@ +package be.ugent.sel.studeez.domain.implementation + +import be.ugent.sel.studeez.data.local.models.SessionReport +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.SessionDAO +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.ktx.snapshots +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class FireBaseSessionDAO @Inject constructor( + private val firestore: FirebaseFirestore, + private val auth: AccountDAO +) : SessionDAO { + + override fun getSessions(): Flow> { + return currentUserSessionsCollection() + .snapshots() + .map { it.toObjects(SessionReport::class.java) } + } + + override fun saveSession(newSessionReport: SessionReport) { + currentUserSessionsCollection().add(newSessionReport) + } + + override fun deleteSession(newTimer: TimerInfo) { + currentUserSessionsCollection().document(newTimer.id).delete() + } + + private fun currentUserSessionsCollection(): CollectionReference = + firestore.collection(FireBaseCollections.USER_COLLECTION) + .document(auth.currentUserId) + .collection(FireBaseCollections.SESSION_COLLECTION) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseSubjectDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseSubjectDAO.kt new file mode 100644 index 0000000..66815dc --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseSubjectDAO.kt @@ -0,0 +1,94 @@ +package be.ugent.sel.studeez.domain.implementation + +import android.util.Log +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.data.local.models.task.SubjectDocument +import be.ugent.sel.studeez.data.local.models.task.Task +import be.ugent.sel.studeez.data.local.models.task.TaskDocument +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.SubjectDAO +import be.ugent.sel.studeez.domain.TaskDAO +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query +import com.google.firebase.firestore.ktx.snapshots +import com.google.firebase.firestore.ktx.toObject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import kotlin.collections.count + +class FireBaseSubjectDAO @Inject constructor( + private val firestore: FirebaseFirestore, + private val auth: AccountDAO, + private val taskDAO: TaskDAO, +) : SubjectDAO { + override fun getSubjects(): Flow> { + return currentUserSubjectsCollection() + .subjectNotArchived() + .snapshots() + .map { it.toObjects(Subject::class.java) } + } + + override suspend fun getSubject(subjectId: String): Subject? { + return currentUserSubjectsCollection().document(subjectId).get().await().toObject() + } + + override fun saveSubject(newSubject: Subject) { + currentUserSubjectsCollection().add(newSubject) + } + + override fun deleteSubject(oldSubject: Subject) { + currentUserSubjectsCollection().document(oldSubject.id).delete() + } + + override fun updateSubject(newSubject: Subject) { + currentUserSubjectsCollection().document(newSubject.id).set(newSubject) + } + + override suspend fun archiveSubject(subject: Subject) { + currentUserSubjectsCollection().document(subject.id).update(SubjectDocument.archived, true) + currentUserSubjectsCollection().document(subject.id) + .collection(FireBaseCollections.TASK_COLLECTION) + .taskNotArchived() + .get().await() + .documents + .forEach { + it.reference.update(TaskDocument.archived, true) + } + } + + override fun getTaskCount(subject: Subject): Flow { + return taskDAO.getTasks(subject) + .map(List::count) + } + + override fun getCompletedTaskCount(subject: Subject): Flow { + return taskDAO.getTasks(subject) + .map { tasks -> tasks.count { it.completed && !it.archived } } + } + + override fun getStudyTime(subject: Subject): Flow { + return taskDAO.getTasks(subject) + .map { tasks -> tasks.sumOf { it.time } } + } + + private fun currentUserSubjectsCollection(): CollectionReference = + firestore.collection(FireBaseCollections.USER_COLLECTION) + .document(auth.currentUserId) + .collection(FireBaseCollections.SUBJECT_COLLECTION) + + private fun subjectTasksCollection(subject: Subject): CollectionReference = + firestore.collection(FireBaseCollections.USER_COLLECTION) + .document(auth.currentUserId) + .collection(FireBaseCollections.SUBJECT_COLLECTION) + .document(subject.id) + .collection(FireBaseCollections.TASK_COLLECTION) + + fun CollectionReference.subjectNotArchived(): Query = + this.whereEqualTo(SubjectDocument.archived, false) + + fun Query.subjectNotArchived(): Query = + this.whereEqualTo(SubjectDocument.archived, false) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseTaskDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseTaskDAO.kt new file mode 100644 index 0000000..685b237 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FireBaseTaskDAO.kt @@ -0,0 +1,68 @@ +package be.ugent.sel.studeez.domain.implementation + +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.data.local.models.task.Task +import be.ugent.sel.studeez.data.local.models.task.TaskDocument +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.TaskDAO +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query +import com.google.firebase.firestore.ktx.snapshots +import com.google.firebase.firestore.ktx.toObject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +class FireBaseTaskDAO @Inject constructor( + private val firestore: FirebaseFirestore, + private val auth: AccountDAO, +) : TaskDAO { + override fun getTasks(subject: Subject): Flow> { + return selectedSubjectTasksCollection(subject.id) + .taskNotArchived() + .snapshots() + .map { it.toObjects(Task::class.java) } + } + + override suspend fun getTask(subjectId: String, taskId: String): Task { + return selectedSubjectTasksCollection(subjectId).document(taskId).get().await().toObject()!! + } + + override fun saveTask(newTask: Task) { + selectedSubjectTasksCollection(newTask.subjectId).add(newTask) + } + + override fun updateTask(newTask: Task) { + selectedSubjectTasksCollection(newTask.subjectId) + .document(newTask.id) + .set(newTask) + } + + override fun deleteTask(oldTask: Task) { + selectedSubjectTasksCollection(oldTask.subjectId).document(oldTask.id).delete() + } + + private fun selectedSubjectTasksCollection(subjectId: String): CollectionReference = + firestore.collection(FireBaseCollections.USER_COLLECTION) + .document(auth.currentUserId) + .collection(FireBaseCollections.SUBJECT_COLLECTION) + .document(subjectId) + .collection(FireBaseCollections.TASK_COLLECTION) + +} + +// Extend CollectionReference and Query with some filters + +fun CollectionReference.taskNotArchived(): Query = + this.whereEqualTo(TaskDocument.archived, false) + +fun Query.taskNotArchived(): Query = + this.whereEqualTo(TaskDocument.archived, false) + +fun CollectionReference.taskNotCompleted(): Query = + this.whereEqualTo(TaskDocument.completed, true) + +fun Query.taskNotCompleted(): Query = + this.whereEqualTo(TaskDocument.completed, true) diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseAccountDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseAccountDAO.kt new file mode 100644 index 0000000..4a490fe --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseAccountDAO.kt @@ -0,0 +1,67 @@ +/* +Copyright 2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + */ + +package be.ugent.sel.studeez.domain.implementation + +import be.ugent.sel.studeez.data.local.models.User +import be.ugent.sel.studeez.domain.AccountDAO +import com.google.firebase.auth.FirebaseAuth +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +class FirebaseAccountDAO @Inject constructor( + private val auth: FirebaseAuth +) : AccountDAO { + + override val currentUserId: String + get() = auth.currentUser?.uid.orEmpty() + + override val hasUser: Boolean + get() = auth.currentUser != null + + override val currentUser: Flow + get() = callbackFlow { + val listener = + FirebaseAuth.AuthStateListener { auth -> + this.trySend(auth.currentUser?.let { User(it.uid) } ?: User()) + } + auth.addAuthStateListener(listener) + awaitClose { auth.removeAuthStateListener(listener) } + } + + override suspend fun signInWithEmailAndPassword(email: String, password: String) { + auth.signInWithEmailAndPassword(email, password).await() + } + + override suspend fun sendRecoveryEmail(email: String) { + auth.sendPasswordResetEmail(email).await() + } + + override suspend fun signUpWithEmailAndPassword(email: String, password: String) { + auth.createUserWithEmailAndPassword(email, password).await() + } + + override suspend fun deleteAccount() { + auth.currentUser!!.delete().await() + } + + override suspend fun signOut() { + auth.signOut() + } +} diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseConfigurationService.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseConfigurationService.kt new file mode 100644 index 0000000..e3db024 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseConfigurationService.kt @@ -0,0 +1,38 @@ +package be.ugent.sel.studeez.domain.implementation + +import be.ugent.sel.studeez.data.local.models.timer_info.* +import be.ugent.sel.studeez.domain.ConfigurationService +import com.google.firebase.ktx.Firebase +import com.google.firebase.remoteconfig.ktx.get +import com.google.firebase.remoteconfig.ktx.remoteConfig +import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import com.google.gson.Gson +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +class FirebaseConfigurationService @Inject constructor() : ConfigurationService { + + init { + // fetch configs elke keer als app wordt opgestart + val configSettings = remoteConfigSettings { minimumFetchIntervalInSeconds = 0 } + remoteConfig.setConfigSettingsAsync(configSettings) + } + + private val remoteConfig + get() = Firebase.remoteConfig + + override suspend fun fetchConfiguration(): Boolean { + return remoteConfig.fetchAndActivate().await() + } + + override fun getDefaultTimers(): List { + val jsonString: String = remoteConfig[DEFAULT_TIMERS].asString() + // Json is een lijst van timers + val timerJsonList: List = ToTimerConverter().jsonToTimerJsonList(jsonString) + return ToTimerConverter().convertToTimerInfoList(timerJsonList) + } + + companion object { + private const val DEFAULT_TIMERS = "default_timers" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseFeedDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseFeedDAO.kt new file mode 100644 index 0000000..6c445bf --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseFeedDAO.kt @@ -0,0 +1,81 @@ +package be.ugent.sel.studeez.domain.implementation + +import android.icu.text.DateFormat +import be.ugent.sel.studeez.data.local.models.FeedEntry +import be.ugent.sel.studeez.data.local.models.SessionReport +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.data.local.models.task.Task +import be.ugent.sel.studeez.domain.FeedDAO +import be.ugent.sel.studeez.domain.SessionDAO +import be.ugent.sel.studeez.domain.SubjectDAO +import be.ugent.sel.studeez.domain.TaskDAO +import com.google.firebase.Timestamp +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class FirebaseFeedDAO @Inject constructor( + private val sessionDAO: SessionDAO, + private val taskDAO: TaskDAO, + private val subjectDAO: SubjectDAO +) : FeedDAO { + + /** + * Return a map as with key the day and value a list of feedentries for that day. + */ + override fun getFeedEntries(): Flow>> { + return sessionDAO.getSessions().map { sessionReports -> + sessionReports + .map { sessionReport -> sessionToFeedEntry(sessionReport) } + .sortedByDescending { it.endTime } + .groupBy { getFormattedTime(it) } + .mapValues { (_, entries) -> + entries + .groupBy { it.taskId } + .map { fuseFeedEntries(it.component2()) } + } + } + } + + private fun getFormattedTime(entry: FeedEntry): String { + return DateFormat.getDateInstance().format(entry.endTime.toDate()) + } + + /** + * Givin a list of entries referencing the same task, in the same day, fuse them into one + * feed-entry by adding the studytime and keeping the most recent end-timestamp + */ + private fun fuseFeedEntries(entries: List): FeedEntry = + entries.drop(1).fold(entries[0]) { accEntry, newEntry -> + accEntry.copy( + totalStudyTime = accEntry.totalStudyTime + newEntry.totalStudyTime, + endTime = getMostRecent(accEntry.endTime, newEntry.endTime) + ) + } + + private fun getMostRecent(t1: Timestamp, t2: Timestamp): Timestamp { + return if (t1 < t2) t2 else t1 + } + + /** + * Convert a sessionReport to a feedEntry. Fetch Task and Subject to get names + */ + private suspend fun sessionToFeedEntry(sessionReport: SessionReport): FeedEntry { + val subjectId: String = sessionReport.subjectId + val taskId: String = sessionReport.taskId + + val task: Task = taskDAO.getTask(subjectId, taskId) + val subject: Subject = subjectDAO.getSubject(subjectId)!! + + return FeedEntry( + argb_color = subject.argb_color, + subJectName = subject.name, + taskName = task.name, + taskId = task.id, + subjectId = subject.id, + totalStudyTime = sessionReport.studyTime, + endTime = sessionReport.endTime, + isArchived = task.archived || subject.archived + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseTimerDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseTimerDAO.kt new file mode 100644 index 0000000..1f37a18 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseTimerDAO.kt @@ -0,0 +1,55 @@ +package be.ugent.sel.studeez.domain.implementation + +import be.ugent.sel.studeez.data.local.models.timer_info.* +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.TimerDAO +import com.google.firebase.firestore.CollectionReference +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.ktx.snapshots +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class FirebaseTimerDAO @Inject constructor( + private val firestore: FirebaseFirestore, + private val configurationService: FirebaseConfigurationService, + private val auth: AccountDAO +) : TimerDAO { + + override fun getUserTimers(): Flow> { + return currentUserTimersCollection() + .snapshots() + .map { it.toObjects(TimerJson::class.java) } + .map { ToTimerConverter().convertToTimerInfoList(it) } + } + + override fun getAllTimers(): Flow> { + // Wrap default timers in een flow en combineer met de userTimer flow. + val defaultTimers: List = configurationService.getDefaultTimers() + val defaultTimersFlow: Flow> = flowOf(defaultTimers) + val userTimersFlow: Flow> = getUserTimers() + return defaultTimersFlow.combine(userTimersFlow) { defaultTimersList, userTimersList -> + defaultTimersList + userTimersList + } + } + + override fun saveTimer(newTimer: TimerInfo) { + currentUserTimersCollection().add(newTimer.asJson()) + } + + override fun updateTimer(timerInfo: TimerInfo) { + currentUserTimersCollection().document(timerInfo.id).set(timerInfo.asJson()) + } + + override fun deleteTimer(timerInfo: TimerInfo) { + currentUserTimersCollection().document(timerInfo.id).delete() + } + + private fun currentUserTimersCollection(): CollectionReference = + firestore.collection(FireBaseCollections.USER_COLLECTION) + .document(auth.currentUserId) + .collection(FireBaseCollections.TIMER_COLLECTION) + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseUserDAO.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseUserDAO.kt new file mode 100644 index 0000000..3158b88 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/FirebaseUserDAO.kt @@ -0,0 +1,37 @@ +package be.ugent.sel.studeez.domain.implementation + +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.snackbar.SnackbarManager +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.UserDAO +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +class FirebaseUserDAO @Inject constructor( + private val firestore: FirebaseFirestore, + private val auth: AccountDAO + ) : UserDAO { + + override suspend fun getUsername(): String? { + return currentUserDocument().get().await().getString("username") + } + + override suspend fun save(newUsername: String) { + currentUserDocument().set(mapOf("username" to newUsername)) + } + + private fun currentUserDocument(): DocumentReference = + firestore.collection(USER_COLLECTION).document(auth.currentUserId) + + companion object { + private const val USER_COLLECTION = "users" + } + + override suspend fun deleteUserReferences() { + currentUserDocument().delete() + .addOnSuccessListener { SnackbarManager.showMessage(R.string.success) } + .addOnFailureListener { SnackbarManager.showMessage(R.string.generic_error) } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/LogServiceImpl.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/LogServiceImpl.kt new file mode 100644 index 0000000..1697872 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/LogServiceImpl.kt @@ -0,0 +1,14 @@ +package be.ugent.sel.studeez.domain.implementation + +import be.ugent.sel.studeez.domain.LogService +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import javax.inject.Inject + +class LogServiceImpl @Inject constructor() : LogService { + override fun logNonFatalCrash(throwable: Throwable) { + Firebase.crashlytics.recordException(throwable) + } + + +} diff --git a/app/src/main/java/be/ugent/sel/studeez/domain/implementation/ToTimerConverter.kt b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/ToTimerConverter.kt new file mode 100644 index 0000000..ea06747 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/domain/implementation/ToTimerConverter.kt @@ -0,0 +1,56 @@ +package be.ugent.sel.studeez.domain.implementation + +import be.ugent.sel.studeez.data.local.models.timer_info.* +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +/** + * Used by ConfigurationService and TimerDAO. + * + * ConfigurationService: configuration is fetched as a JSON-string, + * which is converted into a TimerJson, and converted here into the correct TimerInfo + * + * timerDAO: Timers are being fetched directly to TinerJson and convertes into the correct timerInfo + */ +class ToTimerConverter { + + fun interface TimerFactory { + fun makeTimer(map: TimerJson) : TimerInfo + } + + private val timerInfoMap: Map = mapOf( + TimerType.ENDLESS to TimerFactory { EndlessTimerInfo( + it.name, + it.description, + it.id + ) }, + TimerType.CUSTOM to TimerFactory { CustomTimerInfo( + it.name, + it.description, + it.studyTime, + it.id + ) }, + TimerType.BREAK to TimerFactory { PomodoroTimerInfo( + it.name, + it.description, + it.studyTime, + it.breakTime, + it.repeats, + it.id + ) } + ) + + private fun getTimer(timerJson: TimerJson): TimerInfo{ + val type: TimerType = TimerType.valueOf(timerJson.type.uppercase()) + return timerInfoMap.getValue(type).makeTimer(timerJson) + } + + fun convertToTimerInfoList(timerJsonList: List): List { + return timerJsonList.map(this::getTimer) + } + + fun jsonToTimerJsonList(json: String): List { + val type = object : TypeToken>() {}.type + return Gson().fromJson(json, type) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/navigation/StudeezDestinations.kt b/app/src/main/java/be/ugent/sel/studeez/navigation/StudeezDestinations.kt new file mode 100644 index 0000000..3e7ca35 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/navigation/StudeezDestinations.kt @@ -0,0 +1,42 @@ +package be.ugent.sel.studeez.navigation + +object StudeezDestinations { + // NavBar + const val HOME_SCREEN = "home" + const val SUBJECT_SCREEN = "subjects" + const val SESSIONS_SCREEN = "sessions" + const val PROFILE_SCREEN = "profile" + + // Drawer + const val TIMER_SCREEN = "timer_overview" + const val SETTINGS_SCREEN = "settings" + + // Login flow + const val SPLASH_SCREEN = "splash" + const val LOGIN_SCREEN = "login" + const val SIGN_UP_SCREEN = "signup" + + // Studying flow + const val TIMER_SELECTION_SCREEN = "timer_selection" + const val TIMER_EDIT_SCREEN = "timer_edit" + const val TIMER_TYPE_CHOOSING_SCREEN = "timer_type_choosing_screen" + const val SESSION_SCREEN = "session" + const val SESSION_RECAP = "session_recap" + + const val ADD_SUBJECT_FORM = "add_subject" + const val EDIT_SUBJECT_FORM = "edit_subject" + const val TASKS_SCREEN = "tasks" + const val ADD_TASK_FORM = "add_task" + const val SELECT_SUBJECT = "select_subject" + const val EDIT_TASK_FORM = "edit_task" + + // Friends flow + const val SEARCH_FRIENDS_SCREEN = "search_friends" + + // Create & edit screens + const val CREATE_TASK_SCREEN = "create_task" + const val CREATE_SESSION_SCREEN = "create_session" + const val EDIT_PROFILE_SCREEN = "edit_profile" + + const val ADD_TIMER_SCREEN = "add_timer" +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/navigation/StudeezNavGraph.kt b/app/src/main/java/be/ugent/sel/studeez/navigation/StudeezNavGraph.kt new file mode 100644 index 0000000..c9e0179 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/navigation/StudeezNavGraph.kt @@ -0,0 +1,255 @@ +package be.ugent.sel.studeez.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import be.ugent.sel.studeez.StudeezAppstate +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.common.composable.drawer.DrawerViewModel +import be.ugent.sel.studeez.common.composable.drawer.getDrawerActions +import be.ugent.sel.studeez.common.composable.navbar.NavigationBarActions +import be.ugent.sel.studeez.common.composable.navbar.NavigationBarViewModel +import be.ugent.sel.studeez.common.composable.navbar.getNavigationBarActions +import be.ugent.sel.studeez.screens.home.HomeRoute +import be.ugent.sel.studeez.screens.log_in.LoginRoute +import be.ugent.sel.studeez.screens.profile.EditProfileRoute +import be.ugent.sel.studeez.screens.profile.ProfileRoute +import be.ugent.sel.studeez.screens.session.SessionRoute +import be.ugent.sel.studeez.screens.session_recap.SessionRecapRoute +import be.ugent.sel.studeez.screens.sessions.SessionsRoute +import be.ugent.sel.studeez.screens.settings.SettingsRoute +import be.ugent.sel.studeez.screens.sign_up.SignUpRoute +import be.ugent.sel.studeez.screens.splash.SplashRoute +import be.ugent.sel.studeez.screens.subjects.SubjectRoute +import be.ugent.sel.studeez.screens.subjects.form.SubjectCreateRoute +import be.ugent.sel.studeez.screens.subjects.form.SubjectEditRoute +import be.ugent.sel.studeez.screens.subjects.select.SubjectSelectionRoute +import be.ugent.sel.studeez.screens.tasks.TaskRoute +import be.ugent.sel.studeez.screens.tasks.form.TaskCreateRoute +import be.ugent.sel.studeez.screens.tasks.form.TaskEditRoute +import be.ugent.sel.studeez.screens.timer_form.TimerAddRoute +import be.ugent.sel.studeez.screens.timer_form.TimerEditRoute +import be.ugent.sel.studeez.screens.timer_form.timer_type_select.TimerTypeSelectScreen +import be.ugent.sel.studeez.screens.timer_overview.TimerOverviewRoute +import be.ugent.sel.studeez.screens.timer_selection.TimerSelectionRoute + +@Composable +fun StudeezNavGraph( + appState: StudeezAppstate, + modifier: Modifier = Modifier, +) { + val drawerViewModel: DrawerViewModel = hiltViewModel() + val navBarViewModel: NavigationBarViewModel = hiltViewModel() + + val backStackEntry by appState.navController.currentBackStackEntryAsState() + val getCurrentScreen: () -> String? = { backStackEntry?.destination?.route } + + val goBack: () -> Unit = { appState.popUp() } + val open: (String) -> Unit = { appState.navigate(it) } + val openAndPopUp: (String, String) -> Unit = + { route, popUp -> appState.navigateAndPopUp(route, popUp) } + val clearAndNavigate: (route: String) -> Unit = { route -> appState.clearAndNavigate(route) } + + val drawerActions: DrawerActions = getDrawerActions(drawerViewModel, open, openAndPopUp) + val navigationBarActions: NavigationBarActions = + getNavigationBarActions(navBarViewModel, open, getCurrentScreen) + + NavHost( + navController = appState.navController, + startDestination = StudeezDestinations.SPLASH_SCREEN, + modifier = modifier, + ) { + // NavBar + composable(StudeezDestinations.HOME_SCREEN) { + HomeRoute( + open = open, + drawerActions = drawerActions, + navigationBarActions = navigationBarActions, + feedViewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.SUBJECT_SCREEN) { + SubjectRoute( + open = open, + viewModel = hiltViewModel(), + drawerActions = drawerActions, + navigationBarActions = navigationBarActions, + ) + } + + composable(StudeezDestinations.SELECT_SUBJECT) { + SubjectSelectionRoute( + open = { openAndPopUp(it, StudeezDestinations.SELECT_SUBJECT) }, + goBack = goBack, + viewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.ADD_SUBJECT_FORM) { + SubjectCreateRoute( + goBack = goBack, + openAndPopUp = openAndPopUp, + viewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.EDIT_SUBJECT_FORM) { + SubjectEditRoute( + goBack = goBack, + openAndPopUp = openAndPopUp, + viewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.TASKS_SCREEN) { + TaskRoute( + goBack = { openAndPopUp(StudeezDestinations.SUBJECT_SCREEN, StudeezDestinations.TASKS_SCREEN) }, + open = open, + viewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.ADD_TASK_FORM) { + TaskCreateRoute( + goBack = goBack, + openAndPopUp = openAndPopUp, + viewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.EDIT_TASK_FORM) { + TaskEditRoute( + goBack = goBack, + openAndPopUp = openAndPopUp, + viewModel = hiltViewModel(), + ) + } + + + composable(StudeezDestinations.SESSIONS_SCREEN) { + SessionsRoute( + drawerActions = drawerActions, + navigationBarActions = navigationBarActions + ) + } + + composable(StudeezDestinations.PROFILE_SCREEN) { + ProfileRoute( + open, + viewModel = hiltViewModel(), + drawerActions = drawerActions, + navigationBarActions = navigationBarActions, + ) + } + + // Drawer + composable(StudeezDestinations.TIMER_SCREEN) { + TimerOverviewRoute( + viewModel = hiltViewModel(), + drawerActions = drawerActions, + open = open + ) + } + + composable(StudeezDestinations.SETTINGS_SCREEN) { + SettingsRoute( + drawerActions = drawerActions + ) + } + + // Login flow + composable(StudeezDestinations.SPLASH_SCREEN) { + SplashRoute( + openAndPopUp, + viewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.LOGIN_SCREEN) { + LoginRoute( + openAndPopUp, + viewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.SIGN_UP_SCREEN) { + SignUpRoute( + openAndPopUp, + viewModel = hiltViewModel(), + ) + } + + // Studying flow + composable(StudeezDestinations.TIMER_SELECTION_SCREEN) { + TimerSelectionRoute( + open, + goBack, + viewModel = hiltViewModel(), + ) + } + + composable(StudeezDestinations.TIMER_TYPE_CHOOSING_SCREEN) { + TimerTypeSelectScreen( + open = open, + popUp = goBack + ) + } + + composable(StudeezDestinations.SESSION_SCREEN) { + SessionRoute( + open, + openAndPopUp, + viewModel = hiltViewModel() + ) + } + + composable(StudeezDestinations.SESSION_RECAP) { + SessionRecapRoute( + clearAndNavigate = clearAndNavigate, + viewModel = hiltViewModel() + ) + } + + composable(StudeezDestinations.ADD_TIMER_SCREEN) { + TimerAddRoute( + popUp = goBack, + viewModel = hiltViewModel() + ) + } + + composable(StudeezDestinations.TIMER_EDIT_SCREEN) { + TimerEditRoute( + popUp = goBack, + viewModel = hiltViewModel() + ) + } + + // Friends flow + composable(StudeezDestinations.SEARCH_FRIENDS_SCREEN) { + // TODO + } + + // Create & edit screens + composable(StudeezDestinations.CREATE_TASK_SCREEN) { + // TODO + } + + composable(StudeezDestinations.CREATE_SESSION_SCREEN) { + // TODO + } + + composable(StudeezDestinations.EDIT_PROFILE_SCREEN) { + EditProfileRoute( + goBack, + openAndPopUp, + viewModel = hiltViewModel(), + ) + } + } +} + diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/StudeezViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/StudeezViewModel.kt new file mode 100644 index 0000000..2db85f2 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/StudeezViewModel.kt @@ -0,0 +1,23 @@ +package be.ugent.sel.studeez.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import be.ugent.sel.studeez.common.snackbar.SnackbarManager +import be.ugent.sel.studeez.common.snackbar.SnackbarMessage.Companion.toSnackbarMessage +import be.ugent.sel.studeez.domain.LogService +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +open class StudeezViewModel(private val logService: LogService) : ViewModel() { + fun launchCatching(snackbar: Boolean = true, block: suspend CoroutineScope.() -> Unit) = + viewModelScope.launch( + CoroutineExceptionHandler { _, throwable -> + if (snackbar) { + SnackbarManager.showMessage(throwable.toSnackbarMessage()) + } + logService.logNonFatalCrash(throwable) + }, + block = block + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/home/HomeScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/home/HomeScreen.kt new file mode 100644 index 0000000..c93527b --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/home/HomeScreen.kt @@ -0,0 +1,108 @@ +package be.ugent.sel.studeez.screens.home + +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.PrimaryScreenTemplate +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.common.composable.feed.Feed +import be.ugent.sel.studeez.common.composable.feed.FeedUiState +import be.ugent.sel.studeez.common.composable.feed.FeedViewModel +import be.ugent.sel.studeez.common.composable.navbar.NavigationBarActions +import be.ugent.sel.studeez.data.local.models.FeedEntry +import be.ugent.sel.studeez.resources + +@Composable +fun HomeRoute( + open: (String) -> Unit, + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions, + feedViewModel: FeedViewModel, +) { + val feedUiState by feedViewModel.uiState.collectAsState() + HomeScreen( + drawerActions = drawerActions, + open = open, + navigationBarActions = navigationBarActions, + feedUiState = feedUiState, + continueTask = { subjectId, taskId -> feedViewModel.continueTask(open, subjectId, taskId) }, + onEmptyFeedHelp = { feedViewModel.onEmptyFeedHelp(open) } + ) +} + +@Composable +fun HomeScreen( + open: (String) -> Unit, + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions, + feedUiState: FeedUiState, + continueTask: (String, String) -> Unit, + onEmptyFeedHelp: () -> Unit, +) { + PrimaryScreenTemplate( + title = resources().getString(R.string.home), + drawerActions = drawerActions, + navigationBarActions = navigationBarActions, + // TODO barAction = { FriendsAction() } + ) { + Feed(feedUiState, continueTask, onEmptyFeedHelp) + } +} + +@Composable +fun FriendsAction() { + IconButton(onClick = { /*TODO*/ }) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = resources().getString(R.string.friends) + ) + } +} + +@Preview +@Composable +fun HomeScreenPreview() { + HomeScreen( + drawerActions = DrawerActions({}, {}, {}, {}, {}), + navigationBarActions = NavigationBarActions({ false }, {}, {}, {}, {}, {}, {}, {}), + open = {}, + feedUiState = FeedUiState.Succes( + mapOf( + "08 May 2023" to listOf( + FeedEntry( + argb_color = 0xFFABD200, + subJectName = "Test Subject", + taskName = "Test Task", + totalStudyTime = 600, + ), + FeedEntry( + argb_color = 0xFFFFD200, + subJectName = "Test Subject", + taskName = "Test Task", + totalStudyTime = 20, + ), + ), + "09 May 2023" to listOf( + FeedEntry( + argb_color = 0xFFFD1200, + subJectName = "Test Subject", + taskName = "Test Task", + ), + FeedEntry( + argb_color = 0xFFFF5C89, + subJectName = "Test Subject", + taskName = "Test Task", + ), + ) + ) + ), + continueTask = { _, _ -> run {} }, + onEmptyFeedHelp = {} + ) +} diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginScreen.kt new file mode 100644 index 0000000..fe7524b --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginScreen.kt @@ -0,0 +1,110 @@ +package be.ugent.sel.studeez.screens.log_in + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.common.composable.* +import be.ugent.sel.studeez.common.ext.basicButton +import be.ugent.sel.studeez.common.ext.fieldModifier +import be.ugent.sel.studeez.common.ext.textButton +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.R.string as AppText + +data class LoginScreenActions( + val onEmailChange: (String) -> Unit, + val onPasswordChange: (String) -> Unit, + val onSignUpClick: () -> Unit, + val onSignInClick: () -> Unit, + val onForgotPasswordClick: () -> Unit, +) + +fun getLoginScreenActions( + viewModel: LoginViewModel, + openAndPopUp: (String, String) -> Unit, +): LoginScreenActions { + return LoginScreenActions( + onEmailChange = { viewModel.onEmailChange(it) }, + onPasswordChange = { viewModel.onPasswordChange(it) }, + onSignUpClick = { viewModel.onSignUpClick(openAndPopUp) }, + onSignInClick = { viewModel.onSignInClick(openAndPopUp) }, + onForgotPasswordClick = { viewModel.onForgotPasswordClick() } + ) +} + +@Composable +fun LoginRoute( + openAndPopUp: (String, String) -> Unit, + modifier: Modifier = Modifier, + viewModel: LoginViewModel, +) { + val uiState by viewModel.uiState + + LoginScreen( + modifier = modifier, + uiState = uiState, + loginScreenActions = getLoginScreenActions(viewModel = viewModel, openAndPopUp) + ) +} + +@Composable +fun LoginScreen( + modifier: Modifier = Modifier, + uiState: LoginUiState, + loginScreenActions: LoginScreenActions, +) { + SimpleScreenTemplate(title = resources().getString(AppText.sign_in)) { + Column( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmailField( + uiState.email, + loginScreenActions.onEmailChange, + Modifier.fieldModifier() + ) + PasswordField( + uiState.password, + loginScreenActions.onPasswordChange, + Modifier.fieldModifier() + ) + BasicButton( + AppText.sign_in, + Modifier.basicButton(), + onClick = loginScreenActions.onSignInClick, + ) + + BasicTextButton( + AppText.not_already_user, + Modifier.textButton(), + action = loginScreenActions.onSignUpClick, + ) + + BasicTextButton( + AppText.forgot_password, + Modifier.textButton(), + action = loginScreenActions.onForgotPasswordClick, + ) + } + } +} + +@Preview +@Composable +fun LoginScreenPreview() { + LoginScreen( + uiState = LoginUiState(), + loginScreenActions = LoginScreenActions({}, {}, {}, {}, {}) + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginUiState.kt b/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginUiState.kt new file mode 100644 index 0000000..737dd39 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginUiState.kt @@ -0,0 +1,6 @@ +package be.ugent.sel.studeez.screens.log_in + +data class LoginUiState( + val email: String = "", + val password: String = "" +) diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginViewModel.kt new file mode 100644 index 0000000..c7e88fa --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/log_in/LoginViewModel.kt @@ -0,0 +1,69 @@ +package be.ugent.sel.studeez.screens.log_in + +import androidx.compose.runtime.mutableStateOf +import be.ugent.sel.studeez.common.ext.isValidEmail +import be.ugent.sel.studeez.common.snackbar.SnackbarManager +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.navigation.StudeezDestinations.HOME_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.LOGIN_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.SIGN_UP_SCREEN +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import be.ugent.sel.studeez.R.string as AppText + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val accountDAO: AccountDAO, + logService: LogService +) : StudeezViewModel(logService) { + var uiState = mutableStateOf(LoginUiState()) + private set + + private val email + get() = uiState.value.email + private val password + get() = uiState.value.password + + fun onEmailChange(newValue: String) { + uiState.value = uiState.value.copy(email = newValue) + } + + fun onPasswordChange(newValue: String) { + uiState.value = uiState.value.copy(password = newValue) + } + + fun onSignInClick(openAndPopUp: (String, String) -> Unit) { + if (!email.isValidEmail()) { + SnackbarManager.showMessage(AppText.email_error) + return + } + + if (password.isBlank()) { + SnackbarManager.showMessage(AppText.empty_password_error) + return + } + + launchCatching { + accountDAO.signInWithEmailAndPassword(email, password) + openAndPopUp(HOME_SCREEN, LOGIN_SCREEN) // Is not reached when error occurs. + } + } + + fun onForgotPasswordClick() { + if (!email.isValidEmail()) { + SnackbarManager.showMessage(AppText.email_error) + return + } + + launchCatching { + accountDAO.sendRecoveryEmail(email) + SnackbarManager.showMessage(AppText.recovery_email_sent) + } + } + + fun onSignUpClick(openAndPopUp: (String, String) -> Unit) { + openAndPopUp(SIGN_UP_SCREEN, LOGIN_SCREEN) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditScreen.kt new file mode 100644 index 0000000..c6fcbaf --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditScreen.kt @@ -0,0 +1,86 @@ +package be.ugent.sel.studeez.screens.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.BasicTextButton +import be.ugent.sel.studeez.common.composable.LabelledInputField +import be.ugent.sel.studeez.common.composable.SecondaryScreenTemplate +import be.ugent.sel.studeez.common.ext.textButton +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.ui.theme.StudeezTheme + +data class EditProfileActions( + val onUserNameChange: (String) -> Unit, + val onSaveClick: () -> Unit, + val onDeleteClick: () -> Unit +) + +fun getEditProfileActions( + viewModel: ProfileEditViewModel, + openAndPopUp: (String, String) -> Unit, +): EditProfileActions { + return EditProfileActions( + onUserNameChange = { viewModel.onUsernameChange(it) }, + onSaveClick = { viewModel.onSaveClick() }, + onDeleteClick = { viewModel.onDeleteClick(openAndPopUp) }, + ) +} + +@Composable +fun EditProfileRoute( + goBack: () -> Unit, + openAndPopUp: (String, String) -> Unit, + viewModel: ProfileEditViewModel, +) { + val uiState by viewModel.uiState + EditProfileScreen( + goBack = goBack, + uiState = uiState, + editProfileActions = getEditProfileActions(viewModel, openAndPopUp) + ) +} + +@Composable +fun EditProfileScreen( + goBack: () -> Unit, + uiState: ProfileEditUiState, + editProfileActions: EditProfileActions, +) { + SecondaryScreenTemplate( + title = resources().getString(R.string.editing_profile), + popUp = goBack + ) { + Column { + LabelledInputField( + value = uiState.username, + onNewValue = editProfileActions.onUserNameChange, + label = R.string.username + ) + BasicTextButton( + text = R.string.save, + Modifier.textButton(), + action = { + editProfileActions.onSaveClick() + goBack() + } + ) + BasicTextButton( + text = R.string.delete_profile, + Modifier.textButton(), + action = editProfileActions.onDeleteClick + ) + } + } +} + +@Preview +@Composable +fun EditProfileScreenComposable() { + StudeezTheme { + EditProfileScreen({}, ProfileEditUiState(), EditProfileActions({}, {}, {})) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditUiState.kt b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditUiState.kt new file mode 100644 index 0000000..9ecaba3 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditUiState.kt @@ -0,0 +1,5 @@ +package be.ugent.sel.studeez.screens.profile + +data class ProfileEditUiState ( + val username: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditViewModel.kt new file mode 100644 index 0000000..cb270be --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileEditViewModel.kt @@ -0,0 +1,48 @@ +package be.ugent.sel.studeez.screens.profile + +import androidx.compose.runtime.mutableStateOf +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.snackbar.SnackbarManager +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.UserDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ProfileEditViewModel @Inject constructor( + private val accountDAO: AccountDAO, + private val userDAO: UserDAO, + logService: LogService +) : StudeezViewModel(logService) { + + var uiState = mutableStateOf(ProfileEditUiState()) + private set + + init { + launchCatching { + uiState.value = uiState.value.copy(username = userDAO.getUsername()!!) + } + } + + fun onUsernameChange(newValue: String) { + uiState.value = uiState.value.copy(username = newValue) + } + + fun onSaveClick() { + launchCatching { + userDAO.save(uiState.value.username) + SnackbarManager.showMessage(R.string.success) + } + } + + fun onDeleteClick(openAndPopUp: (String, String) -> Unit) { + launchCatching { + userDAO.deleteUserReferences() // Delete references + accountDAO.deleteAccount() // Delete authentication + } + openAndPopUp(StudeezDestinations.SIGN_UP_SCREEN, StudeezDestinations.EDIT_PROFILE_SCREEN) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileScreen.kt new file mode 100644 index 0000000..9c76337 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileScreen.kt @@ -0,0 +1,93 @@ +package be.ugent.sel.studeez.screens.profile + +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.Headline +import be.ugent.sel.studeez.common.composable.PrimaryScreenTemplate +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.common.composable.navbar.NavigationBarActions +import be.ugent.sel.studeez.resources +import kotlinx.coroutines.CoroutineScope +import be.ugent.sel.studeez.R.string as AppText + +data class ProfileActions( + val getUsername: suspend CoroutineScope.() -> String?, + val onEditProfileClick: () -> Unit, +) + +fun getProfileActions( + viewModel: ProfileViewModel, + open: (String) -> Unit, +): ProfileActions { + return ProfileActions( + getUsername = { viewModel.getUsername() }, + onEditProfileClick = { viewModel.onEditProfileClick(open) }, + ) +} + +@Composable +fun ProfileRoute( + open: (String) -> Unit, + viewModel: ProfileViewModel, + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions, +) { + ProfileScreen( + profileActions = getProfileActions(viewModel, open), + drawerActions = drawerActions, + navigationBarActions = navigationBarActions, + ) +} + +@Composable +fun ProfileScreen( + profileActions: ProfileActions, + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions, +) { + var username: String? by remember { mutableStateOf("") } + LaunchedEffect(key1 = Unit) { + username = profileActions.getUsername(this) + } + PrimaryScreenTemplate( + title = resources().getString(AppText.profile), + drawerActions = drawerActions, + navigationBarActions = navigationBarActions, + barAction = { EditAction(onClick = profileActions.onEditProfileClick) } + ) { + Headline(text = (username ?: resources().getString(R.string.no_username))) + } +} + +@Composable +fun EditAction( + onClick: () -> Unit +) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = resources().getString(AppText.edit_profile) + ) + + } +} + +@Preview +@Composable +fun ProfileScreenPreview() { + ProfileScreen( + profileActions = ProfileActions({ null }, {}), + drawerActions = DrawerActions({}, {}, {}, {}, {}), + navigationBarActions = NavigationBarActions({ false }, {}, {}, {}, {}, {}, {}, {}) + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileViewModel.kt new file mode 100644 index 0000000..e24defd --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/profile/ProfileViewModel.kt @@ -0,0 +1,24 @@ +package be.ugent.sel.studeez.screens.profile + +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.UserDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val userDAO: UserDAO, + logService: LogService +) : StudeezViewModel(logService) { + + suspend fun getUsername(): String? { + return userDAO.getUsername() + } + + fun onEditProfileClick(open: (String) -> Unit) { + open(StudeezDestinations.EDIT_PROFILE_SCREEN) + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session/InvisibleSessionManager.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session/InvisibleSessionManager.kt new file mode 100644 index 0000000..9051fa8 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session/InvisibleSessionManager.kt @@ -0,0 +1,29 @@ +package be.ugent.sel.studeez.screens.session + +import android.media.MediaPlayer +import kotlinx.coroutines.delay +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +@Singleton +object InvisibleSessionManager { + private var viewModel: SessionViewModel? = null + private lateinit var mediaPlayer: MediaPlayer + + fun setParameters(viewModel: SessionViewModel, mediaplayer: MediaPlayer) { + this.viewModel = viewModel + this.mediaPlayer = mediaplayer + } + + suspend fun updateTimer() { + viewModel?.let { + while (!it.getTimer().hasEnded()) { + delay(1.seconds) + it.getTimer().tick() + if (it.getTimer().hasCurrentCountdownEnded()) { + mediaPlayer.start() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session/SessionRoute.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session/SessionRoute.kt new file mode 100644 index 0000000..084ff43 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session/SessionRoute.kt @@ -0,0 +1,56 @@ +package be.ugent.sel.studeez.screens.session + +import android.media.MediaPlayer +import android.media.RingtoneManager +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer +import be.ugent.sel.studeez.screens.session.sessionScreens.AbstractSessionScreen +import be.ugent.sel.studeez.screens.session.sessionScreens.GetSessionScreen + +data class SessionActions( + val getTimer: () -> FunctionalTimer, + val getTask: () -> String, + val startMediaPlayer: () -> Unit, + val releaseMediaPlayer: () -> Unit, + val endSession: () -> Unit +) + +private fun getSessionActions( + viewModel: SessionViewModel, + openAndPopUp: (String, String) -> Unit, + mediaplayer: MediaPlayer, +): SessionActions { + return SessionActions( + getTimer = viewModel::getTimer, + getTask = viewModel::getTask, + endSession = { viewModel.endSession(openAndPopUp) }, + startMediaPlayer = mediaplayer::start, + releaseMediaPlayer = mediaplayer::release, + ) +} + +@Composable +fun SessionRoute( + open: (String) -> Unit, + openAndPopUp: (String, String) -> Unit, + viewModel: SessionViewModel, +) { + val context = LocalContext.current + val uri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val mediaplayer = MediaPlayer.create(context, uri) + mediaplayer.isLooping = false + + InvisibleSessionManager.setParameters( + viewModel = viewModel, + mediaplayer = mediaplayer + ) + + val sessionScreen: AbstractSessionScreen = viewModel.getTimer().accept(GetSessionScreen(mediaplayer)) + + sessionScreen( + open = open, + sessionActions = getSessionActions(viewModel, openAndPopUp, mediaplayer) + ) +} diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session/SessionViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session/SessionViewModel.kt new file mode 100644 index 0000000..0be4147 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session/SessionViewModel.kt @@ -0,0 +1,32 @@ +package be.ugent.sel.studeez.screens.session + +import be.ugent.sel.studeez.data.SelectedSessionReport +import be.ugent.sel.studeez.data.SelectedTask +import be.ugent.sel.studeez.data.SelectedTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SessionViewModel @Inject constructor( + private val selectedTimer: SelectedTimer, + private val sessionReport: SelectedSessionReport, + private val selectedTask: SelectedTask, + logService: LogService +) : StudeezViewModel(logService) { + fun getTimer(): FunctionalTimer { + return selectedTimer() + } + + fun getTask(): String { + return selectedTask().name + } + + fun endSession(openAndPopUp: (String, String) -> Unit) { + sessionReport.set(getTimer().getSessionReport(selectedTask().subjectId, selectedTask().id)) + openAndPopUp(StudeezDestinations.SESSION_RECAP, StudeezDestinations.SESSION_SCREEN) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/AbstractSessionScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/AbstractSessionScreen.kt new file mode 100644 index 0000000..08a8a72 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/AbstractSessionScreen.kt @@ -0,0 +1,150 @@ +package be.ugent.sel.studeez.screens.session.sessionScreens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer +import be.ugent.sel.studeez.screens.session.SessionActions +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +abstract class AbstractSessionScreen { + + @Composable + operator fun invoke( + open: (String) -> Unit, + sessionActions: SessionActions, + ) { + Column( + modifier = Modifier.padding(10.dp) + ) { + Timer( + sessionActions = sessionActions, + ) + Box( + contentAlignment = Alignment.Center, modifier = Modifier + .fillMaxWidth() + .padding(50.dp) + ) { + TextButton( + onClick = { + sessionActions.releaseMediaPlayer + sessionActions.endSession() + }, + modifier = Modifier + .padding(horizontal = 20.dp) + .border(1.dp, Color.Red, RoundedCornerShape(32.dp)) + .background(Color.Transparent) + ) { + Text( + text = "End session", + color = Color.Red, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + modifier = Modifier.padding(1.dp) + ) + } + } + } + } + + @Composable + fun Timer( + sessionActions: SessionActions, + ) { + var tikker by remember { mutableStateOf(false) } + LaunchedEffect(tikker) { + delay(1.seconds) + sessionActions.getTimer().tick() + callMediaPlayer() + tikker = !tikker + } + + val hms = sessionActions.getTimer().getHoursMinutesSeconds() + Column { + Text( + text = hms.toString(), + modifier = Modifier + .fillMaxWidth() + .padding(50.dp), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + fontSize = 40.sp, + ) + + Text( + text = motivationString(), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Light, + fontSize = 30.sp + ) + + MidSection() + + Box( + contentAlignment = Alignment.Center, modifier = Modifier + .fillMaxWidth() + .padding(50.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(16.dp) + .background(Color.Blue, RoundedCornerShape(32.dp)) + ) { + Text( + text = sessionActions.getTask(), + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 20.dp) + ) + } + } + } + } + + @Composable + abstract fun motivationString(): String + + @Composable + open fun MidSection() { + // Default has no midsection, unless overwritten. + } + + abstract fun callMediaPlayer() + +} + +@Preview +@Composable +fun TimerPreview() { + val sessionScreen = object : AbstractSessionScreen() { + @Composable + override fun motivationString(): String = "Test" + override fun callMediaPlayer() {} + + } + sessionScreen.Timer(sessionActions = SessionActions({ FunctionalEndlessTimer() }, { "Preview" }, {}, {}, {})) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/BreakSessionScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/BreakSessionScreen.kt new file mode 100644 index 0000000..f328c5f --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/BreakSessionScreen.kt @@ -0,0 +1,96 @@ +package be.ugent.sel.studeez.screens.session.sessionScreens + +import android.media.MediaPlayer +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.R.string as AppText + +class BreakSessionScreen( + private val funPomoDoroTimer: FunctionalPomodoroTimer, + private var mediaplayer: MediaPlayer? +) : AbstractSessionScreen() { + + @Composable + override fun MidSection() { + Dots() + } + + @Composable + fun Dots() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (funPomoDoroTimer.hasEnded()) { + repeat(funPomoDoroTimer.repeats) { + Dot(Color.Green) + } + } else { + repeat(funPomoDoroTimer.repeats - funPomoDoroTimer.breaksRemaining - 1) { + Dot(color = Color.DarkGray) + } + if (!funPomoDoroTimer.isInBreak) Dot(Color.Green) else Dot(Color.DarkGray) + repeat(funPomoDoroTimer.breaksRemaining) { + Dot(color = Color.Gray) + } + } + } + } + + @Composable + private fun Dot(color: Color) { + Box( + modifier = Modifier + .padding(5.dp) + .size(10.dp) + .clip(CircleShape) + .background(color) + ) + } + + @Composable + override fun motivationString(): String { + if (funPomoDoroTimer.isInBreak) { + return resources().getString(AppText.state_take_a_break) + } + + if (funPomoDoroTimer.hasEnded()) { + return resources().getString(AppText.state_done) + } + + return resources().getString(AppText.state_focus) + } + + override fun callMediaPlayer() { + if (funPomoDoroTimer.hasEnded()) { + mediaplayer?.let { it: MediaPlayer -> + it.setOnCompletionListener { + it.release() + mediaplayer = null + } + it.start() + } + } else if (funPomoDoroTimer.hasCurrentCountdownEnded()) { + mediaplayer?.start() + } + } +} + +@Preview +@Composable +fun MidsectionPreview() { + val funPomoDoroTimer = FunctionalPomodoroTimer(15, 60, 5) + val breakSessionScreen = BreakSessionScreen(funPomoDoroTimer, MediaPlayer()) + breakSessionScreen.MidSection() +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/CustomSessionScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/CustomSessionScreen.kt new file mode 100644 index 0000000..7fc60bc --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/CustomSessionScreen.kt @@ -0,0 +1,35 @@ +package be.ugent.sel.studeez.screens.session.sessionScreens + +import android.media.MediaPlayer +import androidx.compose.runtime.Composable +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.R.string as AppText + + +class CustomSessionScreen( + private val functionalTimer: FunctionalCustomTimer, + private var mediaplayer: MediaPlayer? +): AbstractSessionScreen() { + + @Composable + override fun motivationString(): String { + if (functionalTimer.hasEnded()) { + return resources().getString(AppText.state_done) + } + return resources().getString(AppText.state_focus) + } + + override fun callMediaPlayer() { + if (functionalTimer.hasEnded()) { + mediaplayer?.let { it: MediaPlayer -> + it.setOnCompletionListener { + it.release() + mediaplayer = null + } + it.start() + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/EndlessSessionScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/EndlessSessionScreen.kt new file mode 100644 index 0000000..be67cff --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/EndlessSessionScreen.kt @@ -0,0 +1,16 @@ +package be.ugent.sel.studeez.screens.session.sessionScreens + +import androidx.compose.runtime.Composable +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.R.string as AppText + + +class EndlessSessionScreen : AbstractSessionScreen() { + + @Composable + override fun motivationString(): String { + return resources().getString(AppText.state_focus) + } + + override fun callMediaPlayer() {} +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/GetSessionScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/GetSessionScreen.kt new file mode 100644 index 0000000..98b2d5e --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session/sessionScreens/GetSessionScreen.kt @@ -0,0 +1,18 @@ +package be.ugent.sel.studeez.screens.session.sessionScreens + +import android.media.MediaPlayer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimerVisitor + +class GetSessionScreen(private val mediaplayer: MediaPlayer?) : FunctionalTimerVisitor { + override fun visitFunctionalCustomTimer(functionalCustomTimer: FunctionalCustomTimer): AbstractSessionScreen = + CustomSessionScreen(functionalCustomTimer, mediaplayer) + + override fun visitFunctionalEndlessTimer(functionalEndlessTimer: FunctionalEndlessTimer): AbstractSessionScreen = + EndlessSessionScreen() + + override fun visitFunctionalBreakTimer(functionalPomodoroTimer: FunctionalPomodoroTimer): AbstractSessionScreen = + BreakSessionScreen(functionalPomodoroTimer, mediaplayer) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session_recap/SessionRecapScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session_recap/SessionRecapScreen.kt new file mode 100644 index 0000000..3a1e85f --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session_recap/SessionRecapScreen.kt @@ -0,0 +1,145 @@ +package be.ugent.sel.studeez.screens.session_recap + +import androidx.compose.foundation.layout.* +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.BasicButton +import be.ugent.sel.studeez.common.composable.ImageBackgroundButton +import be.ugent.sel.studeez.common.ext.basicButton +import be.ugent.sel.studeez.data.local.models.SessionReport +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import be.ugent.sel.studeez.data.local.models.timer_functional.Time + +data class SessionRecapActions( + val getSessionReport: () -> SessionReport, + val saveSession: () -> Unit, + val discardSession: () -> Unit +) + +fun getSessionRecapActions( + viewModel: SessionRecapViewModel, + clearAndNavigate: (String) -> Unit, +): SessionRecapActions { + return SessionRecapActions( + viewModel::getSessionReport, + { viewModel.saveSession(clearAndNavigate) }, + { viewModel.discardSession(clearAndNavigate) } + ) +} + +@Composable +fun SessionRecapRoute( + clearAndNavigate: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SessionRecapViewModel, +) { + SessionRecapScreen( + modifier = modifier, + getSessionRecapActions(viewModel, clearAndNavigate) + ) +} + +@Composable +fun SessionRecapScreen(modifier: Modifier, sessionRecapActions: SessionRecapActions) { + val sessionReport: SessionReport = sessionRecapActions.getSessionReport() + val studyTime: Int = sessionReport.studyTime + val hms: HoursMinutesSeconds = Time(studyTime).getAsHMS() + val (background1, setBackground1) = remember { mutableStateOf(Color.Transparent) } + val (background2, setBackground2) = remember { mutableStateOf(Color.Transparent) } + Column( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.congrats, hms), + modifier = Modifier + .fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Light, + fontSize = 30.sp, + + ) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.how_did_it_go), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Light, + fontSize = 30.sp + ) + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterHorizontally) + ) { + ImageBackgroundButton( + paint = painterResource(id = R.drawable.mood_1), + str = stringResource(id = R.string.good), + background2 = background2, + setBackground1 = setBackground2, + setBackground2 = setBackground1 + ) + + ImageBackgroundButton( + paint = painterResource(id = R.drawable.mood_2), + str = stringResource(id = R.string.bad), + background2 = background1, + setBackground1 = setBackground1, + setBackground2 = setBackground2 + ) + } + } + + Column { + BasicButton( + R.string.save, Modifier.basicButton() + ) { + sessionRecapActions.saveSession() + } + BasicButton( + R.string.discard, Modifier.basicButton(), + colors = ButtonDefaults.buttonColors(backgroundColor = Color.Red) + ) { + sessionRecapActions.discardSession() + } + } + } +} + +@Preview +@Composable +fun SessionRecapScreenPreview() { + SessionRecapScreen( + modifier = Modifier, + sessionRecapActions = SessionRecapActions( + { SessionReport( + studyTime = 100, + ) }, + {}, + {}, + ) + ) +} diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/session_recap/SessionRecapViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/session_recap/SessionRecapViewModel.kt new file mode 100644 index 0000000..bf11b93 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/session_recap/SessionRecapViewModel.kt @@ -0,0 +1,38 @@ +package be.ugent.sel.studeez.screens.session_recap + +import be.ugent.sel.studeez.data.SelectedSessionReport +import be.ugent.sel.studeez.data.SelectedTask +import be.ugent.sel.studeez.data.local.models.SessionReport +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.SessionDAO +import be.ugent.sel.studeez.domain.TaskDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SessionRecapViewModel @Inject constructor( + private val selectedSessionReport: SelectedSessionReport, + private val sessionDAO: SessionDAO, + private val taskDAO: TaskDAO, + private val selectedTask: SelectedTask, + logService: LogService +) : StudeezViewModel(logService) { + + fun getSessionReport(): SessionReport { + return selectedSessionReport() + } + + fun saveSession(open: (String) -> Unit) { + sessionDAO.saveSession(getSessionReport()) + val newTask = + selectedTask().copy(time = selectedTask().time + selectedSessionReport().studyTime) + taskDAO.updateTask(newTask) + open(StudeezDestinations.HOME_SCREEN) + } + + fun discardSession(open: (String) -> Unit) { + open(StudeezDestinations.HOME_SCREEN) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/sessions/SessionsScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/sessions/SessionsScreen.kt new file mode 100644 index 0000000..fe60ca8 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/sessions/SessionsScreen.kt @@ -0,0 +1,42 @@ +package be.ugent.sel.studeez.screens.sessions + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import be.ugent.sel.studeez.common.composable.PrimaryScreenTemplate +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.common.composable.navbar.NavigationBarActions +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun SessionsRoute( + // viewModel: SessionsViewModel, + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions +) { + SessionsScreen( + drawerActions = drawerActions, + navigationBarActions = navigationBarActions + ) +} + +@Composable +fun SessionsScreen( + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions +) { + PrimaryScreenTemplate( + title = resources().getString(AppText.upcoming_sessions), + drawerActions = drawerActions, + navigationBarActions = navigationBarActions + ) { + Text( + text = resources().getString(AppText.sessions_temp_description), + modifier = Modifier.fillMaxSize(), + textAlign = TextAlign.Center + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/settings/SettingsScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..e1098b7 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/settings/SettingsScreen.kt @@ -0,0 +1,37 @@ +package be.ugent.sel.studeez.screens.settings + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import be.ugent.sel.studeez.common.composable.DrawerScreenTemplate +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun SettingsRoute( + // viewModel: SettingsViewModel, + drawerActions: DrawerActions +) { + SettingsScreen( + drawerActions = drawerActions + ) +} + +@Composable +fun SettingsScreen( + drawerActions: DrawerActions +) { + DrawerScreenTemplate( + title = resources().getString(AppText.settings), + drawerActions = drawerActions + ) { + Text( + text = resources().getString(AppText.settings_temp_description), + modifier = Modifier.fillMaxSize(), + textAlign = TextAlign.Center + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpScreen.kt new file mode 100644 index 0000000..038bee2 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpScreen.kt @@ -0,0 +1,121 @@ +package be.ugent.sel.studeez.screens.sign_up + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.common.composable.BasicButton +import be.ugent.sel.studeez.common.composable.BasicTextButton +import be.ugent.sel.studeez.common.composable.EmailField +import be.ugent.sel.studeez.common.composable.PasswordField +import be.ugent.sel.studeez.common.composable.RepeatPasswordField +import be.ugent.sel.studeez.common.composable.SimpleScreenTemplate +import be.ugent.sel.studeez.common.composable.UsernameField +import be.ugent.sel.studeez.common.ext.basicButton +import be.ugent.sel.studeez.common.ext.fieldModifier +import be.ugent.sel.studeez.common.ext.textButton +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.R.string as AppText + +data class SignUpActions( + val onUserNameChange: (String) -> Unit, + val onEmailChange: (String) -> Unit, + val onPasswordChange: (String) -> Unit, + val onRepeatPasswordChange: (String) -> Unit, + val onSignUpClick: () -> Unit, + val onLoginClick: () -> Unit, +) + +fun getSignUpActions( + viewModel: SignUpViewModel, + openAndPopUp: (String, String) -> Unit, +): SignUpActions { + return SignUpActions( + onUserNameChange = { viewModel.onUsernameChange(it) }, + onEmailChange = { viewModel.onEmailChange(it) }, + onPasswordChange = { viewModel.onPasswordChange(it) }, + onRepeatPasswordChange = { viewModel.onRepeatPasswordChange(it) }, + onSignUpClick = { viewModel.onSignUpClick(openAndPopUp) }, + onLoginClick = { viewModel.onLoginClick(openAndPopUp) }, + ) +} + +@Composable +fun SignUpRoute( + openAndPopUp: (String, String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SignUpViewModel, +) { + val uiState by viewModel.uiState + SignUpScreen( + modifier = modifier, + uiState, + getSignUpActions(viewModel, openAndPopUp) + ) +} + +@Composable +fun SignUpScreen( + modifier: Modifier = Modifier, + uiState: SignUpUiState, + signUpActions: SignUpActions, +) { + val fieldModifier = Modifier.fieldModifier() + SimpleScreenTemplate(title = resources().getString(AppText.create_account)) { + Column( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + UsernameField( + uiState.username, + signUpActions.onUserNameChange, + fieldModifier + ) + EmailField( + uiState.email, + signUpActions.onEmailChange, + fieldModifier + ) + PasswordField( + uiState.password, + signUpActions.onPasswordChange, + fieldModifier + ) + RepeatPasswordField( + uiState.repeatPassword, + signUpActions.onRepeatPasswordChange, + fieldModifier + ) + BasicButton( + AppText.create_account, + Modifier.basicButton(), + onClick = signUpActions.onSignUpClick + ) + BasicTextButton( + AppText.already_user, + Modifier.textButton(), + action = signUpActions.onLoginClick + ) + } + } +} + +@Preview +@Composable +fun SignUpPreview() { + SignUpScreen( + uiState = SignUpUiState(), + signUpActions = SignUpActions({}, {}, {}, {}, {}, {}) + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpUiState.kt b/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpUiState.kt new file mode 100644 index 0000000..48a5178 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpUiState.kt @@ -0,0 +1,8 @@ +package be.ugent.sel.studeez.screens.sign_up + +data class SignUpUiState( + val username: String = "", + val email: String = "", + val password: String = "", + val repeatPassword: String = "" +) diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpViewModel.kt new file mode 100644 index 0000000..a08d063 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/sign_up/SignUpViewModel.kt @@ -0,0 +1,77 @@ +package be.ugent.sel.studeez.screens.sign_up + +import androidx.compose.runtime.mutableStateOf +import be.ugent.sel.studeez.common.ext.isValidEmail +import be.ugent.sel.studeez.common.ext.isValidPassword +import be.ugent.sel.studeez.common.ext.passwordMatches +import be.ugent.sel.studeez.common.snackbar.SnackbarManager +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.UserDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations.HOME_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.LOGIN_SCREEN +import be.ugent.sel.studeez.navigation.StudeezDestinations.SIGN_UP_SCREEN +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import be.ugent.sel.studeez.R.string as AppText + +@HiltViewModel +class SignUpViewModel @Inject constructor( + private val accountDAO: AccountDAO, + private val userDAO: UserDAO, + logService: LogService + ) : StudeezViewModel(logService) { + var uiState = mutableStateOf(SignUpUiState()) + private set + + private val username + get() = uiState.value.username + private val email + get() = uiState.value.email + private val password + get() = uiState.value.password + + fun onUsernameChange(newValue: String) { + uiState.value = uiState.value.copy(username = newValue) + } + fun onEmailChange(newValue: String) { + uiState.value = uiState.value.copy(email = newValue) + } + + fun onPasswordChange(newValue: String) { + uiState.value = uiState.value.copy(password = newValue) + } + + fun onRepeatPasswordChange(newValue: String) { + uiState.value = uiState.value.copy(repeatPassword = newValue) + } + + fun onSignUpClick(openAndPopUp: (String, String) -> Unit) { + if (!email.isValidEmail()) { + SnackbarManager.showMessage(AppText.email_error) + return + } + + if (!password.isValidPassword()) { + SnackbarManager.showMessage(AppText.password_error) + return + } + + if (!password.passwordMatches(uiState.value.repeatPassword)) { + SnackbarManager.showMessage(AppText.password_match_error) + return + } + + launchCatching { + accountDAO.signUpWithEmailAndPassword(email, password) + accountDAO.signInWithEmailAndPassword(email, password) + userDAO.save(username) + openAndPopUp(HOME_SCREEN, SIGN_UP_SCREEN) + } + } + + fun onLoginClick(openAndPopUp: (String, String) -> Unit) { + openAndPopUp(LOGIN_SCREEN, SIGN_UP_SCREEN) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/splash/SplashScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/splash/SplashScreen.kt new file mode 100644 index 0000000..e70c67b --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/splash/SplashScreen.kt @@ -0,0 +1,87 @@ +package be.ugent.sel.studeez.screens.splash + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.common.composable.BasicButton +import be.ugent.sel.studeez.common.ext.basicButton +import kotlinx.coroutines.delay +import be.ugent.sel.studeez.R.string as AppText + +private const val SPLASH_TIMEOUT = 500L + +@Composable +fun SplashRoute( + openAndPopUp: (String, String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SplashViewModel, +) { + SplashScreen( + modifier = modifier, + onAppStart = { viewModel.onAppStart(openAndPopUp) }, + showError = viewModel.showError.value + ) +} + +@Composable +fun SplashScreen( + modifier: Modifier = Modifier, + onAppStart: () -> Unit, + showError: Boolean, +) { + Column( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + .background(color = MaterialTheme.colors.background) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (showError) { + Text(text = stringResource(AppText.generic_error)) + BasicButton( + AppText.try_again, + Modifier.basicButton(), + onClick = onAppStart, + ) + } else { + CircularProgressIndicator(color = MaterialTheme.colors.onBackground) + } + } + LaunchedEffect(true) { + delay(SPLASH_TIMEOUT) + onAppStart() + } +} + +@Preview +@Composable +fun SplashPreview() { + SplashScreen( + onAppStart = {}, + showError = false, + ) +} + +@Preview +@Composable +fun SplashErrorPreview() { + SplashScreen( + onAppStart = {}, + showError = true, + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/splash/SplashViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/splash/SplashViewModel.kt new file mode 100644 index 0000000..4e1dfbd --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/splash/SplashViewModel.kt @@ -0,0 +1,33 @@ +package be.ugent.sel.studeez.screens.splash + +import androidx.compose.runtime.mutableStateOf +import be.ugent.sel.studeez.domain.AccountDAO +import be.ugent.sel.studeez.domain.ConfigurationService +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val accountDAO: AccountDAO, + private val configurationService: ConfigurationService, + logService: LogService +) : StudeezViewModel(logService) { + val showError = mutableStateOf(false) + + init { + launchCatching { configurationService.fetchConfiguration() } + } + + fun onAppStart(openAndPopUp: (String, String) -> Unit) { + + showError.value = false + if (accountDAO.hasUser) { + openAndPopUp(StudeezDestinations.HOME_SCREEN, StudeezDestinations.SPLASH_SCREEN) + } else{ + openAndPopUp(StudeezDestinations.SIGN_UP_SCREEN, StudeezDestinations.SPLASH_SCREEN) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectScreen.kt new file mode 100644 index 0000000..accb7de --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectScreen.kt @@ -0,0 +1,140 @@ +package be.ugent.sel.studeez.screens.subjects + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.common.composable.NewTaskSubjectButton +import be.ugent.sel.studeez.common.composable.PrimaryScreenTemplate +import be.ugent.sel.studeez.common.composable.StealthButton +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.common.composable.navbar.NavigationBarActions +import be.ugent.sel.studeez.common.composable.tasks.SubjectEntry +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.navigation.StudeezDestinations +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun SubjectRoute( + open: (String) -> Unit, + viewModel: SubjectViewModel, + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions, +) { + val uiState by viewModel.uiState.collectAsState() + SubjectScreen( + drawerActions = drawerActions, + navigationBarActions = navigationBarActions, + onAddSubject = { viewModel.onAddSubject(open) }, + onViewSubject = { viewModel.onSelectSubject(it) { open(StudeezDestinations.TASKS_SCREEN) } }, + getTaskCount = viewModel::getTaskCount, + getCompletedTaskCount = viewModel::getCompletedTaskCount, + getStudyTime = viewModel::getStudyTime, + uiState = uiState, + ) +} + +@Composable +fun SubjectScreen( + drawerActions: DrawerActions, + navigationBarActions: NavigationBarActions, + onAddSubject: () -> Unit, + onViewSubject: (Subject) -> Unit, + getTaskCount: (Subject) -> Flow, + getCompletedTaskCount: (Subject) -> Flow, + getStudyTime: (Subject) -> Flow, + uiState: SubjectUiState, +) { + PrimaryScreenTemplate( + title = stringResource(AppText.my_subjects), + drawerActions = drawerActions, + navigationBarActions = navigationBarActions, + barAction = {}, + ) { + when (uiState) { + SubjectUiState.Loading -> Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator(color = MaterialTheme.colors.onBackground) + } + is SubjectUiState.Succes -> { + Column( + modifier = Modifier.padding(top = 5.dp) + ) { + NewTaskSubjectButton(onClick = onAddSubject, AppText.new_subject) + LazyColumn { + items(uiState.subjects) { subject -> + SubjectEntry( + subject = subject, + getTaskCount = { getTaskCount(subject) }, + getCompletedTaskCount = { getCompletedTaskCount(subject) }, + getStudyTime = { getStudyTime(subject) }, + ) { + StealthButton( + text = AppText.view_tasks, + modifier = Modifier + .padding(start = 10.dp, end = 5.dp) + .weight(1f) + ) { + onViewSubject(subject) + } + } + } + } + } + } + } + } +} + +@Preview +@Composable +fun SubjectScreenPreview() { + SubjectScreen( + drawerActions = DrawerActions({}, {}, {}, {}, {}), + navigationBarActions = NavigationBarActions({ false }, {}, {}, {}, {}, {}, {}, {}), + onAddSubject = {}, + onViewSubject = {}, + getTaskCount = { flowOf() }, + getCompletedTaskCount = { flowOf() }, + getStudyTime = { flowOf() }, + uiState = SubjectUiState.Succes( + listOf( + Subject( + name = "Test Subject", + argb_color = 0xFFFFD200, + ) + ) + ) + ) +} + +@Preview +@Composable +fun SubjectScreenLoadingPreview() { + SubjectScreen( + drawerActions = DrawerActions({}, {}, {}, {}, {}), + navigationBarActions = NavigationBarActions({ false }, {}, {}, {}, {}, {}, {}, {}), + onAddSubject = {}, + onViewSubject = {}, + getTaskCount = { flowOf() }, + getCompletedTaskCount = { flowOf() }, + getStudyTime = { flowOf() }, + uiState = SubjectUiState.Loading, + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectUiState.kt b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectUiState.kt new file mode 100644 index 0000000..2e44e27 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectUiState.kt @@ -0,0 +1,8 @@ +package be.ugent.sel.studeez.screens.subjects + +import be.ugent.sel.studeez.data.local.models.task.Subject + +sealed interface SubjectUiState { + object Loading : SubjectUiState + data class Succes(val subjects: List) : SubjectUiState +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectViewModel.kt new file mode 100644 index 0000000..0c5b354 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/SubjectViewModel.kt @@ -0,0 +1,49 @@ +package be.ugent.sel.studeez.screens.subjects + +import androidx.lifecycle.viewModelScope +import be.ugent.sel.studeez.data.SelectedSubject +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.SubjectDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@HiltViewModel +class SubjectViewModel @Inject constructor( + private val subjectDAO: SubjectDAO, + private val selectedSubject: SelectedSubject, + logService: LogService, +) : StudeezViewModel(logService) { + + val uiState: StateFlow = subjectDAO.getSubjects() + .map { SubjectUiState.Succes(it) } + .stateIn( + scope = viewModelScope, + initialValue = SubjectUiState.Loading, + started = SharingStarted.Eagerly, + ) + + fun onAddSubject(open: (String) -> Unit) { + open(StudeezDestinations.ADD_SUBJECT_FORM) + } + + fun getTaskCount(subject: Subject): Flow { + return subjectDAO.getTaskCount(subject) + } + + fun getCompletedTaskCount(subject: Subject): Flow { + return subjectDAO.getCompletedTaskCount(subject) + } + + fun getStudyTime(subject: Subject): Flow { + return subjectDAO.getStudyTime(subject) + } + + fun onSelectSubject(subject: Subject, open: () -> Unit) { + selectedSubject.set(subject) + open() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormScreen.kt new file mode 100644 index 0000000..9e787dd --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormScreen.kt @@ -0,0 +1,148 @@ +package be.ugent.sel.studeez.screens.subjects.form + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.common.composable.BasicButton +import be.ugent.sel.studeez.common.composable.DeleteButton +import be.ugent.sel.studeez.common.composable.FormComposable +import be.ugent.sel.studeez.common.composable.LabelledInputField +import be.ugent.sel.studeez.common.composable.SecondaryScreenTemplate +import be.ugent.sel.studeez.common.ext.basicButton +import be.ugent.sel.studeez.common.ext.fieldModifier +import be.ugent.sel.studeez.common.ext.generateRandomArgb +import be.ugent.sel.studeez.resources +import kotlinx.coroutines.launch +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun SubjectCreateRoute( + goBack: () -> Unit, + openAndPopUp: (String, String) -> Unit, + viewModel: SubjectCreateFormViewModel, +) { + val uiState by viewModel.uiState + SubjectForm( + title = AppText.new_subject, + goBack = goBack, + uiState = uiState, + onConfirm = { viewModel.onCreate(openAndPopUp) }, + onNameChange = viewModel::onNameChange, + onColorChange = viewModel::onColorChange, + ) +} + +@Composable +fun SubjectEditRoute( + goBack: () -> Unit, + openAndPopUp: (String, String) -> Unit, + viewModel: SubjectEditFormViewModel, +) { + val uiState by viewModel.uiState + val coroutineScope = rememberCoroutineScope() + SubjectForm( + title = AppText.edit_subject, + goBack = goBack, + uiState = uiState, + onConfirm = { viewModel.onEdit(openAndPopUp) }, + onNameChange = viewModel::onNameChange, + onColorChange = viewModel::onColorChange, + ) { + DeleteButton(text = AppText.delete_subject) { + coroutineScope.launch { + viewModel.onDelete(openAndPopUp) + } + } + } +} + +@Composable +fun SubjectForm( + @StringRes title: Int, + goBack: () -> Unit, + uiState: SubjectFormUiState, + onConfirm: () -> Unit, + onNameChange: (String) -> Unit, + onColorChange: (Long) -> Unit, + extraButton: @Composable () -> Unit = {}, +) { + FormComposable( + title = resources().getString(title), + popUp = goBack, + ) { + Column { + LabelledInputField( + singleLine = true, + value = uiState.name, + onNewValue = onNameChange, + label = AppText.name, + ) + ColorPicker(onColorChange, uiState) + BasicButton( + text = AppText.confirm, + modifier = Modifier.basicButton(), + onClick = onConfirm, + ) + extraButton() + } + } +} + +@Composable +fun ColorPicker( + onColorChange: (Long) -> Unit, + uiState: SubjectFormUiState, +) { + Button( + onClick = { onColorChange(Color.generateRandomArgb()) }, + modifier = Modifier.fieldModifier(), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color(uiState.color), + contentColor = Color.White, + ), + shape = RoundedCornerShape(4.dp), + ) { + Text(text = stringResource(id = AppText.regenerate_color)) + } +} + +@Preview +@Composable +fun AddSubjectFormPreview() { + SubjectForm( + title = AppText.new_subject, + goBack = {}, + uiState = SubjectFormUiState(), + onConfirm = {}, + onNameChange = {}, + onColorChange = {}, + ) +} + +@Preview +@Composable +fun EditSubjectFormPreview() { + SubjectForm( + title = AppText.edit_subject, + goBack = {}, + uiState = SubjectFormUiState( + name = "Test Subject", + ), + onConfirm = {}, + onNameChange = {}, + onColorChange = {}, + ) { + DeleteButton(text = AppText.delete_subject) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormUiState.kt b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormUiState.kt new file mode 100644 index 0000000..10a18e8 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormUiState.kt @@ -0,0 +1,9 @@ +package be.ugent.sel.studeez.screens.subjects.form + +import androidx.compose.ui.graphics.Color +import be.ugent.sel.studeez.common.ext.generateRandomArgb + +data class SubjectFormUiState( + val name: String = "", + val color: Long = Color.generateRandomArgb(), +) \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormViewModel.kt new file mode 100644 index 0000000..7a1554b --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/form/SubjectFormViewModel.kt @@ -0,0 +1,91 @@ +package be.ugent.sel.studeez.screens.subjects.form + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.Color +import be.ugent.sel.studeez.common.ext.generateRandomArgb +import be.ugent.sel.studeez.data.SelectedSubject +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.SubjectDAO +import be.ugent.sel.studeez.domain.TaskDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +abstract class SubjectFormViewModel( + protected val subjectDAO: SubjectDAO, + protected val selectedSubject: SelectedSubject, + logService: LogService, +) : StudeezViewModel(logService) { + abstract val uiState: MutableState + + protected val name: String + get() = uiState.value.name + + protected val color: Long + get() = uiState.value.color + + fun onNameChange(newValue: String) { + uiState.value = uiState.value.copy(name = newValue) + } + + fun onColorChange(newValue: Long) { + uiState.value = uiState.value.copy(color = newValue) + } +} + +@HiltViewModel +class SubjectCreateFormViewModel @Inject constructor( + subjectDAO: SubjectDAO, + selectedSubject: SelectedSubject, + logService: LogService, +) : SubjectFormViewModel(subjectDAO, selectedSubject, logService) { + override val uiState = mutableStateOf(SubjectFormUiState()) + + fun onCreate(openAndPopUp: (String, String) -> Unit) { + val newSubject = Subject( + name = name, + argb_color = color, + ) + subjectDAO.saveSubject( + newSubject + ) + // TODO open newly created subject +// selectedSubject.set(newSubject) +// open(StudeezDestinations.TASKS_SCREEN) + openAndPopUp(StudeezDestinations.SUBJECT_SCREEN, StudeezDestinations.ADD_SUBJECT_FORM) + } +} + +@HiltViewModel +class SubjectEditFormViewModel @Inject constructor( + subjectDAO: SubjectDAO, + private val taskDAO: TaskDAO, + selectedSubject: SelectedSubject, + logService: LogService, +) : SubjectFormViewModel(subjectDAO, selectedSubject, logService) { + override val uiState = mutableStateOf( + SubjectFormUiState( + name = selectedSubject().name, + color = selectedSubject().argb_color + ) + ) + + suspend fun onDelete(openAndPopUp: (String, String) -> Unit) { + subjectDAO.archiveSubject(selectedSubject()) + openAndPopUp(StudeezDestinations.SUBJECT_SCREEN, StudeezDestinations.EDIT_SUBJECT_FORM) + } + + fun onEdit(openAndPopUp: (String, String) -> Unit) { + selectedSubject.set( + selectedSubject().copy( + name = name, + argb_color = color, + ) + ) + subjectDAO.updateSubject(selectedSubject()) + openAndPopUp(StudeezDestinations.TASKS_SCREEN, StudeezDestinations.EDIT_SUBJECT_FORM) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/subjects/select/SubjectSelectionScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/select/SubjectSelectionScreen.kt new file mode 100644 index 0000000..099786e --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/subjects/select/SubjectSelectionScreen.kt @@ -0,0 +1,128 @@ +package be.ugent.sel.studeez.screens.subjects.select + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.SecondaryScreenTemplate +import be.ugent.sel.studeez.common.composable.StealthButton +import be.ugent.sel.studeez.common.composable.tasks.SubjectEntry +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.subjects.SubjectUiState +import be.ugent.sel.studeez.screens.subjects.SubjectViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +@Composable +fun SubjectSelectionRoute( + open: (String) -> Unit, + goBack: () -> Unit, + viewModel: SubjectViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + SubjectSelectionScreen( + onViewSubject = { viewModel.onSelectSubject(it) { open(StudeezDestinations.ADD_TASK_FORM) } }, + getTaskCount = viewModel::getTaskCount, + getCompletedTaskCount = viewModel::getCompletedTaskCount, + getStudyTime = viewModel::getStudyTime, + goBack = goBack, + uiState = uiState, + ) +} + +@Composable +fun SubjectSelectionScreen( + goBack: () -> Unit, + onViewSubject: (Subject) -> Unit, + getTaskCount: (Subject) -> Flow, + getCompletedTaskCount: (Subject) -> Flow, + getStudyTime: (Subject) -> Flow, + uiState: SubjectUiState, +) { + SecondaryScreenTemplate( + title = stringResource(R.string.select_subject_title), + barAction = {}, + popUp = goBack, + ) { + when (uiState) { + SubjectUiState.Loading -> Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator(color = MaterialTheme.colors.onBackground) + } + is SubjectUiState.Succes -> { + Column( + modifier = Modifier.padding(top = 5.dp) + ) { + LazyColumn { + items(uiState.subjects) { subject -> + SubjectEntry( + subject = subject, + getTaskCount = { getTaskCount(subject) }, + getCompletedTaskCount = { getCompletedTaskCount(subject) }, + getStudyTime = { getStudyTime(subject) }, + ) { + StealthButton( + text = R.string.select_subject, + modifier = Modifier + .padding(start = 4.dp, end = 4.dp) + .weight(1f) + ) { + onViewSubject(subject) + } + } + } + } + } + } + } + } +} + +@Preview +@Composable +fun SubjectScreenPreview() { + SubjectSelectionScreen( + goBack = {}, + onViewSubject = {}, + getTaskCount = { flowOf() }, + getCompletedTaskCount = { flowOf() }, + getStudyTime = { flowOf() }, + uiState = SubjectUiState.Succes( + listOf( + Subject( + name = "Test Subject", + argb_color = 0xFFFFD200, + ) + ) + ) + ) +} + +@Preview +@Composable +fun SubjectScreenLoadingPreview() { + SubjectSelectionScreen( + goBack = {}, + onViewSubject = {}, + getTaskCount = { flowOf() }, + getCompletedTaskCount = { flowOf() }, + getStudyTime = { flowOf() }, + uiState = SubjectUiState.Loading, + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/tasks/TaskScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/TaskScreen.kt new file mode 100644 index 0000000..516b836 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/TaskScreen.kt @@ -0,0 +1,125 @@ +package be.ugent.sel.studeez.screens.tasks + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.common.composable.NewTaskSubjectButton +import be.ugent.sel.studeez.common.composable.SecondaryScreenTemplate +import be.ugent.sel.studeez.common.composable.tasks.TaskEntry +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.data.local.models.task.Task +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import be.ugent.sel.studeez.R.string as AppText + +data class TaskActions( + val addTask: () -> Unit, + val getSubject: () -> Subject, + val getTasks: () -> Flow>, + val onCheckTask: (Task, Boolean) -> Unit, + val editSubject: () -> Unit, + val startTask: (Task) -> Unit, + val archiveTask: (Task) -> Unit, +) + +fun getTaskActions(viewModel: TaskViewModel, open: (String) -> Unit): TaskActions { + return TaskActions( + addTask = { viewModel.addTask(open) }, + getTasks = viewModel::getTasks, + getSubject = viewModel::getSelectedSubject, + onCheckTask = { task, isChecked -> viewModel.toggleTaskCompleted(task, isChecked) }, + editSubject = { viewModel.editSubject(open) }, + startTask = { task -> viewModel.startTask(task, open) }, + archiveTask = viewModel::archiveTask + ) +} + +@Composable +fun TaskRoute( + goBack: () -> Unit, + open: (String) -> Unit, + viewModel: TaskViewModel, +) { + TaskScreen( + goBack = goBack, + taskActions = getTaskActions(viewModel = viewModel, open = open), + ) +} + +@Composable +fun TaskScreen( + goBack: () -> Unit, + taskActions: TaskActions, +) { + SecondaryScreenTemplate( + title = taskActions.getSubject().name, + popUp = goBack, + barAction = { EditAction(onClick = taskActions.editSubject) } + ) { + val tasks = taskActions.getTasks().collectAsState(initial = emptyList()) + Column( + modifier = Modifier.padding(top = 5.dp) + ) { + NewTaskSubjectButton(onClick = taskActions.addTask, AppText.new_task) + LazyColumn { + items(tasks.value.filter { !it.completed }) { + TaskEntry( + task = it, + onCheckTask = { isChecked -> taskActions.onCheckTask(it, isChecked) }, + onArchiveTask = { taskActions.archiveTask(it) }, + onStartTask = { taskActions.startTask(it) } + ) + } + items(tasks.value.filter { it.completed }) { + TaskEntry( + task = it, + onCheckTask = { isChecked -> taskActions.onCheckTask(it, isChecked) }, + onArchiveTask = { taskActions.archiveTask(it) }, + onStartTask = { taskActions.startTask(it) } + ) + } + } + } + } +} + +@Composable +fun EditAction( + onClick: () -> Unit +) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(AppText.edit_task) + ) + + } +} + +@Preview +@Composable +fun TaskScreenPreview() { + TaskScreen( + goBack = {}, + taskActions = TaskActions( + {}, + { Subject(name = "Test Subject") }, + { flowOf() }, + { _, _ -> run {} }, + {}, + {}, + {}, + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/tasks/TaskViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/TaskViewModel.kt new file mode 100644 index 0000000..e2adbc1 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/TaskViewModel.kt @@ -0,0 +1,54 @@ +package be.ugent.sel.studeez.screens.tasks + +import be.ugent.sel.studeez.data.SelectedSubject +import be.ugent.sel.studeez.data.SelectedTask +import be.ugent.sel.studeez.data.local.models.task.Subject +import be.ugent.sel.studeez.data.local.models.task.Task +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.TaskDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +@HiltViewModel +class TaskViewModel @Inject constructor( + private val taskDAO: TaskDAO, + private val selectedSubject: SelectedSubject, + private val selectedTask: SelectedTask, + logService: LogService, +) : StudeezViewModel(logService) { + fun addTask(open: (String) -> Unit) { + open(StudeezDestinations.ADD_TASK_FORM) + } + + fun getTasks(): Flow> { + return taskDAO.getTasks(selectedSubject()) + } + + fun getSelectedSubject(): Subject { + return selectedSubject() + } + + fun deleteTask(task: Task) { + taskDAO.deleteTask(task) + } + + fun archiveTask(task: Task) { + taskDAO.updateTask(task.copy(archived = true)) + } + + fun toggleTaskCompleted(task: Task, completed: Boolean) { + taskDAO.updateTask(task.copy(completed = completed)) + } + + fun editSubject(open: (String) -> Unit) { + open(StudeezDestinations.EDIT_SUBJECT_FORM) + } + + fun startTask(task: Task, open: (String) -> Unit) { + selectedTask.set(task) + open(StudeezDestinations.TIMER_SELECTION_SCREEN) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormScreen.kt new file mode 100644 index 0000000..79c744d --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormScreen.kt @@ -0,0 +1,113 @@ +package be.ugent.sel.studeez.screens.tasks.form + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.common.composable.BasicButton +import be.ugent.sel.studeez.common.composable.DeleteButton +import be.ugent.sel.studeez.common.composable.FormComposable +import be.ugent.sel.studeez.common.ext.basicButton +import be.ugent.sel.studeez.common.ext.fieldModifier +import be.ugent.sel.studeez.resources +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun TaskCreateRoute( + goBack: () -> Unit, + openAndPopUp: (String, String) -> Unit, + viewModel: TaskCreateFormViewModel, +) { + val uiState by viewModel.uiState + TaskForm( + title = AppText.new_task, + goBack = goBack, + uiState = uiState, + onConfirm = { viewModel.onCreate(openAndPopUp) }, + onNameChange = viewModel::onNameChange + ) +} + +@Composable +fun TaskEditRoute( + goBack: () -> Unit, + openAndPopUp: (String, String) -> Unit, + viewModel: TaskEditFormViewModel, +) { + val uiState by viewModel.uiState + TaskForm( + title = AppText.edit_task, + goBack = goBack, + uiState = uiState, + onConfirm = { viewModel.onEdit(openAndPopUp) }, + onNameChange = viewModel::onNameChange + ) { + DeleteButton(text = AppText.delete_task) { + viewModel.onDelete(openAndPopUp) + } + } +} + +@Composable +fun TaskForm( + @StringRes title: Int, + goBack: () -> Unit, + uiState: TaskFormUiState, + onConfirm: () -> Unit, + onNameChange: (String) -> Unit, + extraButton: @Composable () -> Unit = {} +) { + FormComposable( + title = resources().getString(title), + popUp = goBack, + ) { + Column { + OutlinedTextField( + singleLine = true, + value = uiState.name, + onValueChange = onNameChange, + placeholder = { Text(stringResource(id = AppText.name)) }, + modifier = Modifier.fieldModifier(), + ) + BasicButton( + text = AppText.confirm, + modifier = Modifier.basicButton(), + onClick = onConfirm, + ) + extraButton() + } + } +} + +@Preview +@Composable +fun AddTaskFormPreview() { + TaskForm( + title = AppText.new_task, + goBack = {}, + uiState = TaskFormUiState(), + onConfirm = {}, + onNameChange = {}, + ) +} + +@Preview +@Composable +fun EditTaskFormPreview() { + TaskForm( + title = AppText.edit_task, + goBack = {}, + uiState = TaskFormUiState( + name = "Test Task", + ), + onConfirm = {}, + onNameChange = {}, + ) { + DeleteButton(text = AppText.delete_task) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormUiState.kt b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormUiState.kt new file mode 100644 index 0000000..6156fb7 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormUiState.kt @@ -0,0 +1,5 @@ +package be.ugent.sel.studeez.screens.tasks.form + +data class TaskFormUiState( + val name: String = "", +) \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormViewModel.kt new file mode 100644 index 0000000..07cba5d --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/tasks/form/TaskFormViewModel.kt @@ -0,0 +1,66 @@ +package be.ugent.sel.studeez.screens.tasks.form + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import be.ugent.sel.studeez.data.SelectedSubject +import be.ugent.sel.studeez.data.SelectedTask +import be.ugent.sel.studeez.data.local.models.task.Task +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.TaskDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +abstract class TaskFormViewModel( + protected val taskDAO: TaskDAO, + protected val selectedSubject: SelectedSubject, + protected val selectedTask: SelectedTask, + logService: LogService, +) : StudeezViewModel(logService) { + abstract val uiState: MutableState + + protected val name: String + get() = uiState.value.name + + fun onNameChange(newValue: String) { + uiState.value = uiState.value.copy(name = newValue) + } +} + +@HiltViewModel +class TaskCreateFormViewModel @Inject constructor( + taskDAO: TaskDAO, + selectedSubject: SelectedSubject, + selectedTask: SelectedTask, + logService: LogService, +) : TaskFormViewModel(taskDAO, selectedSubject, selectedTask, logService) { + override val uiState = mutableStateOf(TaskFormUiState()) + + fun onCreate(openAndPopUp: (String, String) -> Unit) { + val newTask = Task(name = name, subjectId = selectedSubject().id) + taskDAO.saveTask(newTask) + openAndPopUp(StudeezDestinations.TASKS_SCREEN, StudeezDestinations.ADD_TASK_FORM) + } +} + +@HiltViewModel +class TaskEditFormViewModel @Inject constructor( + taskDAO: TaskDAO, + selectedSubject: SelectedSubject, + selectedTask: SelectedTask, + logService: LogService, +) : TaskFormViewModel(taskDAO, selectedSubject, selectedTask, logService) { + override val uiState = mutableStateOf(TaskFormUiState()) + + fun onDelete(openAndPopUp: (String, String) -> Unit) { + taskDAO.deleteTask(selectedTask()) + openAndPopUp(StudeezDestinations.TASKS_SCREEN, StudeezDestinations.EDIT_TASK_FORM) + } + + fun onEdit(openAndPopUp: (String, String) -> Unit) { + val newTask = selectedTask().copy(name = name) + taskDAO.updateTask(newTask) + openAndPopUp(StudeezDestinations.TASKS_SCREEN, StudeezDestinations.EDIT_TASK_FORM) + } +} diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/GetTimerFormScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/GetTimerFormScreen.kt new file mode 100644 index 0000000..99426e4 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/GetTimerFormScreen.kt @@ -0,0 +1,27 @@ +package be.ugent.sel.studeez.screens.timer_form + +import be.ugent.sel.studeez.data.local.models.timer_info.CustomTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.EndlessTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.PomodoroTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfoVisitor +import be.ugent.sel.studeez.screens.timer_form.form_screens.AbstractTimerFormScreen +import be.ugent.sel.studeez.screens.timer_form.form_screens.BreakTimerFormScreen +import be.ugent.sel.studeez.screens.timer_form.form_screens.CustomTimerFormScreen +import be.ugent.sel.studeez.screens.timer_form.form_screens.EndlessTimerFormScreen + +class GetTimerFormScreen: TimerInfoVisitor { + + override fun visitCustomTimerInfo(customTimerInfo: CustomTimerInfo): AbstractTimerFormScreen { + return CustomTimerFormScreen(customTimerInfo) + } + + override fun visitEndlessTimerInfo(endlessTimerInfo: EndlessTimerInfo): AbstractTimerFormScreen { + return EndlessTimerFormScreen(endlessTimerInfo) + } + + override fun visitBreakTimerInfo(pomodoroTimerInfo: PomodoroTimerInfo): AbstractTimerFormScreen { + return BreakTimerFormScreen(pomodoroTimerInfo) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/TimerFormScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/TimerFormScreen.kt new file mode 100644 index 0000000..542a7f0 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/TimerFormScreen.kt @@ -0,0 +1,45 @@ +package be.ugent.sel.studeez.screens.timer_form + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import be.ugent.sel.studeez.common.composable.FormComposable +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.R.string as AppText + +@Composable +fun TimerAddRoute( + popUp: () -> Unit, + viewModel: TimerFormViewModel +) { + TimerFormScreen(popUp = popUp, getTimerInfo = viewModel::getTimerInfo, AppText.add_timer) { + viewModel.saveTimer(it, goBack = popUp) + } +} + +@Composable +fun TimerEditRoute( + popUp: () -> Unit, + viewModel: TimerFormViewModel +) { + TimerFormScreen(popUp = popUp, getTimerInfo = viewModel::getTimerInfo, AppText.edit_timer) { + viewModel.editTimer(it, goBack = popUp) + } +} + +@Composable +fun TimerFormScreen( + popUp: () -> Unit, + getTimerInfo: () -> TimerInfo, + @StringRes label: Int, + onConfirmClick: (TimerInfo) -> Unit +) { + val timerFormScreen = getTimerInfo().accept(GetTimerFormScreen()) + + FormComposable( + title = stringResource(id = label), + popUp = popUp + ) { + timerFormScreen(onConfirmClick) + } +} diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/TimerFormViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/TimerFormViewModel.kt new file mode 100644 index 0000000..8a0a4d4 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/TimerFormViewModel.kt @@ -0,0 +1,30 @@ +package be.ugent.sel.studeez.screens.timer_form + +import be.ugent.sel.studeez.data.SelectedTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.TimerDAO +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class TimerFormViewModel @Inject constructor( + private val selectedTimerInfo: SelectedTimerInfo, + private val timerDAO: TimerDAO, + logService: LogService +) : StudeezViewModel(logService) { + fun getTimerInfo(): TimerInfo { + return selectedTimerInfo() + } + + fun editTimer(timerInfo: TimerInfo, goBack: () -> Unit) { + timerDAO.updateTimer(timerInfo) + goBack() + } + + fun saveTimer(timerInfo: TimerInfo, goBack: () -> Unit) { + timerDAO.saveTimer(timerInfo) + goBack() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/AbstractTimerFormScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/AbstractTimerFormScreen.kt new file mode 100644 index 0000000..69d02ef --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/AbstractTimerFormScreen.kt @@ -0,0 +1,54 @@ +package be.ugent.sel.studeez.screens.timer_form.form_screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.BasicButton +import be.ugent.sel.studeez.common.composable.LabelledInputField +import be.ugent.sel.studeez.common.ext.basicButton +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.R.string as AppText + +abstract class AbstractTimerFormScreen(private val timerInfo: TimerInfo) { + + @Composable + operator fun invoke(onSaveClick: (TimerInfo) -> Unit) { + + var name by remember { mutableStateOf(timerInfo.name) } + var description by remember { mutableStateOf(timerInfo.description) } + + // This shall rerun whenever name and description change + timerInfo.name = name + timerInfo.description = description + + Column { + + // Fields that every timer shares (ommited id) + LabelledInputField( + value = name, + onNewValue = { name = it }, + label = R.string.name + ) + + LabelledInputField( + value = description, + onNewValue = { description = it }, + label = AppText.description, + singleLine = false + ) + + ExtraFields() + + BasicButton(R.string.save, Modifier.basicButton()) { + onSaveClick(timerInfo) + } + } + } + + @Composable + open fun ExtraFields() { + // By default no extra fields, unless overwritten by subclass. + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/BreakTimerFormScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/BreakTimerFormScreen.kt new file mode 100644 index 0000000..12d07a4 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/BreakTimerFormScreen.kt @@ -0,0 +1,55 @@ +package be.ugent.sel.studeez.screens.timer_form.form_screens + +import androidx.compose.runtime.* +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.LabeledErrorTextField +import be.ugent.sel.studeez.common.composable.TimePickerCard +import be.ugent.sel.studeez.data.local.models.timer_info.PomodoroTimerInfo +import be.ugent.sel.studeez.ui.theme.StudeezTheme +import be.ugent.sel.studeez.R.string as AppText + + +class BreakTimerFormScreen( + private val breakTimerInfo: PomodoroTimerInfo +): AbstractTimerFormScreen(breakTimerInfo) { + + @Composable + override fun ExtraFields() { + // If the user presses the OK button on the timepicker, the time in the button should change + + TimePickerCard(R.string.studyTime, breakTimerInfo.studyTime) { newTime -> + breakTimerInfo.studyTime = newTime + } + TimePickerCard(R.string.breakTime, breakTimerInfo.breakTime) { newTime -> + breakTimerInfo.breakTime = newTime + } + + LabeledErrorTextField( + initialValue = breakTimerInfo.repeats.toString(), + label = R.string.repeats, + errorText = AppText.repeats_error, + keyboardType = KeyboardType.Decimal, + predicate = { it.matches(Regex("[1-9]+\\d*")) } + ) { correctlyTypedInt -> + breakTimerInfo.repeats = correctlyTypedInt.toInt() + } + + } +} + +@Preview +@Composable +fun BreakEditScreenPreview() { + val pomodoroTimerInfo = PomodoroTimerInfo( + "Breaky the Breaktimer", + "Breaky is a breakdancer", + 10 * 60, + 60, + 5 + ) + StudeezTheme { + BreakTimerFormScreen(pomodoroTimerInfo).invoke(onSaveClick = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/CustomTimerFormScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/CustomTimerFormScreen.kt new file mode 100644 index 0000000..27c0657 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/CustomTimerFormScreen.kt @@ -0,0 +1,34 @@ +package be.ugent.sel.studeez.screens.timer_form.form_screens + +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.common.composable.TimePickerCard +import be.ugent.sel.studeez.data.local.models.timer_info.CustomTimerInfo +import be.ugent.sel.studeez.ui.theme.StudeezTheme +import be.ugent.sel.studeez.R.string as AppText + +class CustomTimerFormScreen( + private val customTimerInfo: CustomTimerInfo + ): AbstractTimerFormScreen(customTimerInfo) { + + @Composable + override fun ExtraFields() { + TimePickerCard( + text = AppText.studyTime, + initialSeconds = customTimerInfo.studyTime + ) { newTime -> + customTimerInfo.studyTime = newTime + } + } + + +} + +@Preview +@Composable +fun CustomEditScreenPreview() { + val customTimerInfo = CustomTimerInfo("custom", "my description", 25) + StudeezTheme { + CustomTimerFormScreen(customTimerInfo).invoke(onSaveClick = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/EndlessTimerFormScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/EndlessTimerFormScreen.kt new file mode 100644 index 0000000..9009fff --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/form_screens/EndlessTimerFormScreen.kt @@ -0,0 +1,23 @@ +package be.ugent.sel.studeez.screens.timer_form.form_screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.data.local.models.timer_info.EndlessTimerInfo +import be.ugent.sel.studeez.ui.theme.StudeezTheme + +class EndlessTimerFormScreen( + endlessTimerInfo: EndlessTimerInfo +): AbstractTimerFormScreen(endlessTimerInfo) { +} + +@Preview +@Composable +fun EndlessEditScreenPreview() { + val endlessTimerInfo = EndlessTimerInfo( + "Forever and beyond", + "My endless timer description", + ) + StudeezTheme { + EndlessTimerFormScreen(endlessTimerInfo).invoke(onSaveClick = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/timer_type_select/TimerTypeSelectScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/timer_type_select/TimerTypeSelectScreen.kt new file mode 100644 index 0000000..fa8d650 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/timer_type_select/TimerTypeSelectScreen.kt @@ -0,0 +1,46 @@ +package be.ugent.sel.studeez.screens.timer_form.timer_type_select + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import be.ugent.sel.studeez.common.composable.SecondaryScreenTemplate +import be.ugent.sel.studeez.data.local.models.timer_info.* +import be.ugent.sel.studeez.R.string as AppText +import be.ugent.sel.studeez.data.local.models.timer_info.TimerType.CUSTOM +import be.ugent.sel.studeez.data.local.models.timer_info.TimerType.BREAK +import be.ugent.sel.studeez.data.local.models.timer_info.TimerType.ENDLESS + +val defaultTimerInfo: Map = mapOf( + CUSTOM to CustomTimerInfo("", "", 0), + BREAK to PomodoroTimerInfo("", "", 0, 0, 1), + ENDLESS to EndlessTimerInfo("", ""), +) + + +@Composable +fun TimerTypeSelectScreen( + open: (String) -> Unit, + popUp: () -> Unit, + viewModel: TimerTypeSelectViewModel = hiltViewModel() +) { + + SecondaryScreenTemplate(title = stringResource(id = AppText.timer_type_select), popUp = popUp) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + TimerType.values().forEach { timerType -> + val default: TimerInfo = defaultTimerInfo.getValue(timerType) + Button(onClick = { viewModel.onTimerTypeChosen(default, open) }) { + Text(text = timerType.name) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/timer_type_select/TimerTypeSelectViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/timer_type_select/TimerTypeSelectViewModel.kt new file mode 100644 index 0000000..c3ed2c4 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_form/timer_type_select/TimerTypeSelectViewModel.kt @@ -0,0 +1,22 @@ +package be.ugent.sel.studeez.screens.timer_form.timer_type_select + +import be.ugent.sel.studeez.data.SelectedTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class TimerTypeSelectViewModel @Inject constructor( + private val selectedTimerInfo: SelectedTimerInfo, + logService: LogService +) : StudeezViewModel(logService) { + + + fun onTimerTypeChosen(timerInfo: TimerInfo, open: (String) -> Unit) { + selectedTimerInfo.set(timerInfo) + open(StudeezDestinations.ADD_TIMER_SCREEN) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_overview/TimerOverviewScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_overview/TimerOverviewScreen.kt new file mode 100644 index 0000000..3c25ddf --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_overview/TimerOverviewScreen.kt @@ -0,0 +1,121 @@ +package be.ugent.sel.studeez.screens.timer_overview + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.BasicButton +import be.ugent.sel.studeez.common.composable.DrawerScreenTemplate +import be.ugent.sel.studeez.common.composable.StealthButton +import be.ugent.sel.studeez.common.composable.TimerEntry +import be.ugent.sel.studeez.common.composable.drawer.DrawerActions +import be.ugent.sel.studeez.common.ext.basicButton +import be.ugent.sel.studeez.data.local.models.timer_info.CustomTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.resources +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +data class TimerOverviewActions( + val getUserTimers: () -> Flow>, + val getDefaultTimers: () -> List, + val onEditClick: (TimerInfo) -> Unit, + val onAddClick: () -> Unit +) + +fun getTimerOverviewActions( + viewModel: TimerOverviewViewModel, + open: (String) -> Unit, +): TimerOverviewActions { + return TimerOverviewActions( + getUserTimers = viewModel::getUserTimers, + getDefaultTimers = viewModel::getDefaultTimers, + onEditClick = { viewModel.update(it, open) }, + onAddClick = { viewModel.onAddClick(open) } + ) +} + +@Composable +fun TimerOverviewRoute( + viewModel: TimerOverviewViewModel, + drawerActions: DrawerActions, + open: (String) -> Unit +) { + TimerOverviewScreen( + timerOverviewActions = getTimerOverviewActions(viewModel, open), + drawerActions = drawerActions, + ) +} + +@Composable +fun TimerOverviewScreen( + timerOverviewActions: TimerOverviewActions, + drawerActions: DrawerActions, +) { + + val timers = timerOverviewActions.getUserTimers().collectAsState(initial = emptyList()) + + DrawerScreenTemplate( + title = resources().getString(R.string.timers), + drawerActions = drawerActions + ) { + Column { // TODO knop beneden + LazyColumn { + // Custom timer, select new duration each time + item { + TimerEntry(timerInfo = CustomTimerInfo( + name = resources().getString(R.string.custom_name), + description = resources().getString(R.string.custom_name), + studyTime = 0 + )) + } + // Default Timers, cannot be edited + items(timerOverviewActions.getDefaultTimers()) { + TimerEntry(timerInfo = it) {} + } + // User timers, can be edited + items(timers.value) { timerInfo -> + TimerEntry( + timerInfo = timerInfo, + rightButton = { + StealthButton( + text = R.string.edit, + onClick = { timerOverviewActions.onEditClick(timerInfo) } + ) + } + ) + + } + + // TODO uit lazy column + item { + BasicButton(R.string.add_timer, Modifier.basicButton()) { + timerOverviewActions.onAddClick() + } + } + } + } + } +} + +@Preview +@Composable +fun TimerOverviewPreview() { + val customTimer = CustomTimerInfo( + "my preview timer", "This is the description of the timer", 60 + ) + TimerOverviewScreen( + timerOverviewActions = TimerOverviewActions( + { flowOf() }, + { listOf(customTimer, customTimer) }, + {}, + {} + ), + drawerActions = DrawerActions({}, {}, {}, {}, {}) + ) +} diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_overview/TimerOverviewViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_overview/TimerOverviewViewModel.kt new file mode 100644 index 0000000..395a155 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_overview/TimerOverviewViewModel.kt @@ -0,0 +1,44 @@ +package be.ugent.sel.studeez.screens.timer_overview + +import be.ugent.sel.studeez.data.SelectedTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.domain.ConfigurationService +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.TimerDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +@HiltViewModel +class TimerOverviewViewModel @Inject constructor( + private val configurationService: ConfigurationService, + private val timerDAO: TimerDAO, + private val selectedTimerInfo: SelectedTimerInfo, + logService: LogService +) : StudeezViewModel(logService) { + + fun getUserTimers(): Flow> { + return timerDAO.getUserTimers() + } + + fun getDefaultTimers(): List { + return configurationService.getDefaultTimers() + } + + fun update(timerInfo: TimerInfo, open: (String) -> Unit) { + selectedTimerInfo.set(timerInfo) + open(StudeezDestinations.TIMER_EDIT_SCREEN) + } + + fun onAddClick(open: (String) -> Unit) { + open(StudeezDestinations.TIMER_TYPE_CHOOSING_SCREEN) + } + + fun delete(timerInfo: TimerInfo) = timerDAO.deleteTimer(timerInfo) + + fun save(timerInfo: TimerInfo) = timerDAO.saveTimer(timerInfo) + + +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_selection/TimerSelectionScreen.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_selection/TimerSelectionScreen.kt new file mode 100644 index 0000000..d78b4bf --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_selection/TimerSelectionScreen.kt @@ -0,0 +1,122 @@ +package be.ugent.sel.studeez.screens.timer_selection + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import be.ugent.sel.studeez.R +import be.ugent.sel.studeez.common.composable.SecondaryScreenTemplate +import be.ugent.sel.studeez.common.composable.StealthButton +import be.ugent.sel.studeez.common.composable.TimePickerButton +import be.ugent.sel.studeez.common.composable.TimerEntry +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import be.ugent.sel.studeez.data.local.models.timer_functional.Time +import be.ugent.sel.studeez.data.local.models.timer_info.CustomTimerInfo +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.resources +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +data class TimerSelectionActions( + val getAllTimers: () -> Flow>, + val startSession: (TimerInfo) -> Unit, + val customTimeStudyTime: Int +) + +fun getTimerSelectionActions( + viewModel: TimerSelectionViewModel, + open: (String) -> Unit, +): TimerSelectionActions { + return TimerSelectionActions( + getAllTimers = viewModel::getAllTimers, + startSession = { viewModel.startSession(open, it) }, + customTimeStudyTime = viewModel.customTimerStudyTime.value + ) +} + +@Composable +fun TimerSelectionRoute( + open: (String) -> Unit, + popUp: () -> Unit, + viewModel: TimerSelectionViewModel, +) { + TimerSelectionScreen( + timerSelectionActions = getTimerSelectionActions(viewModel, open), + popUp = popUp + ) +} + +@Composable +fun TimerSelectionScreen( + timerSelectionActions: TimerSelectionActions, + popUp: () -> Unit +) { + val timers = timerSelectionActions.getAllTimers().collectAsState(initial = emptyList()) + SecondaryScreenTemplate( + title = resources().getString(R.string.timers), + popUp = popUp + ) { + LazyColumn { + // Custom timer with duration selection button + item { + CustomTimerEntry(timerSelectionActions) + } + + // All timers + items(timers.value) { timerInfo -> + TimerEntry( + timerInfo = timerInfo, + leftButton = { + StealthButton( + text = R.string.start, + onClick = { timerSelectionActions.startSession(timerInfo) } + ) + } + ) + } + } + } +} + +@Composable +fun CustomTimerEntry( + timerSelectionActions: TimerSelectionActions +) { + val timerInfo = CustomTimerInfo( + name = resources().getString(R.string.custom_name), + description = resources().getString(R.string.custom_description), + studyTime = timerSelectionActions.customTimeStudyTime + ) + val hms: HoursMinutesSeconds = Time(timerInfo.studyTime).getAsHMS() + + TimerEntry( + timerInfo = timerInfo, + leftButton = { + StealthButton( + text = R.string.start, + onClick = { timerSelectionActions.startSession(timerInfo) } + ) + }, + rightButton = { + TimePickerButton( + initialSeconds = hms.getTotalSeconds(), + modifier = Modifier.padding(horizontal = 5.dp) + ) { chosenTime -> + timerInfo.studyTime = chosenTime + } + } + ) +} + +@Preview +@Composable +fun TimerSelectionPreview() { + TimerSelectionScreen( + timerSelectionActions = TimerSelectionActions({ flowOf() }, {}, 0), + popUp = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/screens/timer_selection/TimerSelectionViewModel.kt b/app/src/main/java/be/ugent/sel/studeez/screens/timer_selection/TimerSelectionViewModel.kt new file mode 100644 index 0000000..c6c6793 --- /dev/null +++ b/app/src/main/java/be/ugent/sel/studeez/screens/timer_selection/TimerSelectionViewModel.kt @@ -0,0 +1,35 @@ +package be.ugent.sel.studeez.screens.timer_selection + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import be.ugent.sel.studeez.data.SelectedTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.TimerDAO +import be.ugent.sel.studeez.navigation.StudeezDestinations +import be.ugent.sel.studeez.screens.StudeezViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +@HiltViewModel +class TimerSelectionViewModel @Inject constructor( + private val timerDAO: TimerDAO, + private val selectedTimer: SelectedTimer, + logService: LogService +) : StudeezViewModel(logService) { + + var customTimerStudyTime: MutableState = mutableStateOf( + HoursMinutesSeconds(1, 0, 0).getTotalSeconds() + ) + + fun getAllTimers(): Flow> { + return timerDAO.getAllTimers() + } + + fun startSession(open: (String) -> Unit, timerInfo: TimerInfo) { + selectedTimer.set(timerInfo.getFunctionalTimer()) + open(StudeezDestinations.SESSION_SCREEN) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/ui/theme/Color.kt b/app/src/main/java/be/ugent/sel/studeez/ui/theme/Color.kt index d432429..3639b8d 100644 --- a/app/src/main/java/be/ugent/sel/studeez/ui/theme/Color.kt +++ b/app/src/main/java/be/ugent/sel/studeez/ui/theme/Color.kt @@ -2,7 +2,6 @@ package be.ugent.sel.studeez.ui.theme import androidx.compose.ui.graphics.Color -val Purple200 = Color(0xFFBB86FC) -val Purple500 = Color(0xFF6200EE) -val Purple700 = Color(0xFF3700B3) -val Teal200 = Color(0xFF03DAC5) \ No newline at end of file +val Blue100 = Color( 30, 100, 200, 255) +val Blue120 = Color( 27, 90, 180, 255) +val Yellow100 = Color(255, 210, 0, 255) \ No newline at end of file diff --git a/app/src/main/java/be/ugent/sel/studeez/ui/theme/Theme.kt b/app/src/main/java/be/ugent/sel/studeez/ui/theme/Theme.kt index bb1884d..9a29e85 100644 --- a/app/src/main/java/be/ugent/sel/studeez/ui/theme/Theme.kt +++ b/app/src/main/java/be/ugent/sel/studeez/ui/theme/Theme.kt @@ -5,17 +5,22 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.darkColors import androidx.compose.material.lightColors import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// Reference colour palette: https://xd.adobe.com/view/3cb1e6ff-eb42-4a74-886e-7739c2ccc5ed-69e2/ private val DarkColorPalette = darkColors( - primary = Purple200, - primaryVariant = Purple700, - secondary = Teal200 + primary = Blue100, + primaryVariant = Blue120, + secondary = Yellow100, + + onPrimary = Color.White ) private val LightColorPalette = lightColors( - primary = Purple500, - primaryVariant = Purple700, - secondary = Teal200 + primary = Blue100, + primaryVariant = Blue120, + secondary = Yellow100 /* Other default colors to override background = Color.White, diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..bceb2ec --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_timer.xml b/app/src/main/res/drawable/ic_timer.xml new file mode 100644 index 0000000..c99a685 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 0000000..92c4856 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_on.xml b/app/src/main/res/drawable/ic_visibility_on.xml new file mode 100644 index 0000000..a3e222a --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/mood_1.xml b/app/src/main/res/drawable/mood_1.xml new file mode 100644 index 0000000..bf009f2 --- /dev/null +++ b/app/src/main/res/drawable/mood_1.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/mood_2.xml b/app/src/main/res/drawable/mood_2.xml new file mode 100644 index 0000000..0fd3daa --- /dev/null +++ b/app/src/main/res/drawable/mood_2.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..485bbf6 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,12 @@ + + - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 + #FF1E64C8 + #FF1B5AB4 + #FF1850A0 + #FFFFD200 + #FFFDD931 #FF000000 #FFFFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c461410..54bca8f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,162 @@ + + Studeez - \ No newline at end of file + Username + Email + Password + Repeat password + Menu + + + Confirm + Save + Discard + Cancel + Go back + Next + Start + + + Success! + Try again + Something wrong happened. Please try again. + Please insert a valid email. + + + + + Home + Start session + + + Continue + Deleted + This is your feed + Click here to create you first subject and tasks to get started + + + Tasks + Task + My Subjects + Select Subject + New Subject + New Task + Edit Subject + Edit Task + Delete Subject + Delete Task + View + Select + Regenerate Color + + + Looks like you found the sessions screen! In here, your upcoming studying sessions with friends will be listed. You can accept invites or edit your own. + Sessions + Session + End session + Upcoming sessions + + + Profile + Unknown username + Edit profile + Editing profile + Delete profile + + + + Log out + Profile Picture + Normal user + + + Timers + Edit + Add timer + Select time + Focus! + + Focus one more time! + Focus! (%d break remaining) + Focus! (%d breaks remaining) + + Done! + Take a break! + Custom + Select how long you want to study + + + Looks like you found the settings screen! In the future, this will enable you to edit your preferenes such as light/dark mode, end sessions automatically when we detect you are gone etc. + Settings + + + About Studeez + + + + + Create account + Your password should have at least six characters and include one digit, one lower case letter and one upper case letter. + Passwords do not match. + Already have an account? Log in. + + + Don\'t have an account yet? Sign up. + Sign in + Enter your login details + Forgot password? Click to get recovery email. + Check your inbox for the recovery email. + Password cannot be empty. + + + + + + Friends + Friend + Adding friends still needs to be implemented. Hang on tight! + + + + + Creating tasks still needs to be implemented. Hang on tight! + + + Creating sessions still needs to be implemented. Hang on tight! + + + Timer description cannot be empty! + Timer description + Timer name cannot be empty! + Timer name + Open Time Picker + " hours and " + " minutes of breaktime" + " break" + " breaks" + With breaks? + " hours and " + " minutes of studytime" + How long do you want to study? + + + Select Timer Type + + + Name + Edit Timer + Repeats must be a positive non-zero number + Description + Study Time + Break Time + Number of Repeats + + + "Congratulations! You studied: %s" + How did it go? + Good + Bad + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a7dd890..b847a88 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,7 +1,8 @@ - + \ No newline at end of file diff --git a/app/src/test/java/be/ugent/sel/studeez/ExampleUnitTest.kt b/app/src/test/java/be/ugent/sel/studeez/ExampleUnitTest.kt deleted file mode 100644 index 4ff9e1c..0000000 --- a/app/src/test/java/be/ugent/sel/studeez/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package be.ugent.sel.studeez - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalCustomTimerUnitTest.kt b/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalCustomTimerUnitTest.kt new file mode 100644 index 0000000..548fe9d --- /dev/null +++ b/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalCustomTimerUnitTest.kt @@ -0,0 +1,40 @@ +package be.ugent.sel.studeez.timer_functional + +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer +import org.junit.Assert +import org.junit.Test + +class FunctionalCustomTimerUnitTest : FunctionalTimerUnitTest() { + override fun setTimer() { + timer = FunctionalCustomTimer(time) + } + + @Test + override fun testOneTick() { + timer.tick() + Assert.assertEquals( + time - 1, + timer.time.time, + ) + } + + @Test + override fun multipleTicks() { + val n = 10 + for (i in 1..n) { + timer.tick() + } + Assert.assertEquals( + time - n, + timer.time.time, + ) + } + + @Test + override fun testEnded() { + timer = FunctionalCustomTimer(0) + timer.tick() + Assert.assertTrue(timer.hasEnded()) + Assert.assertTrue(timer.hasEnded()) + } +} \ No newline at end of file diff --git a/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalEndlessTimerUnitTest.kt b/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalEndlessTimerUnitTest.kt new file mode 100644 index 0000000..17733bc --- /dev/null +++ b/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalEndlessTimerUnitTest.kt @@ -0,0 +1,41 @@ +package be.ugent.sel.studeez.timer_functional + +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer +import org.junit.Assert +import org.junit.Test + +class FunctionalEndlessTimerUnitTest : FunctionalTimerUnitTest() { + override fun setTimer() { + timer = FunctionalEndlessTimer() + } + + @Test + override fun testOneTick() { + timer.tick() + Assert.assertEquals( + 1, + timer.time.time + ) + } + + @Test + override fun multipleTicks() { + val n = 10 + for (i in 1..n) { + timer.tick() + } + Assert.assertEquals( + n, + timer.time.time + ) + } + + @Test + override fun testEnded() { + val n = 1000 + for (i in 1..n) { + timer.tick() + Assert.assertFalse(timer.hasEnded()) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalPomodoroTimerUnitTest.kt b/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalPomodoroTimerUnitTest.kt new file mode 100644 index 0000000..4b259c8 --- /dev/null +++ b/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalPomodoroTimerUnitTest.kt @@ -0,0 +1,77 @@ +package be.ugent.sel.studeez.timer_functional + +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer +import org.junit.Assert +import org.junit.Test + +class FunctionalPomodoroTimerUnitTest : FunctionalTimerUnitTest() { + private val breakTime = 10 + private val breaks = 2 + override val hours = 0 + override val minutes = 0 + override val seconds = 10 + private lateinit var pomodoroTimer: FunctionalPomodoroTimer + + override fun setTimer() { + pomodoroTimer = FunctionalPomodoroTimer(time, breakTime, breaks) + } + + @Test + override fun testOneTick() { + pomodoroTimer.tick() + Assert.assertEquals( + time - 1, + pomodoroTimer.time.time, + ) + Assert.assertFalse(pomodoroTimer.isInBreak) + Assert.assertEquals( + breaks, + pomodoroTimer.breaksRemaining, + ) + } + + @Test + override fun multipleTicks() { + val n = 10 + for (i in 1..n) { + pomodoroTimer.tick() + } + Assert.assertEquals( + time - n, + pomodoroTimer.time.time + ) + } + + @Test + override fun testEnded() { + pomodoroTimer = FunctionalPomodoroTimer(0, 0, 0) + pomodoroTimer.tick() + Assert.assertTrue(pomodoroTimer.hasEnded()) + } + + @Test + fun switchToBreak() { + for (i in 0..10) { + pomodoroTimer.tick() + } + Assert.assertFalse(pomodoroTimer.hasEnded()) + Assert.assertTrue(pomodoroTimer.isInBreak) + } + + @Test + fun switchToStudying() { + for (i in 0..time) { + pomodoroTimer.tick() + } + Assert.assertTrue(pomodoroTimer.isInBreak) + for (i in 0..breakTime) { + pomodoroTimer.tick() + } + Assert.assertFalse(pomodoroTimer.isInBreak) + val breaksRemaining = breaks - 1 + Assert.assertEquals( + breaksRemaining, + pomodoroTimer.breaksRemaining + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalTimerUnitTest.kt b/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalTimerUnitTest.kt new file mode 100644 index 0000000..4d26d06 --- /dev/null +++ b/app/src/test/java/be/ugent/sel/studeez/timer_functional/FunctionalTimerUnitTest.kt @@ -0,0 +1,33 @@ +package be.ugent.sel.studeez.timer_functional + +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalTimer +import org.junit.Before +import org.junit.Test + +abstract class FunctionalTimerUnitTest { + protected lateinit var timer: FunctionalTimer + protected open val hours = 4 + protected open val minutes = 20 + protected open val seconds = 39 + protected var time: Int = 0 + + @Before + fun setup() { + time = seconds + minutes * 60 + hours * 60 * 60 + setTimer() + } + + /** + * The timer-property should be set to the right implementation in this method. + */ + abstract fun setTimer() + + @Test + abstract fun testOneTick() + + @Test + abstract fun multipleTicks() + + @Test + abstract fun testEnded() +} \ No newline at end of file diff --git a/app/src/test/java/be/ugent/sel/studeez/timer_functional/InvisibleSessionManagerTest.kt b/app/src/test/java/be/ugent/sel/studeez/timer_functional/InvisibleSessionManagerTest.kt new file mode 100644 index 0000000..54f673d --- /dev/null +++ b/app/src/test/java/be/ugent/sel/studeez/timer_functional/InvisibleSessionManagerTest.kt @@ -0,0 +1,102 @@ +package be.ugent.sel.studeez.timer_functional + +import android.media.MediaPlayer +import be.ugent.sel.studeez.data.SelectedSessionReport +import be.ugent.sel.studeez.data.SelectedTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalCustomTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalEndlessTimer +import be.ugent.sel.studeez.data.local.models.timer_functional.FunctionalPomodoroTimer +import be.ugent.sel.studeez.domain.LogService +import be.ugent.sel.studeez.domain.implementation.LogServiceImpl +import be.ugent.sel.studeez.screens.session.InvisibleSessionManager +import be.ugent.sel.studeez.screens.session.SessionViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Test +import org.mockito.kotlin.mock + +@ExperimentalCoroutinesApi +class InvisibleSessionManagerTest { + private var selectedTimer: SelectedTimer = SelectedTimer() + private lateinit var viewModel: SessionViewModel + private var mediaPlayer: MediaPlayer = mock() + + @Test + fun InvisibleEndlessTimerTest() = runTest { + selectedTimer.set(FunctionalEndlessTimer()) + viewModel = SessionViewModel(selectedTimer, SelectedSessionReport(), mock(), LogServiceImpl()) + InvisibleSessionManager.setParameters(viewModel, mediaPlayer) + + val test = launch { + InvisibleSessionManager.updateTimer() + } + + Assert.assertEquals(viewModel.getTimer().time.time, 0) + advanceTimeBy(1_000) // Start tikker + advanceTimeBy(10_000_000) + Assert.assertEquals(viewModel.getTimer().time.time, 10000) + + test.cancel() + return@runTest + } + + @Test + fun InvisiblePomodoroTimerTest() = runTest { + val studyTime = 10 + val breakTime = 5 + val repeats = 1 + selectedTimer.set(FunctionalPomodoroTimer(studyTime, breakTime, repeats)) + viewModel = SessionViewModel(selectedTimer, SelectedSessionReport(), mock(), LogServiceImpl()) + InvisibleSessionManager.setParameters(viewModel, mediaPlayer) + + val test = launch { + InvisibleSessionManager.updateTimer() + } + + Assert.assertEquals(viewModel.getTimer().time.time, 10) + advanceTimeBy(1_000) // start tikker + + advanceTimeBy(9_000) + Assert.assertEquals(viewModel.getTimer().time.time, 1) + // focus, 9 sec, 1 sec nog + + advanceTimeBy(2_000) + Assert.assertEquals(viewModel.getTimer().time.time, 4) + // pauze, 11 sec bezig, 4 seconden nog pauze + + advanceTimeBy(5_000) + Assert.assertEquals(viewModel.getTimer().time.time, 9) + // 2e focus, 16 sec, 9 sec in 2e focus nog + + advanceTimeBy(13_000) + Assert.assertTrue(viewModel.getTimer().hasEnded()) + // Done + + test.cancel() + return@runTest + } + + @Test + fun InvisibleCustomTimerTest() = runTest { + selectedTimer.set(FunctionalCustomTimer(5)) + viewModel = SessionViewModel(selectedTimer, SelectedSessionReport(), mock(), LogServiceImpl()) + InvisibleSessionManager.setParameters(viewModel, mediaPlayer) + + val test = launch { + InvisibleSessionManager.updateTimer() + } + + Assert.assertEquals(viewModel.getTimer().time.time, 5) + advanceTimeBy(1_000) // Start tikker + advanceTimeBy(4_000) + Assert.assertEquals(viewModel.getTimer().time.time, 1) + advanceTimeBy(1_000) + Assert.assertEquals(viewModel.getTimer().time.time, 0) + + test.cancel() + return@runTest + } +} \ No newline at end of file diff --git a/app/src/test/java/be/ugent/sel/studeez/timer_functional/TimeUnitTest.kt b/app/src/test/java/be/ugent/sel/studeez/timer_functional/TimeUnitTest.kt new file mode 100644 index 0000000..0d9bf4b --- /dev/null +++ b/app/src/test/java/be/ugent/sel/studeez/timer_functional/TimeUnitTest.kt @@ -0,0 +1,85 @@ +package be.ugent.sel.studeez.timer_functional + +import be.ugent.sel.studeez.data.local.models.timer_functional.HoursMinutesSeconds +import be.ugent.sel.studeez.data.local.models.timer_functional.Time +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class TimeUnitTest { + private val hours = 4 + private val minutes = 20 + private val seconds = 39 + private var time: Time = Time(seconds + minutes * 60 + hours * 60 * 60) + + @Before + fun setup() { + + } + + @Test + fun formatTime() { + Assert.assertEquals( + HoursMinutesSeconds( + hours, + minutes, + seconds, + ), + time.getAsHMS(), + ) + } + + @Test + fun getTime() { + Assert.assertEquals( + seconds + minutes * 60 + hours * 60 * 60, + time.time, + ) + } + + @Test + fun minOne() { + Assert.assertEquals( + (seconds + minutes * 60 + hours * 60 * 60), + time.time, + ) + time-- + Assert.assertEquals( + (seconds + minutes * 60 + hours * 60 * 60) - 1, + time.time, + ) + } + + @Test + fun plusOne() { + time++ + Assert.assertEquals( + (seconds + minutes * 60 + hours * 60 * 60) + 1, + time.time, + ) + } + + @Test + fun minMultiple() { + val n = 10 + for (i in 1 .. n) { + time-- + } + Assert.assertEquals( + (seconds + minutes * 60 + hours * 60 * 60) - n, + time.time, + ) + } + + @Test + fun plusMultiple() { + val n = 10 + for (i in 1 .. n) { + time++ + } + Assert.assertEquals( + (seconds + minutes * 60 + hours * 60 * 60) + n, + time.time, + ) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index e0083ef..7f25617 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,12 @@ buildscript { espressoVersion = '3.4.0' kotlinVersion = '1.6.10' } + + dependencies { + classpath 'com.google.gms:google-services:4.3.15' + classpath 'com.android.tools.build:gradle:7.2.0' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.4' + } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '7.4.2' apply false @@ -15,3 +21,4 @@ plugins { // Hilt id 'com.google.dagger.hilt.android' version '2.44' apply false } + diff --git a/gradle.properties b/gradle.properties index 3c5031e..edf11ef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,8 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.daemon=true +org.gradle.parallel=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects