diff --git a/practice-app/database/database.js b/practice-app/database/database.js new file mode 100644 index 00000000..e4bea253 --- /dev/null +++ b/practice-app/database/database.js @@ -0,0 +1,19 @@ +const mysql = require('mysql2'); + +const pool = mysql.createPool({ + host: 'localhost', + user: 'root', + password: '', + database: 'adventureworks', + connectionLimit: 10, // Adjust this as per your needs +}); + +pool.query('SELECT * FROM address', (err, results, fields) => { + if (err) { + console.error('Error executing the query: ' + err); + return; + } + + // Process the results + return console.log('Query results: ', results); + }); \ No newline at end of file diff --git a/prediction-polls/.idea/.gitignore b/prediction-polls/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/prediction-polls/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/prediction-polls/.idea/vcs.xml b/prediction-polls/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/prediction-polls/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/prediction-polls/android/.gitignore b/prediction-polls/android/.gitignore new file mode 100644 index 00000000..5dd435b3 --- /dev/null +++ b/prediction-polls/android/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/kotlinc.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +/.idea +local.properties diff --git a/prediction-polls/android/.idea/.gitignore b/prediction-polls/android/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/prediction-polls/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/prediction-polls/android/.idea/.name b/prediction-polls/android/.idea/.name new file mode 100644 index 00000000..af160fdd --- /dev/null +++ b/prediction-polls/android/.idea/.name @@ -0,0 +1 @@ +Prediction Polls \ No newline at end of file diff --git a/prediction-polls/android/.idea/compiler.xml b/prediction-polls/android/.idea/compiler.xml new file mode 100644 index 00000000..b589d56e --- /dev/null +++ b/prediction-polls/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/prediction-polls/android/.idea/misc.xml b/prediction-polls/android/.idea/misc.xml new file mode 100644 index 00000000..8978d23d --- /dev/null +++ b/prediction-polls/android/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/prediction-polls/android/.idea/vcs.xml b/prediction-polls/android/.idea/vcs.xml new file mode 100644 index 00000000..b2bdec2d --- /dev/null +++ b/prediction-polls/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/prediction-polls/android/app/.gitignore b/prediction-polls/android/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/prediction-polls/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/prediction-polls/android/app/build.gradle.kts b/prediction-polls/android/app/build.gradle.kts new file mode 100644 index 00000000..860bbd9c --- /dev/null +++ b/prediction-polls/android/app/build.gradle.kts @@ -0,0 +1,127 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") + id("com.google.dagger.hilt.android") +} + +android { + namespace = "com.bounswe.predictionpolls" + compileSdk = 34 + + defaultConfig { + applicationId = "com.bounswe.predictionpolls" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + debug { + applicationIdSuffix = ".debug" + isMinifyEnabled = false + buildConfigField("String", "BASE_URL", gradleLocalProperties(rootDir).getProperty("base_url")) + } + create("staging") { + applicationIdSuffix = ".staging" + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField("String", "BASE_URL", gradleLocalProperties(rootDir).getProperty("base_url")) + } + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField("String", "BASE_URL", gradleLocalProperties(rootDir).getProperty("base_url")) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + buildConfig = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.3" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.9.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx: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") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + // Navigation + val navVersion = "2.7.4" + implementation("androidx.navigation:navigation-compose:$navVersion") + implementation("androidx.hilt:hilt-navigation-compose:1.0.0") + + // Testing + testImplementation("org.mockito:mockito-core:5.3.1") + testImplementation("org.mockito:mockito-inline:5.2.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.00")) + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + // Dagger Hilt + implementation("com.google.dagger:hilt-android:2.48.1") + ksp("com.google.dagger:hilt-compiler:2.48.1") + + // For instrumentation tests Dagger hilt + androidTestImplementation("com.google.dagger:hilt-android-testing:2.48.1") + androidTestAnnotationProcessor("com.google.dagger:hilt-compiler:2.48.1") + + // For local unit tests Dagger hilt + testImplementation("com.google.dagger:hilt-android-testing:2.48.1") + testAnnotationProcessor("com.google.dagger:hilt-compiler:2.48.1") + + // OkHttp + implementation("com.squareup.okhttp3:okhttp:4.9.0") + + // Gson + implementation("com.google.code.gson:gson:2.8.9") + + // Chucker + debugImplementation("com.github.chuckerteam.chucker:library:3.5.2") + releaseImplementation("com.github.chuckerteam.chucker:library-no-op:3.5.2") + + // Retrofit + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") +} \ No newline at end of file diff --git a/prediction-polls/android/app/proguard-rules.pro b/prediction-polls/android/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/prediction-polls/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/CustomInputFieldTest.kt b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/CustomInputFieldTest.kt new file mode 100644 index 00000000..aee2805e --- /dev/null +++ b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/CustomInputFieldTest.kt @@ -0,0 +1,60 @@ +package com.bounswe.predictionpolls.ui.common + +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CustomInputFieldTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun customInputField_isDisplayed() { + composeTestRule.setContent { + CustomInputField( + text = "Test Input" + ) + } + + composeTestRule.onNodeWithText("Test Input").assertIsDisplayed() + } + + @Test + fun customInputField_textInputOutput() { + var inputText = "" + composeTestRule.setContent { + CustomInputField( + text = inputText, + onTextChanged = { inputText = it } + ) + } + + composeTestRule.onNode(hasSetTextAction()).performTextInput("New Text") + + assert(inputText == "New Text") + } + + @Test + fun customInputField_iconButtonClicked() { + var iconClicked = false + var description = "" + + composeTestRule.setContent { + description = stringResource(com.bounswe.predictionpolls.R.string.ok) + CustomInputField( + trailingIconId = com.bounswe.predictionpolls.R.drawable.ic_google, + trailingIconContentDescription = com.bounswe.predictionpolls.R.string.ok, + onTrailingIconClicked = { iconClicked = true } + ) + } + + composeTestRule.onNodeWithContentDescription(description).performClick() + assert(iconClicked) + } +} diff --git a/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/ErrorDialogTest.kt b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/ErrorDialogTest.kt new file mode 100644 index 00000000..0641e42d --- /dev/null +++ b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/ErrorDialogTest.kt @@ -0,0 +1,77 @@ +package com.bounswe.predictionpolls.ui.common + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ErrorDialogTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun errorDialog_Displayed_WhenError() { + val errorMessage = "An unexpected error occurred. Please try again later." + var errorTitle = "" + var confirmButtonText = "" + + composeTestRule.setContent { + errorTitle = stringResource(id = com.bounswe.predictionpolls.R.string.error_dialog_title) + confirmButtonText = stringResource(id = com.bounswe.predictionpolls.R.string.ok) + ErrorDialog(error = errorMessage) + } + + composeTestRule.onNodeWithText(errorTitle).assertIsDisplayed() + composeTestRule.onNodeWithText(confirmButtonText).assertIsDisplayed() + composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed() + } + + @Test + fun errorDialog_NotDisplayed_WhenNoError() { + var errorTitle = "" + var confirmButtonText = "" + + composeTestRule.setContent { + errorTitle = stringResource(id = com.bounswe.predictionpolls.R.string.error_dialog_title) + confirmButtonText = stringResource(id = com.bounswe.predictionpolls.R.string.ok) + ErrorDialog(error = null) + } + + composeTestRule.onNodeWithText(errorTitle).assertDoesNotExist() + composeTestRule.onNodeWithText(confirmButtonText).assertDoesNotExist() + } + + @Test + fun errorDialog_Dismissed_OnConfirmClick() { + var errorTitle = "" + var confirmButtonText = "" + var errorMessage: String? by mutableStateOf("An unexpected error occurred. Please try again later.") + val dismissDialog: () -> Unit = { errorMessage = null } + + composeTestRule.setContent { + errorTitle = stringResource(id = com.bounswe.predictionpolls.R.string.error_dialog_title) + confirmButtonText = stringResource(id = com.bounswe.predictionpolls.R.string.ok) + ErrorDialog( + error = errorMessage, + onDismiss = dismissDialog + ) + } + + composeTestRule.onNodeWithText(confirmButtonText).apply { + assertIsDisplayed() + performClick() + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(errorTitle).assertDoesNotExist() + composeTestRule.onNodeWithText(confirmButtonText).assertDoesNotExist() + assert(errorMessage == null) + } +} 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 new file mode 100644 index 00000000..e41197bc --- /dev/null +++ b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/common/NavigationDrawerTest.kt @@ -0,0 +1,69 @@ +package com.bounswe.predictionpolls.ui.common + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bounswe.predictionpolls.utils.NavItem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NavigationDrawerTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun navigationDrawer_defaultSelection() { + val titleIds = NavItem.values().map { it.titleId } + var titles = listOf() + + composeTestRule.setContent { + titles = titleIds.map { stringResource(id = it) } + NavigationDrawer( + selectedNavItem = NavItem.FEED + ) { + Button( + onClick = { + it() + }) { + Text("Toggle Drawer") + } + } + } + + titles.forEach { + composeTestRule.onNodeWithText(it).assertIsNotDisplayed() + } + composeTestRule.onNodeWithText("Toggle Drawer").apply { + performClick() + } + composeTestRule.waitForIdle() + titles.forEach { + composeTestRule.onNodeWithText(it).assertIsDisplayed() + } + } + + @Test + fun navigationDrawer_itemClick() { + var selectedNavItem = NavItem.FEED + var clickedTitle = "" + + composeTestRule.setContent { + clickedTitle = stringResource(id = NavItem.PROFILE.titleId) + NavigationDrawer( + selectedNavItem = selectedNavItem, + onButtonClick = { + selectedNavItem = it + } + ) + } + + composeTestRule.onNodeWithText(clickedTitle).performClick() + assert(selectedNavItem == NavItem.PROFILE) + } +} diff --git a/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/login/LoginScreenTest.kt b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/login/LoginScreenTest.kt new file mode 100644 index 00000000..4d0c2975 --- /dev/null +++ b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/login/LoginScreenTest.kt @@ -0,0 +1,102 @@ +package com.bounswe.predictionpolls.ui.login + +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bounswe.predictionpolls.ui.login.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +class LoginScreenUITest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun emailInput_updatesText() { + var emailText = "" + composeTestRule.setContent { + LoginScreenUI( + email = emailText, + onEmailChanged = { emailText = it } + ) + } + + composeTestRule + .onNode(hasTestTag("email_input")) + .performTextInput("example@example.com") + + assert(emailText == "example@example.com") + } + + @Test + fun passwordInput_updatesText() { + var passwordText = "" + composeTestRule.setContent { + LoginScreenUI( + password = passwordText, + onPasswordChanged = { passwordText = it } + ) + } + + composeTestRule + .onNode(hasTestTag("password_input")) + .performTextInput("password123") + + assert(passwordText == "password123") + } + + @Test + fun loginButton_triggersLogin() { + var loginClicked = false + composeTestRule.setContent { + LoginScreenUI( + isLoginEnabled = true, + onLoginClicked = { loginClicked = true } + ) + } + + composeTestRule + .onNode(hasTestTag("login_button")) + .performClick() + + assert(loginClicked) + } + + @Test + fun errorDialog_displaysError() { + composeTestRule.setContent { + LoginScreenUI( + error = "Sample Error Message", + errorDismissed = {} + ) + } + + composeTestRule + .onNodeWithText("Sample Error Message") + .assertIsDisplayed() + } + + @Test + fun errorDialog_dismissesError() { + var error: String? = "Sample Error Message" + var confirmTitle = "" + composeTestRule.setContent { + confirmTitle = stringResource(id = com.bounswe.predictionpolls.R.string.ok) + LoginScreenUI( + error = error, + errorDismissed = { error = null } + ) + } + + composeTestRule + .onNodeWithText(confirmTitle) + .performClick() + + assert(error == null) + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/main/MainScreenTest.kt b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/main/MainScreenTest.kt new file mode 100644 index 00000000..9768607a --- /dev/null +++ b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/main/MainScreenTest.kt @@ -0,0 +1,93 @@ +package com.bounswe.predictionpolls.ui.main + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainScreenUITest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun mainScreenUI_displaysCorrectButtons() { + composeTestRule.setContent { + MainScreenUI( + onLoginClick = {}, + onSignUpClick = {}, + onContinueWithoutLoginClick = {} + ) + } + + composeTestRule + .onNode(hasTestTag("login_button")) + .assertIsDisplayed() + + composeTestRule + .onNode(hasTestTag("signup_button")) + .assertIsDisplayed() + + composeTestRule + .onNode(hasTestTag("continue_without_login_button")) + .assertIsDisplayed() + } + + @Test + fun mainScreenUI_loginButton_performsClick() { + var loginClicked = false + composeTestRule.setContent { + MainScreenUI( + onLoginClick = { loginClicked = true }, + onSignUpClick = {}, + onContinueWithoutLoginClick = {} + ) + } + + composeTestRule + .onNode(hasTestTag("login_button")) + .performClick() + + assert(loginClicked) + } + + @Test + fun mainScreenUI_signUpButton_performsClick() { + var signUpClicked = false + composeTestRule.setContent { + MainScreenUI( + onLoginClick = {}, + onSignUpClick = { signUpClicked = true }, + onContinueWithoutLoginClick = {} + ) + } + + composeTestRule + .onNode(hasTestTag("signup_button")) + .performClick() + + assert(signUpClicked) + } + + @Test + fun mainScreenUI_continueWithoutLoginButton_performsClick() { + var continueWithoutLoginClicked = false + composeTestRule.setContent { + MainScreenUI( + onLoginClick = {}, + onSignUpClick = {}, + onContinueWithoutLoginClick = { continueWithoutLoginClicked = true } + ) + } + + composeTestRule + .onNode(hasTestTag("continue_without_login_button")) + .performClick() + + assert(continueWithoutLoginClicked) + } +} + diff --git a/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/signup/SignupScreenTest.kt b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/signup/SignupScreenTest.kt new file mode 100644 index 00000000..2fd98914 --- /dev/null +++ b/prediction-polls/android/app/src/androidTest/java/com/bounswe/predictionpolls/ui/signup/SignupScreenTest.kt @@ -0,0 +1,137 @@ +package com.bounswe.predictionpolls.ui.signup + +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bounswe.predictionpolls.ui.signup.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +class SignupScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun emailInput_updatesText() { + var emailText = "" + composeTestRule.setContent { + SignupScreenUI( + email = emailText, + onEmailChanged = { emailText = it } + ) + } + + composeTestRule + .onNode(hasTestTag("email_input")) + .performTextInput("example@example.com") + + assert(emailText == "example@example.com") + } + + @Test + fun usernameInput_updatesText() { + var usernameText = "" + composeTestRule.setContent { + SignupScreenUI( + username = usernameText, + onUsernameChanged = { usernameText = it } + ) + } + + composeTestRule + .onNode(hasTestTag("username_input")) + .performTextInput("username123") + + assert(usernameText == "username123") + } + + @Test + fun passwordInput_updatesText() { + var passwordText = "" + composeTestRule.setContent { + SignupScreenUI( + password = passwordText, + onPasswordChanged = { passwordText = it } + ) + } + + composeTestRule + .onNode(hasTestTag("password_input")) + .performTextInput("password123") + + assert(passwordText == "password123") + } + + @Test + fun signupButton_triggersSignUp() { + var signUpClicked = false + composeTestRule.setContent { + SignupScreenUI( + isSignUpEnabled = true, + onSignUpClicked = { signUpClicked = true } + ) + } + + composeTestRule + .onNode(hasTestTag("signup_button")) + .performClick() + + assert(signUpClicked) + } + + @Test + fun agreementCheckbox_togglesAgreement() { + var isAgreementChecked = false + composeTestRule.setContent { + SignupScreenUI( + isAgreementChecked = isAgreementChecked, + onAgreementChecked = { isAgreementChecked = it } + ) + } + + composeTestRule + .onNode(hasTestTag("agreement_checkbox")) + .performClick() + + assert(isAgreementChecked) + } + + @Test + fun errorDialog_displaysError() { + composeTestRule.setContent { + SignupScreenUI( + error = "Sample Error Message", + errorDismissed = {} + ) + } + + composeTestRule + .onNodeWithText("Sample Error Message") + .assertIsDisplayed() + } + + @Test + fun errorDialog_dismissesError() { + var error: String? = "Sample Error Message" + var confirmButtonTitle = "" + composeTestRule.setContent { + confirmButtonTitle = stringResource(id = com.bounswe.predictionpolls.R.string.ok) + SignupScreenUI( + error = error, + errorDismissed = { error = null } + ) + } + + composeTestRule + .onNodeWithText(confirmButtonTitle) + .performClick() + + assert(error == null) + } +} + diff --git a/prediction-polls/android/app/src/main/AndroidManifest.xml b/prediction-polls/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c05518ec --- /dev/null +++ b/prediction-polls/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 00000000..64fb8038 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/MainActivity.kt @@ -0,0 +1,32 @@ +package com.bounswe.predictionpolls + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import com.bounswe.predictionpolls.ui.feed.feedScreen +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.signup.signupScreen +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + 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) + } + } + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseApplication.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseApplication.kt new file mode 100644 index 00000000..da51b252 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseApplication.kt @@ -0,0 +1,7 @@ +package com.bounswe.predictionpolls.core + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class BaseApplication : Application() \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseRepository.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseRepository.kt new file mode 100644 index 00000000..ae93f220 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseRepository.kt @@ -0,0 +1,31 @@ +package com.bounswe.predictionpolls.core + +import java.io.IOException +import retrofit2.HttpException + +abstract class BaseRepository { + suspend fun execute(request: suspend () -> T): T { + return try { + request.invoke() + } catch (exception: Exception) { + throw handleException(exception) + } + } + + //TODO handle exceptions + private fun handleException(exception: Exception): Exception { + return when (exception) { + is HttpException -> { + exception.response()?.errorBody()?.string()?.let { + return IOException(it) + }.run { + IOException("Unexpected error occurred. Please try again.") + } + } + + else -> { + IOException("Unexpected error occurred. Please try again.") + } + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseViewModel.kt new file mode 100644 index 00000000..f11c46e4 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/core/BaseViewModel.kt @@ -0,0 +1,64 @@ +package com.bounswe.predictionpolls.core + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +abstract class BaseViewModel : ViewModel() { + companion object { + private const val MAX_RETRY_COUNT = 3 + } + + var trackedActiveJobCount by mutableStateOf(0) + var error by mutableStateOf(null) + + val isLoading: Boolean + get() = trackedActiveJobCount > 0 + + val hasError: Boolean + get() = error != null + + fun launchCatching( + scope: CoroutineScope = viewModelScope, + trackJobProgress: Boolean = false, + maxRetryCount: Int = MAX_RETRY_COUNT, + onSuccess: ((T) -> Unit)? = null, + errorFilter: ((Throwable) -> Boolean)? = null, + onError: ((Throwable?) -> Unit)? = null, + block: suspend () -> T + ) { + var retryCount = 0 + + scope.launch { + while (retryCount < maxRetryCount) { + kotlin.runCatching { + if (trackJobProgress && retryCount == 0) { + trackedActiveJobCount++ + } + block() + }.onFailure { + retryCount++ + if (retryCount == maxRetryCount) { + if (trackJobProgress) { + trackedActiveJobCount-- + } + if (errorFilter?.invoke(it) != false) { + error = it.message + onError?.invoke(it) + } + } + }.onSuccess { + if (trackJobProgress) { + trackedActiveJobCount-- + } + onSuccess?.invoke(it) + return@launch + } + } + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/TokenManager.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/TokenManager.kt new file mode 100644 index 00000000..eeb852e0 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/TokenManager.kt @@ -0,0 +1,49 @@ +package com.bounswe.predictionpolls.data.remote + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +@Singleton +class TokenManager @Inject constructor(@ApplicationContext context: Context) { + companion object { + const val PREFERENCE_FILE_KEY = "AuthPrefs" + const val ACCESS_TOKEN_KEY = "ACCESS_TOKEN" + const val REFRESH_TOKEN_KEY = "REFRESH_TOKEN" + } + + private val prefs = context.getSharedPreferences(PREFERENCE_FILE_KEY, Context.MODE_PRIVATE) + + private val _accessTokenFlow = MutableStateFlow(prefs.getString(ACCESS_TOKEN_KEY, null)) + val accessTokenFlow: Flow = _accessTokenFlow.asStateFlow() + + private val _refreshTokenFlow = MutableStateFlow(prefs.getString(REFRESH_TOKEN_KEY, null)) + val refreshTokenFlow: Flow = _refreshTokenFlow.asStateFlow() + + val isLoggedIn: Flow = _accessTokenFlow.map { it != null } + + var accessToken: String? + get() = _accessTokenFlow.value + set(value) { + _accessTokenFlow.value = value + prefs.edit().putString(ACCESS_TOKEN_KEY, value).apply() + } + + var refreshToken: String? + get() = _refreshTokenFlow.value + set(value) { + _refreshTokenFlow.value = value + prefs.edit().putString(REFRESH_TOKEN_KEY, value).apply() + } + + fun clear() { + accessToken = null + refreshToken = null + } +} + 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 new file mode 100644 index 00000000..88e32e21 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/interceptors/AuthInterceptor.kt @@ -0,0 +1,47 @@ +package com.bounswe.predictionpolls.data.remote.interceptors + +import com.bounswe.predictionpolls.data.remote.TokenManager +import com.bounswe.predictionpolls.data.remote.repositories.AuthRepository +import javax.inject.Inject +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + +class AuthInterceptor @Inject constructor( + private val tokenManager: TokenManager, + private val authRepository: AuthRepository +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + + tokenManager.accessToken?.let { token -> + request = addAuthHeader(request, token) + } + + var response = chain.proceed(request) + if (response.code == 401) { + refreshAccessToken()?.let { newAccessToken -> + tokenManager.accessToken = newAccessToken + request = addAuthHeader(request, newAccessToken) + response = chain.proceed(request) + } + } + + return response + } + + private fun addAuthHeader(request: Request, token: String): Request { + return request.newBuilder() + .header("Authorization", "Bearer $token") + .build() + } + + private fun refreshAccessToken(): String? { + val newAccessToken: String? + runBlocking { + newAccessToken = authRepository.refreshAccessToken() + } + return newAccessToken + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/interceptors/ResponseInterceptor.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/interceptors/ResponseInterceptor.kt new file mode 100644 index 00000000..c270d7aa --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/interceptors/ResponseInterceptor.kt @@ -0,0 +1,15 @@ +package com.bounswe.predictionpolls.data.remote.interceptors + +import okhttp3.Interceptor +import okhttp3.Response + +// If the response code is 204, we need to change it to 200 +// because retrofit throws an exception when the response code is 204 +class ResponseInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response: Response = chain.proceed(request) + return if (response.code == 204) response.newBuilder().code(200).build() + else response + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/AuthRepository.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/AuthRepository.kt new file mode 100644 index 00000000..ee25ddb0 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/repositories/AuthRepository.kt @@ -0,0 +1,63 @@ +package com.bounswe.predictionpolls.data.remote.repositories + +import com.bounswe.predictionpolls.core.BaseRepository +import com.bounswe.predictionpolls.data.remote.TokenManager +import com.bounswe.predictionpolls.data.remote.request.LoginRequest +import com.bounswe.predictionpolls.data.remote.request.LogoutRequest +import com.bounswe.predictionpolls.data.remote.request.RefreshAccessTokenRequest +import com.bounswe.predictionpolls.data.remote.request.SignupRequest +import com.bounswe.predictionpolls.data.remote.services.AuthService +import javax.inject.Inject + +class AuthRepository @Inject constructor( + private val authService: AuthService, + private val tokenManager: TokenManager +) : BaseRepository() { + suspend fun login( + username: String, + password: String + ) { + val loginRequest = LoginRequest(username, password) + execute { + authService.login(loginRequest).let { + tokenManager.accessToken = it.accessToken + tokenManager.refreshToken = it.refreshToken + } + } + } + + suspend fun signup( + email: String, + username: String, + password: String, + birthday: String + ) { + execute { + authService.signup(SignupRequest(email, username, password, birthday)) + login(username, password) + } + } + + suspend fun logout() { + execute { + tokenManager.refreshToken?.let { token -> + authService.logout(LogoutRequest(token)) + tokenManager.accessToken = null + tokenManager.refreshToken = null + } + } + } + + suspend fun refreshAccessToken(): String? { + val refreshToken = tokenManager.refreshToken ?: return null + val refreshAccessTokenRequest = RefreshAccessTokenRequest(refreshToken) + var newToken: String? = null + execute { + authService.refreshAccessToken(refreshAccessTokenRequest).accessToken.let { + newToken = it + tokenManager.accessToken = it + } + } + return newToken + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/LoginRequest.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/LoginRequest.kt new file mode 100644 index 00000000..b96906c6 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/LoginRequest.kt @@ -0,0 +1,6 @@ +package com.bounswe.predictionpolls.data.remote.request + +data class LoginRequest( + val username: String, + val password: String +) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/LogoutRequest.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/LogoutRequest.kt new file mode 100644 index 00000000..d0d82a2d --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/LogoutRequest.kt @@ -0,0 +1,5 @@ +package com.bounswe.predictionpolls.data.remote.request + +data class LogoutRequest( + val refreshToken: String +) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/RefreshAccessTokenRequest.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/RefreshAccessTokenRequest.kt new file mode 100644 index 00000000..242059b9 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/RefreshAccessTokenRequest.kt @@ -0,0 +1,5 @@ +package com.bounswe.predictionpolls.data.remote.request + +data class RefreshAccessTokenRequest( + val refreshToken: String +) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/SignupRequest.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/SignupRequest.kt new file mode 100644 index 00000000..888ee7af --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/request/SignupRequest.kt @@ -0,0 +1,8 @@ +package com.bounswe.predictionpolls.data.remote.request + +data class SignupRequest( + val email: String, + val username: String, + val password: String, + val birthday: String +) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/response/LoginResponse.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/response/LoginResponse.kt new file mode 100644 index 00000000..4c6ae6fa --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/response/LoginResponse.kt @@ -0,0 +1,6 @@ +package com.bounswe.predictionpolls.data.remote.response + +data class LoginResponse( + val accessToken: String, + val refreshToken: String +) diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/response/RefreshAccessTokenResponse.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/response/RefreshAccessTokenResponse.kt new file mode 100644 index 00000000..c6c681ac --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/response/RefreshAccessTokenResponse.kt @@ -0,0 +1,5 @@ +package com.bounswe.predictionpolls.data.remote.response + +data class RefreshAccessTokenResponse( + val accessToken: String +) 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 new file mode 100644 index 00000000..5518be07 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/data/remote/services/AuthService.kt @@ -0,0 +1,32 @@ +package com.bounswe.predictionpolls.data.remote.services + +import com.bounswe.predictionpolls.data.remote.request.LoginRequest +import com.bounswe.predictionpolls.data.remote.request.LogoutRequest +import com.bounswe.predictionpolls.data.remote.request.RefreshAccessTokenRequest +import com.bounswe.predictionpolls.data.remote.request.SignupRequest +import com.bounswe.predictionpolls.data.remote.response.LoginResponse +import com.bounswe.predictionpolls.data.remote.response.RefreshAccessTokenResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthService { + @POST("/signup") + suspend fun signup( + @Body signupRequest: SignupRequest + ) + + @POST("/login") + suspend fun login( + @Body loginRequest: LoginRequest + ): LoginResponse + + @POST("/logout") + suspend fun logout( + @Body logoutRequest: LogoutRequest + ) + + @POST("/access-token") + suspend fun refreshAccessToken( + @Body refreshAccessTokenRequest: RefreshAccessTokenRequest + ): RefreshAccessTokenResponse +} \ 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 new file mode 100644 index 00000000..e581d0d5 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/NetworkModule.kt @@ -0,0 +1,118 @@ +package com.bounswe.predictionpolls.di + +import android.content.Context +import com.bounswe.predictionpolls.BuildConfig +import com.bounswe.predictionpolls.data.remote.TokenManager +import com.bounswe.predictionpolls.data.remote.interceptors.AuthInterceptor +import com.bounswe.predictionpolls.data.remote.interceptors.ResponseInterceptor +import com.bounswe.predictionpolls.data.remote.repositories.AuthRepository +import com.chuckerteam.chucker.api.ChuckerInterceptor +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Provides + @Singleton + fun provideGson(): Gson { + return GsonBuilder() + .serializeNulls() + .create() + } + + @Provides + @Singleton + fun provideTokenManager( + @ApplicationContext context: Context + ): TokenManager { + return TokenManager(context) + } + + @Provides + @Singleton + fun provideChuckerInterceptor( + @ApplicationContext context: Context + ): ChuckerInterceptor { + return ChuckerInterceptor + .Builder(context) + .alwaysReadResponseBody(true) + .build() + } + + @UnauthenticatedOkHttpClient + @Provides + @Singleton + fun provideUnauthenticatedOkHttpClient( + chucker: ChuckerInterceptor + ): OkHttpClient { + return OkHttpClient + .Builder() + .addInterceptor(chucker) + .addInterceptor(ResponseInterceptor()) + .build() + } + + @UnauthenticatedRetrofit + @Provides + @Singleton + fun provideUnauthenticatedRetrofit( + @UnauthenticatedOkHttpClient okHttpClient: OkHttpClient, + gson: Gson + ): Retrofit { + return Retrofit + .Builder() + .baseUrl(BuildConfig.BASE_URL) + .addConverterFactory(GsonConverterFactory.create(gson)) + .client(okHttpClient) + .build() + } + + @Provides + @Singleton + fun provideAuthInterceptor( + tokenManager: TokenManager, + authRepository: AuthRepository + ): AuthInterceptor { + return AuthInterceptor(tokenManager, authRepository) + } + + @AuthenticatedOkHttpClient + @Provides + @Singleton + fun provideAuthenticatedOkHttpClient( + authInterceptor: AuthInterceptor, + chucker: ChuckerInterceptor + ): OkHttpClient { + return OkHttpClient + .Builder() + .addInterceptor(authInterceptor) + .addInterceptor(chucker) + .addInterceptor(ResponseInterceptor()) + .build() + } + + @AuthenticatedRetrofit + @Provides + @Singleton + fun provideAuthenticatedRetrofit( + @AuthenticatedOkHttpClient okHttpClient: OkHttpClient, + gson: Gson + ): Retrofit { + return Retrofit + .Builder() + .baseUrl(BuildConfig.BASE_URL) + .addConverterFactory(GsonConverterFactory.create(gson)) + .client(okHttpClient) + .build() + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/Qualifiers.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/Qualifiers.kt new file mode 100644 index 00000000..dad66d45 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/Qualifiers.kt @@ -0,0 +1,19 @@ +package com.bounswe.predictionpolls.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class UnauthenticatedOkHttpClient + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class UnauthenticatedRetrofit + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class AuthenticatedOkHttpClient + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class AuthenticatedRetrofit \ 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 new file mode 100644 index 00000000..4252d720 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/RepositoryModule.kt @@ -0,0 +1,23 @@ +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.services.AuthService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RepositoryModule { + @Provides + @Singleton + fun provideAuthRepository( + authService: AuthService, + tokenManager: TokenManager + ): AuthRepository { + return AuthRepository(authService, tokenManager) + } +} \ 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 new file mode 100644 index 00000000..462817bc --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/di/ServiceModule.kt @@ -0,0 +1,21 @@ +package com.bounswe.predictionpolls.di + +import com.bounswe.predictionpolls.data.remote.services.AuthService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import retrofit2.Retrofit + +@Module +@InstallIn(SingletonComponent::class) +object ServiceModule { + @Provides + @Singleton + fun provideAuthService( + @UnauthenticatedRetrofit retrofit: Retrofit + ): AuthService { + return retrofit.create(AuthService::class.java) + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/LongExtensions.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/LongExtensions.kt new file mode 100644 index 00000000..410938ef --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/LongExtensions.kt @@ -0,0 +1,13 @@ +package com.bounswe.predictionpolls.extensions + +import androidx.compose.ui.text.intl.Locale as ComposeLocale +import java.util.Locale as JavaLocale +import java.text.SimpleDateFormat +import java.util.Date + +fun Long.toTimeDateString(locale: ComposeLocale): String { + val dateTime = Date(this) + val javaLocale = JavaLocale(locale.language, locale.region) + val format = SimpleDateFormat("ddMMyyyy", javaLocale) + return format.format(dateTime) +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/ModifierExtensions.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/ModifierExtensions.kt new file mode 100644 index 00000000..3f392c62 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/ModifierExtensions.kt @@ -0,0 +1,20 @@ +package com.bounswe.predictionpolls.extensions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +fun Modifier.clickableWithoutIndicator( + onClick: () -> Unit +): Modifier = composed { + val interactionSource = remember { + MutableInteractionSource() + } + return@composed this.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) +} \ 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 new file mode 100644 index 00000000..e593fdcb --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/extensions/StringExtensions.kt @@ -0,0 +1,20 @@ +package com.bounswe.predictionpolls.extensions + +fun String.isValidEmail(): Boolean { + val emailRegex = Regex( + pattern = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", + option = RegexOption.IGNORE_CASE + ) + return emailRegex.matches(this) +} + +// TODO handle date validation better +fun String.isValidDate(): Boolean { + if (this.length != 8) return false + val day = this.substring(0, 2).toIntOrNull() ?: return false + val month = this.substring(2, 4).toIntOrNull() ?: return false + + if (day !in 1..31) return false + if (month !in 1..12) return false + return true +} \ 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 new file mode 100644 index 00000000..823ebea7 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/CustomInputField.kt @@ -0,0 +1,167 @@ +package com.bounswe.predictionpolls.ui.common + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration +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.theme.PredictionPollsTheme +import com.bounswe.predictionpolls.utils.DateTransformation + +@Composable +fun CustomInputField( + modifier: Modifier = Modifier, + borderColor: Color = MaterialTheme.colorScheme.onBackground, + backgroundColor: Color = MaterialTheme.colorScheme.background, + @StringRes labelId: Int? = null, + text: String = "", + onTextChanged: (String) -> Unit = {}, + isError: Boolean = false, + shape: Shape = MaterialTheme.shapes.medium, + @DrawableRes trailingIconId: Int? = null, + @StringRes trailingIconContentDescription: Int? = null, + onTrailingIconClicked: () -> Unit = {}, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text + ), + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + TextField( + modifier = modifier.border(1.dp, borderColor.copy(alpha = 0.2f), shape), + value = text, + onValueChange = onTextChanged, + label = { + CustomInputFieldText( + labelId = labelId, + color = borderColor, + ) + }, + colors = TextFieldDefaults.colors( + errorTextColor = MaterialTheme.colorScheme.error, + focusedTextColor = borderColor, + unfocusedTextColor = borderColor, + unfocusedContainerColor = backgroundColor, + focusedContainerColor = backgroundColor, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ), + isError = isError, + shape = shape, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + textDecoration = TextDecoration.None + ), + trailingIcon = { + CustomInputFieldTrailingIcon( + trailingIconId = trailingIconId, + trailingIconContentDescription = trailingIconContentDescription, + onTrailingIconClicked = onTrailingIconClicked, + ) + }, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, + ) +} + +@Composable +fun CustomInputFieldText( + @StringRes labelId: Int? = null, + color: Color = MaterialTheme.colorScheme.onBackground, +) { + if (labelId == null) return + Text( + text = stringResource(id = labelId), + color = color, + ) +} + +@Composable +fun CustomInputFieldTrailingIcon( + @DrawableRes trailingIconId: Int? = null, + @StringRes trailingIconContentDescription: Int? = null, + onTrailingIconClicked: () -> Unit = {}, +) { + if (trailingIconId == null || trailingIconContentDescription == null) return + IconButton( + onClick = { + onTrailingIconClicked() + } + ) { + Icon( + painter = painterResource(id = trailingIconId), + contentDescription = stringResource(id = trailingIconContentDescription), + ) + } +} + + +@Preview +@Composable +fun CustomInputFieldPreview() { + PredictionPollsTheme { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CustomInputField( + modifier = Modifier.fillMaxWidth(0.8f), + text = "example@outlook.com", + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ) + ) + CustomInputField( + text = "exampleUsername" + ) + CustomInputField( + text = "passssworddd", + trailingIconId = R.drawable.ic_visibile, + trailingIconContentDescription = R.string.cd_visible, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + visualTransformation = PasswordVisualTransformation() + ) + CustomInputField( + text = "105123", + trailingIconId = R.drawable.ic_calendar, + trailingIconContentDescription = R.string.cd_calendar, + visualTransformation = DateTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/ErrorDialog.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/ErrorDialog.kt new file mode 100644 index 00000000..d2c63763 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/ErrorDialog.kt @@ -0,0 +1,60 @@ +package com.bounswe.predictionpolls.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme + +@Composable +fun ErrorDialog( + error: String? = null, + onDismiss: () -> Unit = {} +) { + if (error == null) return + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(id = R.string.error_dialog_title), + fontWeight = FontWeight.Bold + ) + }, + text = { + Text(text = error) + }, + confirmButton = { + TextButton( + onClick = { + onDismiss() + } + ) { + Text(stringResource(id = R.string.ok)) + } + }, + ) +} + +@Preview +@Composable +fun ErrorDialogPreview() { + PredictionPollsTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) + ErrorDialog( + error = "An unexpected error occurred. Please try again later." + ) + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..4914ee5b --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/common/NavigationDrawer.kt @@ -0,0 +1,183 @@ +package com.bounswe.predictionpolls.ui.common + +import androidx.compose.foundation.Image +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.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.material3.Button +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 +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +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 +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme +import com.bounswe.predictionpolls.utils.NavItem +import kotlinx.coroutines.launch + +typealias ToggleDrawerState = () -> Unit + +@Composable +fun NavigationDrawer( + modifier: Modifier = Modifier, + selectedNavItem: NavItem, + onButtonClick: (NavItem) -> Unit = {}, + content: @Composable (ToggleDrawerState) -> Unit = {} +) { + val drawerState = remember { + DrawerState( + initialValue = DrawerValue.Closed + ) + } + val scope = rememberCoroutineScope() + + fun toggleDrawer() { + scope.launch { + if (drawerState.isOpen) { + drawerState.close() + } else { + drawerState.open() + } + } + } + + ModalNavigationDrawer( + modifier = modifier, + drawerState = drawerState, + gesturesEnabled = true, + scrimColor = Color.Transparent, + drawerContent = { + ModalDrawerSheet( + modifier = Modifier.width(IntrinsicSize.Max), + drawerContainerColor = MaterialTheme.colorScheme.background, + drawerContentColor = MaterialTheme.colorScheme.onBackground, + ) { + AppTitle() + Column( + modifier = Modifier + .fillMaxWidth(), + ) { + NavItem.values().forEach { navItem -> + Column { + Divider() + NavDrawerItem( + navItem = navItem, + isSelected = selectedNavItem == navItem, + onButtonClick = onButtonClick + ) + } + } + } + } + }, + content = { + content { + toggleDrawer() + } + } + ) +} + +@Composable +private fun AppTitle() { + Image( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + .height(30.dp), + alignment = Alignment.Center, + painter = painterResource(id = R.drawable.ic_app_title), + contentDescription = stringResource(R.string.cd_app_title) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NavDrawerItem( + navItem: NavItem, + isSelected: Boolean = false, + onButtonClick: (NavItem) -> Unit = {} +) { + Card( + shape = RectangleShape, + colors = CardDefaults.cardColors( + contentColor = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onBackground + }, + containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.background + } + ), + onClick = { + onButtonClick(navItem) + }, + elevation = CardDefaults.cardElevation( + defaultElevation = 1.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = navItem.iconId), + contentDescription = stringResource( + id = R.string.cd_nav_item, + navItem.route + ), + ) + Text( + text = stringResource(id = navItem.titleId) + ) + } + } +} + +@Composable +@Preview +fun NavigationDrawerPreview() { + PredictionPollsTheme( + darkTheme = false + ) { + NavigationDrawer( + selectedNavItem = NavItem.FEED + ) { + Button(onClick = { + it() + }) { + Text(text = "Click me") + } + } + } +} \ 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 new file mode 100644 index 00000000..3c8ba3ce --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreen.kt @@ -0,0 +1,47 @@ +package com.bounswe.predictionpolls.ui.feed + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.bounswe.predictionpolls.ui.main.navigateToMainScreen + +@Composable +fun FeedScreen( + navController: NavController, + viewModel: FeedViewModel = hiltViewModel() +) { + val accessToken by viewModel.tokenManager.accessTokenFlow.collectAsState(initial = null) + val refreshToken by viewModel.tokenManager.refreshTokenFlow.collectAsState(initial = null) + + Column( + modifier = Modifier.fillMaxSize() + ) { + Text(text = "Feed Screen") + Text(text = "Access token: $accessToken\nRefresh token: $refreshToken") + Button( + onClick = { + viewModel.logout( + onSuccess = { + navController.navigateToMainScreen() + } + ) + } + ) { + Text(text = "Logout") + } + Button( + onClick = { + viewModel.clear() + } + ) { + Text(text = "Clear Tokens") + } + } +} \ 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 new file mode 100644 index 00000000..ae7ef027 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedScreenNavigation.kt @@ -0,0 +1,22 @@ +package com.bounswe.predictionpolls.ui.feed + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.composable + +const val FEED_ROUTE = "feed" + +fun NavGraphBuilder.feedScreen(navController: NavController) { + composable(FEED_ROUTE) { + FeedScreen(navController) + } +} + +fun NavController.navigateToFeedScreen( + navOptions: NavOptions? = null, + block: Navigator.Extras? = null +) { + navigate(FEED_ROUTE, navOptions, block) +} \ 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 new file mode 100644 index 00000000..cc9f6881 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/feed/FeedViewModel.kt @@ -0,0 +1,29 @@ +package com.bounswe.predictionpolls.ui.feed + +import com.bounswe.predictionpolls.core.BaseViewModel +import com.bounswe.predictionpolls.data.remote.TokenManager +import com.bounswe.predictionpolls.data.remote.repositories.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class FeedViewModel @Inject constructor( + val tokenManager: TokenManager, + private val authRepository: AuthRepository +): BaseViewModel() { + //TODO Demo function correct the logic when the feed is implemented + fun logout( + onSuccess: () -> Unit = {}, + ) { + launchCatching( + onSuccess = { + onSuccess() + } + ) { + authRepository.logout() + } + } + fun clear(){ + tokenManager.clear() + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreen.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreen.kt new file mode 100644 index 00000000..dd3dd300 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreen.kt @@ -0,0 +1,336 @@ +package com.bounswe.predictionpolls.ui.login + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavOptions +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.extensions.clickableWithoutIndicator +import com.bounswe.predictionpolls.ui.common.CustomInputField +import com.bounswe.predictionpolls.ui.common.ErrorDialog +import com.bounswe.predictionpolls.ui.feed.navigateToFeedScreen +import com.bounswe.predictionpolls.ui.main.MAIN_ROUTE +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme + +@Composable +fun LoginScreen( + navController: NavController, + viewModel: LoginScreenViewModel = hiltViewModel() +) { + val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + LoginScreenUI( + onBackButtonClicked = { dispatcher?.onBackPressed() }, + email = viewModel.screenState.email, + onEmailChanged = { viewModel.onEvent(LoginScreenEvent.OnEmailChanged(it)) }, + password = viewModel.screenState.password, + onPasswordChanged = { viewModel.onEvent(LoginScreenEvent.OnPasswordChanged(it)) }, + onPasswordVisibilityClicked = { viewModel.onEvent(LoginScreenEvent.OnPasswordVisibilityToggleClicked) }, + isPasswordVisible = viewModel.screenState.isPasswordVisible, + onLoginClicked = { + viewModel.onEvent(LoginScreenEvent.OnLoginButtonClicked { + navController.navigateToFeedScreen( + navOptions = NavOptions + .Builder() + .setPopUpTo(MAIN_ROUTE, true) + .build() + ) + }) + }, + isLoginEnabled = viewModel.screenState.isLoginButtonEnabled, + onLoginWithGoogleClicked = { + viewModel.onEvent( + LoginScreenEvent.OnLoginWithGoogleButtonClicked {} + ) + }, + isLoading = viewModel.isLoading, + error = viewModel.error, + errorDismissed = { viewModel.onEvent(LoginScreenEvent.DismissErrorDialog) } + ) +} + +@Composable +fun LoginScreenUI( + onBackButtonClicked: () -> Unit = {}, + email: String = "", + onEmailChanged: (String) -> Unit = {}, + password: String = "", + onPasswordChanged: (String) -> Unit = {}, + onPasswordVisibilityClicked: () -> Unit = {}, + isPasswordVisible: Boolean = false, + onLoginClicked: () -> Unit = {}, + isLoginEnabled: Boolean = false, + onLoginWithGoogleClicked: () -> Unit = {}, + isLoading: Boolean = false, + error: String? = null, + errorDismissed: () -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp, bottom = 60.dp, start = 16.dp, end = 16.dp) + ) { + LoginScreenHeader( + onBackButtonClicked = onBackButtonClicked + ) + Spacer(modifier = Modifier.height(60.dp)) + LoginScreenForm( + email = email, + onEmailChanged = onEmailChanged, + password = password, + onPasswordChanged = onPasswordChanged, + onPasswordVisibilityClicked = onPasswordVisibilityClicked, + isPasswordVisible = isPasswordVisible + ) + Spacer(modifier = Modifier.weight(1f)) + LoginScreenActionButtons( + isLoginEnabled = isLoginEnabled, + onLoginClicked = onLoginClicked, + onGoogleLoginClicked = onLoginWithGoogleClicked + ) + } + LoadingIndicator( + isLoading = isLoading + ) + ErrorDialog( + error = error, + onDismiss = errorDismissed + ) +} + +@Composable +fun LoadingIndicator( + isLoading: Boolean +) { + if (isLoading.not()) return + + Box( + modifier = Modifier + .fillMaxSize() + .clickableWithoutIndicator {} + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } +} + +@Composable +fun LoginScreenHeader( + onBackButtonClicked: () -> Unit = {} +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { onBackButtonClicked() } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_back), + contentDescription = stringResource(id = R.string.cd_back), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = stringResource(id = R.string.login_page_title), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge, + fontSize = 20.sp, + lineHeight = 24.sp + ) + } +} + +@Composable +fun LoginScreenForm( + email: String = "", + onEmailChanged: (String) -> Unit = {}, + password: String = "", + onPasswordChanged: (String) -> Unit = {}, + onPasswordVisibilityClicked: () -> Unit = {}, + isPasswordVisible: Boolean = false, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CustomInputField( + modifier = Modifier + .fillMaxWidth() + .testTag("email_input"), + labelId = R.string.login_email_label, + text = email, + onTextChanged = onEmailChanged, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ) + ) + CustomInputField( + modifier = Modifier + .fillMaxWidth() + .testTag("password_input"), + labelId = R.string.login_password_label, + text = password, + onTextChanged = onPasswordChanged, + trailingIconId = R.drawable.ic_visibile, + trailingIconContentDescription = R.string.cd_visible, + onTrailingIconClicked = onPasswordVisibilityClicked, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation() + ) + } +} + +@Composable +fun LoginScreenActionButtons( + isLoginEnabled: Boolean = false, + onLoginClicked: () -> Unit = {}, + onGoogleLoginClicked: () -> Unit = {}, +) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + LoginScreenActionButton( + modifier = Modifier.testTag("login_button"), + isEnabled = isLoginEnabled, + titleId = R.string.login_button, + backgroundColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = onLoginClicked + ) + ActionButtonDivider() + LoginScreenActionButton( + leadingIconId = R.drawable.ic_google, + leadIconContentDescription = R.string.cd_login_with_google_button, + titleId = R.string.login_with_google_button, + backgroundColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = onGoogleLoginClicked + ) + } +} + +@Composable +private fun ActionButtonDivider() { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Divider( + modifier = Modifier + .weight(1f), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(id = R.string.or), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Divider( + modifier = Modifier + .weight(1f), + color = MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +private fun LoginScreenActionButton( + modifier: Modifier = Modifier, + @DrawableRes leadingIconId: Int? = null, + @StringRes leadIconContentDescription: Int? = null, + @StringRes titleId: Int, + backgroundColor: Color, + contentColor: Color, + isEnabled: Boolean = true, + onClick: () -> Unit = {}, +) { + val shape = MaterialTheme.shapes.medium + + Row( + modifier = modifier + .fillMaxWidth() + .background(backgroundColor, shape) + .clip(shape = shape) + .clickable( + enabled = isEnabled, + ) { + onClick() + } + .padding(vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + leadingIconId?.let { + Icon( + painter = painterResource(id = it), + contentDescription = stringResource(id = leadIconContentDescription!!), + tint = contentColor, + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + text = stringResource(id = titleId), + style = MaterialTheme.typography.labelMedium, + fontSize = 14.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Medium, + color = contentColor, + textAlign = TextAlign.Center, + ) + } +} + +@Preview +@Composable +fun LoginScreenPreview() { + PredictionPollsTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + LoginScreenUI() + } + } +} diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenEvent.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenEvent.kt new file mode 100644 index 00000000..7aa11bfd --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenEvent.kt @@ -0,0 +1,10 @@ +package com.bounswe.predictionpolls.ui.login + +sealed class LoginScreenEvent { + data class OnEmailChanged(val email: String) : LoginScreenEvent() + data object OnPasswordVisibilityToggleClicked : LoginScreenEvent() + data class OnPasswordChanged(val password: String) : LoginScreenEvent() + data class OnLoginButtonClicked(val onSuccess: () -> Unit) : LoginScreenEvent() + data class OnLoginWithGoogleButtonClicked(val onSuccess: () -> Unit) : LoginScreenEvent() + data object DismissErrorDialog : LoginScreenEvent() +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenNavigation.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenNavigation.kt new file mode 100644 index 00000000..d4842f12 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenNavigation.kt @@ -0,0 +1,22 @@ +package com.bounswe.predictionpolls.ui.login + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.composable + +const val LOGIN_ROUTE = "login" + +fun NavGraphBuilder.loginScreen(navController: NavController) { + composable(LOGIN_ROUTE) { + LoginScreen(navController) + } +} + +fun NavController.navigateToLoginScreen( + navOptions: NavOptions? = null, + block: Navigator.Extras? = null +) { + navigate(LOGIN_ROUTE, navOptions, block) +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenState.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenState.kt new file mode 100644 index 00000000..b9b8ca6d --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenState.kt @@ -0,0 +1,20 @@ +package com.bounswe.predictionpolls.ui.login + +data class LoginScreenState( + val email: String = "", + val password: String = "", + val isPasswordVisible: Boolean = false +) { + val isLoginButtonEnabled: Boolean + get() = email.isNotBlank() && + password.isNotBlank() + + fun reduce(event: LoginScreenEvent): LoginScreenState { + return when (event) { + is LoginScreenEvent.OnEmailChanged -> copy(email = event.email) + is LoginScreenEvent.OnPasswordChanged -> copy(password = event.password) + is LoginScreenEvent.OnPasswordVisibilityToggleClicked -> copy(isPasswordVisible = !isPasswordVisible) + else -> this + } + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenViewModel.kt new file mode 100644 index 00000000..d255d62f --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/login/LoginScreenViewModel.kt @@ -0,0 +1,66 @@ +package com.bounswe.predictionpolls.ui.login + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.bounswe.predictionpolls.core.BaseViewModel +import com.bounswe.predictionpolls.data.remote.repositories.AuthRepository +import com.bounswe.predictionpolls.extensions.isValidEmail +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class LoginScreenViewModel @Inject constructor( + private val authRepository: AuthRepository +) : BaseViewModel() { + var screenState by mutableStateOf(LoginScreenState()) + private set + + fun onEvent(event: LoginScreenEvent) { + screenState = screenState.reduce(event) + + when (event) { + is LoginScreenEvent.OnLoginButtonClicked -> onLoginButtonClicked(event.onSuccess) + is LoginScreenEvent.OnLoginWithGoogleButtonClicked -> onLoginWithGoogleButtonClicked() + is LoginScreenEvent.DismissErrorDialog -> onErrorDialogDismissed() + else -> {} + } + } + + private fun onErrorDialogDismissed(){ + error = null + } + + + // TODO handle form validation better + private fun isFormValid(): Boolean { + if (screenState.email.isValidEmail().not()) { + error = "Please enter a valid email address." + return false + } + + return true + } + + private fun onLoginButtonClicked(onSuccess: () -> Unit) { + if(isFormValid().not()) return + + launchCatching( + trackJobProgress = true, + onSuccess = { + onSuccess() + }, + maxRetryCount = 1 + ) { + authRepository.login( + username = screenState.email, + password = screenState.password, + ) + } + } + + private fun onLoginWithGoogleButtonClicked() { + //TODO google sign in implementation + error = "Login with Google is not implemented yet." + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreen.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreen.kt new file mode 100644 index 00000000..86f999f6 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreen.kt @@ -0,0 +1,235 @@ +package com.bounswe.predictionpolls.ui.main + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +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.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavOptions +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.ui.feed.navigateToFeedScreen +import com.bounswe.predictionpolls.ui.login.navigateToLoginScreen +import com.bounswe.predictionpolls.ui.signup.navigateToSignupScreen +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme + + +@Composable +fun MainScreen( + modifier: Modifier = Modifier, + navController: NavController, + viewModel: MainScreenViewModel = hiltViewModel() +) { + val isLoggedIn by viewModel.tokenManager.isLoggedIn.collectAsState(initial = false) + LaunchedEffect(key1 = isLoggedIn){ + if (isLoggedIn.not()) return@LaunchedEffect + + navController.navigateToFeedScreen( + navOptions = NavOptions.Builder().setPopUpTo(MAIN_ROUTE, true).build() + ) + } + + MainScreenUI( + modifier = modifier, + onLoginClick = { + navController.navigateToLoginScreen() + }, + onSignUpClick = { + navController.navigateToSignupScreen() + }, + onContinueWithoutLoginClick = { + navController.navigateToFeedScreen() + } + ) +} + +@Composable +fun MainScreenUI( + modifier: Modifier = Modifier, + onLoginClick: () -> Unit = {}, + onSignUpClick: () -> Unit = {}, + onContinueWithoutLoginClick: () -> Unit = {}, +) { + val mainScreenBackground = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.onPrimary, + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.primaryContainer, + ) + ) + + Column( + modifier = modifier + .fillMaxSize() + .background(mainScreenBackground) + .padding(top = 96.dp, bottom = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AppTitle() + Spacer(modifier = Modifier.height(32.dp)) + AppName() + Spacer(modifier = Modifier.weight(1f)) + AppImage() + Spacer(modifier = Modifier.weight(1f)) + ActionButtons( + onLoginClick = onLoginClick, + onSignUpClick = onSignUpClick, + onContinueWithoutLoginClick = onContinueWithoutLoginClick + ) + } +} + +@Composable +private fun AppTitle() { + Text( + text = stringResource(R.string.welcome_page_title), + style = MaterialTheme.typography.headlineMedium, + fontSize = 20.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun AppName() { + Image( + modifier = Modifier + .fillMaxWidth(0.8f) + .height(48.dp), + alignment = Alignment.Center, + painter = painterResource(id = R.drawable.ic_app_title), + contentDescription = stringResource(R.string.cd_app_title) + ) +} + +@Composable +private fun AppImage() { + Image( + modifier = Modifier + .size(300.dp), + alignment = Alignment.Center, + painter = painterResource(id = R.drawable.ic_welcome_page), + contentDescription = stringResource(R.string.cd_welcome_page_image) + ) +} + +@Composable +private fun ActionButtons( + onLoginClick: () -> Unit = {}, + onSignUpClick: () -> Unit = {}, + onContinueWithoutLoginClick: () -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MainScreenActionButton( + modifier = Modifier.testTag("login_button"), + titleId = R.string.welcome_page_login, + backgroundColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = onLoginClick + ) + Spacer(modifier = Modifier.height(24.dp)) + MainScreenActionButton( + modifier = Modifier.testTag("signup_button"), + titleId = R.string.welcome_page_signup, + backgroundColor = MaterialTheme.colorScheme.onPrimary, + contentColor = MaterialTheme.colorScheme.primary, + onClick = onSignUpClick + ) + Spacer(modifier = Modifier.height(12.dp)) + ContinueWithoutLoginButton( + onClick = onContinueWithoutLoginClick + ) + } +} + +@Composable +private fun MainScreenActionButton( + modifier: Modifier = Modifier, + @StringRes titleId: Int, + backgroundColor: Color, + contentColor: Color, + onClick: () -> Unit = {} +) { + val shape = MaterialTheme.shapes.medium + + Text( + modifier = modifier + .fillMaxWidth() + .background(backgroundColor, shape) + .clip(shape = shape) + .clickable { + onClick() + } + .padding(vertical = 12.dp), + text = stringResource(id = titleId), + style = MaterialTheme.typography.labelMedium, + fontSize = 14.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Medium, + color = contentColor, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun ContinueWithoutLoginButton( + onClick: () -> Unit = {} +) { + Text( + modifier = Modifier + .clickable { + onClick() + } + .padding(vertical = 8.dp) + .testTag("continue_without_login_button"), + text = stringResource(id = R.string.welcome_page_continue_without_login), + style = MaterialTheme.typography.labelMedium, + fontSize = 12.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) +} + +@Preview +@Composable +fun MainScreenPreview() { + PredictionPollsTheme { + MainScreenUI() + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreenNavigation.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreenNavigation.kt new file mode 100644 index 00000000..b30c4563 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreenNavigation.kt @@ -0,0 +1,22 @@ +package com.bounswe.predictionpolls.ui.main + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.composable + +const val MAIN_ROUTE = "home" + +fun NavGraphBuilder.mainScreen(navController: NavController) { + composable(MAIN_ROUTE) { + MainScreen(navController = navController) + } +} + +fun NavController.navigateToMainScreen( + navOptions: NavOptions? = null, + block: Navigator.Extras? = null +) { + navigate(MAIN_ROUTE, navOptions, block) +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreenViewModel.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreenViewModel.kt new file mode 100644 index 00000000..1874203d --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/main/MainScreenViewModel.kt @@ -0,0 +1,11 @@ +package com.bounswe.predictionpolls.ui.main + +import androidx.lifecycle.ViewModel +import com.bounswe.predictionpolls.data.remote.TokenManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class MainScreenViewModel @Inject constructor( + val tokenManager: TokenManager +): ViewModel() \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreen.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreen.kt new file mode 100644 index 00000000..880f1948 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreen.kt @@ -0,0 +1,476 @@ +package com.bounswe.predictionpolls.ui.signup + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.NavOptions +import com.bounswe.predictionpolls.R +import com.bounswe.predictionpolls.extensions.clickableWithoutIndicator +import com.bounswe.predictionpolls.extensions.toTimeDateString +import com.bounswe.predictionpolls.ui.common.CustomInputField +import com.bounswe.predictionpolls.ui.common.ErrorDialog +import com.bounswe.predictionpolls.ui.feed.navigateToFeedScreen +import com.bounswe.predictionpolls.ui.main.MAIN_ROUTE +import com.bounswe.predictionpolls.ui.theme.PredictionPollsTheme +import com.bounswe.predictionpolls.utils.DateTransformation + +@Composable +fun SignupScreen( + navController: NavController, + viewModel: SignupScreenViewModel = hiltViewModel() +) { + val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + SignupScreenUI( + onBackButtonClicked = { dispatcher?.onBackPressed() }, + email = viewModel.screenState.email, + onEmailChanged = { viewModel.onEvent(SignupScreenEvent.OnEmailChanged(it)) }, + username = viewModel.screenState.username, + onUsernameChanged = { viewModel.onEvent(SignupScreenEvent.OnUsernameChanged(it)) }, + password = viewModel.screenState.password, + onPasswordChanged = { viewModel.onEvent(SignupScreenEvent.OnPasswordChanged(it)) }, + onPasswordVisibilityClicked = { viewModel.onEvent(SignupScreenEvent.OnPasswordVisibilityToggleClicked) }, + isPasswordVisible = viewModel.screenState.isPasswordVisible, + birthday = viewModel.screenState.birthday, + onBirthdayChanged = { viewModel.onEvent(SignupScreenEvent.OnBirthdayChanged(it)) }, + onDatePickerClicked = { viewModel.onEvent(SignupScreenEvent.OnDatePickerClicked) }, + isDatePickerVisible = viewModel.screenState.isDatePickerVisible, + isAgreementChecked = viewModel.screenState.isAgreementChecked, + onAgreementChecked = { viewModel.onEvent(SignupScreenEvent.OnAgreementChecked) }, + onSignUpClicked = { + viewModel.onEvent(SignupScreenEvent.OnSignupButtonClicked { + navController.navigateToFeedScreen( + navOptions = NavOptions + .Builder() + .setPopUpTo(MAIN_ROUTE, true) + .build() + ) + }) + }, + isSignUpEnabled = viewModel.screenState.isSignupButtonEnabled, + onSignUpWithGoogleClicked = { + viewModel.onEvent( + SignupScreenEvent.OnSignupWithGoogleButtonClicked {} + ) + }, + isLoading = viewModel.isLoading, + error = viewModel.error, + errorDismissed = { viewModel.onEvent(SignupScreenEvent.DismissErrorDialog) } + ) +} + +@Composable +fun SignupScreenUI( + onBackButtonClicked: () -> Unit = {}, + email: String = "", + onEmailChanged: (String) -> Unit = {}, + username: String = "", + onUsernameChanged: (String) -> Unit = {}, + password: String = "", + onPasswordChanged: (String) -> Unit = {}, + onPasswordVisibilityClicked: () -> Unit = {}, + isPasswordVisible: Boolean = false, + birthday: String = "", + onBirthdayChanged: (String) -> Unit = {}, + onDatePickerClicked: () -> Unit = {}, + isDatePickerVisible: Boolean = false, + isAgreementChecked: Boolean = false, + onAgreementChecked: (Boolean) -> Unit = {}, + onSignUpClicked: () -> Unit = {}, + isSignUpEnabled: Boolean = false, + onSignUpWithGoogleClicked: () -> Unit = {}, + isLoading: Boolean = false, + error: String? = null, + errorDismissed: () -> Unit = {}, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp, bottom = 60.dp, start = 16.dp, end = 16.dp) + ) { + SignupScreenHeader( + onBackButtonClicked = onBackButtonClicked + ) + Spacer(modifier = Modifier.height(60.dp)) + SignupScreenForm( + email = email, + onEmailChanged = onEmailChanged, + username = username, + onUsernameChanged = onUsernameChanged, + password = password, + onPasswordChanged = onPasswordChanged, + onPasswordVisibilityClicked = onPasswordVisibilityClicked, + isPasswordVisible = isPasswordVisible, + birthday = birthday, + onBirthdayChanged = onBirthdayChanged, + onDatePickerClicked = onDatePickerClicked, + ) + Spacer(modifier = Modifier.height(12.dp)) + AgreementBox( + onCheckedChanged = onAgreementChecked, + isChecked = isAgreementChecked + ) + Spacer(modifier = Modifier.weight(1f)) + SignupScreenActionButtons( + isSignUpEnabled = isSignUpEnabled, + onSignUpClicked = onSignUpClicked, + onGoogleSignUpClicked = onSignUpWithGoogleClicked + ) + } + CustomDatePicker( + onDismissRequest = onDatePickerClicked, + isDatePickerVisible = isDatePickerVisible, + onBirthdayChanged = onBirthdayChanged + ) + LoadingIndicator(isLoading = isLoading) + ErrorDialog( + error = error, + onDismiss = errorDismissed + ) +} + +@Composable +fun LoadingIndicator( + isLoading: Boolean +) { + if (isLoading.not()) return + + Box( + modifier = Modifier + .fillMaxSize() + .clickableWithoutIndicator {} + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } +} + +@Composable +fun SignupScreenHeader( + onBackButtonClicked: () -> Unit = {} +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { onBackButtonClicked() } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_back), + contentDescription = stringResource(id = R.string.cd_back), + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = stringResource(id = R.string.signup_page_title), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge, + fontSize = 20.sp, + lineHeight = 24.sp + ) + } +} + +@Composable +fun SignupScreenForm( + email: String = "", + onEmailChanged: (String) -> Unit = {}, + username: String = "", + onUsernameChanged: (String) -> Unit = {}, + password: String = "", + onPasswordChanged: (String) -> Unit = {}, + onPasswordVisibilityClicked: () -> Unit = {}, + isPasswordVisible: Boolean = false, + birthday: String = "", + onBirthdayChanged: (String) -> Unit = {}, + onDatePickerClicked: () -> Unit = {}, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CustomInputField( + modifier = Modifier + .fillMaxWidth() + .testTag("email_input"), + labelId = R.string.signup_email_label, + text = email, + onTextChanged = onEmailChanged, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ) + ) + CustomInputField( + modifier = Modifier + .fillMaxWidth() + .testTag("username_input"), + labelId = R.string.signup_username_label, + text = username, + onTextChanged = onUsernameChanged, + ) + CustomInputField( + modifier = Modifier + .fillMaxWidth() + .testTag("password_input"), + labelId = R.string.signup_password_label, + text = password, + onTextChanged = onPasswordChanged, + trailingIconId = R.drawable.ic_visibile, + trailingIconContentDescription = R.string.cd_visible, + onTrailingIconClicked = onPasswordVisibilityClicked, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password + ), + visualTransformation = if (isPasswordVisible) VisualTransformation.None else PasswordVisualTransformation() + ) + CustomInputField( + modifier = Modifier + .fillMaxWidth() + .testTag("birthday_input"), + labelId = R.string.signup_birthday_label, + text = birthday, + onTextChanged = onBirthdayChanged, + trailingIconId = R.drawable.ic_calendar, + trailingIconContentDescription = R.string.cd_calendar, + onTrailingIconClicked = onDatePickerClicked, + visualTransformation = DateTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomDatePicker( + isDatePickerVisible: Boolean = false, + onDismissRequest: () -> Unit = {}, + onBirthdayChanged: (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 { + onBirthdayChanged(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) + } +} + +@Composable +fun AgreementBox( + onCheckedChanged: (Boolean) -> Unit = {}, + isChecked: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Checkbox( + modifier = Modifier.testTag("agreement_checkbox"), + checked = isChecked, + onCheckedChange = onCheckedChanged + ) + Text( + text = stringResource(id = R.string.signup_agreement_text), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium, + fontSize = 14.sp, + lineHeight = 20.sp + ) + } +} + +@Composable +fun SignupScreenActionButtons( + isSignUpEnabled: Boolean = false, + onSignUpClicked: () -> Unit = {}, + onGoogleSignUpClicked: () -> Unit = {}, +) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + SignupScreenActionButton( + modifier = Modifier.testTag("signup_button"), + isEnabled = isSignUpEnabled, + titleId = R.string.signup_button, + backgroundColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = onSignUpClicked + ) + ActionButtonDivider() + SignupScreenActionButton( + leadingIconId = R.drawable.ic_google, + leadIconContentDescription = R.string.cd_signup_with_google_button, + titleId = R.string.signup_with_google_button, + backgroundColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + onClick = onGoogleSignUpClicked + ) + } +} + +@Composable +private fun ActionButtonDivider() { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Divider( + modifier = Modifier + .weight(1f), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(id = R.string.or), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Divider( + modifier = Modifier + .weight(1f), + color = MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +private fun SignupScreenActionButton( + modifier: Modifier = Modifier, + @DrawableRes leadingIconId: Int? = null, + @StringRes leadIconContentDescription: Int? = null, + @StringRes titleId: Int, + backgroundColor: Color, + contentColor: Color, + isEnabled: Boolean = true, + onClick: () -> Unit = {}, +) { + val shape = MaterialTheme.shapes.medium + + Row( + modifier = modifier + .fillMaxWidth() + .background(backgroundColor, shape) + .clip(shape = shape) + .clickable( + enabled = isEnabled, + ) { + onClick() + } + .padding(vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + leadingIconId?.let { + Icon( + painter = painterResource(id = it), + contentDescription = stringResource(id = leadIconContentDescription!!), + tint = contentColor, + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + text = stringResource(id = titleId), + style = MaterialTheme.typography.labelMedium, + fontSize = 14.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Medium, + color = contentColor, + textAlign = TextAlign.Center, + ) + } +} + +@Preview +@Composable +fun SignupScreenPreview() { + PredictionPollsTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + SignupScreenUI() + } + } +} diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenEvent.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenEvent.kt new file mode 100644 index 00000000..947b90ca --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenEvent.kt @@ -0,0 +1,16 @@ +package com.bounswe.predictionpolls.ui.signup + +sealed class SignupScreenEvent { + data class OnEmailChanged(val email: String) : SignupScreenEvent() + data class OnUsernameChanged(val username: String) : SignupScreenEvent() + data class OnPasswordChanged(val password: String) : SignupScreenEvent() + data object OnPasswordVisibilityToggleClicked : SignupScreenEvent() + data class OnBirthdayChanged(val birthday: String) : SignupScreenEvent() + data object OnDatePickerClicked : SignupScreenEvent() + data object OnAgreementChecked : SignupScreenEvent() + data class OnSignupButtonClicked(val onSuccess: () -> Unit) : SignupScreenEvent() + data class OnSignupWithGoogleButtonClicked(val onSuccess: () -> Unit) : + SignupScreenEvent() + + data object DismissErrorDialog : SignupScreenEvent() +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenNavigation.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenNavigation.kt new file mode 100644 index 00000000..a7a292d0 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenNavigation.kt @@ -0,0 +1,22 @@ +package com.bounswe.predictionpolls.ui.signup + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.composable + +const val SIGNUP_ROUTE = "signup" + +fun NavGraphBuilder.signupScreen(navController: NavController) { + composable(SIGNUP_ROUTE) { + SignupScreen(navController) + } +} + +fun NavController.navigateToSignupScreen( + navOptions: NavOptions? = null, + block: Navigator.Extras? = null +) { + navigate(SIGNUP_ROUTE, navOptions, block) +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenState.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenState.kt new file mode 100644 index 00000000..d3cba798 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenState.kt @@ -0,0 +1,32 @@ +package com.bounswe.predictionpolls.ui.signup + +data class SignupScreenState( + val email: String = "", + val username: String = "", + val password: String = "", + val isPasswordVisible: Boolean = false, + val birthday: String = "", + val isDatePickerVisible: Boolean = false, + val isAgreementChecked: Boolean = false, +) { + val isSignupButtonEnabled: Boolean + get() = email.isNotBlank() && + username.isNotBlank() && + password.isNotBlank() && + birthday.isNotBlank() && + birthday.none { it.isDigit().not() } && + isAgreementChecked + + fun reduce(event: SignupScreenEvent): SignupScreenState { + return when (event) { + is SignupScreenEvent.OnEmailChanged -> copy(email = event.email) + is SignupScreenEvent.OnUsernameChanged -> copy(username = event.username) + is SignupScreenEvent.OnPasswordChanged -> copy(password = event.password) + is SignupScreenEvent.OnPasswordVisibilityToggleClicked -> copy(isPasswordVisible = !isPasswordVisible) + is SignupScreenEvent.OnBirthdayChanged -> copy(birthday = event.birthday) + is SignupScreenEvent.OnAgreementChecked -> copy(isAgreementChecked = !isAgreementChecked) + is SignupScreenEvent.OnDatePickerClicked -> copy(isDatePickerVisible = !isDatePickerVisible) + else -> this + } + } +} 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 new file mode 100644 index 00000000..05b03423 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/signup/SignupScreenViewModel.kt @@ -0,0 +1,71 @@ +package com.bounswe.predictionpolls.ui.signup + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.bounswe.predictionpolls.core.BaseViewModel +import com.bounswe.predictionpolls.data.remote.repositories.AuthRepository +import com.bounswe.predictionpolls.extensions.isValidDate +import com.bounswe.predictionpolls.extensions.isValidEmail +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SignupScreenViewModel @Inject constructor( + private val authRepository: AuthRepository +) : BaseViewModel() { + var screenState by mutableStateOf(SignupScreenState()) + private set + + fun onEvent(event: SignupScreenEvent) { + screenState = screenState.reduce(event) + + when (event) { + is SignupScreenEvent.OnSignupButtonClicked -> onSignupButtonClicked(event.onSuccess) + is SignupScreenEvent.OnSignupWithGoogleButtonClicked -> onSignupWithGoogleButtonClicked() + is SignupScreenEvent.DismissErrorDialog -> onErrorDialogDismissed() + else -> {} + } + } + + private fun onErrorDialogDismissed(){ + error = null + } + + // TODO handle form validation better + private fun isFormValid(): Boolean { + if (screenState.email.isValidEmail().not()) { + error = "Please enter a valid email address." + return false + } else if(screenState.birthday.isValidDate().not()){ + error = "Please enter a valid birthday." + return false + } + + return true + } + + private fun onSignupButtonClicked(onSuccess: () -> Unit) { + if(isFormValid().not()) return + + launchCatching( + trackJobProgress = true, + onSuccess = { + onSuccess() + }, + maxRetryCount = 1 + ) { + authRepository.signup( + email = screenState.email, + username = screenState.username, + password = screenState.password, + birthday = screenState.birthday + ) + } + } + + private fun onSignupWithGoogleButtonClicked() { + //TODO google sign in implementation + error = "Sign up with Google is not implemented yet." + } +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Color.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Color.kt new file mode 100644 index 00000000..8c3517c5 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Color.kt @@ -0,0 +1,65 @@ +package com.bounswe.predictionpolls.ui.theme + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF006684) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFA1DCFA) +val md_theme_light_onPrimaryContainer = Color(0xFF001F2A) +val md_theme_light_secondary = Color(0xFF7D5800) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFFFDEA9) +val md_theme_light_onSecondaryContainer = Color(0xFF271900) +val md_theme_light_tertiary = Color(0xFFBF0027) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFFFDAD8) +val md_theme_light_onTertiaryContainer = Color(0xFF410007) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFF8FDFF) +val md_theme_light_onBackground = Color(0xFF001F25) +val md_theme_light_surface = Color(0xFFF8FDFF) +val md_theme_light_onSurface = Color(0xFF001F25) +val md_theme_light_surfaceVariant = Color(0xFFDCE4E9) +val md_theme_light_onSurfaceVariant = Color(0xFF40484C) +val md_theme_light_outline = Color(0xFF70787D) +val md_theme_light_inverseOnSurface = Color(0xFFD6F6FF) +val md_theme_light_inverseSurface = Color(0xFF00363F) +val md_theme_light_inversePrimary = Color(0xFF66D3FF) +val md_theme_light_shadow = Color(0xFF000000) +val md_theme_light_surfaceTint = Color(0xFF006684) +val md_theme_light_outlineVariant = Color(0xFFC0C8CD) +val md_theme_light_scrim = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFF66D3FF) +val md_theme_dark_onPrimary = Color(0xFF003546) +val md_theme_dark_primaryContainer = Color(0xFF004D64) +val md_theme_dark_onPrimaryContainer = Color(0xFFA1DCFA) +val md_theme_dark_secondary = Color(0xFFFFBA27) +val md_theme_dark_onSecondary = Color(0xFF422C00) +val md_theme_dark_secondaryContainer = Color(0xFF5E4100) +val md_theme_dark_onSecondaryContainer = Color(0xFFFFDEA9) +val md_theme_dark_tertiary = Color(0xFFFFB3B1) +val md_theme_dark_onTertiary = Color(0xFF680011) +val md_theme_dark_tertiaryContainer = Color(0xFF92001C) +val md_theme_dark_onTertiaryContainer = Color(0xFFFFDAD8) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF001F25) +val md_theme_dark_onBackground = Color(0xFFA6EEFF) +val md_theme_dark_surface = Color(0xFF001F25) +val md_theme_dark_onSurface = Color(0xFFA6EEFF) +val md_theme_dark_surfaceVariant = Color(0xFF40484C) +val md_theme_dark_onSurfaceVariant = Color(0xFFC0C8CD) +val md_theme_dark_outline = Color(0xFF8A9297) +val md_theme_dark_inverseOnSurface = Color(0xFF001F25) +val md_theme_dark_inverseSurface = Color(0xFFA6EEFF) +val md_theme_dark_inversePrimary = Color(0xFF006684) +val md_theme_dark_shadow = Color(0xFF000000) +val md_theme_dark_surfaceTint = Color(0xFF66D3FF) +val md_theme_dark_outlineVariant = Color(0xFF40484C) +val md_theme_dark_scrim = Color(0xFF000000) \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Theme.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Theme.kt new file mode 100644 index 00000000..635a0581 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Theme.kt @@ -0,0 +1,113 @@ +package com.bounswe.predictionpolls.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val lightColorScheme = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + + +private val darkColorScheme = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer =md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +@Composable +fun PredictionPollsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkColorScheme + else -> lightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Type.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Type.kt new file mode 100644 index 00000000..3ead5edf --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/ui/theme/Type.kt @@ -0,0 +1,78 @@ +package com.bounswe.predictionpolls.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import com.bounswe.predictionpolls.R + +val MontserratFontFamily = FontFamily( + Font(R.font.montserrat_thin, FontWeight.W100), + Font(R.font.montserrat_thin_italic, FontWeight.W100, FontStyle.Italic), + Font(R.font.montserrat_extra_light, FontWeight.W200), + Font(R.font.montserrat_extra_light_italic, FontWeight.W200, FontStyle.Italic), + Font(R.font.montserrat_light, FontWeight.W300), + Font(R.font.montserrat_light_italic, FontWeight.W300, FontStyle.Italic), + Font(R.font.montserrat_regular, FontWeight.W400), + Font(R.font.montserrat_italic, FontWeight.W400, FontStyle.Italic), + Font(R.font.montserrat_medium, FontWeight.W500), + Font(R.font.montserrat_medium_italic, FontWeight.W500, FontStyle.Italic), + Font(R.font.montserrat_semi_bold, FontWeight.W600), + Font(R.font.montserrat_semi_bold_italic, FontWeight.W600, FontStyle.Italic), + Font(R.font.montserrat_bold, FontWeight.W700), + Font(R.font.montserrat_bold_italic, FontWeight.W700, FontStyle.Italic), + Font(R.font.montserrat_extra_bold, FontWeight.W800), + Font(R.font.montserrat_extra_bold_italic, FontWeight.W800, FontStyle.Italic), + Font(R.font.montserrat_black, FontWeight.W900), + Font(R.font.montserrat_black_italic, FontWeight.W900, FontStyle.Italic), +) + +val Typography = Typography( + displayLarge = TextStyle( + fontFamily = MontserratFontFamily + ), + displayMedium = TextStyle( + fontFamily = MontserratFontFamily + ), + displaySmall = TextStyle( + fontFamily = MontserratFontFamily + ), + headlineLarge = TextStyle( + fontFamily = MontserratFontFamily + ), + headlineMedium = TextStyle( + fontFamily = MontserratFontFamily + ), + headlineSmall = TextStyle( + fontFamily = MontserratFontFamily + ), + titleLarge = TextStyle( + fontFamily = MontserratFontFamily + ), + titleMedium = TextStyle( + fontFamily = MontserratFontFamily + ), + titleSmall = TextStyle( + fontFamily = MontserratFontFamily + ), + bodyLarge = TextStyle( + fontFamily = MontserratFontFamily, + ), + bodyMedium = TextStyle( + fontFamily = MontserratFontFamily, + ), + bodySmall = TextStyle( + fontFamily = MontserratFontFamily, + ), + labelLarge = TextStyle( + fontFamily = MontserratFontFamily, + ), + labelMedium = TextStyle( + fontFamily = MontserratFontFamily, + ), + labelSmall = TextStyle( + fontFamily = MontserratFontFamily, + ), +) \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/utils/DateTransformation.kt b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/utils/DateTransformation.kt new file mode 100644 index 00000000..c205266c --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/utils/DateTransformation.kt @@ -0,0 +1,39 @@ +package com.bounswe.predictionpolls.utils + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +class DateTransformation() : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + return dateFilter(text) + } +} + +fun dateFilter(text: AnnotatedString): TransformedText { + val trimmed = if (text.text.length >= 8) text.text.substring(0..7) else text.text + var out = "" + for (i in trimmed.indices) { + out += trimmed[i] + if (i % 2 == 1 && i < 4) out += "/" + } + + val numberOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 1) return offset + if (offset <= 3) return offset + 1 + if (offset <= 8) return offset + 2 + return 10 + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 2) return offset + if (offset <= 5) return offset - 1 + if (offset <= 10) return offset - 2 + return 8 + } + } + + return TransformedText(AnnotatedString(out), numberOffsetTranslator) +} \ 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 new file mode 100644 index 00000000..78c25992 --- /dev/null +++ b/prediction-polls/android/app/src/main/java/com/bounswe/predictionpolls/utils/NavItem.kt @@ -0,0 +1,47 @@ +package com.bounswe.predictionpolls.utils + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.bounswe.predictionpolls.R + +enum class NavItem( + val route: String, + @StringRes val titleId: Int, + @DrawableRes val iconId: Int +) { + PROFILE( + route = "profile", + titleId = R.string.nav_drawer_profile, + iconId = R.drawable.ic_profile + ), + FEED( + route = "feed", + 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", + titleId = R.string.nav_drawer_create, + iconId = R.drawable.ic_create + ), + MODERATION( + route = "moderation", + titleId = R.string.nav_drawer_moderation, + iconId = R.drawable.ic_moderation + ), + LEADERBOARD( + route = "leaderboard", + titleId = R.string.nav_drawer_leaderboard, + iconId = R.drawable.ic_leaderboard + ), + NOTIFICATIONS( + route = "notifications", + titleId = R.string.nav_drawer_notifications, + iconId = R.drawable.ic_notifications + ), +} \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_app_title.png b/prediction-polls/android/app/src/main/res/drawable/ic_app_title.png new file mode 100644 index 00000000..c8d71296 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/drawable/ic_app_title.png differ diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_back.xml b/prediction-polls/android/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 00000000..1bff67e8 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_calendar.xml b/prediction-polls/android/app/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 00000000..58d040ad --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_create.xml b/prediction-polls/android/app/src/main/res/drawable/ic_create.xml new file mode 100644 index 00000000..e8474030 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_create.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_feed.xml b/prediction-polls/android/app/src/main/res/drawable/ic_feed.xml new file mode 100644 index 00000000..5c5dc662 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_feed.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_google.xml b/prediction-polls/android/app/src/main/res/drawable/ic_google.xml new file mode 100644 index 00000000..99f1bbbd --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_google.xml @@ -0,0 +1,9 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_launcher_background.xml b/prediction-polls/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/prediction-polls/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_leaderboard.xml b/prediction-polls/android/app/src/main/res/drawable/ic_leaderboard.xml new file mode 100644 index 00000000..e254d989 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_leaderboard.xml @@ -0,0 +1,45 @@ + + + + + + + + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_moderation.xml b/prediction-polls/android/app/src/main/res/drawable/ic_moderation.xml new file mode 100644 index 00000000..3f104552 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_moderation.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_notifications.xml b/prediction-polls/android/app/src/main/res/drawable/ic_notifications.xml new file mode 100644 index 00000000..5fadf265 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_notifications.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_profile.xml b/prediction-polls/android/app/src/main/res/drawable/ic_profile.xml new file mode 100644 index 00000000..9f595afe --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_profile.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_visibile.xml b/prediction-polls/android/app/src/main/res/drawable/ic_visibile.xml new file mode 100644 index 00000000..368f9e78 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_visibile.xml @@ -0,0 +1,13 @@ + + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_vote.xml b/prediction-polls/android/app/src/main/res/drawable/ic_vote.xml new file mode 100644 index 00000000..f53c1550 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_vote.xml @@ -0,0 +1,13 @@ + + + diff --git a/prediction-polls/android/app/src/main/res/drawable/ic_welcome_page.xml b/prediction-polls/android/app/src/main/res/drawable/ic_welcome_page.xml new file mode 100644 index 00000000..4f29ce23 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/drawable/ic_welcome_page.xml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_black.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_black.ttf new file mode 100644 index 00000000..7af9fb44 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_black.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_black_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_black_italic.ttf new file mode 100644 index 00000000..c6083667 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_black_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_bold.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_bold.ttf new file mode 100644 index 00000000..0927b813 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_bold.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_bold_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_bold_italic.ttf new file mode 100644 index 00000000..02f57843 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_bold_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_extra_bold.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_extra_bold.ttf new file mode 100644 index 00000000..e33afd43 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_extra_bold.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_extra_bold_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_extra_bold_italic.ttf new file mode 100644 index 00000000..92fc3013 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_extra_bold_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_extra_light.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_extra_light.ttf new file mode 100644 index 00000000..8aa56c17 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_extra_light.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_extra_light_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_extra_light_italic.ttf new file mode 100644 index 00000000..13b6bc29 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_extra_light_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_italic.ttf new file mode 100644 index 00000000..cff3cebc Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_light.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_light.ttf new file mode 100644 index 00000000..fd787a81 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_light.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_light_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_light_italic.ttf new file mode 100644 index 00000000..6a2c9d4c Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_light_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_medium.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_medium.ttf new file mode 100644 index 00000000..4012225c Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_medium.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_medium_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_medium_italic.ttf new file mode 100644 index 00000000..84b25394 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_medium_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_regular.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_regular.ttf new file mode 100644 index 00000000..f4a266dd Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_regular.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_semi_bold.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_semi_bold.ttf new file mode 100644 index 00000000..189ce9d0 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_semi_bold.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_semi_bold_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_semi_bold_italic.ttf new file mode 100644 index 00000000..4c59d861 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_semi_bold_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_thin.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_thin.ttf new file mode 100644 index 00000000..7d085bba Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_thin.ttf differ diff --git a/prediction-polls/android/app/src/main/res/font/montserrat_thin_italic.ttf b/prediction-polls/android/app/src/main/res/font/montserrat_thin_italic.ttf new file mode 100644 index 00000000..6fbfad1a Binary files /dev/null and b/prediction-polls/android/app/src/main/res/font/montserrat_thin_italic.ttf differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/prediction-polls/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/prediction-polls/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/prediction-polls/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/prediction-polls/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/prediction-polls/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/prediction-polls/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/prediction-polls/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/prediction-polls/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/prediction-polls/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/prediction-polls/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/prediction-polls/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/prediction-polls/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/prediction-polls/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/prediction-polls/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/prediction-polls/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/prediction-polls/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/prediction-polls/android/app/src/main/res/values/colors.xml b/prediction-polls/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/prediction-polls/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/res/values/strings.xml b/prediction-polls/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..cb6f57db --- /dev/null +++ b/prediction-polls/android/app/src/main/res/values/strings.xml @@ -0,0 +1,52 @@ + + Prediction Polls + + + Profile + Feed + Vote + Create + Moderation + Leaderboard + Notifications + + + Welcome To + Login + Sign Up + Continue without login + + + Login + Email Address + Password + Login + Sign in with Google + Sign in with Google + + + Sign Up + Email Address + Username + Password + Birthday + I agree to the Platform Terms + Sign Up + Sign Up with Google + Sign Up with Google + Change password visibility + Calendar image + + + or + OK + Cancel + Confirm + Error + + + App Title + Back Button + Welcome page image + Navigation drawer item: %1$s + \ No newline at end of file diff --git a/prediction-polls/android/app/src/main/res/values/themes.xml b/prediction-polls/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..a8892716 --- /dev/null +++ b/prediction-polls/android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +