Merge remote-tracking branch 'origin/development' into uitesting

This commit is contained in:
Rune Dyselinck 2023-05-15 23:30:07 +02:00
commit 4244770f95
43 changed files with 1725 additions and 127 deletions

View file

@ -33,7 +33,11 @@ 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) {
fun BasicTextButton(
@StringRes text: Int,
modifier: Modifier,
action: () -> Unit
) {
TextButton(
onClick = action,
modifier = modifier

View file

@ -0,0 +1,44 @@
package be.ugent.sel.studeez.common.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import be.ugent.sel.studeez.R
import be.ugent.sel.studeez.ui.theme.StudeezTheme
@Composable
fun ProfilePicture() {
Box(
modifier = Modifier
.size(40.dp)
.background(MaterialTheme.colors.primary, CircleShape)
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(id = R.string.username),
modifier = Modifier
.size(30.dp)
.align(Alignment.Center),
tint = MaterialTheme.colors.onPrimary
)
}
}
@Preview
@Composable
fun ProfilePicturePreview() {
StudeezTheme {
ProfilePicture()
}
}

View file

@ -1,6 +1,7 @@
package be.ugent.sel.studeez.common.composable
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
@ -9,6 +10,7 @@ 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.material.icons.filled.Search
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@ -21,6 +23,8 @@ 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 com.google.android.material.color.MaterialColors
import kotlin.math.sin
import be.ugent.sel.studeez.R.drawable as AppIcon
import be.ugent.sel.studeez.R.string as AppText
@ -215,4 +219,34 @@ private fun PasswordField(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = visualTransformation
)
}
@Composable
fun SearchField(
value: String,
onValueChange: (String) -> Unit,
onSubmit: () -> Unit,
@StringRes label: Int,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
label = { Text(text = stringResource(id = label)) },
trailingIcon = {
IconButton(onClick = onSubmit) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(label),
tint = MaterialTheme.colors.primary
)
}
},
singleLine = true,
colors = TextFieldDefaults.outlinedTextFieldColors(
textColor = MaterialTheme.colors.onBackground,
backgroundColor = MaterialTheme.colors.background
)
)
}

View file

@ -5,6 +5,7 @@ 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 be.ugent.sel.studeez.domain.UserDAO
import javax.inject.Inject
import javax.inject.Singleton
@ -42,4 +43,11 @@ class SelectedSubject @Inject constructor() : SelectedState<Subject>() {
@Singleton
class SelectedTimerInfo @Inject constructor() : SelectedState<TimerInfo>() {
override lateinit var value: TimerInfo
}
@Singleton
class SelectedUserId @Inject constructor(
userDAO: UserDAO
): SelectedState<String>() {
override var value: String = userDAO.getCurrentUserId()
}

View file

@ -0,0 +1,11 @@
package be.ugent.sel.studeez.data.local.models
import com.google.firebase.Timestamp
import com.google.firebase.firestore.DocumentId
data class Friendship(
@DocumentId val id: String = "",
val friendId: String = "",
val friendsSince: Timestamp = Timestamp.now(),
val accepted: Boolean = false
)

View file

@ -1,3 +1,9 @@
package be.ugent.sel.studeez.data.local.models
data class User(val id: String = "")
import com.google.firebase.firestore.DocumentId
data class User(
@DocumentId val id: String = "",
val username: String = "",
val biography: String = ""
)

View file

@ -0,0 +1,7 @@
package be.ugent.sel.studeez.data.remote
object FirebaseFriendship {
const val FRIENDID: String = "friendId"
const val ACCEPTED: String = "accepted"
const val FRIENDSSINCE: String = "friendsSince"
}

View file

@ -0,0 +1,6 @@
package be.ugent.sel.studeez.data.remote
object FirebaseSessionReport {
const val STUDYTIME: String = "studyTime"
const val ENDTIME: String = "endTime"
}

View file

@ -0,0 +1,6 @@
package be.ugent.sel.studeez.data.remote
object FirebaseUser {
const val USERNAME: String = "username"
const val BIOGRAPHY: String = "biography"
}

View file

@ -16,6 +16,9 @@ abstract class DatabaseModule {
@Binds
abstract fun provideUserDAO(impl: FirebaseUserDAO): UserDAO
@Binds
abstract fun provideFriendshipDAO(impl: FirebaseFriendshipDAO): FriendshipDAO
@Binds
abstract fun provideTimerDAO(impl: FirebaseTimerDAO): TimerDAO
@ -26,13 +29,13 @@ abstract class DatabaseModule {
abstract fun provideConfigurationService(impl: FirebaseConfigurationService): ConfigurationService
@Binds
abstract fun provideSessionDAO(impl: FireBaseSessionDAO): SessionDAO
abstract fun provideSessionDAO(impl: FirebaseSessionDAO): SessionDAO
@Binds
abstract fun provideSubjectDAO(impl: FireBaseSubjectDAO): SubjectDAO
abstract fun provideSubjectDAO(impl: FirebaseSubjectDAO): SubjectDAO
@Binds
abstract fun provideTaskDAO(impl: FireBaseTaskDAO): TaskDAO
abstract fun provideTaskDAO(impl: FirebaseTaskDAO): TaskDAO
@Binds
abstract fun provideFeedDAO(impl: FirebaseFeedDAO): FeedDAO

View file

@ -0,0 +1,54 @@
package be.ugent.sel.studeez.domain
import be.ugent.sel.studeez.data.local.models.Friendship
import kotlinx.coroutines.flow.Flow
/**
* Should be used for interactions between friends.
*/
interface FriendshipDAO {
/**
* @return all friendships of a chosen user.
*/
fun getAllFriendships(
userId: String
): Flow<List<Friendship>>
/**
* @return the amount of friends of a chosen user.
* This method should be faster than just counting the length of getAllFriends()
*/
fun getFriendshipCount(
userId: String
): Flow<Int>
/**
* @param id the id of the friendship that you want details of
* @return the details of a Friendship
*/
fun getFriendshipDetails(id: String): Friendship
/**
* Send a friend request to a user.
* @param id of the user that you want to add as a friend
* @return Success/faillure of transaction
*/
fun sendFriendshipRequest(id: String): Boolean
/**
* Accept a friend request that has already been sent.
* @param id of the friendship that you want to update
* @return: Success/faillure of transaction
*/
fun acceptFriendship(id: String): Boolean
/**
* Remove a friend or decline a friendrequest.
* @param friendship the one you want to remove
* @return: Success/faillure of transaction
*/
fun removeFriendship(
friendship: Friendship
): Boolean
}

View file

@ -1,12 +1,19 @@
package be.ugent.sel.studeez.domain
import be.ugent.sel.studeez.data.local.models.SessionReport
import be.ugent.sel.studeez.data.local.models.User
import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo
import kotlinx.coroutines.flow.Flow
interface SessionDAO {
fun getSessions(): Flow<List<SessionReport>>
suspend fun getSessionsOfUser(userId: String): List<SessionReport>
/**
* Return a list of pairs, containing the username and all the studysessions of that user.
*/
fun getFriendsSessions(): Flow<List<Pair<String,List<SessionReport>>>>
fun saveSession(newSessionReport: SessionReport)

View file

@ -1,13 +1,52 @@
package be.ugent.sel.studeez.domain
import be.ugent.sel.studeez.data.local.models.User
import kotlinx.coroutines.flow.Flow
interface UserDAO {
suspend fun getUsername(): String?
suspend fun save(newUsername: String)
fun getCurrentUserId(): String
/**
* Delete all references to this user in the database. Similar to the deleteCascade in
* @return all users
*/
fun getAllUsers(): Flow<List<User>>
/**
* @return all users based on a query, a trimmed down version of getAllUsers()
*/
fun getUsersWithQuery(
fieldName: String,
value: String
): Flow<List<User>>
/**
* Request information about a user
*/
fun getUserDetails(
userId: String
): Flow<User>
suspend fun getUsername(
userId: String
): String
/**
* @return information on the currently logged in user.
*/
suspend fun getLoggedInUser(): User
// TODO Should be refactored to fun getLoggedInUser(): Flow<User>, without suspend.
suspend fun saveLoggedInUser(
newUsername: String,
newBiography: String = ""
)
// TODO Should be refactored to fun saveLoggedInUser(...): Boolean, without suspend.
/**
* Delete all references to the logged in user in the database. Similar to the deleteCascade in
* relational databases.
*/
suspend fun deleteUserReferences()
suspend fun deleteLoggedInUserReferences()
// TODO Should be refactored to fun deleteLoggedInUserReferences(): Boolean, without suspend.
}

View file

@ -1,37 +0,0 @@
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

@ -1,8 +1,9 @@
package be.ugent.sel.studeez.domain.implementation
object FireBaseCollections {
object FirebaseCollections {
const val SESSION_COLLECTION = "sessions"
const val USER_COLLECTION = "users"
const val FRIENDS_COLLECTION = "friends"
const val TIMER_COLLECTION = "timers"
const val SUBJECT_COLLECTION = "subjects"
const val TASK_COLLECTION = "tasks"

View file

@ -0,0 +1,134 @@
package be.ugent.sel.studeez.domain.implementation
import androidx.compose.runtime.collectAsState
import be.ugent.sel.studeez.common.snackbar.SnackbarManager
import be.ugent.sel.studeez.data.local.models.Friendship
import be.ugent.sel.studeez.data.remote.FirebaseFriendship.ACCEPTED
import be.ugent.sel.studeez.data.remote.FirebaseFriendship.FRIENDSSINCE
import be.ugent.sel.studeez.data.remote.FirebaseFriendship.FRIENDID
import be.ugent.sel.studeez.domain.AccountDAO
import be.ugent.sel.studeez.domain.FriendshipDAO
import be.ugent.sel.studeez.domain.implementation.FirebaseCollections.FRIENDS_COLLECTION
import be.ugent.sel.studeez.domain.implementation.FirebaseCollections.USER_COLLECTION
import com.google.firebase.Timestamp
import com.google.firebase.firestore.DocumentReference
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ktx.snapshots
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import be.ugent.sel.studeez.R.string as AppText
class FirebaseFriendshipDAO @Inject constructor(
private val firestore: FirebaseFirestore,
private val auth: AccountDAO
): FriendshipDAO {
private fun currentUserDocument(): DocumentReference = firestore
.collection(USER_COLLECTION)
.document(auth.currentUserId)
override fun getAllFriendships(
userId: String
): Flow<List<Friendship>> {
return firestore
.collection(USER_COLLECTION)
.document(userId)
.collection(FRIENDS_COLLECTION)
.snapshots()
.map { it.toObjects(Friendship::class.java) }
}
override fun getFriendshipCount(
userId: String
): Flow<Int> {
return flow {
val friendshipCount = suspendCoroutine { continuation ->
firestore
.collection(USER_COLLECTION)
.document(userId)
.collection(FRIENDS_COLLECTION)
.get()
.addOnSuccessListener { querySnapshot ->
continuation.resume(querySnapshot.size())
}
.addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
}
emit(friendshipCount)
}.catch {
SnackbarManager.showMessage(AppText.generic_error)
}
}
override fun getFriendshipDetails(id: String): Friendship {
TODO("Not yet implemented")
}
override fun sendFriendshipRequest(id: String): Boolean {
val currentUserId: String = auth.currentUserId
val otherUserId: String = id
// Add entry to current user
currentUserDocument()
.collection(FRIENDS_COLLECTION)
.add(mapOf(
FRIENDID to otherUserId,
ACCEPTED to true, // TODO Make it not automatically accepted.
FRIENDSSINCE to Timestamp.now()
))
// Add entry to other user
firestore.collection(USER_COLLECTION)
.document(otherUserId)
.collection(FRIENDS_COLLECTION)
.add(mapOf(
FRIENDID to currentUserId,
ACCEPTED to true, // TODO Make it not automatically accepted.
FRIENDSSINCE to Timestamp.now()
))
return true
}
override fun acceptFriendship(id: String): Boolean {
TODO("Not yet implemented")
}
override fun removeFriendship(
friendship: Friendship
): Boolean {
val currentUserId: String = auth.currentUserId
val otherUserId: String = friendship.friendId
// Remove at logged in user
firestore.collection(USER_COLLECTION)
.document(currentUserId)
.collection(FRIENDS_COLLECTION)
.document(friendship.id)
.delete()
// Remove at other user
firestore.collection(USER_COLLECTION)
.document(otherUserId)
.collection(FRIENDS_COLLECTION)
.whereEqualTo(FRIENDID, currentUserId)
.get()
.addOnSuccessListener {
for (document in it) {
document.reference.delete()
}
}.addOnFailureListener {
SnackbarManager.showMessage(AppText.generic_error)
}
return true
}
}

View file

@ -0,0 +1,79 @@
package be.ugent.sel.studeez.domain.implementation
import be.ugent.sel.studeez.data.local.models.SessionReport
import be.ugent.sel.studeez.data.local.models.User
import be.ugent.sel.studeez.data.local.models.timer_info.TimerInfo
import be.ugent.sel.studeez.data.remote.FirebaseSessionReport
import be.ugent.sel.studeez.data.remote.FirebaseSessionReport.ENDTIME
import be.ugent.sel.studeez.data.remote.FirebaseSessionReport.STUDYTIME
import be.ugent.sel.studeez.domain.AccountDAO
import be.ugent.sel.studeez.domain.FriendshipDAO
import be.ugent.sel.studeez.domain.SessionDAO
import be.ugent.sel.studeez.domain.UserDAO
import be.ugent.sel.studeez.domain.implementation.FirebaseCollections.SESSION_COLLECTION
import be.ugent.sel.studeez.domain.implementation.FirebaseCollections.USER_COLLECTION
import com.google.firebase.Timestamp
import com.google.firebase.firestore.CollectionReference
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ktx.getField
import com.google.firebase.firestore.ktx.snapshots
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
class FirebaseSessionDAO @Inject constructor(
private val firestore: FirebaseFirestore,
private val auth: AccountDAO,
private val userDAO: UserDAO,
private val friendshipDAO: FriendshipDAO
) : SessionDAO {
override fun getSessions(): Flow<List<SessionReport>> {
return currentUserSessionsCollection()
.snapshots()
.map { it.toObjects(SessionReport::class.java) }
}
override suspend fun getSessionsOfUser(userId: String): List<SessionReport> {
val collection = firestore.collection(USER_COLLECTION)
.document(userId)
.collection(SESSION_COLLECTION)
.get().await()
val list: MutableList<SessionReport> = mutableListOf()
for (document in collection) {
val id = document.id
val studyTime: Int = document.getField<Int>(STUDYTIME)!!
val endTime: Timestamp = document.getField<Timestamp>(ENDTIME)!!
list.add(SessionReport(id, studyTime, endTime))
}
return list
}
override fun getFriendsSessions(): Flow<List<Pair<String, List<SessionReport>>>> {
return friendshipDAO.getAllFriendships(auth.currentUserId)
.map { friendships ->
friendships.map { friendship ->
val userId: String = friendship.friendId
val username = userDAO.getUsername(userId)
val userSessions = getSessionsOfUser(userId)
Pair(username, userSessions)
}
}
}
override fun saveSession(newSessionReport: SessionReport) {
currentUserSessionsCollection().add(newSessionReport)
}
override fun deleteSession(newTimer: TimerInfo) {
currentUserSessionsCollection().document(newTimer.id).delete()
}
private fun currentUserSessionsCollection(): CollectionReference =
firestore.collection(USER_COLLECTION)
.document(auth.currentUserId)
.collection(SESSION_COLLECTION)
}

View file

@ -18,7 +18,7 @@ import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import kotlin.collections.count
class FireBaseSubjectDAO @Inject constructor(
class FirebaseSubjectDAO @Inject constructor(
private val firestore: FirebaseFirestore,
private val auth: AccountDAO,
private val taskDAO: TaskDAO,
@ -49,7 +49,7 @@ class FireBaseSubjectDAO @Inject constructor(
override suspend fun archiveSubject(subject: Subject) {
currentUserSubjectsCollection().document(subject.id).update(SubjectDocument.archived, true)
currentUserSubjectsCollection().document(subject.id)
.collection(FireBaseCollections.TASK_COLLECTION)
.collection(FirebaseCollections.TASK_COLLECTION)
.taskNotArchived()
.get().await()
.documents
@ -74,16 +74,16 @@ class FireBaseSubjectDAO @Inject constructor(
}
private fun currentUserSubjectsCollection(): CollectionReference =
firestore.collection(FireBaseCollections.USER_COLLECTION)
firestore.collection(FirebaseCollections.USER_COLLECTION)
.document(auth.currentUserId)
.collection(FireBaseCollections.SUBJECT_COLLECTION)
.collection(FirebaseCollections.SUBJECT_COLLECTION)
private fun subjectTasksCollection(subject: Subject): CollectionReference =
firestore.collection(FireBaseCollections.USER_COLLECTION)
firestore.collection(FirebaseCollections.USER_COLLECTION)
.document(auth.currentUserId)
.collection(FireBaseCollections.SUBJECT_COLLECTION)
.collection(FirebaseCollections.SUBJECT_COLLECTION)
.document(subject.id)
.collection(FireBaseCollections.TASK_COLLECTION)
.collection(FirebaseCollections.TASK_COLLECTION)
fun CollectionReference.subjectNotArchived(): Query =
this.whereEqualTo(SubjectDocument.archived, false)

View file

@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
class FireBaseTaskDAO @Inject constructor(
class FirebaseTaskDAO @Inject constructor(
private val firestore: FirebaseFirestore,
private val auth: AccountDAO,
) : TaskDAO {
@ -45,12 +45,11 @@ class FireBaseTaskDAO @Inject constructor(
}
private fun selectedSubjectTasksCollection(subjectId: String): CollectionReference =
firestore.collection(FireBaseCollections.USER_COLLECTION)
firestore.collection(FirebaseCollections.USER_COLLECTION)
.document(auth.currentUserId)
.collection(FireBaseCollections.SUBJECT_COLLECTION)
.collection(FirebaseCollections.SUBJECT_COLLECTION)
.document(subjectId)
.collection(FireBaseCollections.TASK_COLLECTION)
.collection(FirebaseCollections.TASK_COLLECTION)
}
// Extend CollectionReference and Query with some filters

View file

@ -48,8 +48,8 @@ class FirebaseTimerDAO @Inject constructor(
}
private fun currentUserTimersCollection(): CollectionReference =
firestore.collection(FireBaseCollections.USER_COLLECTION)
firestore.collection(FirebaseCollections.USER_COLLECTION)
.document(auth.currentUserId)
.collection(FireBaseCollections.TIMER_COLLECTION)
.collection(FirebaseCollections.TIMER_COLLECTION)
}

View file

@ -2,34 +2,91 @@ 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.data.local.models.User
import be.ugent.sel.studeez.data.remote.FirebaseUser.BIOGRAPHY
import be.ugent.sel.studeez.data.remote.FirebaseUser.USERNAME
import be.ugent.sel.studeez.domain.AccountDAO
import be.ugent.sel.studeez.domain.UserDAO
import be.ugent.sel.studeez.domain.implementation.FirebaseCollections.USER_COLLECTION
import com.google.firebase.firestore.DocumentReference
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ktx.snapshots
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
class FirebaseUserDAO @Inject constructor(
private val firestore: FirebaseFirestore,
private val auth: AccountDAO
) : UserDAO {
) : UserDAO {
override suspend fun getUsername(): String? {
return currentUserDocument().get().await().getString("username")
}
override suspend fun save(newUsername: String) {
currentUserDocument().set(mapOf("username" to newUsername))
override fun getCurrentUserId(): String {
return auth.currentUserId
}
private fun currentUserDocument(): DocumentReference =
firestore.collection(USER_COLLECTION).document(auth.currentUserId)
firestore
.collection(USER_COLLECTION)
.document(auth.currentUserId)
companion object {
private const val USER_COLLECTION = "users"
override fun getAllUsers(): Flow<List<User>> {
return firestore
.collection(USER_COLLECTION)
.snapshots()
.map { it.toObjects(User::class.java) }
}
override suspend fun deleteUserReferences() {
override fun getUsersWithQuery(
fieldName: String,
value: String
): Flow<List<User>> {
return firestore
.collection(USER_COLLECTION)
.whereEqualTo(fieldName, value)
.snapshots()
.map { it.toObjects(User::class.java) }
}
override fun getUserDetails(userId: String): Flow<User> {
return flow {
val snapshot = firestore
.collection(USER_COLLECTION)
.document(userId)
.get()
.await()
val user = snapshot.toObject(User::class.java)!!
emit(user)
}
}
override suspend fun getUsername(userId: String): String {
val user = firestore.collection(USER_COLLECTION)
.document(userId)
.get().await()
return user.getString(USERNAME)!!
}
override suspend fun getLoggedInUser(): User {
val userDocument = currentUserDocument().get().await()
return User(
username = userDocument.getString(USERNAME) ?: "",
biography = userDocument.getString(BIOGRAPHY) ?: ""
)
}
override suspend fun saveLoggedInUser(
newUsername: String,
newBiography: String
) {
currentUserDocument().set(mapOf(
USERNAME to newUsername,
BIOGRAPHY to newBiography
))
}
override suspend fun deleteLoggedInUserReferences() {
currentUserDocument().delete()
.addOnSuccessListener { SnackbarManager.showMessage(R.string.success) }
.addOnFailureListener { SnackbarManager.showMessage(R.string.generic_error) }

View file

@ -30,7 +30,9 @@ object StudeezDestinations {
const val EDIT_TASK_FORM = "edit_task"
// Friends flow
const val FRIENDS_OVERVIEW_SCREEN = "friends_overview"
const val SEARCH_FRIENDS_SCREEN = "search_friends"
const val PUBLIC_PROFILE_SCREEN = "public_profile"
// Create & edit screens
const val CREATE_TASK_SCREEN = "create_task"

View file

@ -14,10 +14,13 @@ 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.friends.friends_overview.FriendsOveriewRoute
import be.ugent.sel.studeez.screens.friends.friends_search.SearchFriendsRoute
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.edit_profile.EditProfileRoute
import be.ugent.sel.studeez.screens.profile.ProfileRoute
import be.ugent.sel.studeez.screens.profile.public_profile.PublicProfileRoute
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
@ -69,6 +72,7 @@ fun StudeezNavGraph(
drawerActions = drawerActions,
navigationBarActions = navigationBarActions,
feedViewModel = hiltViewModel(),
viewModel = hiltViewModel()
)
}
@ -221,8 +225,28 @@ fun StudeezNavGraph(
}
// Friends flow
composable(StudeezDestinations.FRIENDS_OVERVIEW_SCREEN) {
FriendsOveriewRoute(
open = open,
popUp = goBack,
viewModel = hiltViewModel()
)
}
composable(StudeezDestinations.SEARCH_FRIENDS_SCREEN) {
// TODO
SearchFriendsRoute(
popUp = goBack,
open = open,
viewModel = hiltViewModel()
)
}
composable(StudeezDestinations.PUBLIC_PROFILE_SCREEN) {
PublicProfileRoute(
popUp = goBack,
open = open,
viewModel = hiltViewModel()
)
}
// Create & edit screens

View file

@ -0,0 +1,288 @@
package be.ugent.sel.studeez.screens.friends.friends_overview
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import be.ugent.sel.studeez.R
import be.ugent.sel.studeez.common.composable.BasicButton
import be.ugent.sel.studeez.common.composable.ProfilePicture
import be.ugent.sel.studeez.common.composable.SearchField
import be.ugent.sel.studeez.common.composable.drawer.DrawerEntry
import be.ugent.sel.studeez.common.ext.basicButton
import be.ugent.sel.studeez.data.local.models.Friendship
import be.ugent.sel.studeez.data.local.models.User
import be.ugent.sel.studeez.resources
import be.ugent.sel.studeez.ui.theme.StudeezTheme
import com.google.firebase.Timestamp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import be.ugent.sel.studeez.R.string as AppText
data class FriendsOverviewActions(
val getFriendsFlow: () -> Flow<List<Pair<User, Friendship>>>,
val searchFriends: () -> Unit,
val onQueryStringChange: (String) -> Unit,
val onSubmit: () -> Unit,
val viewProfile: (String) -> Unit,
val removeFriend: (Friendship) -> Unit
)
fun getFriendsOverviewActions(
viewModel: FriendsOverviewViewModel,
open: (String) -> Unit
): FriendsOverviewActions {
return FriendsOverviewActions(
getFriendsFlow = viewModel::getAllFriends,
searchFriends = { viewModel.searchFriends(open) },
onQueryStringChange = viewModel::onQueryStringChange,
onSubmit = { viewModel.onSubmit(open) },
viewProfile = { userId ->
viewModel.viewProfile(userId, open)
},
removeFriend = viewModel::removeFriend
)
}
@Composable
fun FriendsOveriewRoute(
open: (String) -> Unit,
popUp: () -> Unit,
viewModel: FriendsOverviewViewModel
) {
val uiState by viewModel.uiState
FriendsOverviewScreen(
popUp = popUp,
uiState = uiState,
friendsOverviewActions = getFriendsOverviewActions(
viewModel = viewModel,
open = open
)
)
}
@Composable
fun FriendsOverviewScreen(
popUp: () -> Unit,
uiState: FriendsOverviewUiState,
friendsOverviewActions: FriendsOverviewActions
) {
val friends = friendsOverviewActions.getFriendsFlow().collectAsState(initial = emptyList())
Scaffold(
topBar = {
TopAppBar(
title = {
// TODO Link to each other
SearchField(
value = uiState.queryString,
onValueChange = friendsOverviewActions.onQueryStringChange,
onSubmit = friendsOverviewActions.onSubmit,
label = AppText.search_friends
)
},
navigationIcon = {
IconButton(onClick = popUp) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = resources().getString(R.string.go_back)
)
}
}
// TODO Add inbox action
)
}
) { paddingValues ->
LazyColumn (
modifier = Modifier.padding(paddingValues)
) {
if (friends.value.isEmpty()) {
// Show a quick button to search friends when the user does not have any friends yet.
item {
BasicButton(
text = AppText.no_friends,
modifier = Modifier.basicButton()
) {
friendsOverviewActions.searchFriends()
}
}
}
items(friends.value) { friend ->
FriendsEntry(
user = friend.first,
friendship = friend.second,
viewProfile = { userId -> friendsOverviewActions.viewProfile(userId) },
removeFriend = friendsOverviewActions.removeFriend
)
}
}
}
}
@Preview
@Composable
fun FriendsOverviewPreview() {
StudeezTheme {
FriendsOverviewScreen(
popUp = {},
uiState = FriendsOverviewUiState(""),
friendsOverviewActions = FriendsOverviewActions(
getFriendsFlow = { emptyFlow() },
searchFriends = {},
onQueryStringChange = {},
onSubmit = {},
viewProfile = {},
removeFriend = {}
)
)
}
}
@Composable
fun FriendsEntry(
user: User,
friendship: Friendship,
viewProfile: (String) -> Unit,
removeFriend: (Friendship) -> Unit
) {
Row (
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp, vertical = 7.dp),
) {
Box(
modifier = Modifier
.padding(vertical = 4.dp)
) {
ProfilePicture()
}
Box (
modifier = Modifier
.fillMaxWidth()
) {
Column (
modifier = Modifier
.padding(vertical = 4.dp)
) {
Text(
text = user.username,
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${resources().getString(AppText.app_name)} ${resources().getString(AppText.friend)}",
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterEnd
) {
FriendsOverviewDropDown(
friendship = friendship,
viewProfile = viewProfile,
removeFriend = removeFriend
)
}
}
}
}
@Preview
@Composable
fun FriendsEntryPreview() {
StudeezTheme {
FriendsEntry(
user = User(
id = "",
username = "Tibo De Peuter",
biography = "short bio"
),
friendship = Friendship(
id = "",
friendId = "someId",
friendsSince = Timestamp.now(),
accepted = true
),
viewProfile = {},
removeFriend = {}
)
}
}
@Composable
fun FriendsOverviewDropDown(
friendship: Friendship,
viewProfile: (String) -> Unit,
removeFriend: (Friendship) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = { expanded = true }
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_more_horizontal),
contentDescription = resources().getString(AppText.view_more),
modifier = Modifier.fillMaxSize()
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DrawerEntry(
icon = Icons.Default.Person,
text = stringResource(id = AppText.show_profile)
) {
viewProfile(friendship.friendId)
}
DrawerEntry(
icon = Icons.Default.Delete,
text = stringResource(id = AppText.remove_friend)
) {
removeFriend(friendship)
expanded = false
}
}
}
@Preview
@Composable
fun FriendsOverviewDropDownPreview() {
StudeezTheme {
FriendsOverviewDropDown(
friendship = Friendship(
id = "",
friendId = "someId",
friendsSince = Timestamp.now(),
accepted = true
),
viewProfile = {},
removeFriend = { }
)
}
}

View file

@ -0,0 +1,6 @@
package be.ugent.sel.studeez.screens.friends.friends_overview
data class FriendsOverviewUiState(
val userId: String,
val queryString: String = ""
)

View file

@ -0,0 +1,78 @@
package be.ugent.sel.studeez.screens.friends.friends_overview
import androidx.compose.runtime.mutableStateOf
import be.ugent.sel.studeez.data.SelectedUserId
import be.ugent.sel.studeez.data.local.models.Friendship
import be.ugent.sel.studeez.data.local.models.User
import be.ugent.sel.studeez.domain.FriendshipDAO
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapConcat
import javax.inject.Inject
@HiltViewModel
class FriendsOverviewViewModel @Inject constructor(
private val userDAO: UserDAO,
private val friendshipDAO: FriendshipDAO,
private val selectedUserIdState: SelectedUserId,
logService: LogService
) : StudeezViewModel(logService) {
var uiState = mutableStateOf(FriendsOverviewUiState(
userId = selectedUserIdState.value
))
private set
fun getAllFriends(): Flow<List<Pair<User, Friendship>>> {
return friendshipDAO.getAllFriendships(
userId = uiState.value.userId
)
.flatMapConcat { friendships ->
val userFlows = friendships.map { friendship ->
userDAO.getUserDetails(friendship.friendId)
}
combine(userFlows) { users ->
friendships.zip(users) { friendship, user ->
Pair(user, friendship)
}
}
}
}
fun searchFriends(open: (String) -> Unit) {
open(StudeezDestinations.SEARCH_FRIENDS_SCREEN)
}
fun onQueryStringChange(newValue: String) {
uiState.value = uiState.value.copy(
queryString = newValue
)
}
fun onSubmit(open: (String) -> Unit) {
val query = uiState.value.queryString // TODO Pass as argument
open(StudeezDestinations.SEARCH_FRIENDS_SCREEN)
}
fun viewProfile(
userId: String,
open: (String) -> Unit
) {
selectedUserIdState.value = userId
open(StudeezDestinations.PUBLIC_PROFILE_SCREEN)
}
fun removeFriend(
friendship: Friendship
) {
friendshipDAO.removeFriendship(
friendship = friendship
)
}
}

View file

@ -0,0 +1,10 @@
package be.ugent.sel.studeez.screens.friends.friends_search
import be.ugent.sel.studeez.data.local.models.User
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
data class SearchFriendUiState(
val queryString: String = "",
val searchResults: Flow<List<User>> = emptyFlow()
)

View file

@ -0,0 +1,265 @@
package be.ugent.sel.studeez.screens.friends.friends_search
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import be.ugent.sel.studeez.R
import be.ugent.sel.studeez.common.composable.ProfilePicture
import be.ugent.sel.studeez.common.composable.SearchField
import be.ugent.sel.studeez.common.composable.drawer.DrawerEntry
import be.ugent.sel.studeez.data.local.models.User
import be.ugent.sel.studeez.resources
import be.ugent.sel.studeez.ui.theme.StudeezTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import be.ugent.sel.studeez.R.string as AppText
data class SearchFriendsActions(
val onQueryStringChange: (String) -> Unit,
val getUsersWithUsername: (String) -> Unit,
val getAllUsers: () -> Flow<List<User>>,
val goToProfile: (String) -> Unit
)
fun getSearchFriendsActions(
viewModel: SearchFriendsViewModel,
open: (String) -> Unit
): SearchFriendsActions {
return SearchFriendsActions(
onQueryStringChange = viewModel::onQueryStringChange,
getUsersWithUsername = viewModel::getUsersWithUsername,
getAllUsers = { viewModel.getAllUsers() },
goToProfile = { userId -> viewModel.goToProfile(userId, open) }
)
}
@Composable
fun SearchFriendsRoute(
popUp: () -> Unit,
open: (String) -> Unit,
viewModel: SearchFriendsViewModel
) {
val uiState by viewModel.uiState
SearchFriendsScreen(
popUp = popUp,
uiState = uiState,
searchFriendsActions = getSearchFriendsActions(
viewModel = viewModel,
open = open
)
)
}
@Composable
fun SearchFriendsScreen(
popUp: () -> Unit,
uiState: SearchFriendUiState,
searchFriendsActions: SearchFriendsActions
) {
var query by remember { mutableStateOf(uiState.queryString) }
val searchResults = searchFriendsActions.getAllUsers().collectAsState(
initial = emptyList()
)
Scaffold(
topBar = {
TopAppBar(
title = {
SearchField(
value = query,
onValueChange = { newValue ->
searchFriendsActions.onQueryStringChange(newValue)
query = newValue
},
onSubmit = { },
label = AppText.search_friends
)
},
navigationIcon = {
IconButton(onClick = popUp) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = resources().getString(R.string.go_back)
)
}
}
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
items (searchResults.value) { user ->
UserEntry(
user = user,
goToProfile = searchFriendsActions.goToProfile
)
}
}
}
}
@Preview
@Composable
fun SearchFriendsPreview() {
StudeezTheme {
SearchFriendsScreen(
popUp = {},
uiState = SearchFriendUiState(
queryString = "dit is een test",
searchResults = flowOf(listOf(User(
id = "someid",
username = "Eerste user",
biography = "blah blah blah"
)))
),
searchFriendsActions = SearchFriendsActions(
onQueryStringChange = {},
getUsersWithUsername = {},
getAllUsers = {
flowOf(listOf(User(
id = "someid",
username = "Eerste user",
biography = "blah blah blah"
)))
},
goToProfile = { }
)
)
}
}
@Composable
fun UserEntry(
user: User,
goToProfile: (String) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp, vertical = 7.dp),
horizontalArrangement = Arrangement.spacedBy(15.dp)
) {
Box(
modifier = Modifier
.padding(vertical = 4.dp)
) {
ProfilePicture()
}
Box (
modifier = Modifier
.fillMaxWidth()
) {
Column (
modifier = Modifier
.padding(vertical = 4.dp)
) {
Text(
text = user.username,
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${resources().getString(AppText.app_name)} ${resources().getString(AppText.friend)}",
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterEnd
) {
SearchFriendsDropDown(
user = user,
goToProfile = goToProfile
)
}
}
}
}
@Preview
@Composable
fun UserEntryPreview() {
StudeezTheme {
UserEntry(
user = User(
id = "someid",
username = "Eerste user",
biography = "blah blah blah"
),
goToProfile = { }
)
}
}
/**
* Three dots that open a dropdown menu that allow to go the users profile.
*/
@Composable
fun SearchFriendsDropDown(
user: User,
goToProfile: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = { expanded = true }
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_more_horizontal),
contentDescription = stringResource(AppText.view_more),
modifier = Modifier.fillMaxSize()
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(onClick = { expanded = false }) {
DrawerEntry(
icon = Icons.Default.Person,
text = stringResource(id = AppText.show_profile)
) {
goToProfile(user.id)
}
}
}
}
@Preview
@Composable
fun SearchFriendsDropDownPreview() {
StudeezTheme {
SearchFriendsDropDown(
user = User(
id = "someid",
username = "Eerste user",
biography = "blah blah blah"
),
goToProfile = { }
)
}
}

View file

@ -0,0 +1,66 @@
package be.ugent.sel.studeez.screens.friends.friends_search
import androidx.compose.runtime.mutableStateOf
import be.ugent.sel.studeez.data.SelectedUserId
import be.ugent.sel.studeez.data.local.models.User
import be.ugent.sel.studeez.data.remote.FirebaseUser
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import javax.inject.Inject
@HiltViewModel
class SearchFriendsViewModel @Inject constructor(
private val userDAO: UserDAO,
private val selectedProfileState: SelectedUserId,
logService: LogService
): StudeezViewModel(logService) {
var uiState = mutableStateOf(SearchFriendUiState())
private set
fun onQueryStringChange(newValue: String) {
uiState.value = uiState.value.copy(
queryString = newValue
)
uiState.value = uiState.value.copy(
searchResults = userDAO.getUsersWithQuery(
fieldName = FirebaseUser.USERNAME,
value = uiState.value.queryString
)
)
}
fun getUsersWithUsername(
value: String
): Flow<List<User>> {
return userDAO.getUsersWithQuery(
fieldName = FirebaseUser.USERNAME,
value = value
)
}
/**
* Get all users, except for the current user.
*/
fun getAllUsers(): Flow<List<User>> {
return userDAO.getAllUsers()
.filter { users ->
users.any { user ->
user.id != userDAO.getCurrentUserId()
}
}
}
fun goToProfile(
userId: String,
open: (String) -> Unit
) {
selectedProfileState.value = userId
open(StudeezDestinations.PUBLIC_PROFILE_SCREEN)
}
}

View file

@ -21,14 +21,15 @@ import be.ugent.sel.studeez.resources
@Composable
fun HomeRoute(
open: (String) -> Unit,
viewModel: HomeViewModel,
drawerActions: DrawerActions,
navigationBarActions: NavigationBarActions,
feedViewModel: FeedViewModel,
) {
val feedUiState by feedViewModel.uiState.collectAsState()
HomeScreen(
onViewFriendsClick = { viewModel.onViewFriendsClick(open) },
drawerActions = drawerActions,
open = open,
navigationBarActions = navigationBarActions,
feedUiState = feedUiState,
continueTask = { subjectId, taskId -> feedViewModel.continueTask(open, subjectId, taskId) },
@ -38,7 +39,7 @@ fun HomeRoute(
@Composable
fun HomeScreen(
open: (String) -> Unit,
onViewFriendsClick: () -> Unit,
drawerActions: DrawerActions,
navigationBarActions: NavigationBarActions,
feedUiState: FeedUiState,
@ -49,15 +50,17 @@ fun HomeScreen(
title = resources().getString(R.string.home),
drawerActions = drawerActions,
navigationBarActions = navigationBarActions,
// TODO barAction = { FriendsAction() }
barAction = { FriendsAction(onViewFriendsClick) }
) {
Feed(feedUiState, continueTask, onEmptyFeedHelp)
}
}
@Composable
fun FriendsAction() {
IconButton(onClick = { /*TODO*/ }) {
fun FriendsAction(
onClick: () -> Unit
) {
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = resources().getString(R.string.friends)
@ -69,9 +72,9 @@ fun FriendsAction() {
@Composable
fun HomeScreenPreview() {
HomeScreen(
onViewFriendsClick = {},
drawerActions = DrawerActions({}, {}, {}, {}, {}),
navigationBarActions = NavigationBarActions({ false }, {}, {}, {}, {}, {}, {}, {}),
open = {},
feedUiState = FeedUiState.Succes(
mapOf(
"08 May 2023" to listOf(

View file

@ -0,0 +1,17 @@
package be.ugent.sel.studeez.screens.home
import be.ugent.sel.studeez.domain.LogService
import be.ugent.sel.studeez.navigation.StudeezDestinations
import be.ugent.sel.studeez.screens.StudeezViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
logService: LogService
) : StudeezViewModel(logService) {
fun onViewFriendsClick(open: (String) -> Unit) {
open(StudeezDestinations.FRIENDS_OVERVIEW_SCREEN)
}
}

View file

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

View file

@ -1,37 +1,50 @@
package be.ugent.sel.studeez.screens.profile
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
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.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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
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.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.common.ext.defaultButtonShape
import be.ugent.sel.studeez.resources
import be.ugent.sel.studeez.ui.theme.StudeezTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import be.ugent.sel.studeez.R.string as AppText
data class ProfileActions(
val getUsername: suspend CoroutineScope.() -> String?,
val getBiography: suspend CoroutineScope.() -> String?,
val getAmountOfFriends: () -> Flow<Int>,
val onEditProfileClick: () -> Unit,
val onViewFriendsClick: () -> Unit
)
fun getProfileActions(
viewModel: ProfileViewModel,
open: (String) -> Unit,
open: (String) -> Unit
): ProfileActions {
return ProfileActions(
getUsername = { viewModel.getUsername() },
getBiography = { viewModel.getBiography() },
getAmountOfFriends = { viewModel.getAmountOfFriends() },
onEditProfileClick = { viewModel.onEditProfileClick(open) },
onViewFriendsClick = { viewModel.onViewFriendsClick(open) }
)
}
@ -56,8 +69,12 @@ fun ProfileScreen(
navigationBarActions: NavigationBarActions,
) {
var username: String? by remember { mutableStateOf("") }
var biography: String? by remember { mutableStateOf("") }
val amountOfFriends = profileActions.getAmountOfFriends().collectAsState(initial = 0)
LaunchedEffect(key1 = Unit) {
username = profileActions.getUsername(this)
biography = profileActions.getBiography(this)
}
PrimaryScreenTemplate(
title = resources().getString(AppText.profile),
@ -65,7 +82,35 @@ fun ProfileScreen(
navigationBarActions = navigationBarActions,
barAction = { EditAction(onClick = profileActions.onEditProfileClick) }
) {
Headline(text = (username ?: resources().getString(R.string.no_username)))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(15.dp)
) {
item {
Headline(text = username ?: resources().getString(AppText.no_username))
}
item {
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.fillMaxWidth()
.wrapContentWidth(align = Alignment.CenterHorizontally)
) {
AmountOfFriendsButton(
amountOfFriends = amountOfFriends.value
) {
profileActions.onViewFriendsClick()
}
}
}
item {
Text(
text = biography ?: "",
textAlign = TextAlign.Center,
modifier = Modifier.padding(48.dp, 0.dp)
)
}
}
}
}
@ -78,7 +123,6 @@ fun EditAction(
imageVector = Icons.Default.Edit,
contentDescription = resources().getString(AppText.edit_profile)
)
}
}
@ -86,8 +130,38 @@ fun EditAction(
@Composable
fun ProfileScreenPreview() {
ProfileScreen(
profileActions = ProfileActions({ null }, {}),
profileActions = ProfileActions({ null }, { null }, { emptyFlow() }, {}, {}),
drawerActions = DrawerActions({}, {}, {}, {}, {}),
navigationBarActions = NavigationBarActions({ false }, {}, {}, {}, {}, {}, {}, {})
)
}
@Composable
fun AmountOfFriendsButton(
amountOfFriends: Int,
onClick: () -> Unit
){
Button(
onClick = onClick,
shape = defaultButtonShape()
) {
Text(
text = resources().getQuantityString(
/* id = */ R.plurals.friends_amount,
/* quantity = */ amountOfFriends,
/* ...formatArgs = */ amountOfFriends
)
)
}
}
@Preview
@Composable
fun AmountOfFriendsButtonPreview() {
StudeezTheme {
Column {
AmountOfFriendsButton(amountOfFriends = 1) { }
AmountOfFriendsButton(amountOfFriends = 100) { }
}
}
}

View file

@ -1,24 +1,40 @@
package be.ugent.sel.studeez.screens.profile
import be.ugent.sel.studeez.domain.FriendshipDAO
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 com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val userDAO: UserDAO,
private val friendshipDAO: FriendshipDAO,
logService: LogService
) : StudeezViewModel(logService) {
suspend fun getUsername(): String? {
return userDAO.getUsername()
suspend fun getUsername(): String {
return userDAO.getLoggedInUser().username
}
suspend fun getBiography(): String {
return userDAO.getLoggedInUser().biography
}
fun getAmountOfFriends(): Flow<Int> {
return friendshipDAO.getFriendshipCount(userDAO.getCurrentUserId())
}
fun onEditProfileClick(open: (String) -> Unit) {
open(StudeezDestinations.EDIT_PROFILE_SCREEN)
}
fun onViewFriendsClick(open: (String) -> Unit) {
open(StudeezDestinations.FRIENDS_OVERVIEW_SCREEN)
}
}

View file

@ -1,20 +1,21 @@
package be.ugent.sel.studeez.screens.profile
package be.ugent.sel.studeez.screens.profile.edit_profile
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
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
import be.ugent.sel.studeez.R.string as AppText
data class EditProfileActions(
val onUserNameChange: (String) -> Unit,
val onBiographyChange: (String) -> Unit,
val onSaveClick: () -> Unit,
val onDeleteClick: () -> Unit
)
@ -25,6 +26,7 @@ fun getEditProfileActions(
): EditProfileActions {
return EditProfileActions(
onUserNameChange = { viewModel.onUsernameChange(it) },
onBiographyChange = { viewModel.onBiographyChange(it) },
onSaveClick = { viewModel.onSaveClick() },
onDeleteClick = { viewModel.onDeleteClick(openAndPopUp) },
)
@ -51,28 +53,41 @@ fun EditProfileScreen(
editProfileActions: EditProfileActions,
) {
SecondaryScreenTemplate(
title = resources().getString(R.string.editing_profile),
title = resources().getString(AppText.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
)
LazyColumn {
item {
LabelledInputField(
value = uiState.username,
onNewValue = editProfileActions.onUserNameChange,
label = AppText.username
)
}
item {
LabelledInputField(
value = uiState.biography,
onNewValue = editProfileActions.onBiographyChange,
label = AppText.biography
)
}
item {
BasicTextButton(
text = AppText.save,
Modifier.textButton(),
action = {
editProfileActions.onSaveClick()
goBack()
}
)
}
item {
BasicTextButton(
text = AppText.delete_profile,
Modifier.textButton(),
action = editProfileActions.onDeleteClick
)
}
}
}
}
@ -81,6 +96,6 @@ fun EditProfileScreen(
@Composable
fun EditProfileScreenComposable() {
StudeezTheme {
EditProfileScreen({}, ProfileEditUiState(), EditProfileActions({}, {}, {}))
EditProfileScreen({}, ProfileEditUiState(), EditProfileActions({}, {}, {}, {}))
}
}

View file

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

View file

@ -1,8 +1,9 @@
package be.ugent.sel.studeez.screens.profile
package be.ugent.sel.studeez.screens.profile.edit_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.data.local.models.User
import be.ugent.sel.studeez.domain.AccountDAO
import be.ugent.sel.studeez.domain.LogService
import be.ugent.sel.studeez.domain.UserDAO
@ -23,7 +24,11 @@ class ProfileEditViewModel @Inject constructor(
init {
launchCatching {
uiState.value = uiState.value.copy(username = userDAO.getUsername()!!)
val user: User = userDAO.getLoggedInUser()
uiState.value = uiState.value.copy(
username = user.username,
biography = user.biography
)
}
}
@ -31,16 +36,23 @@ class ProfileEditViewModel @Inject constructor(
uiState.value = uiState.value.copy(username = newValue)
}
fun onBiographyChange(newValue: String) {
uiState.value = uiState.value.copy(biography = newValue)
}
fun onSaveClick() {
launchCatching {
userDAO.save(uiState.value.username)
userDAO.saveLoggedInUser(
newUsername = uiState.value.username,
newBiography = uiState.value.biography
)
SnackbarManager.showMessage(R.string.success)
}
}
fun onDeleteClick(openAndPopUp: (String, String) -> Unit) {
launchCatching {
userDAO.deleteUserReferences() // Delete references
userDAO.deleteLoggedInUserReferences() // Delete references
accountDAO.deleteAccount() // Delete authentication
}
openAndPopUp(StudeezDestinations.SIGN_UP_SCREEN, StudeezDestinations.EDIT_PROFILE_SCREEN)

View file

@ -0,0 +1,178 @@
package be.ugent.sel.studeez.screens.profile.public_profile
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MailOutline
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
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.Headline
import be.ugent.sel.studeez.common.composable.SecondaryScreenTemplate
import be.ugent.sel.studeez.common.composable.drawer.DrawerEntry
import be.ugent.sel.studeez.data.local.models.User
import be.ugent.sel.studeez.resources
import be.ugent.sel.studeez.screens.profile.AmountOfFriendsButton
import be.ugent.sel.studeez.ui.theme.StudeezTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import be.ugent.sel.studeez.R.string as AppText
data class PublicProfileActions(
val getUserDetails: () -> Flow<User>,
val getAmountOfFriends: () -> Flow<Int>,
val onViewFriendsClick: () -> Unit,
val sendFriendRequest: () -> Boolean
)
fun getPublicProfileActions(
viewModel: PublicProfileViewModel,
open: (String) -> Unit
): PublicProfileActions {
return PublicProfileActions(
getUserDetails = { viewModel.getUserDetails(viewModel.uiState.value.userId) },
getAmountOfFriends = { viewModel.getAmountOfFriends(
userId = viewModel.uiState.value.userId
) },
onViewFriendsClick = { viewModel.onViewFriendsClick(open) },
sendFriendRequest = { viewModel.sendFriendRequest(
userId = viewModel.uiState.value.userId
) }
)
}
@Composable
fun PublicProfileRoute(
popUp: () -> Unit,
open: (String) -> Unit,
viewModel: PublicProfileViewModel
) {
PublicProfileScreen(
publicProfileActions = getPublicProfileActions(
viewModel = viewModel,
open = open
),
popUp = popUp
)
}
@Composable
fun PublicProfileScreen(
publicProfileActions: PublicProfileActions,
popUp: () -> Unit
) {
val user = publicProfileActions.getUserDetails().collectAsState(initial = User())
val amountOfFriends = publicProfileActions.getAmountOfFriends().collectAsState(initial = 0)
SecondaryScreenTemplate(
title = stringResource(id = AppText.profile),
popUp = popUp,
barAction = {
PublicProfileEllipsis(
sendFriendRequest = publicProfileActions.sendFriendRequest
)
}
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(15.dp)
) {
item {
Headline(text = user.value.username)
}
item {
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(align = Alignment.CenterHorizontally)
) {
AmountOfFriendsButton(
amountOfFriends = amountOfFriends.value
) {
publicProfileActions.onViewFriendsClick()
}
}
}
item {
Text(
text = user.value.biography,
textAlign = TextAlign.Center,
modifier = Modifier.padding(48.dp, 0.dp)
)
}
}
}
}
@Preview
@Composable
fun PublicProfilePreview() {
StudeezTheme {
PublicProfileScreen(
publicProfileActions = PublicProfileActions(
getUserDetails = {
flowOf(User(
id = "someid",
username = "Maxime De Poorter",
biography = "I am a different student and this is my public profile"
))
},
getAmountOfFriends = { flowOf(113) },
onViewFriendsClick = {},
sendFriendRequest = { true }
),
popUp = {}
)
}
}
@Composable
fun PublicProfileEllipsis(
sendFriendRequest: () -> Boolean
) {
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = { expanded = true }
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_more_horizontal),
contentDescription = resources().getString(AppText.view_more),
modifier = Modifier.fillMaxSize()
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(onClick = { expanded = false }) {
DrawerEntry(
icon = Icons.Default.MailOutline,
text = stringResource(id = AppText.send_friend_request)
) {
sendFriendRequest()
}
}
}
}
@Preview
@Composable
fun PublicProfileEllipsisPreview() {
StudeezTheme {
PublicProfileEllipsis(
sendFriendRequest = { true }
)
}
}

View file

@ -0,0 +1,5 @@
package be.ugent.sel.studeez.screens.profile.public_profile
data class PublicProfileUiState(
var userId: String = ""
)

View file

@ -0,0 +1,60 @@
package be.ugent.sel.studeez.screens.profile.public_profile
import androidx.compose.runtime.mutableStateOf
import be.ugent.sel.studeez.data.SelectedUserId
import be.ugent.sel.studeez.data.local.models.User
import be.ugent.sel.studeez.domain.FriendshipDAO
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 kotlinx.coroutines.flow.Flow
import javax.inject.Inject
@HiltViewModel
class PublicProfileViewModel @Inject constructor(
private val userDAO: UserDAO,
private val friendshipDAO: FriendshipDAO,
selectedUserIdState: SelectedUserId,
logService: LogService
): StudeezViewModel(logService) {
val uiState = mutableStateOf(
PublicProfileUiState(
userId = selectedUserIdState.value
)
)
fun getUserDetails(
userId: String
): Flow<User> {
uiState.value = uiState.value.copy(
userId = userId
)
return userDAO.getUserDetails(
userId = uiState.value.userId
)
}
fun getAmountOfFriends(
userId: String
): Flow<Int> {
return friendshipDAO.getFriendshipCount(
userId = userId
)
}
fun onViewFriendsClick(
open: (String) -> Unit
) {
open(StudeezDestinations.FRIENDS_OVERVIEW_SCREEN)
}
fun sendFriendRequest(
userId: String
): Boolean {
return friendshipDAO.sendFriendshipRequest(userId)
}
}

View file

@ -66,7 +66,7 @@ class SignUpViewModel @Inject constructor(
launchCatching {
accountDAO.signUpWithEmailAndPassword(email, password)
accountDAO.signInWithEmailAndPassword(email, password)
userDAO.save(username)
userDAO.saveLoggedInUser(username)
openAndPopUp(HOME_SCREEN, SIGN_UP_SCREEN)
}
}

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View file

@ -16,11 +16,12 @@
<string name="go_back">Go back</string>
<string name="next">Next</string>
<string name="start">Start</string>
<string name="view_more">View more</string>
<!-- Messages -->
<string name="success">Success!</string>
<string name="try_again">Try again</string>
<string name="generic_error">Something wrong happened. Please try again.</string>
<string name="generic_error">Something went wrong. Please try again.</string>
<string name="email_error">Please insert a valid email.</string>
<!-- ========== NavBar ========== -->
@ -61,6 +62,7 @@
<string name="edit_profile">Edit profile</string>
<string name="editing_profile">Editing profile</string>
<string name="delete_profile">Delete profile</string>
<string name="biography">Bio</string>
<!-- ========== Drawer ========== -->
@ -120,7 +122,16 @@
<string name="friends">Friends</string>
<string name="friend">Friend</string>
<plurals name="friends_amount">
<item quantity="one">%d Friend</item>
<item quantity="other">%d Friends</item>
</plurals>
<string name="add_friend_not_possible_yet">Adding friends still needs to be implemented. Hang on tight!</string> <!-- TODO Remove this description line once implemented. -->
<string name="no_friends">You don\'t have any friends yet. Add one!</string>
<string name="search_friends">Search friends</string>
<string name="send_friend_request">Send friend request</string>
<string name="remove_friend">Remove as friend</string>
<string name="show_profile">Show profile</string>
<!-- ========== Create & edit screens ========== -->