This commit is contained in:
brreynie 2023-05-16 00:00:34 +02:00
commit 886f452a21
160 changed files with 7672 additions and 49 deletions

1
.idea/.name generated Normal file
View file

@ -0,0 +1 @@
Studeez

2
.idea/compiler.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
<bytecodeTargetLevel target="17" />
</component>
</project>

1
.idea/gradle.xml generated
View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

View file

@ -37,5 +37,10 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" />
<option name="processLiterals" value="true" />
<option name="processComments" value="true" />
</inspection_tool>
</profile>
</component>

6
.idea/kotlinc.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.0" />
</component>
</project>

3
.idea/misc.xml generated
View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View file

@ -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

39
app/google-services.json Normal file
View file

@ -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"
}

View file

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".StudeezHiltApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@ -12,7 +13,7 @@
android:theme="@style/Theme.Studeez"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:name=".activities.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Studeez">

View file

@ -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
}

View file

@ -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 }
}
}
}

View file

@ -0,0 +1,7 @@
package be.ugent.sel.studeez
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class StudeezHiltApp : Application()

View file

@ -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

View file

@ -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)
}

View file

@ -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")
} }
}

View file

@ -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({}, {}, {})
) }
}

View file

@ -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()
}
}
}

View file

@ -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)
)
)
}

View file

@ -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"
)
}
},
) {}
}
}

View file

@ -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",
{}
) {} }
}

View file

@ -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) }
}

View file

@ -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)
)
}

View file

@ -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
)
}

View file

@ -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<Int> = 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<Int>) {
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 = {})
}
}

View file

@ -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) {}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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 {} }, {}
)
}

View file

@ -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,
)
)
}

View file

@ -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<String, List<FeedEntry>>) : FeedUiState
}

View file

@ -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<FeedUiState> = 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)
}
}

View file

@ -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 }, {}, {}, {}, {}, {}, {}, {}),
)
}
}

View file

@ -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
}
}

View file

@ -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
)
}

View file

@ -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<Int>,
getCompletedTaskCount: () -> Flow<Int>,
getStudyTime: () -> Flow<Int>,
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() },
) {}
}

View file

@ -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,
),
{}, {}, {}
)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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<SnackbarMessage?> = MutableStateFlow(null)
val snackbarMessages: StateFlow<SnackbarMessage?>
get() = messages.asStateFlow()
fun showMessage(@StringRes message: Int) {
messages.value = SnackbarMessage.ResourceSnackbar(message)
}
fun showMessage(message: SnackbarMessage) {
messages.value = message
}
}

View file

@ -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)
}
}
}

View file

@ -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<T> {
abstract var value: T
operator fun invoke() = value
fun set(newValue: T) {
this.value = newValue
}
}
@Singleton
class SelectedSessionReport @Inject constructor() : SelectedState<SessionReport>() {
override lateinit var value: SessionReport
}
@Singleton
class SelectedTask @Inject constructor() : SelectedState<Task>() {
override lateinit var value: Task
}
@Singleton
class SelectedTimer @Inject constructor() : SelectedState<FunctionalTimer>() {
override lateinit var value: FunctionalTimer
}
@Singleton
class SelectedSubject @Inject constructor() : SelectedState<Subject>() {
override lateinit var value: Subject
}
@Singleton
class SelectedTimerInfo @Inject constructor() : SelectedState<TimerInfo>() {
override lateinit var value: TimerInfo
}

View file

@ -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
)

View file

@ -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 = ""
)

View file

@ -0,0 +1,3 @@
package be.ugent.sel.studeez.data.local.models
data class User(val id: String = "")

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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 <T> accept(visitor: FunctionalTimerVisitor<T>): T {
return visitor.visitFunctionalCustomTimer(this)
}
}

View file

@ -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 <T> accept(visitor: FunctionalTimerVisitor<T>): T {
return visitor.visitFunctionalEndlessTimer(this)
}
}

View file

@ -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 <T> accept(visitor: FunctionalTimerVisitor<T>): T {
return visitor.visitFunctionalBreakTimer(this)
}
}

View file

@ -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 <T> accept(visitor: FunctionalTimerVisitor<T>): T
}

View file

@ -0,0 +1,11 @@
package be.ugent.sel.studeez.data.local.models.timer_functional
interface FunctionalTimerVisitor<T> {
fun visitFunctionalCustomTimer(functionalCustomTimer: FunctionalCustomTimer): T
fun visitFunctionalEndlessTimer(functionalEndlessTimer: FunctionalEndlessTimer): T
fun visitFunctionalBreakTimer(functionalPomodoroTimer: FunctionalPomodoroTimer): T
}

View file

@ -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"
}
}

View file

@ -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)
}
}

View file

@ -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<String, Any> {
return mapOf(
"type" to "custom",
"name" to name,
"description" to description,
"studyTime" to studyTime,
)
}
override fun <T> accept(visitor: TimerInfoVisitor<T>): T {
return visitor.visitCustomTimerInfo(this)
}
}

View file

@ -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<String, Any> {
return mapOf(
"type" to "endless",
"name" to name,
"description" to description
)
}
override fun <T> accept(visitor: TimerInfoVisitor<T>): T {
return visitor.visitEndlessTimerInfo(this)
}
}

View file

@ -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<String, Any> {
return mapOf(
"type" to "break",
"name" to name,
"description" to description,
"studyTime" to studyTime,
"breakTime" to breakTime,
"repeats" to repeats,
)
}
override fun <T> accept(visitor: TimerInfoVisitor<T>): T {
return visitor.visitBreakTimerInfo(this)
}
}

View file

@ -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<String, Any>
abstract fun <T> accept(visitor: TimerInfoVisitor<T>): T
}

View file

@ -0,0 +1,11 @@
package be.ugent.sel.studeez.data.local.models.timer_info
interface TimerInfoVisitor<T> {
fun visitCustomTimerInfo(customTimerInfo: CustomTimerInfo): T
fun visitEndlessTimerInfo(endlessTimerInfo: EndlessTimerInfo): T
fun visitBreakTimerInfo(pomodoroTimerInfo: PomodoroTimerInfo): T
}

View file

@ -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 = ""
)

View file

@ -0,0 +1,7 @@
package be.ugent.sel.studeez.data.local.models.timer_info
enum class TimerType {
BREAK,
ENDLESS,
CUSTOM
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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<User>
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()
}

View file

@ -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<TimerInfo>
}

View file

@ -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<Map<String, List<FeedEntry>>>
}

View file

@ -0,0 +1,5 @@
package be.ugent.sel.studeez.domain
interface LogService {
fun logNonFatalCrash(throwable: Throwable)
}

View file

@ -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<List<SessionReport>>
fun saveSession(newSessionReport: SessionReport)
fun deleteSession(newTimer: TimerInfo)
}

View file

@ -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<List<Subject>>
fun saveSubject(newSubject: Subject)
fun deleteSubject(oldSubject: Subject)
fun updateSubject(newSubject: Subject)
suspend fun archiveSubject(subject: Subject)
fun getTaskCount(subject: Subject): Flow<Int>
fun getCompletedTaskCount(subject: Subject): Flow<Int>
fun getStudyTime(subject: Subject): Flow<Int>
suspend fun getSubject(subjectId: String): Subject?
}

View file

@ -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<List<Task>>
fun saveTask(newTask: Task)
fun updateTask(newTask: Task)
fun deleteTask(oldTask: Task)
suspend fun getTask(subjectId: String, taskId: String): Task
}

View file

@ -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<List<TimerInfo>>
fun getAllTimers(): Flow<List<TimerInfo>>
fun saveTimer(newTimer: TimerInfo)
fun updateTimer(timerInfo: TimerInfo)
fun deleteTimer(timerInfo: TimerInfo)
}

View file

@ -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()
}

View file

@ -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"
}

View file

@ -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<List<SessionReport>> {
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)
}

View file

@ -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<List<Subject>> {
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<Int> {
return taskDAO.getTasks(subject)
.map(List<Task>::count)
}
override fun getCompletedTaskCount(subject: Subject): Flow<Int> {
return taskDAO.getTasks(subject)
.map { tasks -> tasks.count { it.completed && !it.archived } }
}
override fun getStudyTime(subject: Subject): Flow<Int> {
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)
}

View file

@ -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<List<Task>> {
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)

View file

@ -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<User>
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()
}
}

View file

@ -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<TimerInfo> {
val jsonString: String = remoteConfig[DEFAULT_TIMERS].asString()
// Json is een lijst van timers
val timerJsonList: List<TimerJson> = ToTimerConverter().jsonToTimerJsonList(jsonString)
return ToTimerConverter().convertToTimerInfoList(timerJsonList)
}
companion object {
private const val DEFAULT_TIMERS = "default_timers"
}
}

View file

@ -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<Map<String, List<FeedEntry>>> {
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>): 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
)
}
}

View file

@ -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<List<TimerInfo>> {
return currentUserTimersCollection()
.snapshots()
.map { it.toObjects(TimerJson::class.java) }
.map { ToTimerConverter().convertToTimerInfoList(it) }
}
override fun getAllTimers(): Flow<List<TimerInfo>> {
// Wrap default timers in een flow en combineer met de userTimer flow.
val defaultTimers: List<TimerInfo> = configurationService.getDefaultTimers()
val defaultTimersFlow: Flow<List<TimerInfo>> = flowOf(defaultTimers)
val userTimersFlow: Flow<List<TimerInfo>> = 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)
}

View file

@ -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) }
}
}

View file

@ -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)
}
}

View file

@ -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<TimerType, TimerFactory> = 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<TimerJson>): List<TimerInfo> {
return timerJsonList.map(this::getTimer)
}
fun jsonToTimerJsonList(json: String): List<TimerJson> {
val type = object : TypeToken<List<TimerJson>>() {}.type
return Gson().fromJson(json, type)
}
}

View file

@ -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"
}

View file

@ -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(),
)
}
}
}

View file

@ -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
)
}

View file

@ -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 = {}
)
}

View file

@ -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({}, {}, {}, {}, {})
)
}

View file

@ -0,0 +1,6 @@
package be.ugent.sel.studeez.screens.log_in
data class LoginUiState(
val email: String = "",
val password: String = ""
)

View file

@ -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)
}
}

View file

@ -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({}, {}, {}))
}
}

View file

@ -0,0 +1,5 @@
package be.ugent.sel.studeez.screens.profile
data class ProfileEditUiState (
val username: String = ""
)

View file

@ -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)
}
}

View file

@ -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 }, {}, {}, {}, {}, {}, {}, {})
)
}

View file

@ -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)
}
}

View file

@ -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()
}
}
}
}
}

View file

@ -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)
)
}

Some files were not shown because too many files have changed in this diff Show more