diff --git a/prediction-polls/android/app/build.gradle.kts b/prediction-polls/android/app/build.gradle.kts index 9bc77094..d263e22a 100644 --- a/prediction-polls/android/app/build.gradle.kts +++ b/prediction-polls/android/app/build.gradle.kts @@ -54,6 +54,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } @@ -77,6 +78,8 @@ android { dependencies { implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2") + implementation("androidx.activity:activity-compose:1.8.0") implementation(platform("androidx.compose:compose-bom:2023.10.00")) implementation("androidx.compose.ui:ui") @@ -134,4 +137,11 @@ dependencies { // Easy Google Login implementation("com.github.stevdza-san:OneTapCompose:1.0.9") + + // Immutable Kotlin Collections + // Used for creating stable domain classes. Check stability meaning in terms of compose if you do not know what I mean. + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6") + + // For date transformation + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") } \ No newline at end of file diff --git a/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/NavigationDrawerTest.kt b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/NavigationDrawerTest.kt index e41197bc..f44506e9 100644 --- a/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/NavigationDrawerTest.kt +++ b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/NavigationDrawerTest.kt @@ -24,9 +24,7 @@ class NavigationDrawerTest { composeTestRule.setContent { titles = titleIds.map { stringResource(id = it) } - NavigationDrawer( - selectedNavItem = NavItem.FEED - ) { + NavigationDrawer() { Button( onClick = { it() @@ -56,7 +54,6 @@ class NavigationDrawerTest { composeTestRule.setContent { clickedTitle = stringResource(id = NavItem.PROFILE.titleId) NavigationDrawer( - selectedNavItem = selectedNavItem, onButtonClick = { selectedNavItem = it } diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/MainActivity.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/MainActivity.kt index 19f430de..c12f76cd 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/MainActivity.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/MainActivity.kt @@ -1,32 +1,110 @@ package com.bounswe.predictionpolls import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavOptions import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.bounswe.predictionpolls.data.remote.TokenManager +import com.bounswe.predictionpolls.ui.common.CommonAppbar +import com.bounswe.predictionpolls.ui.common.NavigationDrawer +import com.bounswe.predictionpolls.ui.create.createPollScreen import com.bounswe.predictionpolls.ui.feed.feedScreen import com.bounswe.predictionpolls.ui.leaderboard.leaderboardScreen import com.bounswe.predictionpolls.ui.login.loginScreen import com.bounswe.predictionpolls.ui.main.MAIN_ROUTE import com.bounswe.predictionpolls.ui.main.mainScreen +import com.bounswe.predictionpolls.ui.main.navigateToMainScreen +import com.bounswe.predictionpolls.ui.profile.profileScreen import com.bounswe.predictionpolls.ui.signup.signupScreen import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme +import com.bounswe.predictionpolls.ui.vote.pollVoteScreen +import com.bounswe.predictionpolls.utils.NavItem import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var tokenManager: TokenManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { PredictionPollsTheme { val navController = rememberNavController() - NavHost(navController = navController, startDestination = MAIN_ROUTE) { - mainScreen(navController) - loginScreen(navController) - signupScreen(navController) - feedScreen(navController) - leaderboardScreen(navController) + val routesWithDrawer = remember { NavItem.entries.map { it.route }.toSet() } + val currentBackStack = navController.currentBackStackEntryAsState() + val currentRoute = rememberUpdatedState(currentBackStack.value?.destination?.route) + val isUserLoggedIn = tokenManager.isLoggedIn.collectAsState(initial = false) + val context = LocalContext.current + val loginRequiredText = stringResource(R.string.login_required_notification) + val logoutSuccessText = stringResource(R.string.logged_out_notification) + + NavigationDrawer( + selectedRoute = currentRoute.value, + onButtonClick = { + if (it.requiresAuth && !isUserLoggedIn.value) { + Toast.makeText(context, loginRequiredText, Toast.LENGTH_SHORT).show() + navController.navigateToMainScreen( + navOptions = NavOptions.Builder().setPopUpTo(MAIN_ROUTE, true) + .build() + ) + } else { + navController.navigate(it.route) + } + }, + isSignedIn = isUserLoggedIn.value, + onAuthButtonClick = { + if (isUserLoggedIn.value) { + tokenManager.clear() + Toast.makeText(context, logoutSuccessText, Toast.LENGTH_SHORT).show() + navController.navigateToMainScreen( + navOptions = NavOptions.Builder().setPopUpTo(MAIN_ROUTE, true) + .build() + ) + } else { + navController.navigateToMainScreen( + navOptions = NavOptions.Builder().setPopUpTo(MAIN_ROUTE, true) + .build() + ) + } + } + ) { toggleDrawerState -> + Column { + CommonAppbar( + isVisible = currentRoute.value in routesWithDrawer, + onMenuClick = { toggleDrawerState() }, + onNotificationClick = { /*TODO implement notification */ } + ) + NavHost(navController = navController, startDestination = MAIN_ROUTE) { + mainScreen(navController) + loginScreen(navController) + signupScreen(navController) + feedScreen(navController, isUserLoggedIn.value) + leaderboardScreen(navController) + createPollScreen() + profileScreen(navController) + pollVoteScreen(navController) + + // TODO: Remove placeholders + composable("settings") { Text(text = "Settings Page WIP") } + composable("notifications") { Text(text = "Notifications Page WIP") } + composable("moderation") { Text(text = "Moderation Page WIP") } + } + } + } } } diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/common/PredictionPollsError.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/common/PredictionPollsError.kt new file mode 100644 index 00000000..157fe09b --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/common/PredictionPollsError.kt @@ -0,0 +1,3 @@ +package com.bounswe.predictionpolls.common + +data class PredictionPollsError(val code: String, val message: String) \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/common/Result.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/common/Result.kt new file mode 100644 index 00000000..336524ea --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/common/Result.kt @@ -0,0 +1,9 @@ +package com.bounswe.predictionpolls.common + +/** + * A generic class that holds a value or an exception + */ +sealed class Result { + data class Success(val data: T) : Result() + data class Error(val exception: Exception) : Result() +} diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedApi.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedApi.kt new file mode 100644 index 00000000..bf47d67d --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedApi.kt @@ -0,0 +1,12 @@ +package com.bounswe.predictionpolls.data.feed + +import com.bounswe.predictionpolls.data.feed.model.PollResponse +import retrofit2.http.GET + +interface FeedApi { + /** + * Fetches the list of polls and returns the result. + */ + @GET("/polls") + suspend fun getPolls(): List +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRemoteDataSource.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRemoteDataSource.kt new file mode 100644 index 00000000..febd0c77 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRemoteDataSource.kt @@ -0,0 +1,12 @@ +package com.bounswe.predictionpolls.data.feed + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.data.feed.model.PollResponse +import com.bounswe.predictionpolls.domain.poll.Poll + +interface FeedRemoteDataSource { + /** + * Fetches the list of polls and returns the result. + */ + suspend fun getPolls(page: Int): Result> +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRemoteDataSourceImpl.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRemoteDataSourceImpl.kt new file mode 100644 index 00000000..530f021a --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRemoteDataSourceImpl.kt @@ -0,0 +1,20 @@ +package com.bounswe.predictionpolls.data.feed + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.data.feed.model.PollResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class FeedRemoteDataSourceImpl @Inject constructor( + private val feedApi: FeedApi +) : FeedRemoteDataSource { + override suspend fun getPolls(page: Int): Result> = withContext(Dispatchers.IO) { + try { + val response = feedApi.getPolls() + Result.Success(response) + } catch (e: Exception) { + Result.Error(e) + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRepositoryImpl.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRepositoryImpl.kt new file mode 100644 index 00000000..eec1f506 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/FeedRepositoryImpl.kt @@ -0,0 +1,33 @@ +package com.bounswe.predictionpolls.data.feed + +import android.util.Log +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.feed.FeedRepository +import com.bounswe.predictionpolls.domain.poll.Poll +import javax.inject.Inject + +private const val TAG = "FeedRepositoryImpl" + +class FeedRepositoryImpl @Inject constructor( + private val feedRemoteDataSource: FeedRemoteDataSource +) : FeedRepository { + override suspend fun getPolls(page: Int): Result> = + when (val result = feedRemoteDataSource.getPolls(page)) { + is Result.Success -> { + val polls = result.data.mapNotNull { + try { + it.toPollDomainModel() + } catch (e: Exception) { + Log.e(TAG, "getPolls: $it cannot be converted to Poll") + null + } + } + Result.Success(polls) + } + + is Result.Error -> { + Result.Error(result.exception) + } + } + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/model/PollResponse.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/model/PollResponse.kt new file mode 100644 index 00000000..072a5380 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/feed/model/PollResponse.kt @@ -0,0 +1,178 @@ +package com.bounswe.predictionpolls.data.feed.model + + +import com.bounswe.predictionpolls.common.PredictionPollsError +import com.bounswe.predictionpolls.domain.poll.ContinuousVoteInputType +import com.bounswe.predictionpolls.domain.poll.Poll +import com.bounswe.predictionpolls.domain.poll.PollOption +import com.google.gson.* +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import kotlinx.collections.immutable.toImmutableList +import java.lang.reflect.Type + +data class PollResponse( + @SerializedName("error") + val predictionPollsError: PredictionPollsError?, + val id: Int, + val question: String, + val tags: List, + val creatorName: String, + val creatorUsername: String, + val creatorImage: String?, + val pollType: String, + val rejectVotes: String?, + val closingDate: String?, + val isOpen: Boolean, + @SerializedName("cont_poll_type") + val contPollType: String?, + val options: List // This can be a mix of Option and Int, handled during deserialization +) { + data class Option( + val id: Int, + @SerializedName("choice_text") + val choiceText: String, + @SerializedName("poll_id") + val pollId: Int, + @SerializedName("voter_count") + val voterCount: Int + ) + + + /** + * Converts the [PollResponse] to [Poll]. Throws [IllegalArgumentException] if the poll type is unknown + */ + fun toPollDomainModel(): Poll = + when (pollType) { + "continuous" -> { + Poll.ContinuousPoll( + polId = id.toString(), + creatorProfilePictureUri = creatorImage, + dueDate = closingDate, + pollCreatorName = creatorName, + pollQuestionTitle = question, + rejectionText = rejectVotes, + commentCount = 0, + tags = tags, + inputType = when (contPollType) { + "numeric" -> ContinuousVoteInputType.Decimal + "date" -> ContinuousVoteInputType.Date + else -> ContinuousVoteInputType.Text + } + ) + } + + "discrete" -> { + val options = options.map { + val option = it as Option + PollOption.DiscreteOption( + id = option.id.toString(), + text = option.choiceText, + voteCount = option.voterCount + ) + } + Poll.DiscretePoll( + polId = id.toString(), + creatorProfilePictureUri = creatorImage, + dueDate = closingDate, + pollCreatorName = creatorName, + pollQuestionTitle = question, + rejectionText = rejectVotes, + commentCount = 0, + tags = tags, + options = options.toImmutableList() + ) + } + + else -> { + throw IllegalArgumentException("Unknown poll type: $pollType") + } + } +} + +class PollResponseDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): PollResponse { + val jsonObject = json.asJsonObject + val error = try { + val errorJson = jsonObject.get("error").asJsonObject + PredictionPollsError( + errorJson.get("code").asString, + errorJson.get("message").asString + ) + } catch (e: Exception) { + null + } + val id = jsonObject.get("id").asInt + val question = jsonObject.get("question").asString + val tags = context.deserialize>( + jsonObject.get("tags"), + object : TypeToken>() {}.type + ) + val creatorName = jsonObject.get("creatorName").asString + val creatorUsername = + jsonObject.get("creatorUsername").asString + + + val creatorImage = try { + jsonObject.get("creatorImage").asString + } catch (e: Exception) { + null + } + val pollType = jsonObject.get("pollType").asString + val rejectVotes = try { + jsonObject.get("rejectVotes").asString + } catch (e: Exception) { + null + } + val closingDate = try { + jsonObject.get("closingDate").asString + } catch (e: Exception) { + null + } + val isOpen = jsonObject.get("isOpen").asBoolean + val contPollType = try { + jsonObject.get("cont_poll_type")?.asString + } catch (e: Exception) { + null + } + val options = mutableListOf() + if (pollType == "continuous") { +// options.addAll( +// context.deserialize>( +// optionsJson, +// object : TypeToken>() {}.type +// ) +// ) + } else { + val optionsJson = jsonObject.get("options").asJsonArray + options.addAll( + context.deserialize>( + optionsJson, + object : TypeToken>() {}.type + ) + ) + } + + return PollResponse( + error, + id, + question, + tags, + creatorName, + creatorUsername, + creatorImage, + pollType, + rejectVotes, + closingDate, + isOpen, + contPollType, + options + ) + } +} + + diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileApi.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileApi.kt new file mode 100644 index 00000000..b412cb11 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileApi.kt @@ -0,0 +1,14 @@ +package com.bounswe.predictionpolls.data.profile + +import com.bounswe.predictionpolls.data.profile.model.ProfileInfoResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface ProfileApi { + + @GET("profiles") + suspend fun fetchProfileInfo(@Query("username") username: String): ProfileInfoResponse + + @GET("profiles/myProfile") + suspend fun fetchCurrentUserProfileInfo(): ProfileInfoResponse +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRemoteDataSource.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRemoteDataSource.kt new file mode 100644 index 00000000..e68380b8 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRemoteDataSource.kt @@ -0,0 +1,10 @@ +package com.bounswe.predictionpolls.data.profile + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.data.profile.model.ProfileInfoResponse + +interface ProfileInfoRemoteDataSource { + + suspend fun fetchProfileInfo(username: String): Result + suspend fun fetchCurrentUserProfileInfo(): Result +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRemoteDataSourceImpl.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRemoteDataSourceImpl.kt new file mode 100644 index 00000000..303d61b3 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRemoteDataSourceImpl.kt @@ -0,0 +1,31 @@ +package com.bounswe.predictionpolls.data.profile + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.data.profile.model.ProfileInfoResponse +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ProfileInfoRemoteDataSourceImpl @Inject constructor( + private val profileApi: ProfileApi +) : ProfileInfoRemoteDataSource { + override suspend fun fetchProfileInfo(username: String): Result = + withContext(Dispatchers.IO) { + try { + val response = profileApi.fetchProfileInfo(username) + Result.Success(response) + } catch (e: Exception) { + Result.Error(e) + } + } + + override suspend fun fetchCurrentUserProfileInfo(): Result = + withContext(Dispatchers.IO) { + try { + val response = profileApi.fetchCurrentUserProfileInfo() + Result.Success(response) + } catch (e: Exception) { + Result.Error(e) + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRepositoryImpl.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRepositoryImpl.kt new file mode 100644 index 00000000..66d55a08 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/ProfileInfoRepositoryImpl.kt @@ -0,0 +1,58 @@ +package com.bounswe.predictionpolls.data.profile + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.profile.ProfileInfo +import com.bounswe.predictionpolls.domain.profile.ProfileInfoRepository +import javax.inject.Inject + +class ProfileInfoRepositoryImpl @Inject constructor( + private val profileInfoRemoteDataSource: ProfileInfoRemoteDataSource +) : ProfileInfoRepository { + override suspend fun getProfileInfo(username: String): Result { + profileInfoRemoteDataSource.fetchProfileInfo(username).let { result -> + return when (result) { + is Result.Success -> { + val profileInfo = result.data.toProfileInfo() + if (profileInfo != null) { + Result.Success(profileInfo) + } else { + Result.Error( + Exception( + result.data.predictionPollsError?.message + ?: "ProfileInfoResponse is not valid" + ) + ) + } + } + + is Result.Error -> { + Result.Error(result.exception) + } + } + } + } + + override suspend fun getCurrentUserProfileInfo(): Result { + profileInfoRemoteDataSource.fetchCurrentUserProfileInfo().let { result -> + return when (result) { + is Result.Success -> { + val profileInfo = result.data.toProfileInfo() + if (profileInfo != null) { + Result.Success(profileInfo) + } else { + Result.Error( + Exception( + result.data.predictionPollsError?.message + ?: "ProfileInfoResponse is not valid" + ) + ) + } + } + + is Result.Error -> { + Result.Error(result.exception) + } + } + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/model/ProfileInfoResponse.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/model/ProfileInfoResponse.kt new file mode 100644 index 00000000..1a97f744 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/profile/model/ProfileInfoResponse.kt @@ -0,0 +1,30 @@ +package com.bounswe.predictionpolls.data.profile.model + +import com.bounswe.predictionpolls.common.PredictionPollsError +import com.bounswe.predictionpolls.domain.profile.ProfileInfo +import com.google.gson.annotations.SerializedName +import kotlinx.collections.immutable.persistentListOf + + +data class ProfileInfoResponse( + @SerializedName("error") + val predictionPollsError: PredictionPollsError?, + val userId: Int?, + val username: String?, + val email: String?, + @SerializedName("profile_picture") + val profilePicture: String?, + val coverPicture: String?, + val biography: String?, + val isHidden: Int?, +) { + + fun toProfileInfo(): ProfileInfo? { + return if (predictionPollsError == null && username != null) { + ProfileInfo(username, "", coverPicture, profilePicture, biography, persistentListOf()) + } else { + null + } + } + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/interceptors/AuthInterceptor.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/interceptors/AuthInterceptor.kt index 88e32e21..5f9ca508 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/interceptors/AuthInterceptor.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/interceptors/AuthInterceptor.kt @@ -21,6 +21,8 @@ class AuthInterceptor @Inject constructor( var response = chain.proceed(request) if (response.code == 401) { + response.close() + refreshAccessToken()?.let { newAccessToken -> tokenManager.accessToken = newAccessToken request = addAuthHeader(request, newAccessToken) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/model/request/CreateContinuousPollRequest.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/model/request/CreateContinuousPollRequest.kt new file mode 100644 index 00000000..250ed9ff --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/model/request/CreateContinuousPollRequest.kt @@ -0,0 +1,26 @@ +package com.bounswe.predictionpolls.data.remote.model.request + +import com.google.gson.annotations.SerializedName + +data class CreateContinuousPollRequest( + val question: String, + val openVisibility: Boolean, + val setDueDate: Boolean, + val dueDatePoll: String?, + val numericFieldValue: Int?, + val selectedTimeUnit: String, + @SerializedName("cont_poll_type") + val pollType: String, +) { + enum class TimeUnit(val value: String) { + MINUTE("min"), + HOUR("h"), + DAY("day"), + MONTH("mth"), + } + + enum class PollRequestType(val value: String) { + NUMERIC("numeric"), + DATE("date"), + } +} diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/model/request/CreateDiscretePollRequest.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/model/request/CreateDiscretePollRequest.kt new file mode 100644 index 00000000..0080492d --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/model/request/CreateDiscretePollRequest.kt @@ -0,0 +1,18 @@ +package com.bounswe.predictionpolls.data.remote.model.request + +data class CreateDiscretePollRequest( + val question: String, + val choices: List, + val openVisibility: Boolean, + val setDueDate: Boolean, + val dueDatePoll: String?, + val numericFieldValue: Int?, + val selectedTimeUnit: String, +) { + enum class TimeUnit(val value: String) { + MINUTE("min"), + HOUR("h"), + DAY("day"), + MONTH("mth"), + } +} diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/PollRepository.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/PollRepository.kt new file mode 100644 index 00000000..e0811009 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/PollRepository.kt @@ -0,0 +1,57 @@ +package com.bounswe.predictionpolls.data.remote.repositories + +import com.bounswe.predictionpolls.core.BaseRepository +import com.bounswe.predictionpolls.data.remote.model.request.CreateContinuousPollRequest +import com.bounswe.predictionpolls.data.remote.model.request.CreateDiscretePollRequest +import com.bounswe.predictionpolls.data.remote.services.PollService + +class PollRepository( + private val pollService: PollService +): BaseRepository(), PollRepositoryInterface { + override suspend fun createContinuousPoll( + question: String, + openVisibility: Boolean, + setDueDate: Boolean, + dueDatePoll: String?, + numericFieldValue: Int?, + selectedTimeUnit: String, + pollType: String, + ){ + val request = CreateContinuousPollRequest( + question, + openVisibility, + setDueDate, + dueDatePoll, + numericFieldValue, + selectedTimeUnit, + pollType + ) + execute { + pollService.createContinuousPoll(request) + } + } + + override suspend fun createDiscretePoll( + question: String, + choices: List, + openVisibility: Boolean, + setDueDate: Boolean, + dueDatePoll: String?, + numericFieldValue: Int?, + selectedTimeUnit: String + ){ + val request = CreateDiscretePollRequest( + question, + choices, + openVisibility, + setDueDate, + dueDatePoll, + numericFieldValue, + selectedTimeUnit, + ) + + execute { + pollService.createDiscretePoll(request) + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/PollRepositoryInterface.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/PollRepositoryInterface.kt new file mode 100644 index 00000000..657c7ffc --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/PollRepositoryInterface.kt @@ -0,0 +1,29 @@ +package com.bounswe.predictionpolls.data.remote.repositories + +interface PollRepositoryInterface { + /** + * Creates a continuous poll and returns the result. + */ + suspend fun createContinuousPoll( + question: String, + openVisibility: Boolean, + setDueDate: Boolean, + dueDatePoll: String? = null, + numericFieldValue: Int? = null, + selectedTimeUnit: String, + pollType: String, + ) + + /** + * Creates a discrete poll and returns the result. + */ + suspend fun createDiscretePoll( + question: String, + choices: List, + openVisibility: Boolean, + setDueDate: Boolean, + dueDatePoll: String? = null, + numericFieldValue: Int? = null, + selectedTimeUnit: String + ) +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/services/AuthService.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/services/AuthService.kt index f97759ac..013324b8 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/services/AuthService.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/services/AuthService.kt @@ -11,22 +11,22 @@ import retrofit2.http.Body import retrofit2.http.POST interface AuthService { - @POST("/signup") + @POST("/auth/signup") suspend fun signup( @Body signupRequest: SignupRequest ) - @POST("/login") + @POST("/auth/login") suspend fun login( @Body loginRequest: LoginRequest ): LoginResponse - @POST("/logout") + @POST("/auth/logout") suspend fun logout( @Body logoutRequest: LogoutRequest ) - @POST("/access-token") + @POST("/auth/access-token") suspend fun refreshAccessToken( @Body refreshAccessTokenRequest: RefreshAccessTokenRequest ): RefreshAccessTokenResponse diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/services/PollService.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/services/PollService.kt new file mode 100644 index 00000000..b5fa221d --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/services/PollService.kt @@ -0,0 +1,42 @@ +package com.bounswe.predictionpolls.data.remote.services + +import com.bounswe.predictionpolls.data.feed.model.PollResponse +import com.bounswe.predictionpolls.data.remote.model.request.CreateContinuousPollRequest +import com.bounswe.predictionpolls.data.remote.model.request.CreateDiscretePollRequest +import com.bounswe.predictionpolls.data.vote.ContinuousPollRequest +import com.bounswe.predictionpolls.data.vote.DiscreteVotePollRequest +import com.bounswe.predictionpolls.data.vote.VotePollResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface PollService { + @POST("/polls/discrete") + suspend fun createDiscretePoll( + @Body createDiscretePollRequest: CreateDiscretePollRequest + ) + + @POST("/polls/continuous") + suspend fun createContinuousPoll( + @Body createContinuousPollRequest: CreateContinuousPollRequest + ) + + @POST("/polls/discrete/{pollId}/vote") + suspend fun voteForDiscretePoll( + @Path("pollId") pollId: String, + @Body discretePollRequest: DiscreteVotePollRequest + ): VotePollResponse + + @POST("/polls/continuous/{pollId}/vote") + suspend fun voteForContinuousPoll( + @Path("pollId") pollId: String, + @Body continuousPollRequest: ContinuousPollRequest + ): VotePollResponse + + + @GET("/polls/{pollId}") + suspend fun getPoll( + @Path("pollId") pollId: String + ): PollResponse +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/DiscreteVotePollRequest.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/DiscreteVotePollRequest.kt new file mode 100644 index 00000000..3ba70130 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/DiscreteVotePollRequest.kt @@ -0,0 +1,6 @@ +package com.bounswe.predictionpolls.data.vote + +data class DiscreteVotePollRequest(val choiceId: Int, val points: String) + +data class ContinuousPollRequest(val choice: String, val points: String) + diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/VotePollRepository.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/VotePollRepository.kt new file mode 100644 index 00000000..f9eb9839 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/VotePollRepository.kt @@ -0,0 +1,73 @@ +package com.bounswe.predictionpolls.data.vote + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.data.remote.services.PollService +import com.bounswe.predictionpolls.domain.poll.Poll +import com.bounswe.predictionpolls.domain.poll.VotePollRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class VotePollRepositoryImpl @Inject constructor( + private val votePollApi: PollService +) : VotePollRepository { + + override suspend fun fetchPoll(pollId: String): Result = + withContext(Dispatchers.IO) { + try { + val result = votePollApi.getPoll(pollId) + result.predictionPollsError?.message?.let { + return@withContext Result.Error(Exception(it)) + } + return@withContext Result.Success(result.toPollDomainModel()) + } catch (e: Exception) { + return@withContext Result.Error(e) + } + } + + override suspend fun voteForDiscretePoll( + pollId: String, + points: Int, + voteId: String + ): Result = + withContext(Dispatchers.IO) { + try { + val result = votePollApi.voteForDiscretePoll( + pollId, + DiscreteVotePollRequest( + voteId.toInt(), + points.toString() + ) + ) + if (result.error != null) { + return@withContext Result.Error(Exception(result.error)) + } + return@withContext Result.Success(Unit) + } catch (e: Exception) { + return@withContext Result.Error(e) + } + } + + override suspend fun voteForContinuousPoll( + pollId: String, + points: Int, + voteInput: String + ): Result = + withContext(Dispatchers.IO) { + try { + val result = votePollApi.voteForContinuousPoll( + pollId, + ContinuousPollRequest( + voteInput, + points.toString(), + ) + ) + if (result.error != null) { + return@withContext Result.Error(Exception(result.error)) + } + return@withContext Result.Success(Unit) + } catch (e: Exception) { + return@withContext Result.Error(e) + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/VotePollResponse.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/VotePollResponse.kt new file mode 100644 index 00000000..c215014c --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/vote/VotePollResponse.kt @@ -0,0 +1,6 @@ +package com.bounswe.predictionpolls.data.vote + + +data class VotePollResponse( + val error: String? +) \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/FeedModule.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/FeedModule.kt new file mode 100644 index 00000000..48c7f650 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/FeedModule.kt @@ -0,0 +1,34 @@ +package com.bounswe.predictionpolls.di + +import com.bounswe.predictionpolls.data.feed.FeedApi +import com.bounswe.predictionpolls.data.feed.FeedRemoteDataSource +import com.bounswe.predictionpolls.data.feed.FeedRemoteDataSourceImpl +import com.bounswe.predictionpolls.data.feed.FeedRepositoryImpl +import com.bounswe.predictionpolls.domain.feed.FeedRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import retrofit2.Retrofit + +@InstallIn(ViewModelComponent::class) +@Module +abstract class FeedModule { + + @Binds + abstract fun bindFeedRepository( + feedRepositoryImpl: FeedRepositoryImpl + ): FeedRepository + + @Binds + abstract fun bindFeedRemoteDataSource( + feedRemoteDataSourceImpl: FeedRemoteDataSourceImpl + ): FeedRemoteDataSource + + companion object { + @Provides + fun provideFeedApi(@UnauthenticatedRetrofit retrofit: Retrofit): FeedApi = + retrofit.create(FeedApi::class.java) + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/NetworkModule.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/NetworkModule.kt index e581d0d5..f7b9c6c0 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/NetworkModule.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/NetworkModule.kt @@ -2,6 +2,8 @@ package com.bounswe.predictionpolls.di import android.content.Context import com.bounswe.predictionpolls.BuildConfig +import com.bounswe.predictionpolls.data.feed.model.PollResponse +import com.bounswe.predictionpolls.data.feed.model.PollResponseDeserializer import com.bounswe.predictionpolls.data.remote.TokenManager import com.bounswe.predictionpolls.data.remote.interceptors.AuthInterceptor import com.bounswe.predictionpolls.data.remote.interceptors.ResponseInterceptor @@ -26,6 +28,7 @@ object NetworkModule { @Singleton fun provideGson(): Gson { return GsonBuilder() + .registerTypeAdapter(PollResponse::class.java, PollResponseDeserializer()) .serializeNulls() .create() } diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/ProfileModule.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/ProfileModule.kt new file mode 100644 index 00000000..c817aa76 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/ProfileModule.kt @@ -0,0 +1,34 @@ +package com.bounswe.predictionpolls.di + +import com.bounswe.predictionpolls.data.profile.ProfileApi +import com.bounswe.predictionpolls.data.profile.ProfileInfoRemoteDataSource +import com.bounswe.predictionpolls.data.profile.ProfileInfoRemoteDataSourceImpl +import com.bounswe.predictionpolls.data.profile.ProfileInfoRepositoryImpl +import com.bounswe.predictionpolls.domain.profile.ProfileInfoRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import retrofit2.Retrofit + +@InstallIn(ViewModelComponent::class) +@Module +abstract class ProfileModule { + @Binds + abstract fun bindProfileInfoRepository( + profileInfoRepository: ProfileInfoRepositoryImpl + ): ProfileInfoRepository + + @Binds + abstract fun bindProfileInfoRemoteDataSource( + profileInfoRemoteDataSourceImpl: ProfileInfoRemoteDataSourceImpl + ): ProfileInfoRemoteDataSource + + companion object { + @Provides + fun provideProfileApi(@AuthenticatedRetrofit unauthenticatedRetrofit: Retrofit): ProfileApi = + unauthenticatedRetrofit.create(ProfileApi::class.java) + + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/RepositoryModule.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/RepositoryModule.kt index 4252d720..40e8050f 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/RepositoryModule.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/RepositoryModule.kt @@ -2,7 +2,10 @@ package com.bounswe.predictionpolls.di import com.bounswe.predictionpolls.data.remote.TokenManager import com.bounswe.predictionpolls.data.remote.repositories.AuthRepository +import com.bounswe.predictionpolls.data.remote.repositories.PollRepository +import com.bounswe.predictionpolls.data.remote.repositories.PollRepositoryInterface import com.bounswe.predictionpolls.data.remote.services.AuthService +import com.bounswe.predictionpolls.data.remote.services.PollService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,4 +23,12 @@ object RepositoryModule { ): AuthRepository { return AuthRepository(authService, tokenManager) } + + @Provides + @Singleton + fun providePollRepository( + pollService: PollService, + ): PollRepositoryInterface { + return PollRepository(pollService) + } } \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/ServiceModule.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/ServiceModule.kt index 462817bc..6a36a94c 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/ServiceModule.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/ServiceModule.kt @@ -1,6 +1,7 @@ package com.bounswe.predictionpolls.di import com.bounswe.predictionpolls.data.remote.services.AuthService +import com.bounswe.predictionpolls.data.remote.services.PollService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,4 +19,12 @@ object ServiceModule { ): AuthService { return retrofit.create(AuthService::class.java) } + + @Provides + @Singleton + fun providePollService( + @AuthenticatedRetrofit retrofit: Retrofit + ): PollService { + return retrofit.create(PollService::class.java) + } } \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/VotePollModule.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/VotePollModule.kt new file mode 100644 index 00000000..859524f8 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/VotePollModule.kt @@ -0,0 +1,17 @@ +package com.bounswe.predictionpolls.di + +import com.bounswe.predictionpolls.data.vote.VotePollRepositoryImpl +import com.bounswe.predictionpolls.domain.poll.VotePollRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@InstallIn(ViewModelComponent::class) +@Module +abstract class VotePollModule { + @Binds + abstract fun bindVotePollRepository( + votePollRepositoryImpl: VotePollRepositoryImpl + ): VotePollRepository +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/feed/FeedRepository.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/feed/FeedRepository.kt new file mode 100644 index 00000000..d0c3d244 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/feed/FeedRepository.kt @@ -0,0 +1,11 @@ +package com.bounswe.predictionpolls.domain.feed + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.poll.Poll + +interface FeedRepository { + /** + * Fetches the list of polls and returns the result. + */ + suspend fun getPolls(page: Int): Result> +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/feed/GetFeedUseCase.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/feed/GetFeedUseCase.kt new file mode 100644 index 00000000..f3bf8fe2 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/feed/GetFeedUseCase.kt @@ -0,0 +1,23 @@ +package com.bounswe.predictionpolls.domain.feed + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.poll.Poll +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject + +class GetFeedUseCase @Inject constructor( + private val feedRepository: FeedRepository +) { + suspend operator fun invoke(page: Int): Result> = + when (val result = feedRepository.getPolls(page)) { + is Result.Success -> { + Result.Success(result.data.toImmutableList()) + } + + is Result.Error -> { + Result.Error(result.exception) + } + } + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/ContinuousVoteInputType.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/ContinuousVoteInputType.kt similarity index 94% rename from prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/ContinuousVoteInputType.kt rename to prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/ContinuousVoteInputType.kt index 16ca676a..f8d433c9 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/ContinuousVoteInputType.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/ContinuousVoteInputType.kt @@ -1,4 +1,4 @@ -package com.bounswe.predictionpolls.ui.common.poll +package com.bounswe.predictionpolls.domain.poll import androidx.compose.ui.text.input.KeyboardType diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/Poll.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/Poll.kt new file mode 100644 index 00000000..cc79a702 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/Poll.kt @@ -0,0 +1,41 @@ +package com.bounswe.predictionpolls.domain.poll + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface Poll { + val polId: String + val creatorProfilePictureUri: String? + val pollCreatorName: String + val pollQuestionTitle: String + val dueDate: String? + val rejectionText: String? + val commentCount: Int + val tags: List + + data class ContinuousPoll( + override val polId: String, + override val creatorProfilePictureUri: String?, + override val dueDate: String?, + override val pollCreatorName: String, + override val pollQuestionTitle: String, + override val rejectionText: String?, + override val commentCount: Int, + override val tags: List, + val inputType: ContinuousVoteInputType, + ) : Poll + + data class DiscretePoll( + override val polId: String, + override val creatorProfilePictureUri: String?, + override val dueDate: String?, + override val pollCreatorName: String, + override val pollQuestionTitle: String, + override val rejectionText: String?, + override val commentCount: Int, + override val tags: List, + val options: ImmutableList + + ) : Poll +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/PollOption.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/PollOption.kt new file mode 100644 index 00000000..439b4009 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/PollOption.kt @@ -0,0 +1,5 @@ +package com.bounswe.predictionpolls.domain.poll + +sealed interface PollOption { + data class DiscreteOption(val id: String, val text: String, val voteCount: Int) : PollOption +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/VotePollRepository.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/VotePollRepository.kt new file mode 100644 index 00000000..4a2ce8f4 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/VotePollRepository.kt @@ -0,0 +1,12 @@ +package com.bounswe.predictionpolls.domain.poll + +import com.bounswe.predictionpolls.common.Result + +interface VotePollRepository { + + suspend fun voteForDiscretePoll(pollId: String, points: Int, voteId: String): Result + + suspend fun fetchPoll(pollId: String): Result + suspend fun voteForContinuousPoll(pollId: String, points: Int, voteInput: String): Result + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/VotePollUseCase.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/VotePollUseCase.kt new file mode 100644 index 00000000..7edb41f7 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/poll/VotePollUseCase.kt @@ -0,0 +1,21 @@ +package com.bounswe.predictionpolls.domain.poll + +import com.bounswe.predictionpolls.common.Result +import javax.inject.Inject + +class VotePollUseCase @Inject constructor( + private val votePollRepository: VotePollRepository +) { + + suspend fun voteForDiscretePoll(pollId: String, points: Int, voteId: String): Result { + return votePollRepository.voteForDiscretePoll(pollId, points, voteId) + } + + suspend fun voteForContinuousPoll( + pollId: String, + points: Int, + voteInput: String + ): Result { + return votePollRepository.voteForContinuousPoll(pollId, points, voteInput) + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/GetCurrentUserProfileUseCase.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/GetCurrentUserProfileUseCase.kt new file mode 100644 index 00000000..916cc70e --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/GetCurrentUserProfileUseCase.kt @@ -0,0 +1,13 @@ +package com.bounswe.predictionpolls.domain.profile + +import com.bounswe.predictionpolls.common.Result +import javax.inject.Inject + +class GetCurrentUserProfileUseCase @Inject constructor( + private val profileInfoRepository: ProfileInfoRepository +) { + + suspend operator fun invoke(): Result = + profileInfoRepository.getCurrentUserProfileInfo() + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/GetProfileInfoUseCase.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/GetProfileInfoUseCase.kt new file mode 100644 index 00000000..67f25999 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/GetProfileInfoUseCase.kt @@ -0,0 +1,13 @@ +package com.bounswe.predictionpolls.domain.profile + +import com.bounswe.predictionpolls.common.Result +import javax.inject.Inject + +class GetProfileInfoUseCase @Inject constructor( + private val profileInfoRepository: ProfileInfoRepository +) { + + suspend operator fun invoke(username: String): Result = + profileInfoRepository.getProfileInfo(username) + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/ProfileInfo.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/ProfileInfo.kt new file mode 100644 index 00000000..1a6c4553 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/ProfileInfo.kt @@ -0,0 +1,12 @@ +package com.bounswe.predictionpolls.domain.profile + +import kotlinx.collections.immutable.ImmutableList + +data class ProfileInfo( + val username: String, + val userFullName: String, + val coverPhotoUri: String?, + val profilePictureUri: String?, + val userDescription: String?, + val badgeUris: ImmutableList +) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/ProfileInfoRepository.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/ProfileInfoRepository.kt new file mode 100644 index 00000000..4f9e178d --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/domain/profile/ProfileInfoRepository.kt @@ -0,0 +1,8 @@ +package com.bounswe.predictionpolls.domain.profile + +import com.bounswe.predictionpolls.common.Result + +interface ProfileInfoRepository { + suspend fun getProfileInfo(username: String): Result + suspend fun getCurrentUserProfileInfo(): Result +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/StringExtensions.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/StringExtensions.kt index de1ce6f8..ad3c4f51 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/StringExtensions.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/StringExtensions.kt @@ -2,6 +2,9 @@ package com.bounswe.predictionpolls.extensions import android.os.Build import java.text.SimpleDateFormat +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.Locale @@ -41,4 +44,24 @@ fun String.isValidDate(): Boolean { false } } +} + +fun String.toISO8601(): String? { + val currentTime = LocalDateTime.now().toLocalTime() + val localDate = LocalDateTime.of( + this.substring(4, 8).toInt(), + this.substring(2, 4).toInt(), + this.substring(0, 2).toInt(), + currentTime.hour, + currentTime.minute + ) + val offsetDate = OffsetDateTime.of(localDate, ZoneOffset.UTC) + return offsetDate.format(DateTimeFormatter.ISO_DATE_TIME) +} + +fun String.fromISO8601(): String { + val offsetDate = OffsetDateTime.parse(this, DateTimeFormatter.ISO_DATE_TIME) + offsetDate.apply { + return "${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')} ${dayOfMonth}/${monthValue}/${year}" + } } \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CommonAppbar.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CommonAppbar.kt new file mode 100644 index 00000000..6ec172c3 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CommonAppbar.kt @@ -0,0 +1,63 @@ +package com.bounswe.predictionpolls.ui.common + +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.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bounswe.predictionpolls.R + +@Composable +fun CommonAppbar( + isVisible: Boolean = true, + onMenuClick: () -> Unit = {}, + onNotificationClick: () -> Unit = {}, +) { + if (!isVisible) return + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton( + onClick = { + onMenuClick() + }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_hamburger), + contentDescription = stringResource(id = R.string.cd_menu), + modifier = Modifier.size(32.dp) + ) + } + IconButton( + onClick = { + onNotificationClick() + }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_notification), + contentDescription = stringResource(id = R.string.cd_notification), + modifier = Modifier.size(32.dp) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CommonAppbarPreview() { + CommonAppbar() +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CustomDatePicker.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CustomDatePicker.kt new file mode 100644 index 00000000..f70dc1ac --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CustomDatePicker.kt @@ -0,0 +1,63 @@ +package com.bounswe.predictionpolls.ui.common + +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.intl.Locale +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.extensions.toTimeDateString + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomDatePicker( + isDatePickerVisible: Boolean = false, + onDismissRequest: () -> Unit = {}, + onDateChanged: (String) -> Unit = {}, +) { + if (isDatePickerVisible.not()) return + + val locale = Locale.current + val datePickerState = rememberDatePickerState() + val confirmEnabled = remember { + derivedStateOf { datePickerState.selectedDateMillis != null } + } + + DatePickerDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { + onDateChanged(it.toTimeDateString(locale)) + } + onDismissRequest() + }, + enabled = confirmEnabled.value + ) { + Text( + text = stringResource(id = R.string.confirm), + ) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismissRequest() + } + ) { + Text( + text = stringResource(id = R.string.cancel), + ) + } + }, + ) { + DatePicker(state = datePickerState) + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CustomInputField.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CustomInputField.kt index 679300f6..4abe1f8e 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CustomInputField.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CustomInputField.kt @@ -56,10 +56,13 @@ fun CustomInputField( visualTransformation: VisualTransformation = VisualTransformation.None, ) { Column( + modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp) ) { TextField( - modifier = modifier.border(1.dp, borderColor.copy(alpha = 0.2f), shape), + modifier = Modifier + .fillMaxWidth() + .border(1.dp, borderColor.copy(alpha = 0.2f), shape), value = text, onValueChange = onTextChanged, label = if (labelId != null) { diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/NavigationDrawer.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/NavigationDrawer.kt index 4914ee5b..7502459d 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/NavigationDrawer.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/NavigationDrawer.kt @@ -5,15 +5,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Divider import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -43,10 +45,16 @@ typealias ToggleDrawerState = () -> Unit @Composable fun NavigationDrawer( modifier: Modifier = Modifier, - selectedNavItem: NavItem, + selectedRoute: String? = null, onButtonClick: (NavItem) -> Unit = {}, + isSignedIn: Boolean = true, + onAuthButtonClick: () -> Unit = {}, content: @Composable (ToggleDrawerState) -> Unit = {} ) { + val selectedNavItem = remember(selectedRoute) { + NavItem.values().firstOrNull { it.route == selectedRoute } + } + val drawerState = remember { DrawerState( initialValue = DrawerValue.Closed @@ -71,7 +79,8 @@ fun NavigationDrawer( scrimColor = Color.Transparent, drawerContent = { ModalDrawerSheet( - modifier = Modifier.width(IntrinsicSize.Max), + modifier = Modifier + .width(IntrinsicSize.Max), drawerContainerColor = MaterialTheme.colorScheme.background, drawerContentColor = MaterialTheme.colorScheme.onBackground, ) { @@ -81,16 +90,18 @@ fun NavigationDrawer( .fillMaxWidth(), ) { NavItem.values().forEach { navItem -> - Column { - Divider() - NavDrawerItem( - navItem = navItem, - isSelected = selectedNavItem == navItem, - onButtonClick = onButtonClick - ) - } + NavDrawerItem( + navItem = navItem, + isSelected = selectedNavItem == navItem, + onButtonClick = onButtonClick + ) } } + Spacer(modifier = Modifier.weight(1f)) + AuthButton( + isSignedIn = isSignedIn, + onAuthButtonClick = onAuthButtonClick + ) } }, content = { @@ -114,6 +125,49 @@ private fun AppTitle() { ) } +@Composable +private fun AuthButton( + isSignedIn: Boolean, + onAuthButtonClick: () -> Unit = {} +) { + val textId = if (isSignedIn) { + R.string.nav_drawer_signout + } else { + R.string.nav_drawer_signin + } + + val color = if (isSignedIn) { + MaterialTheme.colorScheme.onError + } else { + MaterialTheme.colorScheme.onPrimary + } + + val backgroundColor = if (isSignedIn) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + + Button( + onClick = { + onAuthButtonClick() + }, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = backgroundColor, + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(id = textId), + color = color, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NavDrawerItem( @@ -138,9 +192,6 @@ private fun NavDrawerItem( onClick = { onButtonClick(navItem) }, - elevation = CardDefaults.cardElevation( - defaultElevation = 1.dp - ) ) { Row( modifier = Modifier @@ -158,7 +209,8 @@ private fun NavDrawerItem( ), ) Text( - text = stringResource(id = navItem.titleId) + text = stringResource(id = navItem.titleId), + style = MaterialTheme.typography.bodyMedium, ) } } @@ -170,9 +222,7 @@ fun NavigationDrawerPreview() { PredictionPollsTheme( darkTheme = false ) { - NavigationDrawer( - selectedNavItem = NavItem.FEED - ) { + NavigationDrawer { Button(onClick = { it() }) { diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/ContinuousVoteOption.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/ContinuousVoteOption.kt index e83e3104..e60ac4d4 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/ContinuousVoteOption.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/ContinuousVoteOption.kt @@ -32,6 +32,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bounswe.predictionpolls.domain.poll.ContinuousVoteInputType +import com.bounswe.predictionpolls.domain.poll.toKeyboardType import com.bounswe.predictionpolls.ui.theme.MontserratFontFamily import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/DiscreteVoteOption.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/DiscreteVoteOption.kt index bef36d48..0eeaa470 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/DiscreteVoteOption.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/DiscreteVoteOption.kt @@ -60,7 +60,7 @@ fun DiscreteVoteOption( .clip(DiscreteVoteOptionShape) .border( 2.dp, - MaterialTheme.colorScheme.secondary.copy(alpha = if (isSelected) 1f else 0.5f), + MaterialTheme.colorScheme.secondary.copy(alpha = if (isSelected) 1f else 0.1f), DiscreteVoteOptionShape ) .background(MaterialTheme.colorScheme.background) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollComposable.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollComposable.kt index be1fcdf7..4a831752 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollComposable.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollComposable.kt @@ -33,67 +33,69 @@ import androidx.compose.ui.unit.sp import com.bounswe.predictionpolls.R import com.bounswe.predictionpolls.ui.theme.MontserratFontFamily import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun PollComposable( - pollCreatorProfilePictureUri: String, + pollCreatorProfilePictureUri: String?, pollCreatorName: String, tags: List, pollQuestionTitle: String, optionsContent: @Composable () -> Unit, - dueDate: Date, + dueDate: String, rejectionText: String, commentCount: Int, modifier: Modifier = Modifier ) { Box( modifier = modifier - .shadow(4.dp, RoundedCornerShape(12.dp)) + .shadow( + 6.dp, + RoundedCornerShape(12.dp), + ambientColor = MaterialTheme.colorScheme.primary, + spotColor = MaterialTheme.colorScheme.primary + ) .background(Color.White, RoundedCornerShape(12.dp)) - .padding(16.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) .wrapContentSize() ) { Column( modifier = Modifier .fillMaxWidth() - .wrapContentHeight() + .wrapContentHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { PollCreatorProfile( imageUri = pollCreatorProfilePictureUri, userName = pollCreatorName, modifier = Modifier.align(Alignment.End) ) - Spacer(modifier = Modifier.height(8.dp)) - PollTagsContent(tags) + if (tags.isNotEmpty()) PollTagsContent(tags) PollQuestionTitle(pollQuestionTitle = pollQuestionTitle) optionsContent() - Box( + Row( modifier = Modifier .fillMaxWidth() .wrapContentHeight() - .padding(top = 48.dp) - .padding(horizontal = 48.dp) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceAround ) { - DueDateComposable( - dueDate = dueDate, - modifier = Modifier.align(Alignment.CenterStart) - ) - RejectionDateComposable( - rejectionText = rejectionText, - modifier = Modifier.align(Alignment.CenterEnd) - ) + if (dueDate.isNotEmpty()){ + DueDateComposable( + dueDate = dueDate + ) + } + if (rejectionText.isNotEmpty()){ + RejectionDateComposable( + rejectionText = rejectionText + ) + } } Row( modifier = Modifier - .padding(horizontal = 48.dp) - .fillMaxWidth() .wrapContentHeight() - .padding(top = 48.dp), - horizontalArrangement = Arrangement.SpaceBetween + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceAround ) { Column { PollIcon( @@ -134,14 +136,14 @@ private fun PollIcon(@DrawableRes id: Int, modifier: Modifier = Modifier) { painter = painterResource(id = id), contentDescription = null, modifier = modifier - .padding(16.dp) + .padding(12.dp) .size(32.dp) ) } @Composable -private fun DueDateComposable(dueDate: Date, modifier: Modifier = Modifier) { +private fun DueDateComposable(dueDate: String, modifier: Modifier = Modifier) { Column( modifier = modifier.wrapContentSize(), horizontalAlignment = Alignment.CenterHorizontally @@ -156,7 +158,7 @@ private fun DueDateComposable(dueDate: Date, modifier: Modifier = Modifier) { ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = formatDate(dueDate), + text = dueDate, fontFamily = MontserratFontFamily, color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Bold, @@ -173,7 +175,7 @@ private fun RejectionDateComposable(rejectionText: String, modifier: Modifier = horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Closing in", + text = "Reject votes in", fontFamily = MontserratFontFamily, color = MaterialTheme.colorScheme.scrim, fontWeight = FontWeight.Bold, @@ -213,7 +215,6 @@ private fun PollQuestionTitle(pollQuestionTitle: String, modifier: Modifier = Mo color = MaterialTheme.colorScheme.scrim, fontWeight = FontWeight.Bold, modifier = modifier - .padding(vertical = 32.dp) .fillMaxWidth() .wrapContentHeight(), textAlign = TextAlign.Start, @@ -223,7 +224,7 @@ private fun PollQuestionTitle(pollQuestionTitle: String, modifier: Modifier = Mo @Composable @Preview(showBackground = true, showSystemUi = true) -fun PollComposablePreview() { +private fun PollComposablePreview() { PredictionPollsTheme(dynamicColor = false) { PollComposable( "https://picsum.photos/id/237/600/800", @@ -248,7 +249,7 @@ fun PollComposablePreview() { } }, - dueDate = Date(), + dueDate = "21 Nov 2023", rejectionText = "Last 5 Days", commentCount = 265, modifier = Modifier.padding(16.dp) @@ -256,7 +257,3 @@ fun PollComposablePreview() { } } -private fun formatDate(dueDate: Date): String { - val sdf = SimpleDateFormat("dd MMMM yyyy", Locale("tr", "TR")) - return sdf.format(dueDate) -} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollCreatorProfile.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollCreatorProfile.kt index 9a0cb8c6..9ecb9907 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollCreatorProfile.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollCreatorProfile.kt @@ -18,6 +18,7 @@ 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 coil.compose.AsyncImage import coil.request.ImageRequest import com.bounswe.predictionpolls.R @@ -25,7 +26,7 @@ import com.bounswe.predictionpolls.ui.theme.MontserratFontFamily import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme @Composable -fun PollCreatorProfile(imageUri: String, userName: String, modifier: Modifier = Modifier) { +fun PollCreatorProfile(imageUri: String?, userName: String, modifier: Modifier = Modifier) { Row(modifier = modifier.wrapContentSize(), verticalAlignment = CenterVertically) { PollProfilePicture(imageUri = imageUri, modifier = Modifier.size(48.dp)) Spacer(modifier = Modifier.width(16.dp)) @@ -33,10 +34,10 @@ fun PollCreatorProfile(imageUri: String, userName: String, modifier: Modifier = text = userName, fontFamily = MontserratFontFamily, color = MaterialTheme.colorScheme.scrim, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + fontSize = 12.sp, ) } - } @@ -54,7 +55,7 @@ fun PollCreatorProfilePreview() { } @Composable -private fun PollProfilePicture(imageUri: String, modifier: Modifier = Modifier) { +private fun PollProfilePicture(imageUri: String?, modifier: Modifier = Modifier) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(imageUri) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollsComposable.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollsComposable.kt new file mode 100644 index 00000000..f2ea96a1 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/poll/PollsComposable.kt @@ -0,0 +1,82 @@ +package com.bounswe.predictionpolls.ui.common.poll + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.bounswe.predictionpolls.domain.poll.Poll +import com.bounswe.predictionpolls.domain.poll.PollOption +import com.bounswe.predictionpolls.extensions.fromISO8601 +import kotlinx.collections.immutable.ImmutableList + + +/** + * Displays polls in a lazy list. This composable can be used in profile screen and feed screen where continuous flow of polls are expected. + */ +@Composable +fun Polls( + polls: ImmutableList, + onPollClicked: (id: String) -> Unit = {}, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(polls) { + PollComposable( + modifier = Modifier.clickable { + onPollClicked(it.polId) + }, + pollCreatorProfilePictureUri = it.creatorProfilePictureUri, + pollCreatorName = it.pollCreatorName, + tags = it.tags, + pollQuestionTitle = it.pollQuestionTitle, + optionsContent = { + when (it) { + is Poll.ContinuousPoll -> {} + + is Poll.DiscretePoll -> { + ReadOnlyDiscretePollOptions(it.options) + } + } + }, + dueDate = it.dueDate?.fromISO8601() ?: "", + rejectionText = it.rejectionText ?: "", + commentCount = it.commentCount + ) + } + } +} + +@Composable +fun ReadOnlyDiscretePollOptions( + options: ImmutableList, + modifier: Modifier = Modifier +) { + val totalVotes = remember(options) { + val sum = options.sumOf { it.voteCount } + if (sum == 0) { + 1 + } else sum + } + + Column( + modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + options.forEach { + DiscreteVoteOption( + optionName = it.text, + voteCount = it.voteCount, + fillPercentage = it.voteCount.toFloat() / totalVotes, + isSelected = false + ) + } + } +} diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollNavigation.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollNavigation.kt new file mode 100644 index 00000000..0f7f437e --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollNavigation.kt @@ -0,0 +1,23 @@ +package com.bounswe.predictionpolls.ui.create + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.composable + +const val CREATE_POLL_ROUTE = "create_poll" + +fun NavGraphBuilder.createPollScreen() { + composable(CREATE_POLL_ROUTE) { + CreatePollScreen() + } +} + +fun NavController.navigateToCreatePollScreen( + navOptions: NavOptions? = null, + block: Navigator.Extras? = null +) { + navigate(CREATE_POLL_ROUTE, navOptions, block) +} + diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreen.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreen.kt new file mode 100644 index 00000000..306a8832 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreen.kt @@ -0,0 +1,570 @@ +package com.bounswe.predictionpolls.ui.create + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +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 androidx.compose.ui.window.PopupProperties +import androidx.hilt.navigation.compose.hiltViewModel +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.ui.common.CustomDatePicker +import com.bounswe.predictionpolls.ui.common.CustomInputField +import com.bounswe.predictionpolls.ui.common.ErrorDialog +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme +import com.bounswe.predictionpolls.utils.DateTransformation + +@Composable +fun CreatePollScreen( + viewModel: CreatePollViewModel = hiltViewModel() +) { + val successfulCreateText = stringResource(id = R.string.poll_create_successful) + val context = LocalContext.current + + CreatePollScreenUI( + question = viewModel.screenState.question, + onQuestionChanged = { viewModel.onEvent(CreatePollScreenEvent.OnQuestionChanged(it)) }, + activePollType = viewModel.screenState.pollType, + onPollTypeChanged = { viewModel.onEvent(CreatePollScreenEvent.OnPollTypeChanged(it)) }, + options = viewModel.screenState.discreteOptions, + onOptionChanged = { option, position -> + viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionChanged(option, position)) + }, + onOptionAdded = { viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionAdded) }, + onOptionRemoved = { viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionRemoved(it)) }, + activeInputType = viewModel.screenState.continuousInputType, + onInputTypeChanged = { + viewModel.onEvent( + CreatePollScreenEvent.OnContinuousInputTypeChanged( + it + ) + ) + }, + isDueDateChecked = viewModel.screenState.isDueDateEnabled, + onDueDateChecked = { viewModel.onEvent(CreatePollScreenEvent.OnDueDateChecked(it)) }, + dueDate = viewModel.screenState.dueDate, + onDueDateChanged = { viewModel.onEvent(CreatePollScreenEvent.OnDueDateChanged(it)) }, + lastAcceptValue = viewModel.screenState.lastAcceptValue, + onLastAcceptValueChanged = { + viewModel.onEvent( + CreatePollScreenEvent.OnLastAcceptValueChanged( + it + ) + ) + }, + selectedLastAcceptValueType = viewModel.screenState.acceptValueType, + onLastAcceptValueTypeChanged = { + viewModel.onEvent( + CreatePollScreenEvent.OnAcceptValueTypeChanged( + it + ) + ) + }, + isDistributionVisible = viewModel.screenState.isDistributionVisible, + onDistributionVisibilityChanged = { + viewModel.onEvent( + CreatePollScreenEvent.OnDistributionVisibilityChanged( + it + ) + ) + }, + onCreatePollClicked = { + viewModel.onEvent(CreatePollScreenEvent.OnCreatePollClicked { + Toast.makeText(context, successfulCreateText, Toast.LENGTH_SHORT).show() + }) + }, + errorId = viewModel.screenState.inputValidationError, + networkError = viewModel.error, + onDismissErrorDialog = { viewModel.onEvent(CreatePollScreenEvent.OnErrorDismissed) }, + isDatePickerVisible = viewModel.screenState.isDatePickerVisible, + onDatePickerVisibilityChanged = { viewModel.onEvent(CreatePollScreenEvent.ToggleDatePicker) }, + ) +} + +@Composable +private fun CreatePollScreenUI( + question: String = "", + onQuestionChanged: (String) -> Unit = {}, + activePollType: CreatePollScreenState.PollType = CreatePollScreenState.PollType.DISCRETE, + onPollTypeChanged: (CreatePollScreenState.PollType) -> Unit = {}, + options: List = listOf(""), + onOptionChanged: (String, Int) -> Unit = { _, _ -> }, + onOptionAdded: () -> Unit = {}, + onOptionRemoved: (Int) -> Unit = {}, + activeInputType: CreatePollScreenState.ContinuousInputType = CreatePollScreenState.ContinuousInputType.DATE, + onInputTypeChanged: (CreatePollScreenState.ContinuousInputType) -> Unit = {}, + isDueDateChecked: Boolean = false, + onDueDateChecked: (Boolean) -> Unit = {}, + dueDate: String = "", + onDueDateChanged: (String) -> Unit = {}, + lastAcceptValue: String = "", + onLastAcceptValueChanged: (String) -> Unit = {}, + selectedLastAcceptValueType: CreatePollScreenState.AcceptValueType = CreatePollScreenState.AcceptValueType.DAY, + onLastAcceptValueTypeChanged: (CreatePollScreenState.AcceptValueType) -> Unit = {}, + isDistributionVisible: Boolean = false, + onDistributionVisibilityChanged: (Boolean) -> Unit = {}, + onCreatePollClicked: () -> Unit = {}, + errorId: Int? = null, + networkError: String? = null, + onDismissErrorDialog: () -> Unit = {}, + isDatePickerVisible: Boolean = false, + onDatePickerVisibilityChanged: () -> Unit = {}, +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(start = 16.dp, end = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + ScreenHeader() + CustomInputField( + modifier = Modifier.fillMaxWidth(), + labelId = R.string.poll_create_screen_question, + text = question, + onTextChanged = onQuestionChanged, + ) + PollTypeChoice( + activePollType = activePollType, + onPollTypeChanged = onPollTypeChanged, + ) + if (activePollType == CreatePollScreenState.PollType.DISCRETE) { + DiscretePollOptions( + options = options, + onOptionChanged = onOptionChanged, + onOptionAdded = onOptionAdded, + onOptionRemoved = onOptionRemoved, + ) + } else { + ContinuousPollInputType( + activeInputType = activeInputType, + onInputTypeChanged = onInputTypeChanged, + ) + } + DueDateRow( + isDueDateChecked = isDueDateChecked, + onDueDateChecked = onDueDateChecked, + dueDate = dueDate, + onDueDateChanged = onDueDateChanged, + toggleDatePicker = onDatePickerVisibilityChanged, + ) + PollEndTime( + lastAcceptValue = lastAcceptValue, + onLastAcceptValueChanged = onLastAcceptValueChanged, + onLastAcceptValueTypeChanged = onLastAcceptValueTypeChanged, + selectedLastAcceptValueType = selectedLastAcceptValueType, + ) + DistributionVisibility( + isDistributionVisible = isDistributionVisible, + onDistributionVisibilityChanged = onDistributionVisibilityChanged, + ) + Row( + modifier = Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = { + onCreatePollClicked() + }, + colors = ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + shape = RoundedCornerShape(8.dp), + ) { + Text( + modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + text = stringResource(id = R.string.poll_create_action), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + } + } + } + ErrorDialog( + error = errorId?.let { stringResource(id = it) } ?: networkError, + onDismiss = { onDismissErrorDialog() } + ) + CustomDatePicker( + isDatePickerVisible = isDatePickerVisible, + onDismissRequest = onDatePickerVisibilityChanged, + onDateChanged = { onDueDateChanged(it) } + ) +} + +@Composable +private fun PollTypeChoice( + activePollType: CreatePollScreenState.PollType = CreatePollScreenState.PollType.DISCRETE, + onPollTypeChanged: (CreatePollScreenState.PollType) -> Unit = {}, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(id = R.string.poll_create_screen_type), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CreatePollScreenState.PollType.entries.forEach { + TextButton( + onClick = { + onPollTypeChanged(it) + }, + colors = ButtonDefaults.textButtonColors( + containerColor = if (activePollType == it) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + }, + ), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f) + ) { + Text( + modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + text = it.type, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + } + } + } + } +} + +@Composable +private fun ScreenHeader() { + Text( + text = stringResource(id = R.string.poll_create_screen_title), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge, + fontSize = 20.sp, + lineHeight = 24.sp + ) +} + +@Composable +private fun DiscretePollOptions( + options: List, + onOptionChanged: (String, Int) -> Unit = { _, _ -> }, + onOptionAdded: () -> Unit = {}, + onOptionRemoved: (Int) -> Unit = {}, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(id = R.string.poll_create_screen_enter_options), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + options.forEachIndexed { index, option -> + CustomInputField( + modifier = Modifier.fillMaxWidth(), + text = option, + onTextChanged = { onOptionChanged(it, index) }, + trailingIconId = R.drawable.ic_delete, + trailingIconContentDescription = R.string.poll_create_screen_cd_delete, + onTrailingIconClicked = { onOptionRemoved(index) }, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Spacer(modifier = Modifier.weight(1f)) + TextButton( + onClick = { + onOptionAdded() + }, + colors = ButtonDefaults.textButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + shape = RoundedCornerShape(8.dp), + ) { + Text( + modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + text = stringResource(id = R.string.poll_create_screen_add_options), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + } + } + } +} + +@Composable +private fun DueDateRow( + isDueDateChecked: Boolean, + onDueDateChecked: (Boolean) -> Unit = {}, + dueDate: String = "", + onDueDateChanged: (String) -> Unit = {}, + toggleDatePicker: () -> Unit = {}, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = isDueDateChecked, onCheckedChange = onDueDateChecked) + Text( + text = stringResource(id = R.string.poll_create_due_date), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + } + Spacer(modifier = Modifier.width(32.dp)) + if (isDueDateChecked) { + CustomInputField( + text = dueDate, + onTextChanged = onDueDateChanged, + trailingIconId = R.drawable.ic_calendar, + trailingIconContentDescription = R.string.cd_calendar, + visualTransformation = DateTransformation(), + onTrailingIconClicked = { toggleDatePicker() }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun PollEndTime( + lastAcceptValue: String = "", + onLastAcceptValueChanged: (String) -> Unit = {}, + selectedLastAcceptValueType: CreatePollScreenState.AcceptValueType, + onLastAcceptValueTypeChanged: (CreatePollScreenState.AcceptValueType) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(id = R.string.poll_create_screen_poll_end), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp, + softWrap = true, + overflow = TextOverflow.Visible, + modifier = Modifier.width(IntrinsicSize.Max) + ) + CustomInputField( + modifier = Modifier.weight(1f), + text = lastAcceptValue, + onTextChanged = onLastAcceptValueChanged, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + ) + LastAcceptValueTypeSelection( + selectedItem = selectedLastAcceptValueType.type, + onLastAcceptValueTypeChanged = onLastAcceptValueTypeChanged, + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun LastAcceptValueTypeSelection( + selectedItem: String, + onLastAcceptValueTypeChanged: (CreatePollScreenState.AcceptValueType) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(8.dp) + + Column { + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, shape) + .clip(shape = shape) + .clickable { + expanded = expanded.not() + } + .padding(vertical = 12.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedItem, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, + fontSize = 14.sp, + lineHeight = 17.sp, + textAlign = TextAlign.Center, + ) + Icon( + painter = painterResource(id = R.drawable.ic_back), + contentDescription = stringResource(id = R.string.poll_create_screen_poll_end_selection), + modifier = Modifier.rotate(-90f), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + properties = PopupProperties( + usePlatformDefaultWidth = false, + ), + modifier = Modifier + .background(MaterialTheme.colorScheme.primary) + ) { + CreatePollScreenState.AcceptValueType.entries.forEach { item -> + DropdownMenuItem( + onClick = { + onLastAcceptValueTypeChanged(item) + expanded = false + }, + text = { + Text( + text = item.type, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, + fontSize = 14.sp, + lineHeight = 17.sp, + textAlign = TextAlign.Center, + ) + } + ) + } + } + } +} + +@Composable +private fun DistributionVisibility( + isDistributionVisible: Boolean, + onDistributionVisibilityChanged: (Boolean) -> Unit = {}, +) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = isDistributionVisible, onCheckedChange = onDistributionVisibilityChanged) + Text( + text = stringResource(id = R.string.poll_create_screen_distribution_visibility), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + } +} + +@Composable +private fun ContinuousPollInputType( + activeInputType: CreatePollScreenState.ContinuousInputType = CreatePollScreenState.ContinuousInputType.DATE, + onInputTypeChanged: (CreatePollScreenState.ContinuousInputType) -> Unit = {}, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(id = R.string.poll_create_screen_choose_continuous_type), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CreatePollScreenState.ContinuousInputType.entries.forEach { + TextButton( + onClick = { + onInputTypeChanged(it) + }, + colors = ButtonDefaults.textButtonColors( + containerColor = if (activeInputType == it) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + }, + ), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f) + ) { + Text( + modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + text = it.type, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleLarge, + fontSize = 14.sp, + lineHeight = 17.sp + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CreatePollScreenPreview() { + PredictionPollsTheme { + CreatePollScreenUI() + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreenEvent.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreenEvent.kt new file mode 100644 index 00000000..f35a7da2 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreenEvent.kt @@ -0,0 +1,29 @@ +package com.bounswe.predictionpolls.ui.create + +sealed class CreatePollScreenEvent { + data class OnQuestionChanged(val question: String) : CreatePollScreenEvent() + data class OnPollTypeChanged(val pollType: CreatePollScreenState.PollType) : + CreatePollScreenEvent() + + data class OnDiscreteOptionChanged(val option: String, val position: Int) : + CreatePollScreenEvent() + + data object OnDiscreteOptionAdded : CreatePollScreenEvent() + data class OnDiscreteOptionRemoved(val position: Int) : CreatePollScreenEvent() + data class OnContinuousInputTypeChanged(val inputType: CreatePollScreenState.ContinuousInputType) : + CreatePollScreenEvent() + + data class OnDueDateChecked(val isChecked: Boolean) : CreatePollScreenEvent() + data class OnDueDateChanged(val dueDate: String) : CreatePollScreenEvent() + data class OnLastAcceptValueChanged(val value: String) : CreatePollScreenEvent() + data class OnAcceptValueTypeChanged(val type: CreatePollScreenState.AcceptValueType) : + CreatePollScreenEvent() + + data class OnDistributionVisibilityChanged(val isChecked: Boolean) : CreatePollScreenEvent() + data class OnCreatePollClicked( + val onSuccess: () -> Unit, + ) : CreatePollScreenEvent() + + data object OnErrorDismissed : CreatePollScreenEvent() + data object ToggleDatePicker : CreatePollScreenEvent() +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreenState.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreenState.kt new file mode 100644 index 00000000..6b552dfd --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollScreenState.kt @@ -0,0 +1,150 @@ +package com.bounswe.predictionpolls.ui.create + +import androidx.annotation.StringRes +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.data.remote.model.request.CreateContinuousPollRequest +import com.bounswe.predictionpolls.data.remote.model.request.CreateDiscretePollRequest +import com.bounswe.predictionpolls.extensions.isValidDate + +data class CreatePollScreenState( + val question: String = "", + val pollType: PollType = PollType.DISCRETE, + val discreteOptions: List = listOf("",""), + val continuousInputType: ContinuousInputType = ContinuousInputType.DATE, + val isDueDateEnabled: Boolean = false, + val dueDate: String = "", + val lastAcceptValue: String = "", + val acceptValueType: AcceptValueType = AcceptValueType.MINUTE, + val isDistributionVisible: Boolean = false, + @StringRes val inputValidationError: Int? = null, + val isDatePickerVisible: Boolean = false, +) { + enum class PollType(val type: String) { + DISCRETE("Multiple Choice"), + CONTINUOUS("Customized") + } + + enum class ContinuousInputType(val type: String) { + DATE("Date"), + NUMERIC("Numeric"); + + fun toContinuousRequestType(): CreateContinuousPollRequest.PollRequestType { + return when (this) { + DATE -> CreateContinuousPollRequest.PollRequestType.DATE + NUMERIC -> CreateContinuousPollRequest.PollRequestType.NUMERIC + } + } + } + + enum class AcceptValueType(val type: String) { + MINUTE("Minute"), + HOUR("Hour"), + DAY("Day"), + MONTH("Month"); + + fun toDiscreteRequestType(): CreateDiscretePollRequest.TimeUnit { + return when (this) { + MINUTE -> CreateDiscretePollRequest.TimeUnit.MINUTE + HOUR -> CreateDiscretePollRequest.TimeUnit.HOUR + DAY -> CreateDiscretePollRequest.TimeUnit.DAY + MONTH -> CreateDiscretePollRequest.TimeUnit.MONTH + } + } + + fun toContinuousRequestType(): CreateContinuousPollRequest.TimeUnit { + return when (this) { + MINUTE -> CreateContinuousPollRequest.TimeUnit.MINUTE + HOUR -> CreateContinuousPollRequest.TimeUnit.HOUR + DAY -> CreateContinuousPollRequest.TimeUnit.DAY + MONTH -> CreateContinuousPollRequest.TimeUnit.MONTH + } + } + } + + val isQuestionValid: Boolean + get() = question.isNotBlank() + + val isDiscreteOptionsValid: Boolean + get() = pollType == PollType.CONTINUOUS || discreteOptions.all { it.isNotBlank() } + + val isDueDateValid: Boolean + get() = isDueDateEnabled.not() || dueDate.none { it.isDigit().not() } && dueDate.isValidDate() + + fun reduce(event: CreatePollScreenEvent): CreatePollScreenState { + return when (event) { + is CreatePollScreenEvent.OnQuestionChanged -> { + this.copy(question = event.question) + } + + is CreatePollScreenEvent.OnPollTypeChanged -> { + this.copy(pollType = event.pollType) + } + + is CreatePollScreenEvent.OnDiscreteOptionChanged -> { + this.copy( + discreteOptions = discreteOptions.mapIndexed { index, option -> + if (index == event.position) { + event.option + } else { + option + } + } + ) + } + + is CreatePollScreenEvent.OnDiscreteOptionAdded -> { + this.copy(discreteOptions = discreteOptions + "") + } + + is CreatePollScreenEvent.OnDiscreteOptionRemoved -> { + if (discreteOptions.size <= 2) { + this.copy( + inputValidationError = R.string.poll_create_screen_option_count_error + ) + } else { + this.copy( + discreteOptions = discreteOptions.filterIndexed { index, _ -> + index != event.position + } + ) + } + } + + is CreatePollScreenEvent.OnDueDateChecked -> { + this.copy(isDueDateEnabled = event.isChecked) + } + + is CreatePollScreenEvent.OnDueDateChanged -> { + this.copy(dueDate = event.dueDate) + } + + is CreatePollScreenEvent.OnLastAcceptValueChanged -> { + this.copy(lastAcceptValue = event.value) + } + + is CreatePollScreenEvent.OnAcceptValueTypeChanged -> { + this.copy(acceptValueType = event.type) + } + + is CreatePollScreenEvent.OnDistributionVisibilityChanged -> { + this.copy(isDistributionVisible = event.isChecked) + } + + is CreatePollScreenEvent.OnContinuousInputTypeChanged -> { + this.copy(continuousInputType = event.inputType) + } + + is CreatePollScreenEvent.OnErrorDismissed -> { + this.copy(inputValidationError = null) + } + + is CreatePollScreenEvent.ToggleDatePicker -> { + this.copy(isDatePickerVisible = !isDatePickerVisible) + } + + else -> { + this + } + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollViewModel.kt new file mode 100644 index 00000000..3968468c --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/create/CreatePollViewModel.kt @@ -0,0 +1,97 @@ +package com.bounswe.predictionpolls.ui.create + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.core.BaseViewModel +import com.bounswe.predictionpolls.data.remote.repositories.PollRepositoryInterface +import com.bounswe.predictionpolls.extensions.toISO8601 +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CreatePollViewModel @Inject constructor( + private val pollRepository: PollRepositoryInterface +) : BaseViewModel() { + var screenState by mutableStateOf(CreatePollScreenState()) + private set + + fun onEvent(event: CreatePollScreenEvent) { + screenState = screenState.reduce(event) + when (event) { + is CreatePollScreenEvent.OnCreatePollClicked -> { + onCreatePoll(event.onSuccess) + } + + is CreatePollScreenEvent.OnErrorDismissed -> { + error = null + } + + else -> {} + } + } + + private fun isInputValid(): Boolean { + if (screenState.isQuestionValid.not()) { + screenState = + screenState.copy(inputValidationError = R.string.poll_create_screen_question_error) + return false + } else if (screenState.isDiscreteOptionsValid.not()) { + screenState = + screenState.copy(inputValidationError = R.string.poll_create_screen_options_error) + return false + } else if (screenState.isDueDateValid.not()) { + screenState = + screenState.copy(inputValidationError = R.string.poll_create_due_date_error) + return false + } + return true + } + + private fun onCreatePoll(onSuccess: () -> Unit) { + if (isInputValid().not()) return + + val formattedDueDate = if (screenState.isDueDateEnabled) { + screenState.dueDate.toISO8601() + } else { + null + } + + launchCatching( + trackJobProgress = true, + maxRetryCount = 1, + onSuccess = { + onSuccess() + } + ) { + when (screenState.pollType) { + CreatePollScreenState.PollType.DISCRETE -> { + pollRepository.createDiscretePoll( + screenState.question, + screenState.discreteOptions, + screenState.isDistributionVisible, + screenState.isDueDateEnabled, + formattedDueDate, + screenState.lastAcceptValue.filter { it.isDigit() } + .takeIf { it.isBlank().not() }?.toInt(), + screenState.acceptValueType.toDiscreteRequestType().value, + ) + } + + CreatePollScreenState.PollType.CONTINUOUS -> { + pollRepository.createContinuousPoll( + screenState.question, + screenState.isDistributionVisible, + screenState.isDueDateEnabled, + formattedDueDate, + screenState.lastAcceptValue.filter { it.isDigit() } + .takeIf { it.isBlank().not() }?.toInt(), + screenState.acceptValueType.toContinuousRequestType().value, + screenState.continuousInputType.toContinuousRequestType().value, + ) + } + } + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreen.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreen.kt index a670325f..3d18ebdb 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreen.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreen.kt @@ -1,59 +1,86 @@ package com.bounswe.predictionpolls.ui.feed +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.bounswe.predictionpolls.ui.common.poll.DiscreteVoteOption -import com.bounswe.predictionpolls.ui.common.poll.PollComposable -import java.util.Date +import androidx.compose.ui.unit.dp +import com.bounswe.predictionpolls.ui.common.poll.Polls @Composable -fun FeedScreen(modifier: Modifier = Modifier) { +fun FeedScreen( + feedUiState: FeedUiState, + onPollClicked: (pollId: String) -> Unit, + modifier: Modifier = Modifier +) { var text by rememberSaveable { mutableStateOf("") } // this might be stored in VM. I am not sure how we will use this parameter so I will store it here for now.. - Column { - FeedSearchBar(text = text, onTextChanged = { text = it }) - LazyColumn(modifier = modifier) { - items(10) { - PollComposable( - pollCreatorProfilePictureUri = "https://picsum.photos/id/237/200/300", - pollCreatorName = "John Doe", - tags = listOf("Basketball", "NBA", "Lebron James"), - pollQuestionTitle = "When was Lebron James drafted?", - optionsContent = { - Column { - DiscreteVoteOption( - optionName = "2003", - voteCount = 100, - fillPercentage = 0.25f, - isSelected = true - ) - DiscreteVoteOption( - optionName = "2004", - voteCount = 200, - fillPercentage = 0.5f, - isSelected = false - ) - DiscreteVoteOption( - optionName = "2005", - voteCount = 100, - fillPercentage = 0.25f, - isSelected = false - ) - } - - - }, - dueDate = Date(), - rejectionText = "Last 5 days", - commentCount = 127 + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + ) { + FeedSearchBar( + modifier = Modifier.fillMaxWidth(), + text = text, + onTextChanged = { text = it } + ) + Spacer(modifier = Modifier.height(16.dp)) + when (feedUiState) { + is FeedUiState.Loading -> { + FeedLoading() + } + + is FeedUiState.NoFeed -> { + NoFeedDisplay( + modifier = Modifier + .fillMaxSize() ) } + + is FeedUiState.HasFeed -> { + Polls(polls = feedUiState.feed, onPollClicked = onPollClicked) + } + + is FeedUiState.Error -> { + FeedError(errorMessage = feedUiState.message) + } } + + + } + +} + +@Composable +private fun FeedError(errorMessage: String, modifier: Modifier = Modifier) { + Box(modifier = modifier) { + Text(text = errorMessage, modifier = Modifier.align(Alignment.Center)) + } +} + +@Composable +private fun NoFeedDisplay(modifier: Modifier = Modifier) { + Box(modifier = modifier) { + Text(text = "No feed to show", modifier = Modifier.align(Alignment.Center)) + } +} + +@Composable +private fun FeedLoading(modifier: Modifier = Modifier) { + Box(modifier) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } +} -} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreenNavigation.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreenNavigation.kt index 6c38c67c..d5ad3882 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreenNavigation.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreenNavigation.kt @@ -1,18 +1,38 @@ package com.bounswe.predictionpolls.ui.feed +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.Navigator import androidx.navigation.compose.composable +import com.bounswe.predictionpolls.ui.main.MAIN_ROUTE +import com.bounswe.predictionpolls.ui.main.navigateToMainScreen +import com.bounswe.predictionpolls.ui.vote.navigateToPollVoteScreen const val FEED_ROUTE = "feed" -fun NavGraphBuilder.feedScreen(navController: NavController) { +fun NavGraphBuilder.feedScreen(navController: NavController, isUserLoggedIn: Boolean) { composable(FEED_ROUTE) { - val feedViewModel: FeedViewModel = hiltViewModel() // will be used later - FeedScreen() + val feedViewModel: FeedViewModel = hiltViewModel() + LaunchedEffect(Unit) { + // fetch the feed if it is not already fetched + if (feedViewModel.feedUiState.value !is FeedUiState.HasFeed) + feedViewModel.fetchFeed(0) + } + val feedUiState by feedViewModel.feedUiState.collectAsStateWithLifecycle() + FeedScreen(feedUiState, onPollClicked = { + if (isUserLoggedIn) { + navController.navigateToPollVoteScreen(it) + } else { + navController.navigateToMainScreen( + navOptions = NavOptions.Builder().setPopUpTo(MAIN_ROUTE, true).build() + ) + } + }) } } diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedSearchBar.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedSearchBar.kt index 6c2a72d3..8d507eea 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedSearchBar.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedSearchBar.kt @@ -30,7 +30,6 @@ fun FeedSearchBar( } // create a preview for above composable - @Preview @Composable fun FeedSearchBarPreview() { diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedUiState.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedUiState.kt new file mode 100644 index 00000000..4feb4545 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedUiState.kt @@ -0,0 +1,16 @@ +package com.bounswe.predictionpolls.ui.feed + +import com.bounswe.predictionpolls.domain.poll.Poll +import kotlinx.collections.immutable.ImmutableList + +sealed interface FeedUiState { + data object Loading : FeedUiState + data class HasFeed(val feed: ImmutableList, val feedPage: Int) : FeedUiState + + /** + * Represents the case where there is no feed to show. This case is practically equivalent to HasFeed(emptyList()) but is used to make the code more readable. + */ + data object NoFeed : FeedUiState + + data class Error(val message: String) : FeedUiState +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedViewModel.kt index 0efe2034..4ce368b8 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedViewModel.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedViewModel.kt @@ -1,10 +1,43 @@ package com.bounswe.predictionpolls.ui.feed import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.feed.GetFeedUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class FeedViewModel @Inject constructor() : ViewModel() { +class FeedViewModel @Inject constructor(private val getFeedUseCase: GetFeedUseCase) : ViewModel() { + private val _feedUiState: MutableStateFlow = MutableStateFlow(FeedUiState.Loading) + val feedUiState: StateFlow + get() = _feedUiState.asStateFlow() + + + fun fetchFeed(page: Int) = viewModelScope.launch(Dispatchers.IO) { + _feedUiState.update { FeedUiState.Loading } + + val newState = when (val result = getFeedUseCase(page)) { + is Result.Success -> { + if (result.data.isEmpty()) { + FeedUiState.NoFeed + } else { + FeedUiState.HasFeed(result.data, page) + } + } + + is Result.Error -> FeedUiState.Error( + result.exception.message ?: "Unknown error" + ) + } + + _feedUiState.update { newState } + } } \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreen.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreen.kt index 497ad41c..0124cc1d 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreen.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreen.kt @@ -132,8 +132,8 @@ private fun LeaderboardScreenTagSelection( ), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 16.dp) ) { items.forEach { item -> DropdownMenuItem( @@ -187,7 +187,7 @@ private fun ColumnScope.Leaderboard( @Composable private fun LeaderboardRow( position: String, - image: String, + image: String, // TODO: use image username: String, point: String, ) { diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreenNavigation.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreenNavigation.kt index dc17a400..fb09d1f3 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreenNavigation.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreenNavigation.kt @@ -6,7 +6,7 @@ import androidx.navigation.NavOptions import androidx.navigation.Navigator import androidx.navigation.compose.composable -const val LEADERBOARD_ROUTE = "login" +const val LEADERBOARD_ROUTE = "leaderboard" fun NavGraphBuilder.leaderboardScreen(navController: NavController) { composable(LEADERBOARD_ROUTE) { diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreenState.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreenState.kt index d92cefd0..1e88b153 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreenState.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardScreenState.kt @@ -9,13 +9,68 @@ data class LeaderboardScreenState( val DUMMY_STATE = LeaderboardScreenState( tags = listOf("E-sport", "NBA", "Football", "Tennis"), selectedTag = "E-sport", - leaderboardList = (0..20).reversed().map { + leaderboardList = listOf( LeaderboardItem( - username = "user$it", + username = "yigit", + image = "yigit", + score = 420 + ), + LeaderboardItem( + username = "ahmet", image = "", - score = it * 10 - ) - } + score = 410 + ), + LeaderboardItem( + username = "ozan", + image = "", + score = 400 + ), + LeaderboardItem( + username = "batuhan", + image = "", + score = 390 + ), + LeaderboardItem( + username = "şefik", + image = "", + score = 380 + ), + LeaderboardItem( + username = "yusuf", + image = "", + score = 370 + ), + LeaderboardItem( + username = "merve", + image = "", + score = 360 + ), + LeaderboardItem( + username = "aslıhan", + image = "", + score = 350 + ), + LeaderboardItem( + username = "mehmet", + image = "", + score = 340 + ), + LeaderboardItem( + username = "ali", + image = "", + score = 330 + ), + LeaderboardItem( + username = "veli", + image = "", + score = 320 + ), + LeaderboardItem( + username = "ayşe", + image = "", + score = 310 + ), + ) ) } diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardViewModel.kt index cfc3bfed..1e2c9820 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardViewModel.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/leaderboard/LeaderboardViewModel.kt @@ -9,7 +9,7 @@ import javax.inject.Inject @HiltViewModel class LeaderboardViewModel @Inject constructor() : BaseViewModel() { - var screenState by mutableStateOf(LeaderboardScreenState()) + var screenState by mutableStateOf(LeaderboardScreenState.DUMMY_STATE) private set fun onEvent(event: LeaderboardScreenEvent){ diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileCard.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileCard.kt new file mode 100644 index 00000000..4d876e05 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileCard.kt @@ -0,0 +1,338 @@ +package com.bounswe.predictionpolls.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.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.layout.ContentScale +import androidx.compose.ui.res.painterResource +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 androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.ui.theme.MontserratFontFamily +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme + +@Composable +fun ProfileCard( + username: String, + userFullName: String, + coverPhotoUri: String?, + profilePictureUri: String?, + userDescription: String?, + badgeUris: List, + onProfileEditPressed: () -> Unit, + onRequestsClicked: () -> Unit, + modifier: Modifier = Modifier +) { + val paddingAroundContent: Dp = 16.dp + Column( + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.primaryContainer) + .wrapContentHeight() + .fillMaxWidth() + ) { + CoverPhoto( + imageUri = coverPhotoUri, + modifier = Modifier + .clip( + MaterialTheme.shapes.medium.copy( + bottomEnd = CornerSize(0.dp), + bottomStart = CornerSize(0.dp) + ) + ) + .aspectRatio(2.5f) + .fillMaxWidth() + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(paddingAroundContent), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween + ) { + val profilePictureSize: Dp = 100.dp + Column( + modifier = Modifier.offset(y = profilePictureSize / -2f - (paddingAroundContent)), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfilePicture( + imageUri = profilePictureUri, + modifier = Modifier.size(profilePictureSize) + ) + UserInfoText(username = username) + UserInfoText(username = userFullName) + } + ProfileCardButtons( + onRequestsClicked = onRequestsClicked, + onProfileEditPressed = onProfileEditPressed, + modifier = Modifier + .weight(1f) + .padding(paddingAroundContent) + ) + + } + + UserDescription( + description = userDescription, + modifier = Modifier.padding(paddingAroundContent) + ) + + Badges(badgeUris = badgeUris, modifier = Modifier.padding(paddingAroundContent)) + + + } +} + +@Composable +private fun ProfileCardButtons( + onRequestsClicked: () -> Unit, + onProfileEditPressed: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + ProfileEditButton( + onProfileEditPressed = onProfileEditPressed, + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + RequestsButton( + onRequestsClicked = onRequestsClicked, + modifier = Modifier + .border( + 1.dp, + MaterialTheme.colorScheme.primary, + MaterialTheme.shapes.medium + ) + .clip(MaterialTheme.shapes.medium) + .fillMaxWidth() + ) + } +} + +@Composable +private fun ProfileEditButton(onProfileEditPressed: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clickable(onClick = onProfileEditPressed) + .background(MaterialTheme.colorScheme.primary) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_edit), + contentDescription = "Edit Profile", + tint = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "Edit Profile", + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + letterSpacing = 1.5.sp + ) + } + } +} + +@Composable +private fun CoverPhoto(imageUri: String?, modifier: Modifier) { + AsyncImage( + model = imageUri, + contentDescription = "User Badge", + modifier = modifier, + contentScale = if (imageUri == null) ContentScale.Fit else ContentScale.Crop, + alignment = Alignment.Center, + error = painterResource(id = R.drawable.ic_warning), + ) +} + +@Composable +private fun RequestsButton(onRequestsClicked: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clickable(onClick = onRequestsClicked) + .background(MaterialTheme.colorScheme.onPrimary) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_add), + contentDescription = "Requests", + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "Requests", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + letterSpacing = 1.5.sp + ) + } + } + +} + +@Composable +private fun Badges(badgeUris: List, modifier: Modifier = Modifier) { + LazyRow(modifier = modifier) { + items(badgeUris) { uri -> + AsyncImage( + model = uri, + contentDescription = "User Badge", + modifier = modifier + .clip(CircleShape) + .size(48.dp) + .background(Color.Red), + contentScale = ContentScale.Crop, + alignment = Alignment.Center + ) + } + } +} + +@Composable +private fun ProfilePicture(imageUri: String?, modifier: Modifier = Modifier) { + AsyncImage( + model = imageUri, + contentDescription = "User profile picture", + modifier = modifier + .clip(CircleShape) + .size(80.dp), + contentScale = ContentScale.Crop, + alignment = Alignment.Center + ) +} + +@Composable +private fun UserDescription(description: String?, modifier: Modifier = Modifier) { + Text( + text = description ?: "", modifier = modifier, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.scrim, + fontFamily = MontserratFontFamily, + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) +} + +@Composable +private fun UserInfoText(username: String, modifier: Modifier = Modifier) { + Text( + text = username, + maxLines = 1, + modifier = modifier, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.scrim, + fontFamily = MontserratFontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) +} + + +@Preview +@Composable +private fun ProfileEditButtonPreview() { + PredictionPollsTheme { + Column { + ProfileEditButton( + onProfileEditPressed = {}, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + RequestsButton( + onRequestsClicked = {}, + modifier = Modifier + .border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(12.dp)) + .fillMaxWidth() + ) + } + } +} + + +@Preview +@Composable +private fun ProfileCardPreview() { + PredictionPollsTheme { + ProfileCard( + "can.gezer13", + "Can Gezer", + "https://picsum.photos/400/400", + "https://picsum.photos/400/400", + "This is a long description text. Lorem ipsum dolor sit amet, consectet adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo.", + listOf( + "https://picsum.photos/400/400", + "https://picsum.photos/400/400", + "https://picsum.photos/400/400", + "https://picsum.photos/400/400", + "https://picsum.photos/400/400", + "https://picsum.photos/400/400", "https://picsum.photos/400/400" + ), + {}, + {}, + modifier = Modifier + ) + } + +} + +@Preview(showBackground = true) +@Composable +private fun UserDescriptionPreview() { + PredictionPollsTheme { + UserDescription(description = "This is a long description text. Lorem ipsum dolor sit amet, consectet adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo.") + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreen.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreen.kt new file mode 100644 index 00000000..1ddd00ad --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreen.kt @@ -0,0 +1,184 @@ +package com.bounswe.predictionpolls.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bounswe.predictionpolls.domain.poll.Poll +import com.bounswe.predictionpolls.extensions.fromISO8601 +import com.bounswe.predictionpolls.ui.common.poll.DiscreteVoteOption +import com.bounswe.predictionpolls.ui.common.poll.PollComposable +import com.bounswe.predictionpolls.ui.common.poll.ReadOnlyDiscretePollOptions +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme + + +@Composable +fun ProfileScreen(profileScreenUiState: ProfileScreenUiState, modifier: Modifier = Modifier) { + InternalProfileScreen( + modifier = modifier, + profileInformation = { + when (profileScreenUiState) { + is ProfileScreenUiState.Loading -> { + CircularProgressIndicator() + } + + is ProfileScreenUiState.Error -> { + Text(text = profileScreenUiState.message) + } + + is ProfileScreenUiState.ProfileInfoFetched -> { + val profileInfo = profileScreenUiState.profileInfo + ProfileCard( + username = profileInfo.username, + userFullName = profileInfo.userFullName, + coverPhotoUri = profileInfo.coverPhotoUri, + profilePictureUri = profileInfo.profilePictureUri, + userDescription = profileInfo.userDescription, + badgeUris = profileInfo.badgeUris, + onProfileEditPressed = { /*TODO*/ }, + onRequestsClicked = { /*TODO*/ }) + } + + is ProfileScreenUiState.ProfileAndFeedFetched -> { + val profileInfo = profileScreenUiState.profileInfo + ProfileCard( + username = profileInfo.username, + userFullName = profileInfo.userFullName, + coverPhotoUri = profileInfo.coverPhotoUri, + profilePictureUri = profileInfo.profilePictureUri, + userDescription = profileInfo.userDescription, + badgeUris = profileInfo.badgeUris, + onProfileEditPressed = { /*TODO*/ }, + onRequestsClicked = { /*TODO*/ }) + } + } + }, + polls = { + when (profileScreenUiState) { + is ProfileScreenUiState.ProfileAndFeedFetched -> { + items(profileScreenUiState.feed) { + PollComposable( + pollCreatorProfilePictureUri = it.creatorProfilePictureUri, + pollCreatorName = it.pollCreatorName, + tags = it.tags, + pollQuestionTitle = it.pollQuestionTitle, + optionsContent = { + when (it) { + is Poll.ContinuousPoll -> {} + + is Poll.DiscretePoll -> { + ReadOnlyDiscretePollOptions(it.options) + } + } + }, + dueDate = it.dueDate?.fromISO8601() ?: "", + rejectionText = it.rejectionText ?: "", + commentCount = it.commentCount + ) + } + } + + else -> { + // Do nothing + } + } + } + + ) + +} + +/** + * This composable is used to display profile information and polls of a user. + * @param polls is a function that has access to lazy list scope of the profile screen that displays polls of a user. + * Since these items have access to the lazy list scope, they can be added to the lazy list. + */ +@Composable +private fun InternalProfileScreen( + profileInformation: @Composable () -> Unit, + polls: LazyListScope.() -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier + .background(MaterialTheme.colorScheme.surface), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + item { + profileInformation() + } + + this@LazyColumn.polls() + } +} + + +@Preview +@Composable +private fun ProfileScreenPreview() { + PredictionPollsTheme { + InternalProfileScreen( + profileInformation = { + ProfileCard( + username = "can.gezer13", + userFullName = "Can Gezer", + coverPhotoUri = "https://picsum.photos/400/400", + profilePictureUri = "https://picsum.photos/id/237/200/300", + userDescription = "I am a computer engineering student at Bogazici University. I am interested in machine learning and data science.", + badgeUris = listOf( + "https://picsum.photos/id/231/200/300", + "https://picsum.photos/id/232/200/300", + "https://picsum.photos/id/233/200/300" + ), + onProfileEditPressed = { }, + onRequestsClicked = { }, + modifier = Modifier.padding(16.dp) + ) + + }, + polls = { + items(10) { + PollComposable( + pollCreatorProfilePictureUri = "https://picsum.photos/id/236/200/300", + pollCreatorName = "Ahmet Yilmaz", + tags = listOf("Lebron", "James", "NBA"), + pollQuestionTitle = "Who is the best NBA player?", + optionsContent = { + Column { + DiscreteVoteOption( + optionName = "Lebron James", + voteCount = 150, + fillPercentage = 0.75f, + isSelected = false, + ) + DiscreteVoteOption( + optionName = "Michael Jordan", + voteCount = 50, + fillPercentage = 0.25f, + isSelected = false, + ) + } + + }, + modifier = Modifier.padding(16.dp), + dueDate = "", + rejectionText = "Last 5 days", + commentCount = 530 + ) + } + } + ) + } +} + diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenNavigation.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenNavigation.kt new file mode 100644 index 00000000..c7f256c9 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenNavigation.kt @@ -0,0 +1,40 @@ +package com.bounswe.predictionpolls.ui.profile + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.composable + +const val PROFILE_SCREEN_ROUTE = "profile" + +fun NavGraphBuilder.profileScreen(navController: NavController) { + composable(PROFILE_SCREEN_ROUTE) { + //TODO: I have replaced index navigation. It should be implemented + val profileViewModel: ProfileScreenViewModel = hiltViewModel() + LaunchedEffect(key1 = Unit) { + if ( + profileViewModel.profileScreenUiState.value is ProfileScreenUiState.Loading || + profileViewModel.profileScreenUiState.value is ProfileScreenUiState.Error + ) { + profileViewModel.fetchProfileInfo() + profileViewModel.fetchFeed(0) + } + } + val profileScreenUiState by profileViewModel.profileScreenUiState.collectAsStateWithLifecycle() + + ProfileScreen(profileScreenUiState) + + } +} + +fun NavController.navigateToFeedScreen( + navOptions: NavOptions? = null, + block: Navigator.Extras? = null +) { + navigate(PROFILE_SCREEN_ROUTE, navOptions, block) +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenUiState.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenUiState.kt new file mode 100644 index 00000000..920e673a --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenUiState.kt @@ -0,0 +1,23 @@ +package com.bounswe.predictionpolls.ui.profile + +import com.bounswe.predictionpolls.domain.poll.Poll +import com.bounswe.predictionpolls.domain.profile.ProfileInfo +import kotlinx.collections.immutable.ImmutableList + +sealed interface ProfileScreenUiState { + data object Loading : ProfileScreenUiState + + /** + * Indicates that the profile info cannot be fetched. + */ + data class Error(val message: String) : ProfileScreenUiState + + /** + * Indicates that the profile info is fetched successfully but feed cannot be fetched. + */ + data class ProfileInfoFetched(val profileInfo: ProfileInfo, val errorMessage: String) : ProfileScreenUiState + + + data class ProfileAndFeedFetched(val profileInfo: ProfileInfo, val feed: ImmutableList) : ProfileScreenUiState + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenViewModel.kt new file mode 100644 index 00000000..da7507a0 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/profile/ProfileScreenViewModel.kt @@ -0,0 +1,109 @@ +package com.bounswe.predictionpolls.ui.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.feed.GetFeedUseCase +import com.bounswe.predictionpolls.domain.poll.Poll +import com.bounswe.predictionpolls.domain.profile.GetCurrentUserProfileUseCase +import com.bounswe.predictionpolls.domain.profile.ProfileInfo +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private data class ProfileScreenViewModelState( + val profileInfo: ProfileInfo? = null, + val feed: ImmutableList? = null, + val isLoading: Boolean = true, + val error: String? = null +) { + fun toProfileScreenUiState(): ProfileScreenUiState { + return if (isLoading) { + ProfileScreenUiState.Loading + } else { + if (profileInfo == null) { + ProfileScreenUiState.Error(error ?: "Unknown error") + } else { + if (feed == null) { + ProfileScreenUiState.ProfileInfoFetched(profileInfo, error ?: "Unknown error") + } else { + ProfileScreenUiState.ProfileAndFeedFetched(profileInfo, feed) + } + } + } + } +} + + +@HiltViewModel +class ProfileScreenViewModel @Inject constructor( + private val getProfileInfoUseCase: GetCurrentUserProfileUseCase, + private val getFeedUseCase: GetFeedUseCase +) : ViewModel() { + + private val _profileScreenUiState: MutableStateFlow = + MutableStateFlow(ProfileScreenViewModelState()) + + val profileScreenUiState: StateFlow + get() = _profileScreenUiState + .map { it.toProfileScreenUiState() } + .stateIn(viewModelScope, SharingStarted.Eagerly, ProfileScreenUiState.Loading) + + + fun fetchProfileInfo() = viewModelScope.launch { + _profileScreenUiState.update { it.copy(isLoading = true) } + when (val result = getProfileInfoUseCase()) { + is Result.Success -> { + _profileScreenUiState.update { + it.copy( + isLoading = false, + profileInfo = result.data, + error = null + ) + } + } + + is Result.Error -> { + _profileScreenUiState.update { + it.copy( + isLoading = false, + error = result.exception.message, + profileInfo = null + ) + } + } + } + } + + fun fetchFeed(page: Int) = viewModelScope.launch { + _profileScreenUiState.update { it.copy(isLoading = true) } + + when (val result = getFeedUseCase(page)) { + is Result.Success -> { + _profileScreenUiState.update { + it.copy( + isLoading = false, + feed = result.data, + ) + } + } + + is Result.Error -> { + _profileScreenUiState.update { + it.copy( + isLoading = false, + error = result.exception.message, + feed = null + ) + } + } + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenViewModel.kt index 2950cb7d..99eeb3be 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenViewModel.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenViewModel.kt @@ -57,7 +57,7 @@ class SignupScreenViewModel @Inject constructor( maxRetryCount = 1 ) { val givenBirthday = screenState.birthday - val formattedBirthday = "${givenBirthday.substring(0, 2)}/${givenBirthday.substring(2, 4)}/${givenBirthday.substring(4, 8)}" + val formattedBirthday = "${givenBirthday.substring(4, 8)}-${givenBirthday.substring(2, 4)}-${givenBirthday.substring(0, 2)}" authRepository.signup( email = screenState.email, diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVote.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVote.kt index 066a3e16..e6113c85 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVote.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVote.kt @@ -2,13 +2,16 @@ package com.bounswe.predictionpolls.ui.vote import androidx.compose.foundation.background 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.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize 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.material3.MaterialTheme import androidx.compose.material3.Text @@ -23,12 +26,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bounswe.predictionpolls.R -import com.bounswe.predictionpolls.ui.common.poll.ContinuousVoteInputType +import com.bounswe.predictionpolls.domain.poll.ContinuousVoteInputType import com.bounswe.predictionpolls.ui.common.poll.ContinuousVoteOption import com.bounswe.predictionpolls.ui.common.poll.PollComposable import com.bounswe.predictionpolls.ui.theme.MontserratFontFamily import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme -import java.util.Date /** * This composable represents the vote screen for a poll. @@ -41,11 +43,17 @@ fun PollVote( onVotePressed: () -> Unit, modifier: Modifier = Modifier ) { - Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { pollContent() Text( text = stringResource(id = R.string.vote_screen_text), - fontSize = 20.sp, + fontSize = 16.sp, fontFamily = MontserratFontFamily, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, @@ -116,8 +124,8 @@ private fun PollVotePreview() { pollCreatorName = "Zehra Eser", tags = listOf("Basketball", "Cleveland Cavaliers", "Lebron James"), pollQuestionTitle = "Who is the best basketball player of all time?", - optionsContent = { /*TODO*/ }, - dueDate = Date(), + optionsContent = { }, + dueDate = "", rejectionText = "Last 5 days", commentCount = 145 ) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteNavGraph.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteNavGraph.kt new file mode 100644 index 00000000..6f756691 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteNavGraph.kt @@ -0,0 +1,75 @@ +package com.bounswe.predictionpolls.ui.vote + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument + +const val POLL_VOTE_ROUTE = "pollVote/{pollId}" + +fun NavGraphBuilder.pollVoteScreen(navController: NavController) { + composable( + POLL_VOTE_ROUTE, arguments = listOf(navArgument("pollId") { type = NavType.StringType }) + ) { + val pollVoteViewModel: PollVoteViewModel = hiltViewModel() + // Accessing state from ViewModel + val pollId = it.arguments?.getString("pollId") ?: "" // Default value as fallback + + val state by pollVoteViewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(key1 = Unit) { + pollVoteViewModel.fetchPoll(pollId) + } + + // PollVoteScreen Composable + PollVoteScreen( + state = state, + onPointsReservedChanged = { points -> + pollVoteViewModel.onPointsReservedChanged(points) + }, + onVotePressed = { + // Assuming you have pollId, points, and voteInput available + when (state) { + is PollVoteScreenUiState.DiscretePoll -> { + val pollId = (state as PollVoteScreenUiState.DiscretePoll).poll.polId + val points = + (state as PollVoteScreenUiState.DiscretePoll).currentPointsReserved + val voteInput = (state as PollVoteScreenUiState.DiscretePoll).currentVoteId + voteInput?.let { + pollVoteViewModel.onVotePressed(pollId, points, voteInput) + } + } + + is PollVoteScreenUiState.ContinuousPoll -> { + val pollId = (state as PollVoteScreenUiState.ContinuousPoll).poll.polId + val points = + (state as PollVoteScreenUiState.ContinuousPoll).currentPointsReserved + val voteInput = + (state as PollVoteScreenUiState.ContinuousPoll).currentVoteInput + voteInput?.let { + pollVoteViewModel.onVotePressed(pollId, points, voteInput) + } + } + + else -> { + } + } + }, + onVoteInputChanged = { voteInput -> + pollVoteViewModel.onVoteInputChanged(voteInput) + }, + onToastConsumed = { + pollVoteViewModel.consumeToastMessage() + } + ) + } +} + + +fun NavController.navigateToPollVoteScreen(pollId: String) { + this.navigate("pollVote/$pollId") +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteScreen.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteScreen.kt new file mode 100644 index 00000000..61d87297 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteScreen.kt @@ -0,0 +1,135 @@ +package com.bounswe.predictionpolls.ui.vote + +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bounswe.predictionpolls.ui.common.poll.ContinuousVoteOption +import com.bounswe.predictionpolls.ui.common.poll.DiscreteVoteOption +import com.bounswe.predictionpolls.ui.common.poll.PollComposable + +@Composable +fun PollVoteScreen( + state: PollVoteScreenUiState, + onPointsReservedChanged: (Int) -> Unit, + onVotePressed: () -> Unit, + onVoteInputChanged: (String) -> Unit, // either the vote input or the selected option id is passed as a string + onToastConsumed: () -> Unit, + modifier: Modifier = Modifier +) { + when (state) { + is PollVoteScreenUiState.Loading -> { + VotePollLoadingComposable(modifier = modifier) + } + + is PollVoteScreenUiState.Error -> { + VotePollErrorComposable(modifier = modifier) + } + + is PollVoteScreenUiState.DiscretePoll -> { + val context = LocalContext.current + LaunchedEffect(key1 = state.toastMessage) { + state.toastMessage?.let { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + onToastConsumed() + } + } + PollVote( + pollContent = { + val poll = state.poll + PollComposable( + pollCreatorProfilePictureUri = poll.creatorProfilePictureUri, + pollCreatorName = poll.pollCreatorName, + tags = poll.tags, + pollQuestionTitle = poll.pollQuestionTitle, + optionsContent = { + val sumOfTotalVotes = remember(poll.options) { + val sum = poll.options.sumOf { it.voteCount } + if (sum == 0) -1 else sum// to prevent division by 0 + } + poll.options.forEachIndexed { index, discreteOption -> + DiscreteVoteOption( + optionName = discreteOption.text, + voteCount = discreteOption.voteCount, + fillPercentage = discreteOption.voteCount / sumOfTotalVotes.toFloat(), + isSelected = discreteOption.id == state.currentVoteId, + modifier = Modifier.clickable { + onVoteInputChanged(discreteOption.id) + } + ) + } + }, + dueDate = poll.dueDate ?: "", + rejectionText = poll.rejectionText ?: "", + commentCount = 0 + ) + }, + currentPointsReserved = state.currentPointsReserved, + onPointsReservedChanged = onPointsReservedChanged, + onVotePressed = onVotePressed, + modifier = modifier + ) + + } + + is PollVoteScreenUiState.ContinuousPoll -> { + val poll = state.poll + val context = LocalContext.current + LaunchedEffect(key1 = state.toastMessage) { + state.toastMessage?.let { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + onToastConsumed() + } + } + PollVote( + pollContent = { + PollComposable( + pollCreatorProfilePictureUri = poll.creatorProfilePictureUri, + pollCreatorName = poll.pollCreatorName, + tags = poll.tags, + pollQuestionTitle = poll.pollQuestionTitle, + optionsContent = { + ContinuousVoteOption( + title = "Enter your vote", + vote = state.currentVoteInput ?: "", + isVotingEnabled = true, + voteType = poll.inputType, + onVoteInputChanged = onVoteInputChanged + ) + }, + dueDate = poll.dueDate ?: "", + rejectionText = poll.rejectionText ?: "", + commentCount = 0 + ) + }, + currentPointsReserved = state.currentPointsReserved, + onPointsReservedChanged = onPointsReservedChanged, + onVotePressed = onVotePressed, + modifier = modifier + ) + + } + } +} + +@Composable +private fun VotePollErrorComposable(modifier: Modifier = Modifier) { + Box(modifier = modifier) { + Text(text = "An error occurred", modifier = Modifier.align(Alignment.Center)) + } +} + +@Composable +private fun VotePollLoadingComposable(modifier: Modifier = Modifier) { + Box(modifier = modifier) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteScreenUiState.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteScreenUiState.kt new file mode 100644 index 00000000..04712930 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteScreenUiState.kt @@ -0,0 +1,21 @@ +package com.bounswe.predictionpolls.ui.vote + +import com.bounswe.predictionpolls.domain.poll.Poll + +sealed interface PollVoteScreenUiState { + data object Loading : PollVoteScreenUiState + data class Error(val message: String) : PollVoteScreenUiState + data class DiscretePoll( + val poll: Poll.DiscretePoll, + val currentVoteId: String?, + val currentPointsReserved: Int, + val toastMessage: String? + ) : PollVoteScreenUiState + + data class ContinuousPoll( + val poll: Poll.ContinuousPoll, + val currentVoteInput: String?, + val currentPointsReserved: Int, + val toastMessage: String? + ) : PollVoteScreenUiState +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteViewModel.kt new file mode 100644 index 00000000..54f130c9 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/vote/PollVoteViewModel.kt @@ -0,0 +1,140 @@ +package com.bounswe.predictionpolls.ui.vote + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.poll.Poll +import com.bounswe.predictionpolls.domain.poll.VotePollRepository +import com.bounswe.predictionpolls.domain.poll.VotePollUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class PollVoteViewModel @Inject constructor( + private val votePollUseCase: VotePollUseCase, private val votePollRepository: VotePollRepository +) : + ViewModel() { + + private val _state = MutableStateFlow(PollVoteScreenUiState.Loading) + + val state: StateFlow + get() = _state.asStateFlow() + + + fun fetchPoll(pollId: String) = viewModelScope.launch { + when (val result = votePollRepository.fetchPoll(pollId)) { + is Result.Success -> { + when (val poll = result.data) { + is Poll.DiscretePoll -> { + _state.update { PollVoteScreenUiState.DiscretePoll(poll, "", 0, null) } + } + + is Poll.ContinuousPoll -> { + _state.update { PollVoteScreenUiState.ContinuousPoll(poll, "", 0, null) } + } + + else -> { + // Handle other poll types if necessary + } + } + } + + is Result.Error -> { + _state.update { + PollVoteScreenUiState.Error( + result.exception.message ?: "Unknown error" + ) + } + } + } + } + + fun onPointsReservedChanged(points: Int) { + // Update the state with the new points value + _state.update { currentState -> + when (currentState) { + is PollVoteScreenUiState.DiscretePoll -> currentState.copy(currentPointsReserved = points) + is PollVoteScreenUiState.ContinuousPoll -> currentState.copy(currentPointsReserved = points) + else -> currentState + } + } + } + + + fun onVoteInputChanged(voteInput: String) { + _state.update { currentState -> + when (currentState) { + is PollVoteScreenUiState.DiscretePoll -> currentState.copy(currentVoteId = voteInput) + is PollVoteScreenUiState.ContinuousPoll -> currentState.copy(currentVoteInput = voteInput) + else -> currentState + } + } + } + + + fun onVotePressed(pollId: String, points: Int, voteInput: String) { + when (_state.value) { + is PollVoteScreenUiState.DiscretePoll -> { + // Assuming voteInput is the id of the selected option in discrete polls + voteForDiscretePoll(pollId, points, voteInput) + } + + is PollVoteScreenUiState.ContinuousPoll -> { + // For continuous polls, voteInput is the input value + voteForContinuousPoll(pollId, points, voteInput) + } + + else -> { + // Handle other states if necessary + } + } + } + + fun voteForDiscretePoll(pollId: String, points: Int, voteId: String) = viewModelScope.launch { + when (val result = votePollUseCase.voteForDiscretePoll(pollId, points, voteId)) { + is Result.Success -> { + _state.update { (it as PollVoteScreenUiState.DiscretePoll).copy(toastMessage = "Successfully voted") } + } + + is Result.Error -> { + _state.update { (it as PollVoteScreenUiState.DiscretePoll).copy(toastMessage = "Error: ${result.exception.message}") } + } + } + } + + fun voteForContinuousPoll(pollId: String, points: Int, voteInput: String) = + viewModelScope.launch { + when (val result = votePollUseCase.voteForContinuousPoll(pollId, points, voteInput)) { + is Result.Success -> { + _state.update { (it as PollVoteScreenUiState.ContinuousPoll).copy(toastMessage = "Successfully voted") } + } + + is Result.Error -> { + _state.update { (it as PollVoteScreenUiState.ContinuousPoll).copy(toastMessage = "Error: ${result.exception.message}") } + } + } + } + + fun consumeToastMessage() = viewModelScope.launch { + when (val state = _state.value) { + is PollVoteScreenUiState.DiscretePoll -> { + _state.update { state.copy(toastMessage = null) } + } + + is PollVoteScreenUiState.ContinuousPoll -> { + _state.update { state.copy(toastMessage = null) } + } + + else -> { + // do nothing + } + } + } + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/utils/NavItem.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/utils/NavItem.kt index 78c25992..5741e651 100644 --- a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/utils/NavItem.kt +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/utils/NavItem.kt @@ -3,45 +3,54 @@ package com.bounswe.predictionpolls.utils import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.ui.create.CREATE_POLL_ROUTE +import com.bounswe.predictionpolls.ui.feed.FEED_ROUTE +import com.bounswe.predictionpolls.ui.leaderboard.LEADERBOARD_ROUTE +import com.bounswe.predictionpolls.ui.profile.PROFILE_SCREEN_ROUTE enum class NavItem( val route: String, @StringRes val titleId: Int, - @DrawableRes val iconId: Int + @DrawableRes val iconId: Int, + val requiresAuth: Boolean = false, ) { PROFILE( - route = "profile", + route = PROFILE_SCREEN_ROUTE, titleId = R.string.nav_drawer_profile, - iconId = R.drawable.ic_profile + iconId = R.drawable.ic_profile, + requiresAuth = true ), FEED( - route = "feed", + route = FEED_ROUTE, titleId = R.string.nav_drawer_feed, iconId = R.drawable.ic_feed ), - VOTE_POLL( - route = "vote_poll", - titleId = R.string.nav_drawer_vote, - iconId = R.drawable.ic_vote - ), CREATE_POLL( - route = "create_poll", + route = CREATE_POLL_ROUTE, titleId = R.string.nav_drawer_create, - iconId = R.drawable.ic_create + iconId = R.drawable.ic_create, + requiresAuth = true ), MODERATION( - route = "moderation", + route = "moderation", // TODO: change this route with the actual route titleId = R.string.nav_drawer_moderation, - iconId = R.drawable.ic_moderation + iconId = R.drawable.ic_moderation, + requiresAuth = true ), LEADERBOARD( - route = "leaderboard", + route = LEADERBOARD_ROUTE, titleId = R.string.nav_drawer_leaderboard, iconId = R.drawable.ic_leaderboard ), NOTIFICATIONS( - route = "notifications", + route = "notifications", // TODO: change this route with the actual route titleId = R.string.nav_drawer_notifications, - iconId = R.drawable.ic_notifications + iconId = R.drawable.ic_notifications, + requiresAuth = true + ), + SETTINGS( + route = "settings", // TODO: change this route with the actual route + titleId = R.string.nav_drawer_settings, + iconId = R.drawable.ic_settings, ), } \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_add.xml b/prediction-polls/android/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..8f702491 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_delete.xml b/prediction-polls/android/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..e133f9a0 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_edit.xml b/prediction-polls/android/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 00000000..32c70eb9 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_settings.xml b/prediction-polls/android/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 00000000..967e1cd0 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,20 @@ + + + + diff --git a/prediction-polls/android/app/src/main/res/values/strings.xml b/prediction-polls/android/app/src/main/res/values/strings.xml index 69b2d756..20f5d97e 100644 --- a/prediction-polls/android/app/src/main/res/values/strings.xml +++ b/prediction-polls/android/app/src/main/res/values/strings.xml @@ -9,6 +9,9 @@ Moderation Leaderboard Notifications + Settings + Sign in + Sign out Welcome To @@ -57,13 +60,37 @@ App Title Back Button + Open menu + Open notifications Welcome page image Navigation drawer item: %1$s Poll Profile Picture Tamam İptal - Choose one of the options you want to vote for + Choose one of the options you want Vote Vote points + + Create Poll + Enter the question title + Question cannot be empty + Choose input type + Add Option + Enter the options + Every option must be filled + At least 2 options must exist. + Don\'t accept any\nvotes in last + Enter the options + Delete the option + Set Due Date + Due date must be valid date + Create + Choose customized input type + Open distribution visibility + Create + Poll created successfully + + You have signed out successfully + Please sign in to access this page. \ No newline at end of file diff --git a/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/GetFeedUseCaseTest.kt b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/GetFeedUseCaseTest.kt new file mode 100644 index 00000000..317e6264 --- /dev/null +++ b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/GetFeedUseCaseTest.kt @@ -0,0 +1,48 @@ +package com.bounswe.predictionpolls + +import com.bounswe.predictionpolls.domain.feed.GetFeedUseCase +import com.bounswe.predictionpolls.repo.FakeFeedRepository +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class GetFeedUseCaseTest { + + private lateinit var fakeFeedRepository: FakeFeedRepository + private lateinit var getFeedUseCase: GetFeedUseCase + + @Before + fun setUp() { + fakeFeedRepository = FakeFeedRepository() + getFeedUseCase = GetFeedUseCase(fakeFeedRepository) + } + + @Test + fun `invoke returns success with correct data`() = runBlockingTest { + val result = getFeedUseCase.invoke(1) + assertTrue(result is com.bounswe.predictionpolls.common.Result.Success) + assertNotNull((result as com.bounswe.predictionpolls.common.Result.Success).data) + // Further assertions can be added to check the data content + } + + + @Test + fun `invoke returns success with correct data on page 2`() = runBlockingTest { + val result = getFeedUseCase.invoke(2) + assertTrue(result is com.bounswe.predictionpolls.common.Result.Success) + assertNotNull((result as com.bounswe.predictionpolls.common.Result.Success).data) + // Further assertions can be added to check the data content + } + + @Test + fun `invoke returns success with correct data on page 0`() = runBlockingTest { + val result = getFeedUseCase.invoke(0) + assertTrue(result is com.bounswe.predictionpolls.common.Result.Success) + assertNotNull((result as com.bounswe.predictionpolls.common.Result.Success).data) + // Further assertions can be added to check the data content + } + + +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/GetProfileUseCaseTest.kt b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/GetProfileUseCaseTest.kt new file mode 100644 index 00000000..5720145d --- /dev/null +++ b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/GetProfileUseCaseTest.kt @@ -0,0 +1,36 @@ +package com.bounswe.predictionpolls + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.profile.GetProfileInfoUseCase +import com.bounswe.predictionpolls.repo.FakeProfileInfoRepository +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Test +import org.junit.Assert.* + +class GetProfileInfoUseCaseTest { + + private lateinit var fakeProfileInfoRepository: FakeProfileInfoRepository + private lateinit var getProfileInfoUseCase: GetProfileInfoUseCase + + @Before + fun setUp() { + fakeProfileInfoRepository = FakeProfileInfoRepository() + getProfileInfoUseCase = GetProfileInfoUseCase(fakeProfileInfoRepository) + } + + @Test + fun `invoke returns success with correct data`() = runBlockingTest { + val result = getProfileInfoUseCase("testUser") + assertTrue(result is Result.Success) + assertEquals("testUser", (result as Result.Success).data.username) + } + + @Test + fun `invoke returns error on repository failure`() = runBlockingTest { + fakeProfileInfoRepository.setReturnError(true) + + val result = getProfileInfoUseCase("testUser") + assertTrue(result is Result.Error) + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakeFeedRepository.kt b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakeFeedRepository.kt new file mode 100644 index 00000000..f108c78c --- /dev/null +++ b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakeFeedRepository.kt @@ -0,0 +1,29 @@ +package com.bounswe.predictionpolls.repo + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.feed.FeedRepository +import com.bounswe.predictionpolls.domain.poll.Poll +import kotlinx.collections.immutable.persistentListOf + +class FakeFeedRepository : FeedRepository { + // Test data + private val polls = listOf( + Poll.DiscretePoll( + polId = "1", + creatorProfilePictureUri = null, + dueDate = "2023-12-31", + pollCreatorName = "Test User", + pollQuestionTitle = "Sample Poll 1", + rejectionText = null, + commentCount = 5, + tags = listOf("test", "sample"), + options = persistentListOf() // Add your options here + ), + // Add more Poll objects as needed for testing + ) + + override suspend fun getPolls(page: Int): Result> { + // Return the test data, simulating a successful response + return Result.Success(polls) + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakePollRepository.kt b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakePollRepository.kt new file mode 100644 index 00000000..fc5b533a --- /dev/null +++ b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakePollRepository.kt @@ -0,0 +1,29 @@ +package com.bounswe.predictionpolls.repo + +import com.bounswe.predictionpolls.data.remote.repositories.PollRepositoryInterface + +class FakePollRepository: PollRepositoryInterface { + override suspend fun createContinuousPoll( + question: String, + openVisibility: Boolean, + setDueDate: Boolean, + dueDatePoll: String?, + numericFieldValue: Int?, + selectedTimeUnit: String, + pollType: String + ) { + // Do nothing + } + + override suspend fun createDiscretePoll( + question: String, + choices: List, + openVisibility: Boolean, + setDueDate: Boolean, + dueDatePoll: String?, + numericFieldValue: Int?, + selectedTimeUnit: String + ) { + // Do nothing + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakeProfileRepository.kt b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakeProfileRepository.kt new file mode 100644 index 00000000..7930f612 --- /dev/null +++ b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/repo/FakeProfileRepository.kt @@ -0,0 +1,33 @@ +package com.bounswe.predictionpolls.repo + + +import com.bounswe.predictionpolls.common.Result +import com.bounswe.predictionpolls.domain.profile.ProfileInfo +import com.bounswe.predictionpolls.domain.profile.ProfileInfoRepository +import kotlinx.collections.immutable.persistentListOf + +class FakeProfileInfoRepository : ProfileInfoRepository { + + private var shouldReturnError = false + + fun setReturnError(value: Boolean) { + shouldReturnError = value + } + + override suspend fun getProfileInfo(username: String): Result { + return if (!shouldReturnError) { + Result.Success( + ProfileInfo( + username = username, + userFullName = "Test User", + coverPhotoUri = "http://example.com/cover.jpg", + profilePictureUri = "http://example.com/profile.jpg", + userDescription = "This is a test user.", + badgeUris = persistentListOf("http://example.com/badge1.jpg") + ) + ) + } else { + Result.Error(Exception("Fake error")) + } + } +} diff --git a/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/ui/create/CreatePollViewModelTest.kt b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/ui/create/CreatePollViewModelTest.kt new file mode 100644 index 00000000..6ef0e350 --- /dev/null +++ b/prediction-polls/android/app/src/test/java/com/bounswe/predictionpolls/ui/create/CreatePollViewModelTest.kt @@ -0,0 +1,193 @@ +package com.bounswe.predictionpolls.ui.create + +import com.bounswe.predictionpolls.data.remote.repositories.PollRepositoryInterface +import com.bounswe.predictionpolls.repo.FakePollRepository +import org.junit.Before +import org.junit.Test + +class CreatePollViewModelTest { + private lateinit var pollRepository: PollRepositoryInterface + private lateinit var viewModel: CreatePollViewModel + + @Before + fun setup() { + pollRepository = FakePollRepository() + viewModel = CreatePollViewModel(pollRepository) + } + + @Test + fun `test On Create poll click event with no form data`() { + var isTriggered = false + viewModel.onEvent(CreatePollScreenEvent.OnCreatePollClicked{ + isTriggered = true + }) + + assert(isTriggered.not()) + assert(viewModel.screenState.inputValidationError != null) + } + + @Test + fun `test on question changed`() { + val question = "What is your favorite color?" + viewModel.onEvent(CreatePollScreenEvent.OnQuestionChanged(question)) + + assert(viewModel.screenState.question == question) + } + + @Test + fun `test on poll type changed`() { + val pollType = CreatePollScreenState.PollType.DISCRETE + viewModel.onEvent(CreatePollScreenEvent.OnPollTypeChanged(pollType)) + + assert(viewModel.screenState.pollType == pollType) + } + + @Test + fun `test on discrete option changed`() { + val option = "Red" + val position = 0 + viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionAdded) + viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionChanged(option, position)) + + assert(viewModel.screenState.discreteOptions[position] == option) + } + + @Test + fun `test on discrete option added`() { + viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionAdded) + assert(viewModel.screenState.discreteOptions.size == 3) + } + + @Test + fun `test on discrete option removed`() { + val option = "Red" + viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionAdded) + viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionRemoved(0)) + + assert(viewModel.screenState.discreteOptions.contains(option).not()) + } + + @Test + fun `test on continuous input type changed`() { + val inputType = CreatePollScreenState.ContinuousInputType.NUMERIC + viewModel.onEvent(CreatePollScreenEvent.OnContinuousInputTypeChanged(inputType)) + + assert(viewModel.screenState.continuousInputType == inputType) + } + + @Test + fun `test on due date checked`() { + val isChecked = true + viewModel.onEvent(CreatePollScreenEvent.OnDueDateChecked(isChecked)) + + assert(viewModel.screenState.isDueDateEnabled == isChecked) + } + + @Test + fun `test on due date changed`() { + val dueDate = "2021-05-01" + viewModel.onEvent(CreatePollScreenEvent.OnDueDateChanged(dueDate)) + + assert(viewModel.screenState.dueDate == dueDate) + } + + @Test + fun `test on last accept value changed`() { + val value = "10" + viewModel.onEvent(CreatePollScreenEvent.OnLastAcceptValueChanged(value)) + + assert(viewModel.screenState.lastAcceptValue == value) + } + + @Test + fun `test on accept value type changed`() { + val type = CreatePollScreenState.AcceptValueType.DAY + viewModel.onEvent(CreatePollScreenEvent.OnAcceptValueTypeChanged(type)) + + assert(viewModel.screenState.acceptValueType == type) + } + + @Test + fun `test on distribution visibility changed`() { + val isChecked = true + viewModel.onEvent(CreatePollScreenEvent.OnDistributionVisibilityChanged(isChecked)) + + assert(viewModel.screenState.isDistributionVisible == isChecked) + } + + @Test + fun `test on error dismissed`() { + viewModel.onEvent(CreatePollScreenEvent.OnErrorDismissed) + + assert(viewModel.screenState.inputValidationError == null) + } + + @Test + fun `test toggle date picker`() { + viewModel.onEvent(CreatePollScreenEvent.ToggleDatePicker) + assert(viewModel.screenState.isDatePickerVisible) + } + + @Test + fun `test on create poll clicked with discrete form data`() { + val onSuccess = {} + val question = "What is your favorite color?" + val pollType = CreatePollScreenState.PollType.DISCRETE + val option = "Red" + val position = 0 + viewModel.onEvent(CreatePollScreenEvent.OnQuestionChanged(question)) + viewModel.onEvent(CreatePollScreenEvent.OnPollTypeChanged(pollType)) + viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionAdded) + viewModel.onEvent(CreatePollScreenEvent.OnDiscreteOptionChanged(option, position)) + viewModel.onEvent(CreatePollScreenEvent.OnCreatePollClicked(onSuccess)) + + assert(viewModel.screenState.inputValidationError != null) + } + + @Test + fun `test on create poll clicked with continuous form data`() { + val onSuccess = {} + val question = "What is your favorite color?" + val pollType = CreatePollScreenState.PollType.CONTINUOUS + val inputType = CreatePollScreenState.ContinuousInputType.NUMERIC + val isChecked = true + val dueDate = "2021-05-01" + val value = "10" + val type = CreatePollScreenState.AcceptValueType.DAY + val isDistributionVisible = true + viewModel.onEvent(CreatePollScreenEvent.OnQuestionChanged(question)) + viewModel.onEvent(CreatePollScreenEvent.OnPollTypeChanged(pollType)) + viewModel.onEvent(CreatePollScreenEvent.OnContinuousInputTypeChanged(inputType)) + viewModel.onEvent(CreatePollScreenEvent.OnDueDateChecked(isChecked)) + viewModel.onEvent(CreatePollScreenEvent.OnDueDateChanged(dueDate)) + viewModel.onEvent(CreatePollScreenEvent.OnLastAcceptValueChanged(value)) + viewModel.onEvent(CreatePollScreenEvent.OnAcceptValueTypeChanged(type)) + viewModel.onEvent(CreatePollScreenEvent.OnDistributionVisibilityChanged(isDistributionVisible)) + viewModel.onEvent(CreatePollScreenEvent.OnCreatePollClicked(onSuccess)) + + assert(viewModel.screenState.inputValidationError != null) + } + + + @Test + fun `test on create poll clicked with continuous form data with no last accept value`() { + val onSuccess = {} + val question = "What is your favorite color?" + val pollType = CreatePollScreenState.PollType.CONTINUOUS + val inputType = CreatePollScreenState.ContinuousInputType.NUMERIC + val isChecked = true + val dueDate = "2021-05-01" + val type = CreatePollScreenState.AcceptValueType.DAY + val isDistributionVisible = true + viewModel.onEvent(CreatePollScreenEvent.OnQuestionChanged(question)) + viewModel.onEvent(CreatePollScreenEvent.OnPollTypeChanged(pollType)) + viewModel.onEvent(CreatePollScreenEvent.OnContinuousInputTypeChanged(inputType)) + viewModel.onEvent(CreatePollScreenEvent.OnDueDateChecked(isChecked)) + viewModel.onEvent(CreatePollScreenEvent.OnDueDateChanged(dueDate)) + viewModel.onEvent(CreatePollScreenEvent.OnAcceptValueTypeChanged(type)) + viewModel.onEvent(CreatePollScreenEvent.OnDistributionVisibilityChanged(isDistributionVisible)) + viewModel.onEvent(CreatePollScreenEvent.OnCreatePollClicked(onSuccess)) + + assert(viewModel.screenState.inputValidationError != null) + } +} \ No newline at end of file diff --git a/prediction-polls/backend/package.json b/prediction-polls/backend/package.json index de4e65ae..8b5c09f1 100644 --- a/prediction-polls/backend/package.json +++ b/prediction-polls/backend/package.json @@ -11,6 +11,9 @@ "author": "", "license": "ISC", "dependencies": { + "@aws-sdk/client-s3": "^3.456.0", + "@aws-sdk/s3-request-presigner": "^3.456.0", + "aws-sdk": "^2.1502.0", "axios": "^1.6.0", "bcrypt": "^5.1.1", "body-parser": "^1.20.2", @@ -18,7 +21,10 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", "mysql2": "^3.6.2", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.7", "querystring": "^0.2.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0" diff --git a/prediction-polls/backend/src/app.js b/prediction-polls/backend/src/app.js index 1e6053b2..c95e41ed 100644 --- a/prediction-polls/backend/src/app.js +++ b/prediction-polls/backend/src/app.js @@ -1,6 +1,8 @@ const express = require('express'); const authRouter = require('./routes/AuthorizationRouter.js'); -const pollRouter = require('./routes/PollRouter.js'); +const pollRouter = require('./routes/PollRouter.js'); +const profileRouter = require('./routes/ProfileRouter.js'); +const tagRoutine = require('./routines/tagRoutine.js'); const cors = require("cors"); @@ -19,6 +21,7 @@ app.use(bodyParser.urlencoded({ extended: true })); // for parsing application/x app.use('/polls',pollRouter); app.use('/auth', authRouter); +app.use('/profiles', profileRouter); const swaggerSpec = swaggerJsDoc(swaggerOptions); app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); diff --git a/prediction-polls/backend/src/errorCodes.js b/prediction-polls/backend/src/errorCodes.js new file mode 100644 index 00000000..8a6b09a4 --- /dev/null +++ b/prediction-polls/backend/src/errorCodes.js @@ -0,0 +1,181 @@ +const errorCodes = { + // General errors + GENERIC_ERROR: { + code: 1000, + message: 'An unexpected error occurred.', + }, + + INTERNAL_SERVER_ERROR: { + code: 1001, + message: "Something went wrong", + }, + + ACCESS_TOKEN_INVALID_ERROR: { + code: 1002, + message: 'The access token is invalid.', + }, + + REFRESH_TOKEN_INVALID_ERROR: { + code: 1003, + message: 'The refresh token is invalid.', + }, + + ACCESS_TOKEN_NEEDED_ERROR: { + code: 1004, + message: 'A access token is needed', + }, + + REFRESH_TOKEN_NEEDED_ERROR: { + code: 1005, + message: 'A refresh token is needed', + }, + + REGISTRATION_FAILED: { + code: 1006, + message: 'Registration failed' + }, + + USER_NOT_FOUND: { + code: 2000, + message: 'User not found.', + }, + + USER_NOT_FOUND_WITH_USERID: { + code: 2001, + message: 'User with the given user id not found.', + }, + + USER_NOT_FOUND_WITH_USERNAME: { + code: 2002, + message: 'User with the given username not found.', + }, + + USER_NOT_FOUND_WITH_EMAIL: { + code: 2003, + message: 'User with the given email not found.', + }, + + USERNAME_ALREADY_EXISTS: { + code: 2004, + message: 'Username is taken', + }, + + EMAIL_ALREADY_EXISTS: { + code: 2005, + message: 'Email is already in use', + }, + + WRONG_PASSWORD: { + code: 2006, + message: 'Password is wrong', + }, + + INVALID_EMAIL: { + code: 2007, + message: 'Email does not meet required criteria', + }, + + INVALID_DATE: { + code: 2008, + message: 'Date does not meet required criteria', + }, + + INVALID_PASSWORD: { + code: 2009, + message: 'Password does not meet required criteria', + }, + + GOOGLE_LOGIN_FAILED: { + code: 2100, + message: 'Google authentication failed', + }, + + GOOGLE_LOGIN_BODY_EMPTY: { + code: 2101, + message: 'Google login requires code or googleId', + }, + + GOOGLE_LOGIN_INVALID_GOOGLE_CODE: { + code: 2102, + message: 'Given google code is not valid', + }, + + GOOGLE_LOGIN_INVALID_GOOGLE_ID: { + code: 2103, + message: 'Given google id is not valid', + }, + + GOOGLE_LOGIN_NONVERIFIED_GOOGLE_ACCOUNT: { + code: 2104, + message: 'Google account is not verified', + }, + + PROFILE_NOT_FOUND: { + code: 3000, + message: 'Profile not found.', + }, + + USER_ALREADY_HAS_PROFILE: { + code: 3001, + message: 'User already has profile', + }, + + PROFILE_COULD_NOT_BE_CREATED: { + code: 3002, + message: 'Profile could not be created.', + }, + + PROFILE_COULD_NOT_BE_UPDATED: { + code: 3003, + message: 'Profile could not be updated.', + }, + + DATABASE_ERROR: { + code: 3004, + message: 'Error while accessing the database.', + }, + + NO_SUCH_POLL_ERROR: { + code: 3005, + message: 'No such poll found.' + }, + + INSUFFICIENT_POINTS_ERROR: { + code: 3006, + message: 'User does not have enough points' + }, + + USER_MUST_GIVE_POINTS_ERROR: { + code: 3007, + message: 'User has to put points to be able to vote polls' + }, + + BAD_DISCRETE_POLL_REQUEST_ERROR: { + code: 4000, + message: 'Bad request body for creating a discrete poll.' + }, + + BAD_CONT_POLL_REQUEST_ERROR: { + code: 4001, + message: 'Bad request body for creating a continuous poll.' + }, + + MINMAX_BAD_CONT_POLL_REQUEST_ERROR: { + code: 4002, + message: 'Minimum value allowed was higher than maximum value allowed in the poll.' + }, + + CHOICE_DOES_NOT_EXIST_ERROR: { + code: 5000, + message: 'Choice for the poll does not exist.' + }, + + CHOICE_OUT_OF_BOUNDS_ERROR: { + code: 5001, + message: 'Choice for the poll is out of given bounds.' + } + + // Add more error codes as needed +}; + +module.exports = errorCodes; \ No newline at end of file diff --git a/prediction-polls/backend/src/repositories/AuthorizationDB.js b/prediction-polls/backend/src/repositories/AuthorizationDB.js index febe5701..db6bb5a1 100644 --- a/prediction-polls/backend/src/repositories/AuthorizationDB.js +++ b/prediction-polls/backend/src/repositories/AuthorizationDB.js @@ -1,4 +1,5 @@ const mysql = require('mysql2') +const errorCodes = require("../errorCodes.js") require('dotenv').config(); @@ -9,6 +10,110 @@ const pool = mysql.createPool({ database: process.env.MYSQL_DATABASE }).promise() +function hashPassword(password) { + return new Promise((resolve, reject) => { + bcrypt.hash(password, 10, (err, hashedPassword) => { + if (err) reject(err); + resolve(hashedPassword); + }); + }); +} + +async function checkCredentials(username, password) { + try { + /* We want to store our passwords hashed in the db But currently + * our hashing function does not return the same hashed password + * for the same password value. This creates an issue with verification. + */ + + // const hashedPassword = await hashPassword(password); + + const sql = 'SELECT * FROM users WHERE (username = ? || email = ?) && password = ?'; + const values = [username, username, password]; + + const [rows] = await pool.query(sql, values); + return rows; + } catch (error) { + throw {error:errorCodes.WRONG_PASSWORD}; + } +} + +async function addUser(username, password,email,birthday){ + try { + /* We want to store our passwords hashed in the db But currently + * our hashing function does not return the same hashed password + * for the same password value. This creates an issue with verification. + */ + // const hashedPassword = await hashPassword(password); + + // Store the user in the database + const sql = 'INSERT INTO users (username, email, password, birthday) VALUES (?, ?, ?, ?)'; + const values = [username, email, password, birthday]; + + const [result] = await pool.query(sql, values); + + return {userId: result.insertId} ; + } catch (error) { + return {error:error} ; + } + } + + +async function findUser({userId,username,email}){ + try{ + if(userId){ + const sql = 'SELECT * FROM users WHERE id = ?'; + + const [result] = await pool.query(sql, [userId]); + if(result.length == 0){ + throw {error:errorCodes.USER_NOT_FOUND_WITH_USERID} + } + return result[0]; + } + + if(username){ + const sql = 'SELECT * FROM users WHERE username = ?'; + + const [result] = await pool.query(sql, [username]); + if(result.length == 0){ + throw {error:errorCodes.USER_NOT_FOUND_WITH_USERNAME} + } + return result[0]; + } + if(email){ + const sql = 'SELECT * FROM users WHERE email = ?'; + + const [result] = await pool.query(sql, [email]); + if(result.length == 0){ + throw {error:errorCodes.USER_NOT_FOUND_WITH_EMAIL} + } + return result[0]; + } + throw {error:errorCodes.USER_NOT_FOUND} + } catch(error){ + return error + } +} + +async function isUsernameOrEmailInUse(username, email) { + try { + const usernameSql = 'SELECT 1 FROM users WHERE username = ?'; + const [usernameRows] = await pool.query(usernameSql, [username]); + + const emailSql = 'SELECT 1 FROM users WHERE email = ?'; + const [emailRows] = await pool.query(emailSql, [email]); + + const isUsernameInUse = usernameRows.length > 0; + const isEmailInUse = emailRows.length > 0; + + return { usernameInUse: isUsernameInUse, emailInUse: isEmailInUse }; + } catch (error) { + console.error("Database error in isUsernameOrEmailInUse:", error); + return { usernameInUse: false, emailInUse: false, error: 'Database error occurred' }; + } +} + + //Add the given refresh token to db async function addRefreshToken(token){ const sql = 'INSERT INTO refresh_tokens (token) VALUES (?)'; @@ -43,4 +148,31 @@ async function deleteRefreshToken(token){ return result.affectedRows > 0; } -module.exports = {pool, addRefreshToken,checkRefreshToken,deleteRefreshToken} \ No newline at end of file +async function saveEmailVerificationToken(userId, token) { + const sql = 'UPDATE users SET email_verification_token = ? WHERE id = ?'; + const values = [token, userId]; + + return await pool.query(sql, values); +} + +async function verifyEmail(token) { + const sql = 'UPDATE users SET email_verified = TRUE WHERE email_verification_token = ?'; + const values = [token]; + + return await pool.query(sql, values); +} +const nodemailer = require('nodemailer'); + +function createTransporter() { + return nodemailer.createTransport({ + host: 'smtp.zoho.eu', + port: 465, + secure: true, + auth: { + user: 'predictionpolls@zohomail.eu', + pass: 'nzDnTajTmFpz' // this is not safe change to env later + } + }); +} + +module.exports = {pool, addRefreshToken,checkRefreshToken,deleteRefreshToken,isUsernameOrEmailInUse,checkCredentials,addUser,findUser,saveEmailVerificationToken,verifyEmail,createTransporter} diff --git a/prediction-polls/backend/src/repositories/PollDB.js b/prediction-polls/backend/src/repositories/PollDB.js index a1f575b0..f11d332b 100644 --- a/prediction-polls/backend/src/repositories/PollDB.js +++ b/prediction-polls/backend/src/repositories/PollDB.js @@ -1,4 +1,7 @@ -const mysql = require('mysql2') +const mysql = require('mysql2'); +const { addRefreshToken, deleteRefreshToken } = require('./AuthorizationDB'); +const { updatePoints } = require('./ProfileDB'); +const errorCodes = require("../errorCodes.js"); require('dotenv').config(); @@ -10,27 +13,27 @@ const pool = mysql.createPool({ }).promise() -async function getDiscretePolls(){ - const sql = 'SELECT * FROM discrete_polls'; +async function getPolls(){ + const sql = 'SELECT * FROM polls'; try { const [rows, fields] = await pool.query(sql); return rows } catch (error) { console.error('getDiscretePolls(): Database Error'); - throw error; + throw {error: errorCodes.DATABASE_ERROR}; } } -async function getContinuousPolls(){ - const sql = 'SELECT * FROM continuous_polls'; +async function getPollWithId(pollId){ + const sql = 'SELECT * FROM polls WHERE id = ?'; try { - const [rows, fields] = await pool.query(sql); - return rows + const [rows, fields] = await pool.query(sql, [pollId]); + return rows; } catch (error) { - console.error('getDiscretePolls(): Database Error'); - throw error; + console.error('getPollWithID(): Database Error'); + throw {error: errorCodes.DATABASE_ERROR}; } } @@ -59,39 +62,66 @@ async function getContinuousPollWithId(pollId){ } } -async function addDiscretePoll(question, choices){ - const sql_poll = 'INSERT INTO discrete_polls (question) VALUES (?)'; +async function addDiscretePoll(question, username, choices, openVisibility, setDueDate, dueDatePoll, numericFieldValue, selectedTimeUnit){ + const connection = await pool.getConnection(); + + const sql_poll = 'INSERT INTO polls (question, username, poll_type, openVisibility, setDueDate, closingDate, numericFieldValue, selectedTimeUnit) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'; + const sql_discrete_poll = 'INSERT INTO discrete_polls (id) VALUES (?)'; const sql_choice = 'INSERT INTO discrete_poll_choices (choice_text, poll_id) VALUES (?, ?)'; try { - const [resultSetHeader] = await pool.query(sql_poll, [question]); + await connection.beginTransaction() + const [resultSetHeader] = await connection.query(sql_poll, [question, username, 'discrete', openVisibility, setDueDate, dueDatePoll, numericFieldValue, selectedTimeUnit]); poll_id = resultSetHeader.insertId; + if (!poll_id) { - return false; + await connection.rollback(); + throw {error: errorCodes.DATABASE_ERROR}; } + + await connection.query(sql_discrete_poll, [poll_id]); + await Promise.all(choices.map(choice => { - return pool.query(sql_choice, [choice, poll_id]); + return connection.query(sql_choice, [choice, poll_id]); })) - return true; + + await connection.commit(); + return poll_id; } catch (error) { console.error('addDiscretePoll(): Database Error'); - throw error; + await connection.rollback(); + throw {error: errorCodes.DATABASE_ERROR}; + } finally { + connection.release(); } } -async function addContinuousPoll(question, min, max){ - const sql_poll = 'INSERT INTO continuous_polls (question, min_value, max_value) VALUES (?, ?, ?)'; +async function addContinuousPoll(question, username, cont_poll_type, setDueDate, dueDatePoll, numericFieldValue, selectedTimeUnit){ + const connection = await pool.getConnection(); + + const sql_poll = 'INSERT INTO polls (question, username, poll_type, openVisibility, setDueDate, closingDate, numericFieldValue, selectedTimeUnit) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'; + const sql_continuous_poll = 'INSERT INTO continuous_polls (id, cont_poll_type) VALUES (?, ?)' try { - const [resultSetHeader] = await pool.query(sql_poll, [question, min, max]); + await connection.beginTransaction(); + const [resultSetHeader] = await connection.query(sql_poll, [question, username, 'continuous', false, setDueDate, dueDatePoll, numericFieldValue, selectedTimeUnit]); poll_id = resultSetHeader.insertId; + if (!poll_id) { - return false; + await connection.rollback(); + throw {error: errorCodes.DATABASE_ERROR}; } - return true; + + await connection.query(sql_continuous_poll, [poll_id, cont_poll_type]); + + await connection.commit(); + return poll_id; } catch (error) { console.error('addContinuousPoll(): Database Error'); + await connection.rollback(); throw error; + } finally { + connection.release(); } } @@ -119,41 +149,112 @@ async function getDiscreteVoteCount(choiceid) { } } -async function voteDiscretePoll(pollId, userId, choiceId){ - const deleteExistingSql = "DELETE FROM discrete_polls_selections WHERE poll_id = ? AND user_id = ?" - const addVoteSql = "INSERT INTO discrete_polls_selections (poll_id, choice_id, user_id) VALUES (?, ?, ?)" +async function voteDiscretePoll(pollId, userId, choiceId, pointsSpent){ + const connection = await pool.getConnection(); + + const oldSelectionPointsSql = "SELECT * FROM discrete_polls_selections WHERE poll_id = ? AND user_id = ?"; + const deleteExistingSql = "DELETE FROM discrete_polls_selections WHERE poll_id = ? AND user_id = ?"; + const addVoteSql = "INSERT INTO discrete_polls_selections (poll_id, choice_id, user_id, given_points) VALUES (?, ?, ?, ?)"; + const findPointsSql = 'SELECT * FROM profiles WHERE userId= ?'; + const updatePointSql = 'UPDATE profiles SET points = ? WHERE userId = ?'; try { - // TODO: Consider making this a transaction, commit - deleteResult = await pool.query(deleteExistingSql, [pollId, userId]); - addResult = await pool.query(addVoteSql, [pollId, choiceId, userId]); + await connection.beginTransaction() + + const pollObject = await getPollWithId(pollId); + + if (pollObject[0].isOpen == 0) { + throw { error: errorCodes.DATABASE_ERROR }; + } + + const [rows] = await connection.query(findPointsSql, [userId]); + const current_points = rows[0].points; + + const [oldSelection] = await connection.query(oldSelectionPointsSql, [pollId, userId]); + + const deleteResult = await connection.query(deleteExistingSql, [pollId, userId]); + const addResult = await connection.query(addVoteSql, [pollId, choiceId, userId, pointsSpent]); + + const oldPoint = (oldSelection.length != 0 ? oldSelection[0].given_points : 0); + + const newPoints = current_points - pointsSpent + oldPoint; + if(newPoints < 0){ + connection.rollback() + throw {error: errorCodes.INSUFFICIENT_POINTS_ERROR}; + } + + const [resultSetHeader] = await connection.query(updatePointSql, [newPoints, userId]); + + connection.commit(); + return {status:"success"}; + } catch (error) { - console.error('voteDiscretePoll(): Database Error'); + await connection.rollback(); + console.error('voteDiscretePoll(): Database Error: ', error); throw error; + } finally { + connection.release(); } } -async function voteContinuousPoll(pollId, userId, choice){ - const deleteExistingSql = "DELETE FROM continuous_poll_selections WHERE poll_id = ? AND user_id = ?" - const addVoteSql = "INSERT INTO continuous_poll_selections (poll_id, user_id, selected_value) VALUES (?, ?, ?)" +async function voteContinuousPoll(pollId, userId, choice, contPollType, pointsSpent){ + const connection = await pool.getConnection(); + + const oldSelectionPointsSql = "SELECT * FROM continuous_poll_selections WHERE poll_id = ? AND user_id = ?"; + const deleteExistingSql = "DELETE FROM continuous_poll_selections WHERE poll_id = ? AND user_id = ?"; + const addVoteSql = "INSERT INTO continuous_poll_selections (poll_id, user_id, float_value, date_value, given_points) VALUES (?, ?, ?, ?, ?)"; + const findPointsSql = 'SELECT points FROM profiles WHERE userId= ?'; + const updatePointSql = 'UPDATE profiles SET points = ? WHERE userId = ?'; try { - // TODO: Consider making this a transaction, commit - deleteResult = await pool.query(deleteExistingSql, [pollId, userId]); - addResult = await pool.query(addVoteSql, [pollId, userId, choice]); + await connection.beginTransaction() + + const pollObject = await getPollWithId(pollId); + + if (pollObject[0].isOpen == 0) { + throw { error: errorCodes.DATABASE_ERROR }; + } + + const [rows] = await connection.query(findPointsSql, [userId]); + const current_points = rows[0].points; + + const [oldSelection] = await connection.query(oldSelectionPointsSql, [pollId, userId]); + + deleteResult = await connection.query(deleteExistingSql, [pollId, userId]); + if (contPollType === "numeric") { + addResult = await connection.query(addVoteSql, [pollId, userId, choice, null, pointsSpent]); + } else if (contPollType === "date") { + addResult = await connection.query(addVoteSql, [pollId, userId, null, choice, pointsSpent]); + } else { + throw {error: errorCodes.NO_SUCH_POLL_ERROR}; + } + + const oldPoint = (oldSelection.length != 0 ? oldSelection[0].given_points : 0); + const newPoints = current_points - pointsSpent + oldPoint; + + if(newPoints < 0){ + connection.rollback() + throw {error: errorCodes.INSUFFICIENT_POINTS_ERROR}; + } + + const [resultSetHeader] = await connection.query(updatePointSql, [newPoints, userId]); + connection.commit(); } catch (error) { + await connection.rollback(); console.error('voteContinuousPoll(): Database Error'); throw error; + } finally { + connection.release(); } } async function getContinuousPollVotes(pollId) { - const sql = "SELECT selected_value FROM continuous_poll_selections"; + const sql = "SELECT float_value, date_value FROM continuous_poll_selections WHERE poll_id = ?"; try { - [rows, fields] = await pool.query(sql); + [rows, fields] = await pool.query(sql, [pollId]); return rows; } catch (error) { console.error('getContinuousPollVotes(): Database Error'); @@ -161,9 +262,93 @@ async function getContinuousPollVotes(pollId) { } } +async function getTagsOfPoll(pollId) { + const sql = "SELECT tags.topic FROM tags WHERE poll_id = ?"; + + try { + [rows] = await pool.query(sql, [pollId]); + return rows.map(item => item.topic.trim()); + } catch (error) { + console.error('getTagsOfPoll(): Database Error'); + throw error; + } +} + +async function getUntaggedPolls() { + const sql = 'SELECT polls.id, polls.question, polls.tagsScanned FROM polls' + + try { + [rows] = await pool.query(sql, []); + return rows; + } catch (error) { + console.error('getUntaggedPolls(): Database Error'); + throw error; + } +} +async function updateTagsScanned(pollId, tagsScanned) { + const sql = 'UPDATE polls SET tagsScanned = ? WHERE id = ?' + + try { + [rows] = await pool.query(sql, [tagsScanned, pollId]); + return rows; + } catch (error) { + console.error('updateTagsScanned(): Database Error'); + throw error; + } +} + +async function addTopic(pollId, topic) { + const sql = 'INSERT into tags (topic, poll_id) VALUES (?, ?)' + + try { + [rows] = await pool.query(sql, [topic, pollId]); + return rows; + } catch (error) { + console.error('updateTopic(): Database Error'); + throw error; + } +} + +async function getDiscreteSelectionsWithPollId(pollId) { + const sql = "SELECT * FROM discrete_polls_selections WHERE poll_id = ?"; + + try { + [result] = await pool.query(sql, [pollId]); + return result; + } catch (error) { + console.error('getDiscreteSelectionWithPollId(): Database Error'); + throw error; + } +} + +async function closePoll(pollId, rewards) { + const pointUpdateSql = 'UPDATE profiles SET points = points + ? WHERE userId = ?'; + const closePollSql = 'UPDATE polls SET isOpen = false WHERE id = ?'; + + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + rewards.map(reward => { + connection.query(pointUpdateSql, [reward.reward, reward.user_id]); + }); + + connection.query(closePollSql, [pollId]); + + await connection.commit(); + return true; + } catch (error) { + console.error('closePoll(): Database Error'); + await connection.rollback(); + throw {error: errorCodes.DATABASE_ERROR}; + } finally { + connection.release(); + } +} -module.exports = {getDiscretePolls, getContinuousPolls, getDiscretePollWithId, getContinuousPollWithId, +module.exports = {getPolls, getPollWithId, getDiscretePollWithId, getContinuousPollWithId, addDiscretePoll,addContinuousPoll, getDiscretePollChoices, getDiscreteVoteCount, voteDiscretePoll, voteContinuousPoll, - getContinuousPollVotes} + getContinuousPollVotes,getTagsOfPoll, getUntaggedPolls, updateTagsScanned, addTopic, getDiscreteSelectionsWithPollId, closePoll} \ No newline at end of file diff --git a/prediction-polls/backend/src/repositories/ProfileDB.js b/prediction-polls/backend/src/repositories/ProfileDB.js new file mode 100644 index 00000000..459ab60c --- /dev/null +++ b/prediction-polls/backend/src/repositories/ProfileDB.js @@ -0,0 +1,150 @@ +const authDb = require("../repositories/AuthorizationDB.js"); +const mysql = require('mysql2') +const errorCodes = require("../errorCodes.js") + +require('dotenv').config(); + +const pool = mysql.createPool({ + host: process.env.MYSQL_HOST, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE +}).promise() + + +async function getProfileWithProfileId(profileId){ + + const sql = 'SELECT * FROM profiles WHERE id= ?'; + + try { + const [rows] = await pool.query(sql, [profileId]); + if(rows.length == 0){ + throw {error:errorCodes.PROFILE_NOT_FOUND} + } + return {profile:rows[0]}; + } catch (error) { + return error; + } +} + +async function getProfileWithUserId(userId){ + const sql = 'SELECT * FROM profiles WHERE userId= ?'; + + try { + const [rows] = await pool.query(sql, [userId]); + if(rows.length == 0){ + throw {error:errorCodes.PROFILE_NOT_FOUND} + } + return {profile:rows[0]}; + } catch (error) { + return error; + } +} + +async function addProfile(userId, username, email){ + + try { + const {error} = await getProfileWithUserId(userId); + if(error != errorCodes.PROFILE_NOT_FOUND){ + throw {error:errorCodes.USER_ALREADY_HAS_PROFILE}; + } + + const sql = 'INSERT INTO profiles (userId, username, email, points, profile_picture, biography, birthday, isHidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'; + values = [userId,username,email,10000,null,null,null,null] + const [resultSetHeader] = await pool.query(sql, values); + if(!resultSetHeader.insertId){ + throw {error:errorCodes.PROFILE_COULD_NOT_BE_CREATED}; + } + return {profileId:resultSetHeader.insertId}; + }catch(error){ + return error; + } + +} + +async function updateProfile({ + userId, + profile_picture, + biography, + birthday , + isHidden}){ + + try { + const {error} = await getProfileWithUserId(userId); + if(error){ + throw {error:errorCodes.PROFILE_NOT_FOUND}; + } + if(profile_picture){ + const sql = 'UPDATE profiles SET profile_picture = ? WHERE userId = ?'; + values = [profile_picture,userId]; + + const [resultSetHeader] = await pool.query(sql, values); + } + if(biography){ + const sql = 'UPDATE profiles SET biography = ? WHERE userId = ?'; + values = [biography,userId]; + + const [resultSetHeader] = await pool.query(sql, values); + } + if(birthday){ + const sql = 'UPDATE profiles SET birthday = ? WHERE userId = ?'; + values = [birthday,userId]; + + const [resultSetHeader] = await pool.query(sql, values); + } + if(isHidden){ + const sql = 'UPDATE profiles SET isHidden = ? WHERE userId = ?'; + values = [isHidden,userId]; + + const [resultSetHeader] = await pool.query(sql, values); + } + return {status:"success"}; + }catch(error) { + return {error:errorCodes.PROFILE_COULD_NOT_BE_UPDATED}; + } +} + + +async function getProfileIsHidden(profileId){ + result = await getProfileWithProfileId(profileId); + return result.profile.isHidden; +} + +async function getBadges(userId){ + const sql = 'SELECT * FROM badges WHERE userId= ?'; + + try { + const [rows] = await pool.query(sql, [userId]); + const badges = rows.map(badge => {return {topic:badge.topic,rank:badge.userRank}}) + + return {badges:badges}; + } catch (error) { + return {error:errorCodes.DATABASE_ERROR}; + } +} + +async function updatePoints(userId,additional_points){ + + const find_points_sql = 'SELECT * FROM profiles WHERE userId= ?'; + const sql = 'UPDATE profiles SET points = ? WHERE userId = ?'; + + try { + const [rows] = await pool.query(find_points_sql, [userId]); + const current_points = rows[0].points + + if(current_points + additional_points < 0){ + return {error:errorCodes.INSUFFICIENT_POINTS_ERROR}; + } + + const [resultSetHeader] = await pool.query(sql, [current_points + additional_points, userId]); + + return {status:"success"}; + + } catch (error) { + return {error:errorCodes.DATABASE_ERROR}; + } +} + + +module.exports = {getProfileWithProfileId,getProfileWithUserId,addProfile,updateProfile,getBadges,updatePoints} + \ No newline at end of file diff --git a/prediction-polls/backend/src/routes/AuthorizationRouter.js b/prediction-polls/backend/src/routes/AuthorizationRouter.js index 7bacff81..5531ae16 100644 --- a/prediction-polls/backend/src/routes/AuthorizationRouter.js +++ b/prediction-polls/backend/src/routes/AuthorizationRouter.js @@ -183,7 +183,23 @@ router.post("/signup", service.signup) * description: Server was not able to log in user with the given data. */ router.post("/google", googleService.googleLogIn) +router.get('/verify-email', async (req, res) => { + const token = req.query.token; + try { + // Verify the token and update the user's email_verified status in the database + const verificationSuccessful = await verifyEmailToken(token); + + if (verificationSuccessful) { + res.send('Email successfully verified'); + } else { + res.status(400).send('Invalid or expired verification token'); + } + } catch (error) { + console.error("Error during email verification:", error); + res.status(500).send('Internal server error'); + } +}); module.exports = router; \ No newline at end of file diff --git a/prediction-polls/backend/src/routes/PollRouter.js b/prediction-polls/backend/src/routes/PollRouter.js index c0ac7a44..ffc136de 100644 --- a/prediction-polls/backend/src/routes/PollRouter.js +++ b/prediction-polls/backend/src/routes/PollRouter.js @@ -3,14 +3,88 @@ const service = require("../services/PollService.js"); const express = require('express'); const router = express.Router(); +/** + * @swagger + * components: + * objects: + * pollObject: + * type: object + * required: ["id", "question", "tags", "creatorName", "creatorUsername", "creatorImage", "pollType", "closingDate", "rejectVotes", "isOpen", "comments", "options"] + * properties: + * id: + * type: integer + * question: + * type: string + * tags: + * type: array + * items: + * type: string + * creatorName: + * type: string + * creatorUsername: + * type: string + * creatorImage: + * type: string + * pollType: + * type: string + * closingDate: + * type: string + * rejectVotes: + * type: string + * isOpen: + * type: boolean + * cont_poll_type: + * type: string + * comments: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * content: + * type: string + * options: + * oneOf: + * - type: array + * items: + * type: object + * properties: + * id: + * type: integer + * choice_text: + * type: string + * poll_id: + * type: integer + * voter_count: + * type: integer + * - type: array + * items: + * oneOf: + * - type: integer + * example: 9 + * - type: string + * example: 2023-11-26 + * schemas: + * error: + * type: object + * properties: + * error: + * type: object + * properties: + * message: + * type: string + * code: + * type: integer + */ /** * @swagger - * /polls/discrete: + * /polls: * get: * tags: * - polls - * description: Get all discrete polls + * description: Get all polls * responses: * 200: * description: Successful response @@ -19,20 +93,63 @@ const router = express.Router(); * schema: * type: array * items: - * type: object - * properties: - * id: - * type: integer - * example: 1 - * question: - * type: string - * example: "Who will become POTUS?" + * $ref: '#/components/objects/pollObject' + * examples: + * genericExample: + * value: + * - id: 1 + * question: "Who will become POTUS?" + * tags: ["tag1", "tag2"] + * creatorName: "user123" + * creatorUsername: "GhostDragon" + * creatorImage: null + * pollType: "discrete" + * rejectVotes: "5 min" + * closingDate: "2023-11-20T21:00:00.000Z" + * isOpen: 1 + * comments: [] + * options: + * - id: 1 + * choice_text: "Trumpo" + * poll_id: 1 + * voter_count: 0 + * - id: 2 + * choice_text: "Biden" + * poll_id: 1 + * voter_count: 1 + * - id: 2 + * question: "Test question?" + * tags: ["tag1", "tag2"] + * creatorName: "GhostDragon" + * creatorUsername: "GhostDragon" + * creatorImage: null + * pollType: "continuous" + * rejectVotes: "2 hr" + * closingDate: null + * isOpen: 1 + * cont_poll_type: "numeric" + * comments: [] + * options: + * - 7 + * - 8 + * 500: + * description: Internal Server Error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/error' + * examples: + * databaseError: + * value: + * error: + * message: Error while accessing the database. + * code: 3004 */ -router.get('/discrete', service.getDiscretePolls); +router.get('/', service.getPolls); /** * @swagger - * /polls/discrete/{pollId}: + * /polls/{pollId}: * get: * tags: * - polls @@ -50,54 +167,94 @@ router.get('/discrete', service.getDiscretePolls); * content: * application/json: * schema: - * type: object - * properties: - * poll: - * type: object - * properties: - * id: - * type: integer - * example: 1 - * question: - * type: string - * example: "Who will become POTUS?" - * choices: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * example: 1 - * choice_text: - * type: string - * example: "Trumpo" - * poll_id: - * type: integer - * example: 1 - * voter_count: - * type: integer - * example: 0 - * example: - * poll: + * $ref: '#/components/objects/pollObject' + * examples: + * discrete: + * value: * id: 1 * question: "Who will become POTUS?" - * choices: - * - id: 1 - * choice_text: "Trumpo" - * poll_id: 1 - * voter_count: 0 - * - id: 2 - * choice_text: "Biden" - * poll_id: 1 - * voter_count: 1 - * + * tags: ["tag1", "tag2"] + * creatorName: "user123" + * creatorUsername: "GhostDragon" + * creatorImage: null + * pollType: "discrete" + * rejectVotes: "5 min" + * closingDate: "2023-11-20T21:00:00.000Z" + * isOpen: 1 + * comments: [] + * options: + * - id: 1 + * choice_text: "Trumpo" + * poll_id: 1 + * voter_count: 0 + * - id: 2 + * choice_text: "Biden" + * poll_id: 1 + * voter_count: 1 + * continuousNumeric: + * value: + * id: 2 + * question: "Test question?" + * tags: ["tag1", "tag2"] + * creatorName: "GhostDragon" + * creatorUsername: "GhostDragon" + * creatorImage: null + * pollType: "continuous" + * rejectVotes: "2 hr" + * closingDate: null + * isOpen: 1 + * cont_poll_type: "numeric" + * comments: [] + * options: + * - 7 + * - 8 + * continuousDate: + * value: + * id: 2 + * question: "Test question?" + * tags: ["tag1", "tag2"] + * creatorName: "GhostDragon" + * creatorUsername: "GhostDragon" + * creatorImage: null + * pollType: "continuous" + * rejectVotes: "2 hr" + * closingDate: null + * isOpen: 1 + * cont_poll_type: "date" + * comments: [] + * options: + * - "2023-12-22T21:00:00.000Z" + * - "2023-12-24T21:00:00.000Z" + * 404: + * description: Resource Not Found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/error' + * examples: + * NO_SUCH_POLL_ERROR: + * value: + * error: + * message: No such poll found. + * code: 3005 + * 500: + * description: Internal Server Error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/error' + * examples: + * databaseError: + * value: + * error: + * message: Error while accessing the database. + * code: 3004 */ -router.get('/discrete/:pollId', authenticator.authorizeAccessToken, service.getDiscretePollWithId); +router.get('/:pollId', service.getPollWithId); /** * @swagger - * /polls/discrete/: + * /polls/discrete: * post: * tags: * - polls @@ -108,113 +265,91 @@ router.get('/discrete/:pollId', authenticator.authorizeAccessToken, service.getD * content: * application/json: * schema: + * required: ["question", "choices", "openVisibility", "setDueDate"] * type: object * properties: * question: * type: string - * example: "Who will become POTUS?" * choices: * type: array * items: * type: string - * example: ["Trumpino", "Biden"] + * openVisibility: + * type: boolean + * setDueDate: + * type: boolean + * dueDatePoll: + * type: string + * numericFieldValue: + * type: integer + * selectedTimeUnit: + * type: string + * examples: + * setDueDateTrue: + * value: + * question: Question 4 + * choices: + * - choice 1 + * - choice 2 + * openVisibility: true + * setDueDate: true + * dueDatePoll: 2023-11-21T11:39:00+03:00 + * numericFieldValue: 2 + * selectedTimeUnit: min + * setDueDateFalse: + * value: + * question: Question 4 + * choices: + * - choice 1 + * - choice 2 + * openVisibility: true + * setDueDate: false * responses: * 201: * description: true * 400: - * description: Bad request - * 500: - * description: Internal Server Error - */ -router.post('/discrete', authenticator.authorizeAccessToken, service.addDiscretePoll); - -/** - * @swagger - * /polls/continuous: - * get: - * tags: - * - polls - * description: Get a list of continuous polls - * responses: - * 200: - * description: Successful response + * description: Bad Request * content: * application/json: * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * example: 1 - * question: - * type: string - * example: "Test3?" - * min_value: - * type: integer - * example: 6 - * max_value: - * type: integer - * example: 10 - * 404: - * description: Polls not found - * 500: - * description: Internal Server Error - */ -router.get('/continuous', authenticator.authorizeAccessToken, service.getContinuousPolls); - -/** - * @swagger - * /polls/continuous/{pollId}: - * get: - * tags: - * - polls - * description: Get a specific continuous poll by ID - * parameters: - * - in: path - * name: pollId - * required: true - * schema: - * type: integer - * description: The ID of the continuous poll. - * responses: - * 200: - * description: Successful response + * $ref: '#/components/schemas/error' + * examples: + * badRequest: + * value: + * error: + * message: Bad request body for creating a discrete poll. + * code: 4000 + * ACCESS_TOKEN_INVALID_ERROR: + * value: + * error: + * message: The access token is invalid. + * code: 1002 + * 401: + * description: Unauthorized * content: * application/json: * schema: - * type: object - * properties: - * poll: - * type: object - * properties: - * id: - * type: integer - * example: 1 - * question: - * type: string - * example: "Test3?" - * min_value: - * type: integer - * example: 6 - * max_value: - * type: integer - * example: 10 - * choices: - * type: array - * items: - * type: object - * properties: - * selected_value: - * type: integer - * example: 7 - * 404: - * description: Poll not found + * $ref: '#/components/schemas/error' + * examples: + * ACCESS_TOKEN_INVALID_ERROR: + * value: + * error: + * message: The access token is invalid. + * code: 1002 * 500: * description: Internal Server Error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/error' + * examples: + * databaseError: + * value: + * error: + * message: Error while accessing the database. + * code: 3004 */ -router.get('/continuous/:pollId', authenticator.authorizeAccessToken, service.getContinuousPollWithId); +router.post('/discrete', authenticator.authorizeAccessToken, service.addDiscretePoll); /** * @swagger @@ -229,24 +364,93 @@ router.get('/continuous/:pollId', authenticator.authorizeAccessToken, service.ge * content: * application/json: * schema: + * required: ["question", "openVisibility", "setDueDate", "cont_poll_type"] * type: object * properties: * question: * type: string - * example: "Test3?" - * min: - * type: integer - * example: 6 - * max: + * openVisibility: + * type: boolean + * setDueDate: + * type: boolean + * dueDatePoll: + * type: string + * numericFieldValue: * type: integer - * example: 10 + * selectedTimeUnit: + * type: string + * cont_poll_type: + * type: string + * examples: + * setDueDateTrueNumeric: + * value: + * question: Question 5 + * openVisibility: true + * setDueDate: true + * dueDatePoll: 2023-11-21T11:39:00+03:00 + * numericFieldValue: 2 + * selectedTimeUnit: min + * cont_poll_type: numeric + * setDueDateFalseNumeric: + * value: + * question: Question 4 + * openVisibility: true + * setDueDate: false + * cont_poll_type: numeric + * setDueDateTrueDate: + * value: + * question: Question 5 + * openVisibility: true + * setDueDate: true + * dueDatePoll: 2023-11-21T11:39:00+03:00 + * numericFieldValue: 2 + * selectedTimeUnit: min + * cont_poll_type: date + * setDueDateFalseDate: + * value: + * question: Question 4 + * openVisibility: true + * setDueDate: false + * cont_poll_type: date * responses: * 201: * description: true * 400: - * description: Bad request + * description: Bad Request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/error' + * examples: + * badRequest: + * value: + * error: + * message: Bad request body for creating a continuous poll. + * code: 4001 + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/error' + * examples: + * ACCESS_TOKEN_INVALID_ERROR: + * value: + * error: + * message: The access token is invalid. + * code: 1002 * 500: * description: Internal Server Error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/error' + * examples: + * databaseError: + * value: + * error: + * message: Error while accessing the database. + * code: 3004 */ router.post('/continuous', authenticator.authorizeAccessToken, service.addContinuousPoll); @@ -275,6 +479,9 @@ router.post('/continuous', authenticator.authorizeAccessToken, service.addContin * choiceId: * type: integer * example: 2 + * points: + * type: integer + * example: 25 * responses: * 201: * description: Vote submitted successfully @@ -310,8 +517,14 @@ router.post('/discrete/:pollId/vote',authenticator.authorizeAccessToken, service * type: object * properties: * choice: - * type: integer - * example: 9 + * oneOf: + * - type: integer + * example: 9 + * - type: string + * example: 2023-11-26 + * points: + * type: integer + * example: 25 * responses: * 201: * description: Vote submitted successfully @@ -332,5 +545,42 @@ router.post('/discrete/:pollId/vote',authenticator.authorizeAccessToken, service */ router.post('/continuous/:pollId/vote',authenticator.authorizeAccessToken, service.voteContinuousPoll); +/** + * @swagger + * /polls/close/{pollId}: + * post: + * tags: + * - polls + * description: Close a discrete poll and redistribute its points + * parameters: + * - in: path + * name: pollId + * required: true + * schema: + * type: integer + * description: The ID of the continuous poll to vote on. + * requestBody: + * description: Vote details + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * choiceId: + * type: integer + * example: 1 + * responses: + * 200: + * description: Poll closed successfully + * 400: + * description: Bad request + * 404: + * description: Poll not found + * 500: + * description: Internal Server Error + */ +router.post('/close/:pollId', authenticator.authorizeAccessToken, service.closePoll); + module.exports = router; diff --git a/prediction-polls/backend/src/routes/ProfileRouter.js b/prediction-polls/backend/src/routes/ProfileRouter.js new file mode 100644 index 00000000..dda8b23f --- /dev/null +++ b/prediction-polls/backend/src/routes/ProfileRouter.js @@ -0,0 +1,256 @@ +const authenticator = require("../services/AuthorizationService.js"); +const service = require("../services/ProfileService.js"); +const multer = require('multer'); +const express = require('express'); +const router = express.Router(); + +const storage = multer.memoryStorage() +const upload = multer({ storage: storage }) + +/** + * @swagger + * components: + * schemas: + * Profile: + * type: object + * properties: + * userId: + * type: integer + * username: + * type: string + * email: + * type: string + * profile_picture: + * type: string + * points: + * type: integer + * biography: + * type: string + * birthday: + * type: string + * isHidden: + * type: integer + * + */ + + +/** + * @swagger + * /profiles: + * get: + * tags: + * - profiles + * description: Get a profile using user info. user id, username or email can be used.If multiple info is provided only the most prioritized one is used + * parameters: + * - in: path + * name: userId + * required: false + * schema: + * type: integer + * description: The ID of the requested profile's user. + * - in: path + * name: username + * required: false + * schema: + * type: string + * description: The username of the requested profile's user. + * - in: path + * name: email + * required: false + * schema: + * type: string + * description: The email of the requested profile's user. + * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * schema: + * $ref: '#/components/schemas/Profile' + * 400: + * description: Bad Request + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * code: + * type: integer + * examples: + * userNotFoundWithUserId: + * value: + * error: + * code: 2001, + * message: User with the given user id not found + * ProfileNotFound: + * value: + * error: + * code: 3000, + * message: Profile not found, + */ +router.get('/', service.getProfile); + +/** + * @swagger + * /profiles/myProfile: + * get: + * tags: + * - profiles + * description: Get your profile. + * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * schema: + * $ref: '#/components/schemas/Profile' + * 400: + * description: Bad Request + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * code: + * type: integer + * examples: + * userNotFoundWithUserId: + * value: + * error: + * code: 2001, + * message: User with the given user id not found + * ProfileNotFound: + * value: + * error: + * code: 3000, + * message: Profile not found, + */ +router.get('/myProfile',authenticator.authorizeAccessToken ,service.getMyProfile); + +/** + * @swagger + * /profiles/{profileId}: + * get: + * tags: + * - profiles + * description: Get a profile by profile ID. + * parameters: + * - in: path + * name: profileId + * required: true + * schema: + * type: integer + * description: The ID of the requested profile. + * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Profile' + * 400: + * description: Bad Request + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * code: + * type: integer + * examples: + * ProfileNotFound: + * value: + * error: + * code: 3000, + * message: Profile not found, + */ +router.get('/:profileId', service.getProfileWithProfileId); + + +/** + * @swagger + * /profiles/profilePhoto: + * post: + * tags: + * - profiles + * description: Upload profile photo. + * requestBody: + * description: Image content + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * image: + * type: binary + * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * examples: + * value: + * status: + * Image uploaded successfully! + * 400: + * description: Bad Request + */ +router.post("/profilePhoto",authenticator.authorizeAccessToken,upload.single('image'),service.uploadImagetoS3); + +/** + * @swagger + * /profiles: + * patch: + * tags: + * - profiles + * description: Update a profile. UserId, username and email only one of them have to be added. The others wont be used + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Profile' + * responses: + * 201: + * description: Profile created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Profile' + * 400: + * description: Bad Request + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * code: + * type: integer + * examples: + * ProfileNotFound: + * value: + * error: + * code: 3003, + * message: Profile could not be updated. + */ +router.patch('/', service.updateProfile); + + + +module.exports = router; \ No newline at end of file diff --git a/prediction-polls/backend/src/routines/tagRoutine.js b/prediction-polls/backend/src/routines/tagRoutine.js new file mode 100644 index 00000000..bca2ec16 --- /dev/null +++ b/prediction-polls/backend/src/routines/tagRoutine.js @@ -0,0 +1,75 @@ +const cron = require('node-cron'); +const axios = require('axios'); +const topics = require('./topics.json'); +const {getUntaggedPolls, updateTagsScanned, addTopic} = require('../repositories/PollDB'); +require('dotenv').config(); + +const scoreThreshold = 0.85; + +async function tagRoutine() { + const doTag = process.env.DO_TAG === 'true'; + + console.log('tagRoutine Running'); + + if (!doTag) { + console.log('DO_TAG is set to false. tagRoutine will do nothing.'); + return; + } + + const apiUrl = process.env.TAG_API_URL; + const apiToken = process.env.TAG_API_TOKEN; + + if (!apiUrl) { + console.error('TAG_API_URL is not defined in the environment variables.'); + return; + } + + const untaggedPolls = await getUntaggedPolls(); + + const filteredPolls = untaggedPolls.filter(poll => poll.tagsScanned >= 0 && poll.tagsScanned < topics.topics.length); + if (filteredPolls.length === 0) { + console.log("No polls to tag."); + return; + } + const randomPoll = filteredPolls[Math.floor(Math.random() * filteredPolls.length)]; + const pollId = randomPoll.id; + const pollQuestion = randomPoll.question; + const tagsScanned = randomPoll.tagsScanned; + + const batchSize = 10; // the api accepts at most 10 candidate_labels + const topicsBatch = topics.topics.slice(tagsScanned, tagsScanned + batchSize); + const additionalScans = topicsBatch.length; + + const candidateLabels = topicsBatch.map(topic => topic.name).join(', '); + + axios.post(apiUrl, { + // inputs: "What will be the result of the Besiktas vs. Fenerbahce football match at 9.12.2023?", + inputs: pollQuestion, + parameters: { + candidate_labels: candidateLabels, + multi_label: true + } + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiToken}` + } + }) + .then(response => { + const filteredLabels = response.data.labels.filter((label, index) => response.data.scores[index] > scoreThreshold); + + updateTagsScanned(pollId, tagsScanned + additionalScans) + filteredLabels.map((label) => { + addTopic(pollId, label) + }); + }) + .catch(error => { + + console.error('Error:', error.message); + }); +} + +// Schedule the routine to run every 30 seconds +cron.schedule('*/30 * * * * *', tagRoutine); + +module.exports = tagRoutine; diff --git a/prediction-polls/backend/src/routines/topics.json b/prediction-polls/backend/src/routines/topics.json new file mode 100644 index 00000000..018a94d5 --- /dev/null +++ b/prediction-polls/backend/src/routines/topics.json @@ -0,0 +1,154 @@ +{ + "topics": [ + { + "name": "Weather", + "subtopics": [] + }, + { + "name": "Sports", + "subtopics": [ + "Football", + "Basketball", + "Baseball", + "Tennis", + "Athletics", + "Esports", + "Extreme sports", + "Sports analytics", + "Sports psychology" + ] + }, + { + "name": "Economy" + }, + { + "name": "Politics", + "subtopics": [ + "International relations", + "Political ideologies", + "Political philosophy", + "Political systems", + "Government policies", + "Political economy", + "Political history", + "Geopolitics", + "Political activism" + ] + }, + { + "name": "Health", + "subtopics": [ + "Nutrition and diet", + "Mental health", + "Physical fitness", + "Chronic diseases", + "Healthcare systems", + "Public health", + "Alternative medicine", + "Health education", + "Healthcare technology" + ] + }, + { + "name": "Technology", + "subtopics": [ + "Artificial intelligence", + "Blockchain technology", + "Virtual reality", + "Augmented reality", + "Cybersecurity", + "Internet of Things (IoT)", + "Machine learning", + "Robotics", + "Quantum computing" + ] + }, + { + "name": "Science", + "subtopics": [ + "Physics", + "Chemistry", + "Biology", + "Astronomy", + "Earth science", + "Environmental science", + "Psychology", + "Neuroscience", + "Scientific research methods" + ] + }, + { + "name": "Disaster", + "subtopics": [ + "Zoology", + "Animal", + "Endangered species", + "Wildlife conservation", + "Veterinary medicine", + "Animal welfare", + "Ethology", + "Pets and domestication" + ] + }, + { + "name": "Lifestyle", + "subtopics": [ + "Fashion and style", + "Travel and leisure", + "Home decor", + "Personal development", + "Beauty and skincare", + "Hobbies and crafts", + "Food and cooking", + "Relationships and dating", + "Parenting" + ] + }, + { + "name": "Art", + "subtopics": [ + "Visual arts", + "Performing arts", + "Literature and writing", + "Film and cinema", + "Photography", + "Art history", + "Digital art", + "Contemporary art" + ] + }, + { + "name": "Environment", + "subtopics": [ + "Climate change", + "Conservation efforts", + "Renewable energy", + "Biodiversity", + "Pollution control", + "Sustainable living", + "Environmental policies", + "Ecotourism", + "Environmental activism" + ] + }, + { + "name": "Education", + "subtopics": [ + "Online education platforms", + "Educational technology", + "Pedagogy", + "Learning strategies", + "Special education", + "Vocational training", + "Higher education trends", + "Education policies", + "Lifelong learning" + ] + }, + { + "name": "Entertainment", + "subtopics": [ + ] + } + ] +} diff --git a/prediction-polls/backend/src/schema.sql b/prediction-polls/backend/src/schema.sql index 2ed858e7..32b4b3e2 100644 --- a/prediction-polls/backend/src/schema.sql +++ b/prediction-polls/backend/src/schema.sql @@ -7,7 +7,10 @@ CREATE TABLE users ( email VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, birthday DATETIME, - UNIQUE (username) + email_verified BOOLEAN DEFAULT FALSE, + email_verification_token VARCHAR(255), + UNIQUE (username), + UNIQUE (email) ); CREATE TABLE refresh_tokens ( @@ -18,16 +21,36 @@ CREATE TABLE refresh_tokens ( UNIQUE KEY token (token) ); -CREATE TABLE discrete_polls ( +CREATE TABLE polls ( id INT AUTO_INCREMENT PRIMARY KEY, - question VARCHAR(255) NOT NULL + question VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL, + poll_type ENUM('discrete', 'continuous') NOT NULL, + openVisibility BOOLEAN NOT NULL, + setDueDate BOOLEAN NOT NULL, + closingDate DATE, + numericFieldValue INT, + selectedTimeUnit ENUM('min', 'h', 'day', 'mth'), + tagsScanned INT DEFAULT 0 + isOpen BOOLEAN DEFAULT true +); + +CREATE TABLE discrete_polls ( + id INT PRIMARY KEY, + FOREIGN KEY (id) REFERENCES polls(id) +); + +CREATE TABLE continuous_polls ( + id INT PRIMARY KEY, + FOREIGN KEY (id) REFERENCES polls(id), + cont_poll_type ENUM('date', 'numeric') NOT NULL ); CREATE TABLE discrete_poll_choices ( id INT AUTO_INCREMENT PRIMARY KEY, choice_text VARCHAR(255) NOT NULL, poll_id INT, - FOREIGN KEY (poll_id) REFERENCES discrete_polls(id) + FOREIGN KEY (poll_id) REFERENCES polls(id) ); CREATE TABLE discrete_polls_selections ( @@ -35,23 +58,48 @@ CREATE TABLE discrete_polls_selections ( poll_id INT, choice_id INT, user_id INT, - FOREIGN KEY (poll_id) REFERENCES discrete_polls(id), + given_points INT, + FOREIGN KEY (poll_id) REFERENCES polls(id), FOREIGN KEY (choice_id) REFERENCES discrete_poll_choices(id), FOREIGN KEY (user_id) REFERENCES users(id) ); -CREATE TABLE continuous_polls ( - id INT PRIMARY KEY AUTO_INCREMENT, - question VARCHAR(255) NOT NULL, - min_value FLOAT NOT NULL, - max_value FLOAT NOT NULL -); - CREATE TABLE continuous_poll_selections ( id INT PRIMARY KEY AUTO_INCREMENT, poll_id INT, user_id INT, - selected_value FLOAT NOT NULL, - FOREIGN KEY (poll_id) REFERENCES continuous_polls(id), + given_points INT, + float_value FLOAT, + date_value DATE, + FOREIGN KEY (poll_id) REFERENCES polls(id), FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE profiles ( + id INT AUTO_INCREMENT PRIMARY KEY, + userId INT NOT NULL, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + profile_picture VARCHAR(255), + points INT NOT NULL, + biography VARCHAR(5000), + birthday DATETIME, + isHidden BOOLEAN DEFAULT False, + unique(userId), + FOREIGN KEY (userId) REFERENCES users(id) +); + +CREATE TABLE badges ( + id INT AUTO_INCREMENT PRIMARY KEY, + userRank INT NOT NULL, + topic VARCHAR(255) NOT NULL, + userId INT, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + topic VARCHAR(255) NOT NULL, + poll_id INT, + FOREIGN KEY (poll_id) REFERENCES polls(id) ON DELETE SET NULL ); \ No newline at end of file diff --git a/prediction-polls/backend/src/services/AuthGoogleService.js b/prediction-polls/backend/src/services/AuthGoogleService.js index 81d4d094..d6a13815 100644 --- a/prediction-polls/backend/src/services/AuthGoogleService.js +++ b/prediction-polls/backend/src/services/AuthGoogleService.js @@ -2,8 +2,9 @@ const qs = require("querystring"); const axios = require("axios"); const db = require("../repositories/AuthorizationDB.js"); const crypto = require('crypto'); +const errorCodes = require("../errorCodes.js"); + -const { addUser } = require('./AuthenticationService.js'); const { generateAccessToken, generateRefreshToken } = require('./AuthorizationService.js'); @@ -18,7 +19,7 @@ async function googleLogIn(req,res){ googleLogInWithCode(code,res); } else{ - res.status(400).send("Invalid Body"); + res.status(400).send({error:errorCodes.GOOGLE_LOGIN_BODY_EMPTY}); } } @@ -27,43 +28,53 @@ async function googleLogInWithCode(code,res){ try { // get the id and access token with the code const { token_error, id_token, access_token } = await getGoogleOAuthTokens({ code }); - if(token_error){return res.status(403).send("Invalid Code")} + if(token_error){return res.status(403).json({error:errorCodes.GOOGLE_LOGIN_INVALID_GOOGLE_CODE}) }; // get user with tokens const googleUser = await getGoogleUser({ id_token, access_token }); - if(googleUser.error){return res.status(403).send(googleUser.error)} + if(googleUser.error){return res.status(403).json({error:errorCodes.GOOGLE_LOGIN_INVALID_GOOGLE_CODE}) }; if (!( googleUser.verified_email || googleUser.email_verified)) { - return res.status(403).send("Google account is not verified"); + return res.status(403).json({error:errorCodes.GOOGLE_LOGIN_NONVERIFIED_GOOGLE_ACCOUNT}); } const generatedPassword = generateRandomPassword(12); - const { success, error, userid} = await addUser(googleUser.given_name,generatedPassword,googleUser.email,null); + const { error, userId} = await db.addUser(googleUser.given_name,generatedPassword,googleUser.email,null); + + const result = await profileDb.addProfile(userId,googleUser.given_name,googleUser.email); + if(!result.profileId){ + return res.status(400).send({error:errorCodes.REGISTRATION_FAILED}); + } - const user = {name : googleUser.given_name, id: userid}; + const user = {name : googleUser.given_name, id: userId}; const accesToken = generateAccessToken(user); const refreshToken = generateRefreshToken(user); db.addRefreshToken(refreshToken); res.json({accessToken: accesToken, refreshToken: refreshToken}) } catch (error) { - return res.status(500).send("Google Auth Failed"); + return res.status(500).json({error:errorCodes.GOOGLE_LOGIN_FAILED}); } } async function googleLogInWithGoogleId(googleId,res){ try { const googleUser = await getGoogleIdTokenData(googleId) - if(googleUser.error){return res.status(403).send("Invalid Google Id")} + if(googleUser.error){return res.status(403).json({error:errorCodes.GOOGLE_LOGIN_INVALID_GOOGLEID})} if (!( googleUser.verified_email || googleUser.email_verified)) { - return res.status(403).send("Google account is not verified"); + return res.status(403).json({error:errorCodes.GOOGLE_LOGIN_NONVERIFIED_GOOGLE_ACCOUNT}); } const generatedPassword = generateRandomPassword(12); - const { success, error, userid} = await addUser(googleUser.given_name,generatedPassword,googleUser.email,null); + const {error, userId} = await db.addUser(googleUser.given_name,generatedPassword,googleUser.email,null); + + const result = await profileDb.addProfile(userId,googleUser.given_name,googleUser.email); + if(!result.profileId){ + return res.status(400).send({error:errorCodes.REGISTRATION_FAILED}); + } - const user = {name : googleUser.given_name, id: userid}; + const user = {name : googleUser.given_name, id: userId}; const accesToken = generateAccessToken(user); const refreshToken = generateRefreshToken(user); @@ -71,7 +82,7 @@ async function googleLogInWithCode(code,res){ res.json({accessToken: accesToken, refreshToken: refreshToken}) } catch (error) { - return res.status(500).send("Google Auth Failed"); + return res.status(500).json({error:errorCodes.GOOGLE_LOGIN_FAILED}); } } @@ -100,7 +111,7 @@ async function googleLogInWithCode(code,res){ ); return res.data; } catch (error) { - return {token_error:error.message}; + return {token_error:error}; } } @@ -119,7 +130,7 @@ async function googleLogInWithCode(code,res){ ); return res.data; } catch (error) { - return {"error":error.message}; + return {error:error}; } } @@ -130,7 +141,7 @@ async function googleLogInWithCode(code,res){ ); return res.data; } catch (error) { - return {"error":error.message}; + return {error:error}; } } diff --git a/prediction-polls/backend/src/services/AuthenticationService.js b/prediction-polls/backend/src/services/AuthenticationService.js deleted file mode 100644 index b13ea017..00000000 --- a/prediction-polls/backend/src/services/AuthenticationService.js +++ /dev/null @@ -1,56 +0,0 @@ -const db = require("../repositories/AuthorizationDB.js"); - -require('dotenv').config(); - -const bcrypt = require('bcrypt'); - -function hashPassword(password) { - return new Promise((resolve, reject) => { - bcrypt.hash(password, 10, (err, hashedPassword) => { - if (err) reject(err); - resolve(hashedPassword); - }); - }); -} - -async function checkCredentials(username, password) { - try { - /* We want to store our passwords hashed in the db But currently - * our hashing function does not return the same hashed password - * for the same password value. This creates an issue with verification. - */ - - // const hashedPassword = await hashPassword(password); - - const sql = 'SELECT * FROM users WHERE (username = ? || email = ?) && password = ?'; - const values = [username, username, password]; - - const [rows] = await db.pool.query(sql, values); - return rows; - } catch (error) { - console.error(error); - return false; - } -} - -async function addUser(username, password,email,birthday){ - try { - /* We want to store our passwords hashed in the db But currently - * our hashing function does not return the same hashed password - * for the same password value. This creates an issue with verification. - */ - // const hashedPassword = await hashPassword(password); - - // Store the user in the database - const sql = 'INSERT INTO users (username, email, password, birthday) VALUES (?, ?, ?, ?)'; - const values = [username, email, password, birthday]; - - const [result] = await db.pool.query(sql, values); - return {"success":true,"error":undefined, "userid": result.insertId} ; - } catch (error) { - return {"success":false,"error":error} ; - } - } - - -module.exports = {checkCredentials,addUser} \ No newline at end of file diff --git a/prediction-polls/backend/src/services/AuthorizationService.js b/prediction-polls/backend/src/services/AuthorizationService.js index 8257790b..869460e4 100644 --- a/prediction-polls/backend/src/services/AuthorizationService.js +++ b/prediction-polls/backend/src/services/AuthorizationService.js @@ -1,8 +1,8 @@ require('dotenv').config(); const jwt = require("jsonwebtoken"); const db = require("../repositories/AuthorizationDB.js"); - -const { checkCredentials, addUser } = require('./AuthenticationService.js'); +const profileDb = require("../repositories/ProfileDB.js"); +const errorCodes = require("../errorCodes.js") const bcrypt = require('bcrypt') @@ -10,20 +10,114 @@ function homePage(req, res){ res.status(200).json({"username":req.user.name,"key":"very-secret"}); } -async function signup(req, res){ +async function signup(req, res) { const { username, password, email, birthday } = req.body; - const { success, error} = await addUser( username, password, email, birthday ); + + // Email validation + if (!isValidEmail(email)) { + return res.status(400).json({error:errorCodes.INVALID_EMAIL}); + } + + // Birthday validation + if (birthday && !isValidBirthday(birthday)) { + return res.status(400).json({error:errorCodes.INVALID_DATE}); + + } + + // Check if username or email is in use + const { usernameInUse, emailInUse, error } = await db.isUsernameOrEmailInUse(username, email); - if(!success) res.status(400).send('Registration failed '+ error); - else{res.status(201).send("Registration successful")}; + if (error) { + return res.status(500).json({error:errorCodes.DATABASE_ERROR}); + } + + if (usernameInUse) { + return res.status(400).json({error:errorCodes.USERNAME_ALREADY_EXISTS}); + } + if (emailInUse) { + return res.status(400).json({error:errorCodes.USERNAME_ALREADY_EXISTS}); + } + + // Validate password + if (!isValidPassword(password)) { + return res.status(400).json({error:errorCodes.INVALID_PASSWORD}); + } + // Attempt to add user + const { userId, error: addUserError } = await db.addUser(username, password, email, birthday); + if (error) { + return res.status(400).json({error:errorCodes.REGISTRATION_FAILED}); + } + + const result = await profileDb.addProfile(userId,username,email); + if(!result.profileId){ + return res.status(400).json({error:errorCodes.REGISTRATION_FAILED}); + } + + const verificationToken = generateVerificationToken(); + await db.saveEmailVerificationToken(userId, verificationToken); + await sendVerificationEmail(email, verificationToken); + + res.status(201).json({status:"success"}); + +} + +async function sendVerificationEmail(email, token) { + const transporter = db.createTransporter(); + const verificationUrl = `http://ec2-3-78-169-139.eu-central-1.compute.amazonaws.com:3000/verify-email?token=${token}`; + + const mailOptions = { + from: '"Prediction Polls" ', + to: email, + subject: 'Email Verification', + html: `

Please verify your email by clicking on the link: ${verificationUrl}

` + }; + + try { + await transporter.sendMail(mailOptions); + } catch (error) { + return {error:errorCodes.INTERNAL_SERVER_ERROR}; + } +} +const crypto = require('crypto'); + +function generateVerificationToken() { + return crypto.randomBytes(20).toString('hex'); +} + + +function isValidPassword(password) { + const lower = /[a-z]/; + const upper = /[A-Z]/; + const number = /[0-9]/; + const special = /[!@#$%^&*]/; + + let count = 0; + if (lower.test(password)) count++; + if (upper.test(password)) count++; + if (number.test(password)) count++; + if (special.test(password)) count++; + + return password.length >= 8 && count >= 3; +} + +function isValidBirthday(birthday) { + const date = new Date(birthday); + const now = new Date(); + // Check if birthday is a valid date and if the date is in the past + return date instanceof Date && !isNaN(date) && date < now; + } + +function isValidEmail(email) { + const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/; + return emailRegex.test(email); } function createAccessTokenFromRefreshToken(req, res){ const refreshToken = req.body.refreshToken; - if (refreshToken == null) return res.status(400).send('A refresh token is needed'); + if (refreshToken == null) return res.status(400).json({ error: errorCodes.REFRESH_TOKEN_NEEDED_ERROR}); jwt.verify(refreshToken,process.env.REFRESH_TOKEN_SECRET, (err, user) => { - if (err) return res.status(401).send('The refresh token is invalid') + if (err) return res.status(401).json({ error: errorCodes.REFRESH_TOKEN_INVALID_ERROR }) const accessToken = generateAccessToken({name : user.name, id : user.id}); res.status(201).json({accessToken:accessToken}); }) @@ -33,17 +127,22 @@ async function logIn(req,res){ // Authorize User const username = req.body.username; const password = req.body.password; - let [userAuthenticated] = await checkCredentials(username,password); - if (!userAuthenticated) { - res.status(401).send("Could not find a matching (username, email) - password tuple"); - return; + try { + let [userAuthenticated] = await db.checkCredentials(username,password); + if (!userAuthenticated) { + return res.status(401).json({ error: errorCodes.USER_NOT_FOUND }); + } + + const user = {name : username, id: userAuthenticated.id}; + const accesToken = generateAccessToken(user); + const refreshToken = generateRefreshToken(user); + await db.addRefreshToken(refreshToken); + return res.status(201).json({accessToken: accesToken, refreshToken: refreshToken}) + + } catch (error) { + return res.status(401).json({ error: errorCodes.USER_NOT_FOUND }); } - const user = {name : username, id: userAuthenticated.id}; - const accesToken = generateAccessToken(user); - const refreshToken = generateRefreshToken(user); - db.addRefreshToken(refreshToken); - res.status(201).json({accessToken: accesToken, refreshToken: refreshToken}) } async function logOut(req, res) { @@ -52,17 +151,17 @@ async function logOut(req, res) { if (tokenDeleted){ res.status(204).send("Token deleted successfully"); } else { - res.status(404).send("Refresh token not found"); + res.status(404).json({ error: errorCodes.REFRESH_TOKEN_INVALID_ERROR }); } } function authorizeAccessToken(req, res, next) { const authHeader = req.headers["authorization"]; const token = authHeader && authHeader.split(" ")[1]; - if (token == null) return res.sendStatus(400); + if (token == null) return res.status(400).json({ error: errorCodes.ACCESS_TOKEN_NEEDED_ERROR}); jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => { - if (err) return res.status(401).send('The access token is invalid'); + if (err) return res.status(401).json({ error: errorCodes.ACCESS_TOKEN_INVALID_ERROR }); req.user = user; next(); diff --git a/prediction-polls/backend/src/services/PollService.js b/prediction-polls/backend/src/services/PollService.js index 6bae27c8..bfd67420 100644 --- a/prediction-polls/backend/src/services/PollService.js +++ b/prediction-polls/backend/src/services/PollService.js @@ -1,69 +1,151 @@ const db = require("../repositories/PollDB.js"); +const {updatePoints} = require("../repositories/ProfileDB.js"); +const { findUser } = require('../repositories/AuthorizationDB.js'); +const errorCodes = require("../errorCodes.js") -function getDiscretePolls(req,res){ - db.getDiscretePolls() - .then((rows) => { - res.json(rows); - }) - .catch((error) => { +async function getPolls(req,res){ + try { + const rows = await db.getPolls(); + const pollObjects = await Promise.all(rows.map(async (pollObject) => { + const tag_rows = await db.getTagsOfPoll(pollObject.id); + + const properties = { + "id": pollObject.id, + "question": pollObject.question, + "tags": tag_rows, + "creatorName": pollObject.username, + "creatorUsername": pollObject.username, + "creatorImage": null, + "pollType": pollObject.poll_type, + "closingDate": pollObject.closingDate, + "rejectVotes": (pollObject.numericFieldValue && pollObject.selectedTimeUnit) ? `${pollObject.numericFieldValue} ${pollObject.selectedTimeUnit}` : null, + "isOpen": pollObject.isOpen ? true : false, + "comments": [] + }; + + if (properties.pollType === 'discrete') { + const choices = await db.getDiscretePollChoices(properties.id); + + const choicesWithVoteCount = await Promise.all(choices.map(async (choice) => { + const voterCount = await db.getDiscreteVoteCount(choice.id); + return { ...choice, voter_count: voterCount }; + })); + + return { ...properties, "options": choicesWithVoteCount }; + } else if (properties.pollType === 'continuous') { + const contPollRows = await db.getContinuousPollWithId(properties.id); + const choices = await db.getContinuousPollVotes(properties.id); + + const newChoices = choices.map(item => item.float_value ? item.float_value : item.date_value); + + return { ...properties, "cont_poll_type": contPollRows[0].cont_poll_type, "options": newChoices }; + } + })) + res.json(pollObjects); + } catch (error) { console.error(error); - res.status(500).json({ message: "Internal Server Error" }); - }) + res.status(500).json(error); + } } -function getDiscretePollWithId(req,res){ - const pollId = req.params.pollId; - - db.getDiscretePollWithId(pollId) - .then((rows) => { +async function getPollWithId(req, res) { + try { + const pollId = req.params.pollId; + const rows = await db.getPollWithId(pollId); + if (rows.length === 0) { - res.status(404).end("Resource Not Found"); - } else { - db.getDiscretePollChoices(pollId) - .then((choices) => { - - const choicesWithVoteCount = choices.map((choice) => { - // Call your function and set the "voter_count" property based on the result - return db.getDiscreteVoteCount(choice.id) - .then((voterCount) => { - return { ...choice, voter_count: voterCount }; - }) - }); - - Promise.all(choicesWithVoteCount) - .then((updatedChoices) => { - res.json({ "poll": rows[0], "choices": updatedChoices }); - }) - }) + res.status(404).json({ error: errorCodes.NO_SUCH_POLL_ERROR }); + return; } - }) - .catch((error) => { + + const tag_rows = await db.getTagsOfPoll(pollId); + const pollObject = rows[0]; + const pollType = pollObject.poll_type; + const properties = { + "id": pollObject.id, + "question": pollObject.question, + "tags": tag_rows, + "creatorName": pollObject.username, + "creatorUsername": pollObject.username, + "creatorImage": null, + "pollType": pollObject.poll_type, + "closingDate": pollObject.closingDate, + "rejectVotes": (pollObject.numericFieldValue && pollObject.selectedTimeUnit) ? `${pollObject.numericFieldValue} ${pollObject.selectedTimeUnit}` : null, + "isOpen": pollObject.isOpen ? true : false, + "comments": [] + }; + + if (pollType === 'discrete') { + const choices = await db.getDiscretePollChoices(pollId); + + const choicesWithVoteCount = await Promise.all(choices.map(async (choice) => { + const voterCount = await db.getDiscreteVoteCount(choice.id); + return { ...choice, voter_count: pollObject.openVisibility ? voterCount : null }; + })); + + res.json({ ...properties, "options": choicesWithVoteCount }); + } else if (pollType === 'continuous') { + const contPollRows = await db.getContinuousPollWithId(pollId); + + if (contPollRows.length === 0) { + res.status(404).json({ error: errorCodes.NO_SUCH_POLL_ERROR }); + return; + } + + const choices = await db.getContinuousPollVotes(pollId); + const newChoices = choices.map(item => item.float_value ? item.float_value : item.date_value); + res.json({ ...properties, "cont_poll_type": contPollRows[0].cont_poll_type/*, "options": newChoices */}); + } + } catch (error) { console.error(error); - res.status(500).json({ message: "Internal Server Error" }); - }) + res.status(500).json(error); + } } -function addDiscretePoll(req,res){ - if (!validateAddDiscretePoll(req.body)) { - return res.status(400).json({ error: 'Invalid request body' }); - } - const question = req.body.question; - const choices = req.body.choices; - - db.addDiscretePoll(question, choices) - .then((result) => { - res.end(result.toString()); - }) - .catch((error) => { +async function addDiscretePoll(req, res) { + try { + if (!validateAddDiscretePoll(req.body)) { + return res.status(400).json({ error: errorCodes.BAD_DISCRETE_POLL_REQUEST_ERROR }); + } + + const question = req.body.question; + const choices = req.body.choices; + const openVisibility = req.body.openVisibility; + const setDueDate = req.body.setDueDate; + const numericFieldValue = req.body.numericFieldValue; + const dueDatePoll = setDueDate ? new Date(req.body.dueDatePoll).toISOString().split('T')[0] : null; + const selectedTimeUnit = req.body.selectedTimeUnit; + const findUserResult = await findUser({userId: req.user.id}); + const username = findUserResult.username; + + const result = await db.addDiscretePoll( + question, + username, + choices, + openVisibility, + setDueDate, + dueDatePoll, + numericFieldValue, + selectedTimeUnit + ); + + res.json({ + success: true, + newPollId: result + }); + } catch (error) { console.error(error); - res.status(500).send('Internal Server Error'); - }); + res.status(500).json(error); + } } + function validateAddDiscretePoll(body) { if ( typeof body !== 'object' || - typeof body.question !== 'string' + typeof body.question !== 'string' || + typeof body.openVisibility !== 'boolean' || + typeof body.setDueDate !== 'boolean' ) { return false; } @@ -79,65 +161,45 @@ function validateAddDiscretePoll(body) { return true; } -function getContinuousPolls(req,res){ - db.getContinuousPolls() - .then((rows) => { - res.json(rows); - }) - .catch((error) => { - console.error(error); - res.status(500).json({ message: "Internal Server Error" }); - }) -} - -function getContinuousPollWithId(req,res){ - const pollId = req.params.pollId; - - db.getContinuousPollWithId(pollId) - .then((rows) => { - if (rows.length === 0) { - res.status(404).end("Resource Not Found"); - } else { - db.getContinuousPollVotes(pollId) - .then((choices) => { - res.json({"poll": rows[0], "choices": choices}) - }) +async function addContinuousPoll(req, res) { + try { + if (!validateAddContinuousPoll(req.body)) { + return res.status(400).json({ error: errorCodes.BAD_CONT_POLL_REQUEST_ERROR }); } - }) - .catch((error) => { - console.error(error); - res.status(500).json({ message: "Internal Server Error" }); - }) -} -function addContinuousPoll(req,res){ - if (!validateAddContinuousPoll(req.body)) { - return res.status(400).json({ error: 'Invalid request body' }); - } - const question = req.body.question; - const min = req.body.min; - const max = req.body.max; + const question = req.body.question; + const cont_poll_type = req.body.cont_poll_type; + const setDueDate = req.body.setDueDate; + const numericFieldValue = req.body.numericFieldValue; + const dueDatePoll = setDueDate ? new Date(req.body.dueDatePoll).toISOString().split('T')[0] : null; + const selectedTimeUnit = req.body.selectedTimeUnit; + const findUserResult = await findUser({userId: req.user.id}); + const username = findUserResult.username; - if (max <= min) { - return res.status(400).json({ error: 'minValue higher than maxValue' }); - } + const result = await db.addContinuousPoll( + question, + username, + cont_poll_type, + setDueDate, + dueDatePoll, + numericFieldValue, + selectedTimeUnit + ); - db.addContinuousPoll(question, min, max) - .then((result) => { - res.end(result.toString()); - }) - .catch((error) => { + res.json({ + success: true, + newPollId: result + }); + } catch (error) { console.error(error); - res.status(500).send('Internal Server Error'); - }); + res.status(500).json(error); + } } function validateAddContinuousPoll(body) { if ( typeof body !== 'object' || - typeof body.question !== 'string' || - typeof body.min !== 'number' || - typeof body.max !== 'number' + typeof body.question !== 'string' ) { return false; } @@ -145,49 +207,112 @@ function validateAddContinuousPoll(body) { return true; } -function voteDiscretePoll(req,res){ - const pollId = req.params.pollId; - const userId = req.user.id; - const choiceId = req.body.choiceId; - - db.getDiscretePollChoices(pollId) - .then((choices) => { +async function voteDiscretePoll(req,res){ + try { + const pollId = req.params.pollId; + const userId = req.user.id; + const choiceId = req.body.choiceId; + const points = req.body.points; + + if (points <= 0) { + res.status(404).json({ error: errorCodes.USER_MUST_GIVE_POINTS_ERROR }); + return; + } + + const choices = await db.getDiscretePollChoices(pollId); const choiceExists = choices.some(choice => choice.id === choiceId); + if (!choiceExists) { - res.status(404).json({ error: "choice with specified id does not exist" }); + res.status(404).json({ error: errorCodes.CHOICE_DOES_NOT_EXIST_ERROR }); } else { - db.voteDiscretePoll(pollId, userId, choiceId) - .then(() => { - res.json({ message: "Vote Successful" }); - }) + await db.voteDiscretePoll(pollId, userId, choiceId, points ? points : 10); + res.status(200).json({ message: "Vote Successful" }); } - }) - .catch((error) => { - console.error(error); - res.status(500).json({ message: "Internal Server Error" }); - }) + } catch (error) { + if (error) { + res.status(400).json(error); + } else { + res.status(500).json({ error: errorCodes.DATABASE_ERROR }); + } + } +} + +async function voteContinuousPoll(req, res) { + try { + const pollId = req.params.pollId; + const userId = req.user.id; + const choice = req.body.choice; + const points = req.body.points; + + if (points <= 0) { + res.status(404).json({ error: errorCodes.USER_MUST_GIVE_POINTS_ERROR }); + return; + } + + const result = await db.getContinuousPollWithId(pollId); + const contPollType = result[0].cont_poll_type; + + await db.voteContinuousPoll(pollId, userId, choice, contPollType, points ? points : 10); + res.status(200).json({ message: "Vote Successful" }); + } catch (error) { + if (error) { + res.status(400).json(error); + } else { + res.status(500).json({ error: errorCodes.DATABASE_ERROR }); + } + } } -function voteContinuousPoll(req,res){ - const pollId = req.params.pollId; - const userId = req.user.id; - const choice = req.body.choice; - - db.getContinuousPollWithId(pollId) - .then((result) => { - const minValue = result[0].min_value; - const maxValue = result[0].max_value; - const choiceValid = minValue <= choice && choice <= maxValue; - if (!choiceValid) { - res.status(400).json({ error: "Choice is out of bounds" }); +async function closePoll(req, res) { + try { + const pollIdInput = req.params.pollId; + const choiceIdInput = req.body.choiceId; + + if (!(/^\d+$/.test(pollIdInput))) { + throw {error: {code: 5100, message: "pollId not a number."}}; + } + + if (!(typeof choiceIdInput === 'number')) { + throw {error: {code: 5101, message: "choiceId not a number."}} + } + + pollId = parseInt(pollIdInput); + choiceId = parseInt(choiceIdInput); + + const rows = await db.getPollWithId(pollId); + if (rows.length === 0) { + throw errorCodes.NO_SUCH_POLL_ERROR; + } + + const pollObject = rows[0]; + + if (pollObject.poll_type === 'continuous') { + throw {error: {code: 5102, message: "Closing unsupported."}}; + } + + if (!pollObject.isOpen) { + throw {error: {code: 5013, message: "Poll already closed"}}; + } + + const selections = await db.getDiscreteSelectionsWithPollId(pollId); + const totalPointsBet = selections.reduce((sum, selection) => sum + selection.given_points, 0); + const correctSelections = selections.filter(selection => selection.choice_id === choiceId); + const totalCorrectBet = correctSelections.reduce((sum, selection) => sum + selection.given_points, 0); + + const rewardPoints = correctSelections.map((selection) => { + return {user_id: selection.user_id, reward: Math.floor(totalPointsBet * (selection.given_points / totalCorrectBet))} + }) + + await db.closePoll(pollId, rewardPoints); + + res.status(200).json({success: true}); + } catch (error) { + if (error) { + res.status(400).json(error); } else { - db.voteContinuousPoll(pollId, userId, choice) - .then(() => { - res.json({ message: "Vote Successful" }); - }) + res.status(500).json({ error: errorCodes.DATABASE_ERROR }); } - }) + } } -module.exports = {getDiscretePolls,getDiscretePollWithId,addDiscretePoll,getContinuousPolls, - getContinuousPollWithId,addContinuousPoll,voteDiscretePoll,voteContinuousPoll} \ No newline at end of file +module.exports = {getPolls, getPollWithId, addDiscretePoll, addContinuousPoll, voteDiscretePoll, voteContinuousPoll, closePoll} \ No newline at end of file diff --git a/prediction-polls/backend/src/services/ProfileService.js b/prediction-polls/backend/src/services/ProfileService.js new file mode 100644 index 00000000..b9b01fb0 --- /dev/null +++ b/prediction-polls/backend/src/services/ProfileService.js @@ -0,0 +1,187 @@ +const db = require("../repositories/ProfileDB.js"); +const authDb = require("../repositories/AuthorizationDB.js"); +const crypto = require('crypto'); +const aws = require('aws-sdk'); +const { GetObjectCommand } = require('@aws-sdk/client-s3'); + +const generateFileName = (bytes = 32) => crypto.randomBytes(bytes).toString('hex'); + +const bucketName = process.env.AWS_BUCKET_NAME +const region = process.env.AWS_BUCKET_REGION +const accessKeyId = process.env.AWS_ACCESS_KEY +const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY + +aws.config.update({ + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + region: region, +}); + +const s3Client = new aws.S3(); + + +async function getImagefromS3(imageName){ + const params = { + Bucket: bucketName, + Key: imageName, + Expires: 60*5 // Time in seconds + }; + + try { + const signedUrl = await s3Client.getSignedUrl('getObject', params); + + return {signedUrl:signedUrl}; + } catch (error) { + return {error:error}; + } +} + +async function uploadImagetoS3(req,res){ + const userId = req.user.id; + const file = req.file + const imageName = generateFileName() + + const fileBuffer = file.buffer + + const uploadParams = { + Bucket: bucketName, + Body: fileBuffer, + Key: imageName, + ContentType: req.file.mimetype + } + + try { + await s3Client.putObject(uploadParams).promise(); + await db.updateProfile({ userId, profile_picture: imageName }); + res.status(200).send({status:"Image uploaded successfully!"}); + } catch (error) { + res.status(500).send({error:error}); + } +} + + + +async function getProfile(req,res){ + const {userId, username, email} = req.query; + try{ + const result = await authDb.findUser({userId,username,email}) + if(result.error){ + throw result.error; + } + + const {profile,error} = await db.getProfileWithUserId(result.id); + if(error){ + throw error; + } + + if(profile.profile_picture){ + const image_result = await getImagefromS3(profile.profile_picture); + if(image_result.error){ + throw image_result.error; + } + profile.profile_picture = image_result.signedUrl; + } + + const {badges,error:badge_error} = await db.getBadges(result.id); + if(badge_error){ + throw badge_error; + } + profile.badges = badges; + + return res.status(200).json(profile); + + }catch(error){ + return res.status(400).json({error:error}); + } +} + +async function getProfileWithProfileId(req,res){ + const profileId = req.params.profileId; + try{ + const {profile,error} = await db.getProfileWithProfileId(profileId); + if(error){ + throw error + } + + if(profile.profile_picture){ + const image_result = await getImagefromS3(profile.profile_picture); + if(image_result.error){ + throw image_result.error; + } + profile.profile_picture = image_result.signedUrl; + } + + + const {badges,error:badge_error} = await db.getBadges(profile.userId); + + if(badge_error){ + throw badge_error; + } + profile.badges = badges; + + res.status(200).json(profile); + }catch(error){ + return res.status(400).json({error:error}); + } +} + +async function getMyProfile(req,res){ + const userId = req.user.id; + try{ + const result = await authDb.findUser({userId}) + if(result.error){ + throw result.error; + } + + const {profile,error} = await db.getProfileWithUserId(result.id); + if(error){ + throw error; + } + + if(profile.profile_picture){ + const image_result = await getImagefromS3(profile.profile_picture); + if(image_result.error){ + throw image_result.error; + } + profile.profile_picture = image_result.signedUrl; + } + + const {badges,error:badge_error} = await db.getBadges(result.id); + if(badge_error){ + throw badge_error; + } + profile.badges = badges; + + + return res.status(200).json(profile); + + }catch(error){ + return res.status(400).json({error:error}); + } +} + +async function updateProfile(req,res){ + const {userId,username,email,profile_picture,biography, birthday, isHidden} = req.body; + + try{ + const result = await authDb.findUser({userId,username,email}) + if(result.error){ + throw result.error; + } + const {status,error} = await db.updateProfile({userId:result.id,profile_picture,biography, birthday, isHidden}); + if(error){ + throw error; + } + + const result_profile = await db.getProfileWithUserId(result.id); + if(result_profile.error){ + throw result_profile.error; + } + + return res.status(200).json(result_profile.profile); + }catch(error){ + return res.status(400).json({error:error}); + } +} + +module.exports = {getProfile,getProfileWithProfileId,getMyProfile,updateProfile,uploadImagetoS3} \ No newline at end of file diff --git a/prediction-polls/backend/tests/AuthorizationService.test.js b/prediction-polls/backend/tests/AuthorizationService.test.js index cf9aa12d..fc5d8b0c 100644 --- a/prediction-polls/backend/tests/AuthorizationService.test.js +++ b/prediction-polls/backend/tests/AuthorizationService.test.js @@ -1,233 +1,207 @@ -const service = require('../src/services/AuthorizationService.js'); -const db = require('../src/repositories/AuthorizationDB.js'); -const authService = require('../src/services/AuthenticationService.js'); +const { + signup, + createAccessTokenFromRefreshToken, + logIn, + logOut, + authorizeAccessToken, + generateAccessToken, + generateRefreshToken, +} = require('../src/services/AuthorizationService'); +const authDb = require('../src/repositories/AuthorizationDB'); +const profileDb = require('../src/repositories/ProfileDB'); const jwt = require('jsonwebtoken'); - -require('dotenv').config(); - -// Mock the database functions -jest.mock('../src/repositories/AuthorizationDB.js', () => ({ - addRefreshToken: jest.fn(), - checkRefreshToken: jest.fn(), - deleteRefreshToken: jest.fn(), -})); - -// Mock the AuthenticationService module -jest.mock('../src/services/AuthenticationService.js', () => { - return { - checkCredentials: jest.fn().mockImplementation(async (username, password) => { - return true; // Customize this based on your test scenarios - }), - addUser: jest.fn().mockImplementation(async (username, password,email,birthday) => { - return {"success":true,"error":undefined}; - }) +const errorCodes = require('../src/errorCodes'); + +jest.mock('../src/repositories/AuthorizationDB'); +jest.mock('../src/repositories/ProfileDB'); +jest.mock('jsonwebtoken'); + +describe('signup()', () => { + let req, res; + + beforeEach(() => { + req = { + body: { + username: 'testUser', + password: 'TestPassword1.', + email: 'test@example.com', + birthday: '1990-01-01', + }, }; -}); - -// Mock the jwt.verify function -jest.mock('jsonwebtoken', () => ({ - verify: jest.fn((token, secret, callback) => { - if (token === 'validRefreshToken') { - callback(null, { name: 'testUser' }); - } else { - callback(new Error('Invalid token')); - } - }), - sign: jest.fn(), - })); - -jwt.sign.mockReturnValue('mockToken'); - -test('test if logIn returns accessToken and refreshToken', async () => { - // Set up mock behavior for checkCredentials - const username = 'testUsername'; - const password = 'asdadasd'; - db.checkCredentials = jest.fn().mockResolvedValue(true); - - const req = { body: { username, password } }; - const res = { - status: jest.fn(() => res), - json: jest.fn(), - send: jest.fn(), + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), }; + }); - await service.logIn(req, res); + it('should successfully sign up a new user', async () => { + authDb.isUsernameOrEmailInUse.mockResolvedValueOnce({ + usernameInUse: false, + emailInUse: false, + }); + authDb.addUser.mockResolvedValueOnce({ userId: 1 }); + profileDb.addProfile.mockResolvedValueOnce({profileId:1}) - // Assert that the correct functions were called and with the correct arguments - expect(authService.checkCredentials).toHaveBeenCalledWith(username, password); - expect(db.addRefreshToken).toHaveBeenCalledWith(expect.any(String)); + await signup(req, res); - // Assert that the response was sent with the correct data expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith({ - accessToken: expect.any(String), - refreshToken: expect.any(String), - }); -}); + expect(res.json).toHaveBeenCalledWith({ status: 'success' }); + }); +}); -test('test if signup returns registration successful response ', async () => { - // Set up mock behavior for checkCredentials - const username = 'testUsername'; - const password = 'asdadasd'; - const email= 'test@mail.com'; - const birthday = '2004-10-8'; +describe('createAccessTokenFromRefreshToken()', () => { + let req, res; - const req = { body: { username, password, email, birthday } }; - const res = { - status: jest.fn(() => res), - send: jest.fn(), + beforeEach(() => { + req = { + body: { + refreshToken: 'testRefreshToken', + }, }; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }); - await service.signup(req, res); + it('should create access token from refresh token', async () => { + jwt.verify.mockImplementationOnce((token, secret, callback) => { + callback(null, { name: 'testUser', id: 1 }); + }); + jwt.sign.mockReturnValueOnce('testAccessToken'); - // Assert that the correct functions were called and with the correct arguments - expect(authService.addUser).toHaveBeenCalledWith(username, password, email, birthday); + await createAccessTokenFromRefreshToken(req, res); - // Assert that the response was sent with the correct data expect(res.status).toHaveBeenCalledWith(201); -}); - -test('test if createAccessTokenFromRefreshToken returns accessToken', async () => { - const req = { body: { refreshToken: 'validRefreshToken' } }; - const res = { - status: jest.fn(() => res), - json: jest.fn(), - send: jest.fn(), - }; - - await service.createAccessTokenFromRefreshToken(req, res); - - // Assert that the correct functions were called and with the correct arguments - expect(jwt.verify).toHaveBeenCalledWith( - 'validRefreshToken', - process.env.REFRESH_TOKEN_SECRET, - expect.any(Function) - ); - - // Assert that the response was sent with the correct data - expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith({ - accessToken: expect.any(String), + expect(res.json).toHaveBeenCalledWith({ accessToken: 'testAccessToken' }); }); + }); -test('test if logout returns refresh token deleted', async () => { - // Set up mock behavior for checkCredentials - const refreshToken = 'refreshToken'; +describe('logIn()', () => { + let req, res; - const req = { body: {refreshToken } }; - const res = { - status: jest.fn(() => res), - send: jest.fn(), + beforeEach(() => { + req = { + body: { + username: 'testUser', + password: 'testPassword', + }, + }; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), }; + }); - db.deleteRefreshToken = jest.fn().mockResolvedValue(true); + it('should successfully log in a user', async () => { + authDb.checkCredentials.mockResolvedValueOnce([{ id: 1 }]); - await service.logOut(req, res); + jwt.sign.mockReturnValueOnce('testAccessToken'); + jwt.sign.mockReturnValueOnce('testRefreshToken'); - expect(db.deleteRefreshToken).toHaveBeenCalledWith(expect.any(String)); + await logIn(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ + accessToken: 'testAccessToken', + refreshToken: 'testRefreshToken', + }); + }); - // Assert that the response was sent with the correct data - expect(res.status).toHaveBeenCalledWith(204); }); -test('test if authorizeAccessToken returns refresh token deleted', async () => { - // Set up mock behavior for checkCredentials - const refreshToken = 'refreshToken'; +describe('logOut()', () => { + let req, res; - const req = { body: {refreshToken } }; - const res = { - status: jest.fn(() => res), - send: jest.fn(), + beforeEach(() => { + req = { + body: { + refreshToken: 'testRefreshToken', + }, }; + res = { + send: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }); - db.deleteRefreshToken = jest.fn().mockResolvedValue(true); - - await service.logOut(req, res); + it('should log out a user by deleting the refresh token', async () => { + authDb.deleteRefreshToken.mockResolvedValueOnce(true); - expect(db.deleteRefreshToken).toHaveBeenCalledWith(expect.any(String)); + await logOut(req, res); - // Assert that the response was sent with the correct data expect(res.status).toHaveBeenCalledWith(204); -}); + expect(res.send).toHaveBeenCalledWith('Token deleted successfully'); + }); + + it('should handle invalid refresh token during logout', async () => { + authDb.deleteRefreshToken.mockResolvedValueOnce(false); -test('test if authorizeAccessToken authorizes correctly', async () => { - // Set up mock behavior for jwt.verify - jwt.verify.mockImplementation((token, secret, callback) => { - if (token === 'validAccessToken') { - callback(null, { name: 'testUser' }); - } else { - callback(new Error('Invalid token')); - } + await logOut(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: errorCodes.REFRESH_TOKEN_INVALID_ERROR, }); - - const req = { headers: { authorization: 'Bearer validAccessToken' } }; - const res = { - sendStatus: jest.fn(), - status: jest.fn(() => res), + }); + +}); + +describe('authorizeAccessToken()', () => { + let req, res, next; + + beforeEach(() => { + req = { + headers: { + authorization: 'Bearer testAccessToken', + }, + }; + res = { send: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), }; - const next = jest.fn(); - - await service.authorizeAccessToken(req, res, next); - - // Assert that the correct functions were called and with the correct arguments - expect(jwt.verify).toHaveBeenCalledWith( - 'validAccessToken', - process.env.ACCESS_TOKEN_SECRET, - expect.any(Function) - ); - - // Assert that req.user was set - expect(req.user).toEqual({ name: 'testUser' }); - - // Assert that next was called + next = jest.fn(); + }); + + it('should authorize access with a valid access token', () => { + jwt.verify.mockImplementationOnce((token, secret, callback) => { + callback(null, { name: 'testUser', id: 1 }); + }); + + authorizeAccessToken(req, res, next); + expect(next).toHaveBeenCalled(); + expect(req.user).toEqual({ name: 'testUser', id: 1 }); }); - - test('test if authorizeAccessToken handles missing token', async () => { - const req = { headers: {} }; - const res = { - sendStatus: jest.fn(), - }; - const next = jest.fn(); - - await service.authorizeAccessToken(req, res, next); - - // Assert that sendStatus was called with status 400 - expect(res.sendStatus).toHaveBeenCalledWith(400); - - // Assert that next was not called + + it('should handle missing access token during authorization', () => { + req.headers.authorization = undefined; + + authorizeAccessToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: errorCodes.ACCESS_TOKEN_NEEDED_ERROR, + }); expect(next).not.toHaveBeenCalled(); }); - - test('test if authorizeAccessToken handles invalid token', async () => { - // Set up mock behavior for jwt.verify - jwt.verify.mockImplementation((token, secret, callback) => { + + it('should handle invalid access token during authorization', () => { + jwt.verify.mockImplementationOnce((token, secret, callback) => { callback(new Error('Invalid token')); }); - - const req = { headers: { authorization: 'Bearer invalidAccessToken' } }; - const res = { - status: jest.fn(() => res), - send: jest.fn(), - }; - const next = jest.fn(); - - await service.authorizeAccessToken(req, res, next); - - // Assert that the correct functions were called and with the correct arguments - expect(jwt.verify).toHaveBeenCalledWith( - 'invalidAccessToken', - process.env.ACCESS_TOKEN_SECRET, - expect.any(Function) - ); - - // Assert that res.status and res.send were called with the correct messages + + authorizeAccessToken(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); - expect(res.send).toHaveBeenCalledWith('The access token is invalid'); - - // Assert that next was not called + expect(res.json).toHaveBeenCalledWith({ + error: errorCodes.ACCESS_TOKEN_INVALID_ERROR, + }); expect(next).not.toHaveBeenCalled(); }); + +}); \ No newline at end of file diff --git a/prediction-polls/backend/tests/PollService.test.js b/prediction-polls/backend/tests/PollService.test.js new file mode 100644 index 00000000..3be72a5e --- /dev/null +++ b/prediction-polls/backend/tests/PollService.test.js @@ -0,0 +1,436 @@ + +const {addDiscretePoll, addContinuousPoll, voteDiscretePoll, voteContinuousPoll, getPolls, getPollWithId} = require('../src/services/PollService'); +const db = require('../src/repositories/PollDB'); +const {findUser} = require('../src/repositories/AuthorizationDB'); +const errorCodes = require('../src/errorCodes'); + +jest.mock('../src/repositories/PollDB', () => ({ + addDiscretePoll: jest.fn().mockResolvedValue(1), + addContinuousPoll: jest.fn().mockResolvedValue(1), + getContinuousPollWithId: jest.fn().mockResolvedValue([ { id: 2, cont_poll_type: 'numeric' } ]), + getContinuousPollVotes: jest.fn().mockResolvedValue([]), + voteDiscretePoll: jest.fn().mockResolvedValue(0), + voteContinuousPoll: jest.fn().mockResolvedValue(0), + getTagsOfPoll: jest.fn().mockResolvedValue([]), + choicesWithVoteCount: jest.fn().mockResolvedValue(0), + getDiscreteVoteCount: jest.fn().mockResolvedValue(0), + getDiscretePollChoices: jest.fn().mockResolvedValue([ + { id: 1, choice_text: 'Trump', poll_id: 1 }, + { id: 2, choice_text: 'Biden', poll_id: 1 } + ]), + getPolls: jest.fn().mockResolvedValue([ + { + id: 1, + question: 'Who will win the 2024 Election to become President?', + username: 'sefik2', + poll_type: 'discrete', + openVisibility: 1, + setDueDate: 1, + closingDate: '2023-12-29T21:00:00.000Z', + numericFieldValue: 2, + selectedTimeUnit: 'min', + isOpen: true + }, + { + id: 2, + question: 'Question 2?', + username: 'sefik2', + poll_type: 'continuous', + openVisibility: 0, + setDueDate: 1, + closingDate: '2023-11-20T21:00:00.000Z', + numericFieldValue: 5, + selectedTimeUnit: 'min', + isOpen: true + } + ]), + getPollWithId: jest.fn().mockResolvedValue([ + { + id: 1, + question: 'Who will win the 2024 Election to become President?', + username: 'sefik2', + poll_type: 'discrete', + openVisibility: 1, + setDueDate: 1, + closingDate: '2023-12-29T21:00:00.000Z', + numericFieldValue: 2, + selectedTimeUnit: 'min', + isOpen: true + } + ]) +})); + +jest.mock('../src/repositories/AuthorizationDB', () => ({ + findUser: jest.fn().mockResolvedValue({ username: 'mocked-username' }), +})); + +describe('addDiscretePoll()', () => { + let req, res; + + beforeEach(() => { + req = { + body: { + question: "Who will win the 2024 Election to become President?", + choices: [ + "Trump", + "Biden" + ], + openVisibility: true, + setDueDate: true, + dueDatePoll: "2023-12-30T11:39:00+03:00", + numericFieldValue: 2, + selectedTimeUnit: "min" + }, + user: { + id: 1, + }, + } + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }) + + it('should return success and newPollId on successful addition', async () => { + const insertId = 1; + db.addDiscretePoll.mockResolvedValueOnce(insertId); + await addDiscretePoll(req, res); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + success: true, + newPollId: insertId, + }); + }); + + it('return error when addDiscretePoll throws database Error', async () => { + db.addDiscretePoll.mockRejectedValueOnce({error: errorCodes.DATABASE_ERROR}); + await addDiscretePoll(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({error: errorCodes.DATABASE_ERROR}); + }); + + it('returns 400 when passed in empty body', async () => { + req.body = {}; + await addDiscretePoll(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: errorCodes.BAD_DISCRETE_POLL_REQUEST_ERROR }); + }); +}); + +describe('addContinuousPoll()', () => { + let req, res; + + beforeEach(() => { + req = { + body: { + question: "Question 2?", + "setDueDate": true, + "dueDatePoll": "2023-11-21T11:39:00+03:00", + "numericFieldValue": 5, + "selectedTimeUnit": "min", + "cont_poll_type": "numeric" + }, + user: { + id: 1, + }, + } + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }) + + it('should return success and newPollId on successful addition', async () => { + const insertId = 1; + db.addContinuousPoll.mockResolvedValueOnce(insertId); + await addContinuousPoll(req, res); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + success: true, + newPollId: insertId, + }); + }); + + it('return error when addDiscretePoll throws database Error', async () => { + db.addContinuousPoll.mockRejectedValueOnce({error: errorCodes.DATABASE_ERROR}); + await addContinuousPoll(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({error: errorCodes.DATABASE_ERROR}); + }); + + it('returns 400 when passed in empty body', async () => { + req.body = {}; + await addContinuousPoll(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: errorCodes.BAD_CONT_POLL_REQUEST_ERROR }); + }); + +}); + +describe('voteDiscretePoll()', () => { + let req, res; + + beforeEach(() => { + req = { + params: { + pollId: 1 + }, + user: { + id: 1 + }, + body: { + choiceId: 1, + points: 50 + } + } + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }) + + it('should return success on successful voting', async () => { + const choices = [{id: 1}, {id: 2}, {id: 3}]; + db.getDiscretePollChoices.mockResolvedValueOnce(choices); + await voteDiscretePoll(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: "Vote Successful" }); + }); + + it('should return 404 on choice not found', async () => { + const choices = [{id: 2}, {id: 3}]; + db.getDiscretePollChoices.mockResolvedValueOnce(choices); + await voteDiscretePoll(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({error: errorCodes.CHOICE_DOES_NOT_EXIST_ERROR}); + }); + + it('should return 404 on negative points passed', async () => { + const choices = [{id: 1}, {id: 2}, {id: 3}]; + req.body.points = -1; + await voteDiscretePoll(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({error: errorCodes.USER_MUST_GIVE_POINTS_ERROR}); + }); + + it('should return 404 on no points passed', async () => { + const choices = [{id: 1}, {id: 2}, {id: 3}]; + req.body.points = null; + await voteDiscretePoll(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: errorCodes.USER_MUST_GIVE_POINTS_ERROR }); + }); +}); + +describe('voteContinuousPoll()', () => { + let req, res; + + beforeEach(() => { + req = { + params: { + pollId: 1 + }, + user: { + id: 1 + }, + body: { + choice: 1, + points: 50 + } + } + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }) + + it('should return 200 on successful voting on discrete polls', async () => { + db.getContinuousPollWithId.mockResolvedValueOnce([{ + cont_poll_type: 'numeric' + }]); + await voteContinuousPoll(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: "Vote Successful" }); + }); + + it('should return 200 on successful voting on continuous polls', async () => { + db.getContinuousPollWithId.mockResolvedValueOnce([{ + cont_poll_type: 'date' + }]); + await voteContinuousPoll(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ message: "Vote Successful" }); + }); + + it('should return 404 on giving negative points', async () => { + req.body.points = -1 + await voteContinuousPoll(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: errorCodes.USER_MUST_GIVE_POINTS_ERROR }); + }); + + it('should return 404 on no points passed', async () => { + req.body.points = null; + await voteContinuousPoll(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: errorCodes.USER_MUST_GIVE_POINTS_ERROR }); + }); +}); + +describe('getPolls()', () => { + let req, res; + + beforeEach(() => { + req = {}; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }) + + it('should return pollObjects on successful get request', async () => { + // db.getContinuousPollWithId.mockResolvedValueOnce([ { id: 2, cont_poll_type: 'numeric' } ]) + await getPolls(req, res); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith( + [ + { + id: 1, + question: "Who will win the 2024 Election to become President?", + tags: [], + creatorName: "sefik2", + creatorUsername: "sefik2", + creatorImage: null, + pollType: "discrete", + closingDate: "2023-12-29T21:00:00.000Z", + rejectVotes: "2 min", + isOpen: true, + comments: [], + options: [ + { + id: 1, + choice_text: "Trump", + poll_id: 1, + voter_count: 0 + }, + { + id: 2, + choice_text: "Biden", + poll_id: 1, + voter_count: 0 + } + ] + }, + { + id: 2, + question: "Question 2?", + tags: [], + creatorName: "sefik2", + creatorUsername: "sefik2", + creatorImage: null, + pollType: "continuous", + closingDate: "2023-11-20T21:00:00.000Z", + rejectVotes: "5 min", + isOpen: true, + options: [], + comments: [], + cont_poll_type: "numeric" + } + ]); + }); +}); + +describe('getPollWithId()', () => { + let req, res; + + beforeEach(() => { + req = {params: {pollId: 1}}; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }) + + it('should return pollObjects on successful get request for discretePoll', async () => { + await getPollWithId(req, res); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + id: 1, + question: "Who will win the 2024 Election to become President?", + tags: [], + creatorName: "sefik2", + creatorUsername: "sefik2", + creatorImage: null, + pollType: "discrete", + closingDate: "2023-12-29T21:00:00.000Z", + rejectVotes: "2 min", + isOpen: true, + comments: [], + options: [ + { + id: 1, + choice_text: "Trump", + poll_id: 1, + voter_count: 0 + }, + { + id: 2, + choice_text: "Biden", + poll_id: 1, + voter_count: 0 + } + ] + }); + }); + + it('should return correct pollObjects on successful get request for a continuous poll', async () => { + req.params.pollId = 2; + db.getPollWithId.mockResolvedValue([ + { + id: 2, + question: 'Question 2?', + username: 'sefik2', + poll_type: 'continuous', + openVisibility: 0, + setDueDate: 1, + closingDate: '2023-11-20T21:00:00.000Z', + numericFieldValue: 5, + selectedTimeUnit: 'min', + isOpen: true + } + ]) + await getPollWithId(req, res); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + id: 2, + question: "Question 2?", + tags: [], + creatorName: "sefik2", + creatorUsername: "sefik2", + creatorImage: null, + pollType: "continuous", + closingDate: "2023-11-20T21:00:00.000Z", + rejectVotes: "5 min", + isOpen: true, + comments: [], + cont_poll_type: "numeric" + }); + }); +}); \ No newline at end of file diff --git a/prediction-polls/backend/tests/ProfileService.test.js b/prediction-polls/backend/tests/ProfileService.test.js new file mode 100644 index 00000000..b883d03f --- /dev/null +++ b/prediction-polls/backend/tests/ProfileService.test.js @@ -0,0 +1,222 @@ +const { + getProfile, + getProfileWithProfileId, + getMyProfile, + updateProfile, + uploadImagetoS3 + } = require('../src/services/ProfileService'); + const db = require('../src/repositories/ProfileDB'); + const authDb = require('../src/repositories/AuthorizationDB'); + const aws = require('aws-sdk'); + + jest.mock('aws-sdk'); + jest.mock('../src/repositories/ProfileDB'); + jest.mock('../src/repositories/AuthorizationDB'); + + describe('getProfile()', () => { + let req, res; + + beforeEach(() => { + req = { + query: { userId: 1, username: 'testUser', email: 'test@example.com' }, + }; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }); + + it('should return user profile with badges', async () => { + const profileResult = { + profile: { + id: 2, + userId: 1, + username: 'testUser', + email: 'test@example.com', + profile_picture: null, + badges: ['Badge1', 'Badge2'], + }, + }; + + db.getProfileWithUserId.mockResolvedValueOnce(profileResult); + authDb.findUser.mockResolvedValueOnce({ id: 1, username: 'testUser', email: 'test@example.com' }); + db.getBadges.mockResolvedValueOnce({badges:[{topic:"basketball",userRank:1}]}) + + await getProfile(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(profileResult.profile); + }); + + it('should handle errors and return 400 status', async () => { + const errorMessage = 'Error fetching profile'; + + db.getProfileWithUserId.mockResolvedValueOnce({ error: errorMessage }); + authDb.findUser.mockResolvedValueOnce({ id: 1, username: 'testUser', email: 'test@example.com' }); + db.getBadges.mockResolvedValueOnce({badges:[{topic:"basketball",userRank:1}]}) + + await getProfile(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: errorMessage }); + }); + }); + + + describe('getProfileWithProfileId()', () => { + let req, res; + + beforeEach(() => { + req = { + params: { profileId: 2 }, + }; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }); + + it('should return user profile with badges', async () => { + const profileResult = { + profile: { + id: 2, + userId: 1, + username: 'testUser', + email: 'test@example.com', + profile_picture: null, + badges: ['Badge1', 'Badge2'], + }, + }; + + db.getProfileWithProfileId.mockResolvedValueOnce(profileResult); + db.getBadges.mockResolvedValueOnce({badges:[{topic:"basketball",userRank:1}]}) + + await getProfileWithProfileId(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(profileResult.profile); + }); + + it('should handle errors and return 400 status', async () => { + const errorMessage = 'Error fetching profile'; + + db.getProfileWithProfileId.mockResolvedValueOnce({ error: errorMessage }); + db.getBadges.mockResolvedValueOnce({badges:[{topic:"basketball",userRank:1}]}) + + await getProfileWithProfileId(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: errorMessage }); + }); + }); + + + describe('getMyProfile()', () => { + let req, res; + + beforeEach(() => { + req = { + user: { id: 1 }, + }; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }); + + it('should return user profile with badges', async () => { + const profileResult = { + profile: { + id: 2, + userId: 1, + username: 'testUser', + email: 'test@example.com', + profile_picture: null, + badges: ['Badge1', 'Badge2'], + }, + }; + + db.getProfileWithUserId.mockResolvedValueOnce(profileResult); + authDb.findUser.mockResolvedValueOnce({ id: 1, username: 'testUser', email: 'test@example.com' }); + db.getBadges.mockResolvedValueOnce({badges:[{topic:"basketball",userRank:1}]}) + + await getMyProfile(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(profileResult.profile); + }); + + it('should handle errors and return 400 status', async () => { + const errorMessage = 'Error fetching profile'; + + db.getProfileWithUserId.mockResolvedValueOnce({ error: errorMessage }); + authDb.findUser.mockResolvedValueOnce({ id: 1, username: 'testUser', email: 'test@example.com' }); + db.getBadges.mockResolvedValueOnce({badges:[{topic:"basketball",userRank:1}]}) + + await getMyProfile(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: errorMessage }); + }); + }); + + + describe('updateProfile()', () => { + let req, res; + + beforeEach(() => { + req = { + body: { + userId: 1, + username: 'testUser', + email: 'test@example.com', + biography: 'New biography', + birthday: null, + isHidden: true, + }, + }; + res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + }); + + it('should update user profile and return the updated profile', async () => { + const updatedProfileResult = { + profile: { + userId: 1, + username: 'testUser', + email: 'test@example.com', + profile_picture: null, + biography: 'New biography', + birthday: null, + isHidden: true, + }, + }; + + db.updateProfile.mockResolvedValueOnce({ status: 'Updated successfully' }); + db.getProfileWithUserId.mockResolvedValueOnce(updatedProfileResult); + authDb.findUser.mockResolvedValueOnce({ id: 1, username: 'testUser', email: 'test@example.com' }); + + await updateProfile(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(updatedProfileResult.profile); + }); + + it('should handle errors and return 400 status', async () => { + const errorMessage = 'Error updating profile'; + + db.updateProfile.mockResolvedValueOnce({ error: errorMessage }); + authDb.findUser.mockResolvedValueOnce({ id: 1, username: 'testUser', email: 'test@example.com' }); + + await updateProfile(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: errorMessage }); + }); + }); + + + // Add similar test cases for other functions (getProfileWithProfileId, getMyProfile, updateProfile, uploadImagetoS3) + \ No newline at end of file diff --git a/prediction-polls/frontend/package-lock.json b/prediction-polls/frontend/package-lock.json index a8b88ffc..8be481c1 100644 --- a/prediction-polls/frontend/package-lock.json +++ b/prediction-polls/frontend/package-lock.json @@ -12,8 +12,10 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "antd": "^5.10.0", - "jest-matchmedia-mock": "^1.1.0", + "form-data": "^4.0.0", "history": "^5.3.0", + "jest-matchmedia-mock": "^1.1.0", + "moment": "^2.29.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.16.0", @@ -8818,9 +8820,9 @@ } }, "node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -12285,6 +12287,19 @@ "node": ">=4.0" } }, + "node_modules/jsdom/node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/jsdom/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -12824,6 +12839,14 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -25177,9 +25200,9 @@ } }, "form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -27639,6 +27662,16 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" }, + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -28051,6 +28084,11 @@ "minimist": "^1.2.6" } }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/prediction-polls/frontend/package.json b/prediction-polls/frontend/package.json index 932370aa..08bad541 100644 --- a/prediction-polls/frontend/package.json +++ b/prediction-polls/frontend/package.json @@ -7,8 +7,10 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "antd": "^5.10.0", - "jest-matchmedia-mock": "^1.1.0", + "form-data": "^4.0.0", "history": "^5.3.0", + "jest-matchmedia-mock": "^1.1.0", + "moment": "^2.29.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.16.0", diff --git a/prediction-polls/frontend/src/Components/Badge/Badge.module.css b/prediction-polls/frontend/src/Components/Badge/Badge.module.css new file mode 100644 index 00000000..0b3e7ccf --- /dev/null +++ b/prediction-polls/frontend/src/Components/Badge/Badge.module.css @@ -0,0 +1,48 @@ +.badge { + width: 85px; + height: 85px; + border-radius: 100%; + justify-content: center; + align-items: center; + display: flex; + flex-direction: column; + gap: 5px; + box-sizing: border-box; + overflow-wrap: anywhere; + padding: 4px; + text-align: center; + vertical-align: middle; + position: relative; + } + .badge::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 85px; + height: 85px; + background-color: var(--neutral-white); + opacity: 0.2; + border-radius: 100%; + pointer-events: none; + } + .badgeText { + font-weight: 700; + color: var(--neutral-black); + line-height: 14px; + font-size: 14px; + margin: 0px 0px; + z-index: 1; + } + + .silver { + background-color: silver; + } + + .bronze { + background-color: #cd7f32; /* Bronze color */ + } + + .gold { + background-color: gold; + } \ No newline at end of file diff --git a/prediction-polls/frontend/src/Components/Badge/index.jsx b/prediction-polls/frontend/src/Components/Badge/index.jsx new file mode 100644 index 00000000..f2df8bf4 --- /dev/null +++ b/prediction-polls/frontend/src/Components/Badge/index.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import styles from "./Badge.module.css"; + +function Badge({ number, text }) { + let badgeClass = styles.badge; + + if (number === 1) { + badgeClass = `${styles.badge} ${styles.gold}`; + } + else if (number === 2) { + badgeClass = `${styles.badge} ${styles.silver}`; + } else if (number === 3) { + badgeClass = `${styles.badge} ${styles.bronze}`; + } + + return ( +
+

{number}

+

{text}

+
+ ); +} + +export default Badge; diff --git a/prediction-polls/frontend/src/Components/PointsButton/index.jsx b/prediction-polls/frontend/src/Components/PointsButton/index.jsx index b6737371..c0447da0 100644 --- a/prediction-polls/frontend/src/Components/PointsButton/index.jsx +++ b/prediction-polls/frontend/src/Components/PointsButton/index.jsx @@ -3,21 +3,21 @@ import styles from "./PointsButton.module.css"; import { ReactComponent as ArrowDown } from "../../Assets/icons/ArrowDown.svg"; import { ReactComponent as ArrowUp } from "../../Assets/icons/ArrowUp.svg"; -function PointsButton({ points }) { +function PointsButton({ point }) { const [isOpen, setIsOpen] = useState(false); - const GPValue = points.find((point) => "GP" in point).GP; + //const GPValue = points.find((point) => "GP" in point).GP; return (
{isOpen && ( -
+ {/*
{points.slice(1).map((point, index) => { const [category, value] = Object.entries(point)[0]; return ( @@ -26,7 +26,7 @@ function PointsButton({ points }) {
); })} -
+
*/} )} ); diff --git a/prediction-polls/frontend/src/Components/PollCard/PollCard.module.css b/prediction-polls/frontend/src/Components/PollCard/PollCard.module.css index 9362480a..5cb93904 100644 --- a/prediction-polls/frontend/src/Components/PollCard/PollCard.module.css +++ b/prediction-polls/frontend/src/Components/PollCard/PollCard.module.css @@ -30,6 +30,7 @@ align-items: center; gap: 5px; cursor: pointer; + justify-content: flex-end; } .creatorImage { width: 35px; @@ -37,6 +38,18 @@ border-radius: 100px; cursor: pointer; } + +.creatorImagePlaceholder { + width: 35px; + height: 35px; + border-radius: 100px; + cursor: pointer; + background-color: var(--neutral-100); + justify-content: center; + align-items: center; + display: flex; +} + .creatorName { font-size: 16px; font-weight: 700; @@ -49,7 +62,7 @@ justify-content: space-between; width: 100%; gap: 3%; - align-items: start; + align-items: flex-start; } .question { font-size: 20px; @@ -117,7 +130,7 @@ flex-direction: column; gap: 3px; align-items: center; - justify-content: start; + justify-content: flex-start; } .commentCount { @@ -158,6 +171,8 @@ font-size: 20px; background-color: var(--neutral-pollCard); color: var(--neutral-black); + cursor: pointer!important; + } diff --git a/prediction-polls/frontend/src/Components/PollCard/index.jsx b/prediction-polls/frontend/src/Components/PollCard/index.jsx index 43e9e40a..d6efd15c 100644 --- a/prediction-polls/frontend/src/Components/PollCard/index.jsx +++ b/prediction-polls/frontend/src/Components/PollCard/index.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import styles from "./PollCard.module.css"; import PollTag from "../PollTag"; import { useNavigate } from "react-router-dom"; @@ -6,22 +6,54 @@ import { ReactComponent as CommentIcon } from "../../Assets/icons/Comment.svg"; import { ReactComponent as ShareIcon } from "../../Assets/icons/Share.svg"; import { ReactComponent as ReportIcon } from "../../Assets/icons/Warning.svg"; import PollOption from "../PollOption"; +import { Input, DatePicker } from "antd"; +import { useLocation } from "react-router-dom"; +import ProfileIcon from "../../Assets/icons/ProfileIcon.jsx"; +import getProfile from "../../api/requests/profile.jsx"; -function PollCard({ PollData }) { +function PollCard({ PollData, setAnswer, onClick }) { const [selectedArray, setSelectedArray] = React.useState( !PollData.isCustomPoll ? Array(PollData["options"].length).fill(false) : [] ); const [pollData, setPollData] = React.useState( JSON.parse(JSON.stringify(PollData)) ); + const [userData, setUserData] = React.useState({}); + const navigate = useNavigate(); + const location = useLocation(); + const [isVotePath, setIsVotePath] = React.useState( + /^\/vote\//.test(location.pathname) + ); + + + useEffect(() => { + const data = getProfile(PollData.creatorUsername); + data.then((result) => { + setUserData(result); + }); + }, []); + + + React.useEffect(() => { + setIsVotePath(/^\/vote\//.test(location.pathname)); + }, [location.pathname]); + + const clickHandle = () => { + navigate("/vote/" + PollData.id); + }; + var totalPoints = !PollData.isCustomPoll - ? PollData.options.reduce((acc, curr) => acc + curr.votes, 0) + ? PollData.options.reduce( + (acc, curr) => (curr.votes == null ? acc : acc + curr.votes), + 0 + ) : 0; const handleSelect = (newList) => { setSelectedArray(newList); var newPoll = JSON.parse(JSON.stringify(PollData)); for (let i = 0; i < PollData["options"].length; i++) { if (newList[i] == true) { + setAnswer(PollData["options"][i].id); newPoll["options"][i]["votes"] = newPoll["options"][i]["votes"] + 1; break; } @@ -31,10 +63,9 @@ function PollCard({ PollData }) { ? PollData.options.reduce((acc, curr) => acc + curr.votes, 0) : 0; }; - const navigate = useNavigate(); return ( -
+
{pollData.tags.map((tag, index) => ( @@ -49,7 +80,10 @@ function PollCard({ PollData }) { {!pollData.isCustomPoll ? (
{pollData.options.map((option, index) => { - const widthPercentage = (option.votes / totalPoints) * 100; + let widthPercentage = 0; + if (totalPoints > 0) { + widthPercentage = (option.votes / totalPoints) * 100; + } return ( ); })}
+ ) : pollData.cont_poll_type == "date" ? ( +
+

Enter a date

+ setAnswer(dateString)} + onClick={() => !isVotePath && clickHandle()} + > +
) : (
-

- Enter a {PollData.optionType} -

- Enter a number

+ navigate("/vote")} - > + onChange={(e) => setAnswer(e.target.value)} + onClick={() => !isVotePath && clickHandle()} + >
)}
@@ -97,7 +140,6 @@ function PollCard({ PollData }) {
@@ -105,11 +147,15 @@ function PollCard({ PollData }) {
diff --git a/prediction-polls/frontend/src/Components/PollOption/PollOption.module.css b/prediction-polls/frontend/src/Components/PollOption/PollOption.module.css index b34e42a9..2190cb1c 100644 --- a/prediction-polls/frontend/src/Components/PollOption/PollOption.module.css +++ b/prediction-polls/frontend/src/Components/PollOption/PollOption.module.css @@ -40,6 +40,13 @@ font-weight: normal; z-index: 2; } +.optionPoints_hidden { + font-size: 14px; + font-weight: normal; + z-index: 2; + visibility: hidden; +} + .backgroundDiv { position: absolute; top: 0; diff --git a/prediction-polls/frontend/src/Components/PollOption/index.jsx b/prediction-polls/frontend/src/Components/PollOption/index.jsx index fd84004a..4528fd11 100644 --- a/prediction-polls/frontend/src/Components/PollOption/index.jsx +++ b/prediction-polls/frontend/src/Components/PollOption/index.jsx @@ -48,10 +48,13 @@ function PollOption({ } style={{ width: `${widthPercentage}%` }} >
-
{option.title}
-
-

{option.votes}

-
+
{option.choice_text}
+ {option.voter_count == null ?
+

{"1"}

+
:
+

{option.voter_count}

+
} +
); } diff --git a/prediction-polls/frontend/src/Components/SearchBar/SearchBar.module.css b/prediction-polls/frontend/src/Components/SearchBar/SearchBar.module.css new file mode 100644 index 00000000..9ab21e92 --- /dev/null +++ b/prediction-polls/frontend/src/Components/SearchBar/SearchBar.module.css @@ -0,0 +1,12 @@ +.searchbar { + margin-bottom: 20px; + margin-top: -50px; + max-width: 400px; + padding: 10px; +} + +@media (max-width: 990px) { + .searchbar { + max-width: 250px; + } +} diff --git a/prediction-polls/frontend/src/Components/SearchBar/index.jsx b/prediction-polls/frontend/src/Components/SearchBar/index.jsx new file mode 100644 index 00000000..cb9a9626 --- /dev/null +++ b/prediction-polls/frontend/src/Components/SearchBar/index.jsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; +import { Input } from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; +import styles from "./SearchBar.module.css"; + +function SearchBar({ onSearch }) { + const [searchText, setSearchText] = useState(''); + + const handleSearch = value => { + setSearchText(value); + onSearch(value); + }; + + return ( +
+ handleSearch(e.target.value)} + prefix={} + allowClear + /> +
+ ); +} + +export default SearchBar; diff --git a/prediction-polls/frontend/src/Components/Sidebar/index.jsx b/prediction-polls/frontend/src/Components/Sidebar/index.jsx index 9579b2b8..97dc89f3 100644 --- a/prediction-polls/frontend/src/Components/Sidebar/index.jsx +++ b/prediction-polls/frontend/src/Components/Sidebar/index.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import ProfileIcon from "../../Assets/icons/ProfileIcon.jsx"; import FeedIcon from "../../Assets/icons/FeedIcon.jsx"; import VoteIcon from "../../Assets/icons/VoteIcon.jsx"; @@ -10,19 +10,8 @@ import SettingsIcon from "../../Assets/icons/SettingsIcon.jsx"; import { ReactComponent as Logo } from "../../Assets/Logo.svg"; import styles from "./Sidebar.module.css"; import { useNavigate } from "react-router-dom"; -import logout from "../../api/requests/logout.jsx"; - - -const menuData = [ - { key: "Profile", Icon: ProfileIcon, to:"profile/can.gezer" }, - { key: "Feed", Icon: FeedIcon, to:"feed" }, - { key: "Vote", Icon: VoteIcon, to:"vote" }, - { key: "Create", Icon: CreateIcon , to:"create"}, - { key: "Moderation", Icon: ModerationIcon , to:"moderation"}, - { key: "Leaderboard", Icon: LeaderboardIcon, to:"leaderboard" }, - { key: "Notifications", Icon: NotificationsIcon, to:"notifications" }, - { key: "Settings", Icon: SettingsIcon, to:"settings" }, -]; +import logout from "../../api/requests/logout.jsx"; +import { Link } from "react-router-dom"; const SidebarMenuItem = ({ currentPage, @@ -32,30 +21,45 @@ const SidebarMenuItem = ({ navigate, to, }) => { - - const isSelected = currentPage === pageKey; return ( -
navigate(to)} + to={to} > - {Icon && ( - - )} + {Icon && }

{label || pageKey}

-
+ ); }; const Sidebar = ({ currentPage, handlePageChange }) => { const navigate = useNavigate(); + const [username, setUsername] = React.useState( + localStorage.getItem("username") + ); + + useEffect(() => { + const username = localStorage.getItem("username"); + setUsername(username); + }, []); + + const menuData = [ + { key: "Profile", Icon: ProfileIcon, to: `profile/${username}` }, + { key: "Feed", Icon: FeedIcon, to: "feed" }, + { key: "Vote", Icon: VoteIcon, to: "vote" }, + { key: "Create", Icon: CreateIcon, to: "create" }, + { key: "Moderation", Icon: ModerationIcon, to: "moderation" }, + { key: "Leaderboard", Icon: LeaderboardIcon, to: "leaderboard" }, + { key: "Notifications", Icon: NotificationsIcon, to: "notifications" }, + { key: "Settings", Icon: SettingsIcon, to: "settings" }, + ]; const handleLogout = async () => { - const refreshToken = localStorage.getItem('refreshToken'); + const refreshToken = localStorage.getItem("refreshToken"); const isLoggedOut = await logout(refreshToken); if (isLoggedOut) { navigate("/auth/sign-in"); @@ -75,7 +79,9 @@ const Sidebar = ({ currentPage, handlePageChange }) => { to={`/${item.to}`} /> ))} - + ); }; diff --git a/prediction-polls/frontend/src/Pages/Auth/SignIn/index.jsx b/prediction-polls/frontend/src/Pages/Auth/SignIn/index.jsx index 6037b451..9bdd6226 100644 --- a/prediction-polls/frontend/src/Pages/Auth/SignIn/index.jsx +++ b/prediction-polls/frontend/src/Pages/Auth/SignIn/index.jsx @@ -36,6 +36,7 @@ function SignIn() { if (response.status === 201 && data.accessToken && data.refreshToken) { localStorage.setItem('accessToken', data.accessToken); localStorage.setItem('refreshToken', data.refreshToken); + localStorage.setItem('username', username); navigate("/feed"); } @@ -54,8 +55,8 @@ function SignIn() {
-
diff --git a/prediction-polls/frontend/src/Pages/Auth/SignUp/index.jsx b/prediction-polls/frontend/src/Pages/Auth/SignUp/index.jsx index b30765cd..ee775ccb 100644 --- a/prediction-polls/frontend/src/Pages/Auth/SignUp/index.jsx +++ b/prediction-polls/frontend/src/Pages/Auth/SignUp/index.jsx @@ -33,7 +33,7 @@ function SignUp() { formattedValues.birthday = undefined; } - const res = await fetch(process.env.REACT_APP_BACKEND_LINK + "/signup", { + const res = await fetch(process.env.REACT_APP_BACKEND_LINK + "/auth/signup", { method: "POST", headers: { "Content-Type": "application/json", @@ -152,7 +152,7 @@ function SignUp() { message: "Password must be at least 8 characters!", }, { - pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{8,}$/, + pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d]{8,}$/, message: "Password must include uppercase, lowercase, and a number!", }, diff --git a/prediction-polls/frontend/src/Pages/Create/Create.module.css b/prediction-polls/frontend/src/Pages/Create/Create.module.css index 6634eefb..5f0622d3 100644 --- a/prediction-polls/frontend/src/Pages/Create/Create.module.css +++ b/prediction-polls/frontend/src/Pages/Create/Create.module.css @@ -1,7 +1,7 @@ .page { display: flex; background-color: var(--neutral-white); - width: 100%; + width: 75%; } @media (max-width: 640px) { @@ -15,22 +15,49 @@ padding: 20px; border: 1px solid #ccc; border-radius: 8px; - background-color: #fff; + background-color: var(--neutral-white); width: 60%; } .questionContainer { + margin-top: 64px; margin-bottom: 16px; } .pollTypeContainer { margin-bottom: 16px; } + + .optButton { + padding-left: 25px; + padding-right: 25px; + border:none; + color: var(--neutral-white); + } + + .optButton:hover { + border: none; + color:#fff !important ; + } .submitContainer { position: absolute; bottom: 40px; width: 40%; + + } + + .submitButton { + background-color: var(--secondary-500); + color: #fff; + padding-left: 25px; + padding-right: 25px; + border:none; + } + + .submitButton:hover { + background-color: var(--secondary-300); + color:#fff !important ; } .multipleChoiceInputs { @@ -39,6 +66,7 @@ .choiceInput { margin-bottom: 8px; + margin-right: 4px; } .datePickerContainer { @@ -70,4 +98,3 @@ position: absolute; bottom: 100px; } - diff --git a/prediction-polls/frontend/src/Pages/Create/Create.test.js b/prediction-polls/frontend/src/Pages/Create/Create.test.js new file mode 100644 index 00000000..5fcb8dd8 --- /dev/null +++ b/prediction-polls/frontend/src/Pages/Create/Create.test.js @@ -0,0 +1,122 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import Create from './index.jsx'; +import { MemoryRouter } from 'react-router-dom'; + +describe('Create', () => { + test('renders Create component', () => { + render( + + + + ); + }); + + test('allows the user to fill the question input', () => { + const { getByLabelText } = render( + + + + ); + const input = getByLabelText('Enter the question title'); + fireEvent.change(input, { target: { value: 'Test Question' } }); + expect(input.value).toBe('Test Question'); + }); + + test('allows the user to select multiple choice as poll type', () => { + const { getByText } = render( + + + + ); + const button = getByText('Multiple Choice'); + fireEvent.click(button); + expect(button).toHaveStyle({ backgroundColor: 'var(--secondary-500)' }); + }); + + test('allows the user to select customized as poll type', () => { + const { getByText } = render( + + + + ); + const button = getByText('Customized'); + fireEvent.click(button); + expect(button).toHaveStyle({ backgroundColor: 'var(--secondary-500)' }); + }); + + test('allows the user to select date when customized is selected', () => { + const { getByText } = render( + + + + ); + const button = getByText('Customized'); + fireEvent.click(button); + const addButton = getByText('Date'); + fireEvent.click(addButton); + expect(addButton).toHaveStyle({ backgroundColor: 'var(--secondary-500)' }); + }); + + test('allows the user to select numeric when customized is selected', () => { + const { getByText } = render( + + + + ); + const button = getByText('Customized'); + fireEvent.click(button); + const addButton = getByText('Numeric'); + fireEvent.click(addButton); + expect(addButton).toHaveStyle({ backgroundColor: 'var(--secondary-500)' }); + }); + + test('allows the user to set a due date', () => { + const { getByLabelText } = render( + + + + ); + const checkbox = getByLabelText('Set Due Date'); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + + test('allows the user to add a choice when multiple choice is selected', () => { + const { getByText } = render( + + + + ); + const button = getByText('Multiple Choice'); + fireEvent.click(button); + const addButton = getByText('+ Add'); + fireEvent.click(addButton); + const choiceInput = document.querySelector('.ant-input'); + expect(choiceInput).toBeInTheDocument(); + }); + + test('allows the user to open distribution visibility', () => { + const { getByLabelText, getByText } = render( + + + + ); + const button = getByText('Multiple Choice'); + fireEvent.click(button); + const checkbox = getByLabelText('Open Distribution Visibility'); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + + test('allows the user to submit poll', () => { + const { getByText } = render( + + + + ); + const button = getByText('Create Poll'); + fireEvent.click(button); + expect(button).toHaveStyle({ backgroundColor: 'var(--secondary-500)' }); + }); +}); \ No newline at end of file diff --git a/prediction-polls/frontend/src/Pages/Create/index.jsx b/prediction-polls/frontend/src/Pages/Create/index.jsx index 87ab1337..75dc6b9e 100644 --- a/prediction-polls/frontend/src/Pages/Create/index.jsx +++ b/prediction-polls/frontend/src/Pages/Create/index.jsx @@ -1,24 +1,52 @@ -import React from 'react' +import React, { useEffect } from 'react' import Menu from '../../Components/Menu' import styles from './Create.module.css' import { useState } from 'react'; import { Button, Input, DatePicker, Checkbox, Select } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import pointData from "../../MockData/PointList.json" +import PointsButton from "../../Components/PointsButton"; +import getProfileMe from '../../api/requests/profileMe'; const { TextArea } = Input; const { Option } = Select; + function Create() { const [question, setQuestion] = useState(''); const [pollType, setPollType] = useState(''); const [showMultipleChoiceInputs, setShowMultipleChoiceInputs] = useState(false); const [additionalChoices, setAdditionalChoices] = useState(['']); - const [customizedType, setCustomizedType] = useState('text'); - const [customizedOptions, setCustomizedOptions] = useState(['']); - const [selectedDate, setSelectedDate] = useState(null); + const [customizedType, setCustomizedType] = useState(''); const [setDueDate, setSetDueDate] = useState(false); const [dueDatePoll, setDueDatePoll] = useState(null); const [numericFieldValue, setNumericFieldValue] = useState(''); const [selectedTimeUnit, setSelectedTimeUnit] = useState('min'); - const [openVisibility, setOpenVisibility] = useState(false); + const [openVisibility, setOpenVisibility] = useState(false); + const [userData, setUserData] = useState({}) + const url = process.env.REACT_APP_BACKEND_LINK; + const navigate = useNavigate() + + useEffect( () => { + const data = getProfileMe(); + data.then((result) => { + setUserData(result); + }); + },[]) + + const choices = additionalChoices.filter(choice => choice.trim() !== '') + const isSubmitDisabled = question.trim() === '' || + pollType === '' || + (pollType === 'multipleChoice' && choices.length < 2) || + (setDueDate && numericFieldValue.trim() === '') || + (setDueDate && dueDatePoll === null) || + (setDueDate && isFutureDate(dueDatePoll) === false) || + (setDueDate && numericFieldValue < 0) || + (pollType === 'customized' && customizedType === ''); + + function isFutureDate(date) { + const currentDate = new Date(); + return date.isAfter(currentDate); + } const handleOpenVisibilityChange = (e) => { setOpenVisibility(e.target.checked); @@ -30,6 +58,8 @@ function Create() { const handleSetDueDateChange = (e) => { setSetDueDate(e.target.checked); + setDueDatePoll(null); + setNumericFieldValue(''); }; const handleQuestionChange = (e) => { @@ -39,7 +69,6 @@ function Create() { const handlePollTypeChange = (type) => { setPollType(type); setShowMultipleChoiceInputs(type === 'multipleChoice'); - setSelectedDate(null); }; const handleAddChoice = () => { @@ -62,23 +91,207 @@ function Create() { setCustomizedType(type); }; - const handleDateChange = (date) => { - setSelectedDate(date); - }; + const handleSubmit = async () => { + + if (pollType === 'multipleChoice' && setDueDate) { + const choicesData = additionalChoices.filter(choice => choice.trim() !== ''); // Remove empty choices + const multipleChoiceData = { + question: question, + openVisibility: openVisibility, + choices: choicesData, + setDueDate: setDueDate, + dueDatePoll: dueDatePoll ? dueDatePoll.format() : null, // Convert dueDatePoll to a string format if it exists + numericFieldValue: numericFieldValue, + selectedTimeUnit: selectedTimeUnit, + }; + + try { + const response = await fetch(url + "/polls/discrete/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem("accessToken")}`, + + }, + body: JSON.stringify(multipleChoiceData), + }); + if (!response.ok) { + console.error('Error:', response.statusText); + return; + } + const responseData = await response.json(); + console.log('API Response:', responseData); + // Redirect or navigate to another page after successful API request + navigate('/feed'); + } catch (error) { + console.error('API Request Failed:', error.message); + } + + } else if (pollType === 'multipleChoice' && !setDueDate) { + const choicesData = additionalChoices.filter(choice => choice.trim() !== ''); // Remove empty choices + const multipleChoiceData = { + question: question, + openVisibility: openVisibility, + choices: choicesData, + setDueDate: setDueDate, + }; + + try { + const response = await fetch(url + "/polls/discrete/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem("accessToken")}`, + }, + body: JSON.stringify(multipleChoiceData), + }); + if (!response.ok) { + console.error('Error:', response.statusText); + return; + } + const responseData = await response.json(); + console.log('API Response:', responseData); + // Redirect or navigate to another page after successful API request + navigate('/feed'); + } catch (error) { + console.error('API Request Failed:', error.message); + } + + } else if (pollType === 'customized' && setDueDate && customizedType === 'date') { + + const customizedData = { + question: question, + setDueDate: setDueDate, + dueDatePoll: dueDatePoll ? dueDatePoll.format() : null, // Convert dueDatePoll to a string format if it exists + numericFieldValue: numericFieldValue, + selectedTimeUnit: selectedTimeUnit, + cont_poll_type: customizedType, + }; + + try { + const response = await fetch(url + "/polls/continuous/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem("accessToken")}`, + }, + body: JSON.stringify(customizedData), + }); + if (!response.ok) { + console.error('Error:', response.statusText); + return; + } + const responseData = await response.json(); + console.log('API Response:', responseData); + // Redirect or navigate to another page after successful API request + navigate('/feed'); + } catch (error) { + console.error('API Request Failed:', error.message); + } + + } else if (pollType === 'customized' && !setDueDate && customizedType === 'date') { + + const customizedData = { + question: question, + setDueDate: setDueDate, + cont_poll_type: customizedType, + }; + + try { + const response = await fetch(url + "/polls/continuous/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem("accessToken")}`, + }, + body: JSON.stringify(customizedData), + }); + if (!response.ok) { + console.error('Error:', response.statusText); + return; + } + const responseData = await response.json(); + console.log('API Response:', responseData); + // Redirect or navigate to another page after successful API request + navigate('/feed'); + } catch (error) { + console.error('API Request Failed:', error.message); + } + + } else if (pollType === 'customized' && setDueDate && customizedType === 'numeric') { + + const customizedData = { + question: question, + setDueDate: setDueDate, + dueDatePoll: dueDatePoll ? dueDatePoll.format() : null, // Convert dueDatePoll to a string format if it exists + numericFieldValue: numericFieldValue, + selectedTimeUnit: selectedTimeUnit, + cont_poll_type: customizedType, + }; + + try { + const response = await fetch(url + "/polls/continuous/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem("accessToken")}`, + }, + body: JSON.stringify(customizedData), + }); + if (!response.ok) { + console.error('Error:', response.statusText); + return; + } + const responseData = await response.json(); + console.log('API Response:', responseData); + // Redirect or navigate to another page after successful API request + navigate('/feed'); + } catch (error) { + console.error('API Request Failed:', error.message); + } + + } else if (pollType === 'customized' && !setDueDate && customizedType === 'numeric') { + + const customizedData = { + question: question, + setDueDate: setDueDate, + cont_poll_type: customizedType, + }; + + try { + const response = await fetch(url + "/polls/continuous/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem("accessToken")}`, + }, + body: JSON.stringify(customizedData), + }); + if (!response.ok) { + console.error('Error:', response.statusText); + return; + } + const responseData = await response.json(); + console.log('API Response:', responseData); + // Redirect or navigate to another page after successful API request + navigate('/feed'); + } catch (error) { + console.error('API Request Failed:', error.message); + } + } - const handleSubmit = () => { - // Additional logic will be added }; + + return (
- - Create - + +
-

Create Poll

- + +