Merge pull request #49 from SELab1/firebaseIntegration

Firebase integration
This commit is contained in:
lbarraga 2023-04-08 21:52:48 +02:00 committed by GitHub Enterprise
commit 227473895a
27 changed files with 623 additions and 23 deletions

View file

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<application <application
android:name=".StudeezHiltApp"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"

View file

@ -0,0 +1,81 @@
package be.ugent.sel.studeez
import android.content.res.Resources
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import be.ugent.sel.studeez.common.snackbar.SnackbarManager
import be.ugent.sel.studeez.navigation.StudeezDestinations
import be.ugent.sel.studeez.screens.splash.SplashScreen
import be.ugent.sel.studeez.ui.theme.StudeezTheme
import kotlinx.coroutines.CoroutineScope
@Composable
fun StudeezApp() {
StudeezTheme {
Surface(color = MaterialTheme.colors.background) {
val appState = rememberAppState()
Scaffold(
snackbarHost = {
SnackbarHost(
hostState = it,
modifier = Modifier.padding(8.dp),
snackbar = { snackbarData ->
Snackbar(snackbarData, contentColor = MaterialTheme.colors.onPrimary)
}
)
},
scaffoldState = appState.scaffoldState
) { innerPaddingModifier ->
NavHost(
navController = appState.navController,
startDestination = StudeezDestinations.SPLASH_SCREEN,
modifier = Modifier.padding(innerPaddingModifier)
) {
studeezGraph(appState)
}
}
}
}
}
@Composable
fun rememberAppState(
scaffoldState: ScaffoldState = rememberScaffoldState(),
navController: NavHostController = rememberNavController(),
snackbarManager: SnackbarManager = SnackbarManager,
resources: Resources = resources(),
coroutineScope: CoroutineScope = rememberCoroutineScope()
) =
remember(scaffoldState, navController, snackbarManager, resources, coroutineScope) {
StudeezAppstate(scaffoldState, navController, snackbarManager, resources, coroutineScope)
}
@Composable
@ReadOnlyComposable
fun resources(): Resources {
LocalConfiguration.current
return LocalContext.current.resources
}
fun NavGraphBuilder.studeezGraph(appState: StudeezAppstate) {
composable(StudeezDestinations.SPLASH_SCREEN) {
SplashScreen(openAndPopUp = { route, popUp -> appState.navigateAndPopUp(route, popUp) })
}
}

View file

@ -0,0 +1,51 @@
package be.ugent.sel.studeez
import android.content.res.Resources
import androidx.compose.material.ScaffoldState
import androidx.compose.runtime.Stable
import androidx.navigation.NavHostController
import be.ugent.sel.studeez.common.snackbar.SnackbarManager
import be.ugent.sel.studeez.common.snackbar.SnackbarMessage.Companion.toMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
@Stable
class StudeezAppstate(
val scaffoldState: ScaffoldState,
val navController: NavHostController,
private val snackbarManager: SnackbarManager,
private val resources: Resources,
coroutineScope: CoroutineScope
) {
init {
coroutineScope.launch {
snackbarManager.snackbarMessages.filterNotNull().collect { snackbarMessage ->
val text = snackbarMessage.toMessage(resources)
scaffoldState.snackbarHostState.showSnackbar(text)
}
}
}
fun popUp() {
navController.popBackStack()
}
fun navigate(route: String) {
navController.navigate(route) { launchSingleTop = true }
}
fun navigateAndPopUp(route: String, popUp: String) {
navController.navigate(route) {
launchSingleTop = true
popUpTo(popUp) { inclusive = true }
}
}
fun clearAndNavigate(route: String) {
navController.navigate(route) {
launchSingleTop = true
popUpTo(0) { inclusive = true }
}
}
}

View file

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

View file

@ -10,8 +10,11 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import be.ugent.sel.studeez.StudeezApp
import be.ugent.sel.studeez.ui.theme.StudeezTheme import be.ugent.sel.studeez.ui.theme.StudeezTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -22,7 +25,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background color = MaterialTheme.colors.background
) { ) {
Greeting("Android") StudeezApp()
} }
} }
} }

View file

@ -0,0 +1,46 @@
package be.ugent.sel.studeez.common.composable
import androidx.annotation.StringRes
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
@Composable
fun BasicTextButton(@StringRes text: Int, modifier: Modifier, action: () -> Unit) {
TextButton(onClick = action, modifier = modifier) { Text(text = stringResource(text)) }
}
@Composable
fun BasicButton(@StringRes text: Int, modifier: Modifier, action: () -> Unit) {
Button(
onClick = action,
modifier = modifier
) {
Text(text = stringResource(text), fontSize = 16.sp)
}
}
@Composable
fun DialogConfirmButton(@StringRes text: Int, action: () -> Unit) {
Button(
onClick = action
) {
Text(text = stringResource(text))
}
}
@Composable
fun DialogCancelButton(@StringRes text: Int, action: () -> Unit) {
Button(
onClick = action,
colors =
ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.onPrimary,
contentColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(text))
}
}

View file

@ -0,0 +1,102 @@
package be.ugent.sel.studeez.common.composable
import androidx.annotation.StringRes
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Lock
import androidx.compose.runtime.*
import be.ugent.sel.studeez.R.string as AppText
import be.ugent.sel.studeez.R.drawable as AppIcon
import androidx.compose.ui.Modifier
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
@Composable
fun BasicField(
@StringRes text: Int,
value: String,
onNewValue: (String) -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
singleLine = true,
modifier = modifier,
value = value,
onValueChange = { onNewValue(it) },
placeholder = { Text(stringResource(text)) }
)
}
@Composable
fun EmailField(
value: String,
onNewValue: (String) -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
singleLine = true,
modifier = modifier,
value = value,
onValueChange = { onNewValue(it) },
placeholder = { Text(stringResource(AppText.email)) },
leadingIcon = { Icon(imageVector = Icons.Default.Email, contentDescription = "Email") }
)
}
@Composable
fun PasswordField(
value: String,
onNewValue: (String) -> Unit,
modifier: Modifier = Modifier
) {
PasswordField(value, AppText.password, onNewValue, modifier)
}
@Composable
fun RepeatPasswordField(
value: String,
onNewValue: (String) -> Unit,
modifier: Modifier = Modifier
) {
PasswordField(value, AppText.repeat_password, onNewValue, modifier)
}
@Composable
private fun PasswordField(
value: String,
@StringRes placeholder: Int,
onNewValue: (String) -> Unit,
modifier: Modifier = Modifier
) {
var isVisible by remember { mutableStateOf(false) }
val icon =
if (isVisible) painterResource(AppIcon.ic_visibility_on)
else painterResource(AppIcon.ic_visibility_off)
val visualTransformation =
if (isVisible) VisualTransformation.None else PasswordVisualTransformation()
OutlinedTextField(
modifier = modifier,
value = value,
onValueChange = { onNewValue(it) },
placeholder = { Text(text = stringResource(placeholder)) },
leadingIcon = { Icon(imageVector = Icons.Default.Lock, contentDescription = "Lock") },
trailingIcon = {
IconButton(onClick = { isVisible = !isVisible }) {
Icon(painter = icon, contentDescription = "Visibility")
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = visualTransformation
)
}

View file

@ -0,0 +1,42 @@
package be.ugent.sel.studeez.common.ext
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
fun Modifier.textButton(): Modifier {
return this.fillMaxWidth().padding(16.dp, 8.dp, 16.dp, 0.dp)
}
fun Modifier.basicButton(): Modifier {
return this.fillMaxWidth().padding(16.dp, 8.dp)
}
fun Modifier.card(): Modifier {
return this.padding(16.dp, 0.dp, 16.dp, 8.dp)
}
fun Modifier.contextMenu(): Modifier {
return this.wrapContentWidth()
}
fun Modifier.dropdownSelector(): Modifier {
return this.fillMaxWidth()
}
fun Modifier.fieldModifier(): Modifier {
return this.fillMaxWidth().padding(16.dp, 4.dp)
}
fun Modifier.toolbarActions(): Modifier {
return this.wrapContentSize(Alignment.TopEnd)
}
fun Modifier.spacer(): Modifier {
return this.fillMaxWidth().padding(12.dp)
}
fun Modifier.smallSpacer(): Modifier {
return this.fillMaxWidth().height(8.dp)
}

View file

@ -0,0 +1,25 @@
package be.ugent.sel.studeez.common.ext
import android.util.Patterns
import java.util.regex.Pattern
private const val MIN_PASS_LENGTH = 6
private const val PASS_PATTERN = "^(?=.*\\d)(?=.*[a-z])(?=.*[A-Z])(?=\\S+$).{4,}$"
fun String.isValidEmail(): Boolean {
return this.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(this).matches()
}
fun String.isValidPassword(): Boolean {
return this.isNotBlank() &&
this.length >= MIN_PASS_LENGTH &&
Pattern.compile(PASS_PATTERN).matcher(this).matches()
}
fun String.passwordMatches(repeated: String): Boolean {
return this == repeated
}
fun String.idFromParameter(): String {
return this.substring(1, this.length - 1)
}

View file

@ -0,0 +1,20 @@
package be.ugent.sel.studeez.common.snackbar
import androidx.annotation.StringRes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object SnackbarManager {
private val messages: MutableStateFlow<SnackbarMessage?> = MutableStateFlow(null)
val snackbarMessages: StateFlow<SnackbarMessage?>
get() = messages.asStateFlow()
fun showMessage(@StringRes message: Int) {
messages.value = SnackbarMessage.ResourceSnackbar(message)
}
fun showMessage(message: SnackbarMessage) {
messages.value = message
}
}

View file

@ -0,0 +1,25 @@
package be.ugent.sel.studeez.common.snackbar
import android.content.res.Resources
import androidx.annotation.StringRes
import be.ugent.sel.studeez.R.string as AppText
sealed class SnackbarMessage {
class StringSnackbar(val message: String) : SnackbarMessage()
class ResourceSnackbar(@StringRes val message: Int) : SnackbarMessage()
companion object {
fun SnackbarMessage.toMessage(resources: Resources): String {
return when (this) {
is StringSnackbar -> this.message
is ResourceSnackbar -> resources.getString(this.message)
}
}
fun Throwable.toSnackbarMessage(): SnackbarMessage {
val message = this.message.orEmpty()
return if (message.isNotBlank()) StringSnackbar(message)
else ResourceSnackbar(AppText.generic_error)
}
}
}

View file

@ -0,0 +1,19 @@
package be.ugent.sel.studeez.di
import be.ugent.sel.studeez.domain.AccountDAO
import be.ugent.sel.studeez.domain.LogService
import be.ugent.sel.studeez.domain.implementation.FirebaseAccountDAO
import be.ugent.sel.studeez.domain.implementation.LogServiceImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class DatabaseModule {
@Binds abstract fun provideAccountDAO(impl: FirebaseAccountDAO): AccountDAO
@Binds abstract fun provideLogService(impl: LogServiceImpl): LogService
}

View file

@ -0,0 +1,21 @@
package be.ugent.sel.studeez.di
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.ktx.auth
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ktx.firestore
import com.google.firebase.ktx.Firebase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
object FirebaseModule {
@Provides
fun auth(): FirebaseAuth = Firebase.auth
@Provides
fun firestore(): FirebaseFirestore = Firebase.firestore
}

View file

@ -25,10 +25,9 @@ interface AccountDAO {
val currentUser: Flow<User> val currentUser: Flow<User>
suspend fun authenticate(email: String, password: String) suspend fun signInWithEmailAndPassword(email: String, password: String)
suspend fun sendRecoveryEmail(email: String) suspend fun sendRecoveryEmail(email: String)
suspend fun createAnonymousAccount() suspend fun signUpWithEmailAndPassword(email: String, password: String)
suspend fun linkAccount(email: String, password: String)
suspend fun deleteAccount() suspend fun deleteAccount()
suspend fun signOut() suspend fun signOut()
} }

View file

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

View file

@ -47,7 +47,7 @@ class FirebaseAccountDAO @Inject constructor(
awaitClose { auth.removeAuthStateListener(listener) } awaitClose { auth.removeAuthStateListener(listener) }
} }
override suspend fun authenticate(email: String, password: String) { override suspend fun signInWithEmailAndPassword(email: String, password: String) {
auth.signInWithEmailAndPassword(email, password).await() auth.signInWithEmailAndPassword(email, password).await()
} }
@ -55,31 +55,15 @@ class FirebaseAccountDAO @Inject constructor(
auth.sendPasswordResetEmail(email).await() auth.sendPasswordResetEmail(email).await()
} }
override suspend fun createAnonymousAccount() { override suspend fun signUpWithEmailAndPassword(email: String, password: String) {
auth.signInAnonymously().await() auth.createUserWithEmailAndPassword(email, password).await()
} }
override suspend fun linkAccount(email: String, password: String): Unit =
trace(LINK_ACCOUNT_TRACE) {
val credential = EmailAuthProvider.getCredential(email, password)
auth.currentUser!!.linkWithCredential(credential).await()
}
override suspend fun deleteAccount() { override suspend fun deleteAccount() {
auth.currentUser!!.delete().await() auth.currentUser!!.delete().await()
} }
override suspend fun signOut() { override suspend fun signOut() {
if (auth.currentUser!!.isAnonymous) {
auth.currentUser!!.delete()
}
auth.signOut() auth.signOut()
// Sign the user back in anonymously.
createAnonymousAccount()
}
companion object {
private const val LINK_ACCOUNT_TRACE = "linkAccount"
} }
} }

View file

@ -0,0 +1,14 @@
package be.ugent.sel.studeez.domain.implementation
import be.ugent.sel.studeez.domain.LogService
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import javax.inject.Inject
class LogServiceImpl @Inject constructor() : LogService {
override fun logNonFatalCrash(throwable: Throwable) {
Firebase.crashlytics.recordException(throwable)
}
}

View file

@ -0,0 +1,8 @@
package be.ugent.sel.studeez.navigation
object StudeezDestinations {
const val SPLASH_SCREEN = "splash"
const val SIGN_UP_SCREEN = "signup"
const val LOGIN_SCREEN = "login"
}

View file

@ -0,0 +1,23 @@
package be.ugent.sel.studeez.screens
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import be.ugent.sel.studeez.common.snackbar.SnackbarManager
import be.ugent.sel.studeez.common.snackbar.SnackbarMessage.Companion.toSnackbarMessage
import be.ugent.sel.studeez.domain.LogService
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
open class StudeezViewModel(private val logService: LogService) : ViewModel() {
fun launchCatching(snackbar: Boolean = true, block: suspend CoroutineScope.() -> Unit) =
viewModelScope.launch(
CoroutineExceptionHandler { _, throwable ->
if (snackbar) {
SnackbarManager.showMessage(throwable.toSnackbarMessage())
}
logService.logNonFatalCrash(throwable)
},
block = block
)
}

View file

@ -0,0 +1,55 @@
package be.ugent.sel.studeez.screens.splash
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import be.ugent.sel.studeez.common.composable.BasicButton
import be.ugent.sel.studeez.common.ext.basicButton
import kotlinx.coroutines.delay
import be.ugent.sel.studeez.R.string as AppText
private const val SPLASH_TIMEOUT = 1000L
@Composable
fun SplashScreen(
openAndPopUp: (String, String) -> Unit,
modifier: Modifier = Modifier,
viewModel: SplashViewModel = hiltViewModel()
) {
Column(
modifier =
modifier
.fillMaxWidth()
.fillMaxHeight()
.background(color = MaterialTheme.colors.background)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (viewModel.showError.value) {
Text(text = stringResource(AppText.generic_error))
BasicButton(AppText.try_again, Modifier.basicButton()) { viewModel.onAppStart(openAndPopUp) }
} else {
CircularProgressIndicator(color = MaterialTheme.colors.onBackground)
}
}
LaunchedEffect(true) {
delay(SPLASH_TIMEOUT)
viewModel.onAppStart(openAndPopUp)
}
}

View file

@ -0,0 +1,26 @@
package be.ugent.sel.studeez.screens.splash
import androidx.compose.runtime.mutableStateOf
import be.ugent.sel.studeez.domain.AccountDAO
import be.ugent.sel.studeez.domain.LogService
import be.ugent.sel.studeez.screens.StudeezViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class SplashViewModel @Inject constructor(
private val accountDAO: AccountDAO,
logService: LogService
) : StudeezViewModel(logService) {
val showError = mutableStateOf(false)
fun onAppStart(openAndPopUp: (String, String) -> Unit) {
showError.value = false
if (accountDAO.hasUser) {
// openAndPopUp( <homeScreen>, SPLASH_SCREEN)
} else{
// openAndPopUp(<login>, SPLASH_SCREEN)
}
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>

View file

@ -1,3 +1,26 @@
<resources> <resources>
<!-- Common -->
<string name="app_name">Studeez</string> <string name="app_name">Studeez</string>
<string name="email">Email</string>
<string name="password">Password</string>
<string name="repeat_password">Repeat password</string>
<string name="generic_error">Something wrong happened. Please try again.</string>
<string name="email_error">Please insert a valid email.</string>
<string name="cancel">Cancel</string>
<string name="try_again">Try again</string>
<!-- SignUpScreen -->
<string name="create_account">Create account</string>
<string name="password_error">Your password should have at least six characters and include one digit, one lower case letter and one upper case letter.</string>
<string name="password_match_error">Passwords do not match.</string>
<string name="already_user">Already have an account? Log in.</string>
<!-- LoginScreen -->
<string name="not_already_user">Don\'t have an account yet? Sign up.</string>
<string name="sign_in">Sign in</string>
<string name="login_details">Enter your login details</string>
<string name="forgot_password">Forgot password? Click to get recovery email</string>
<string name="recovery_email_sent">Check your inbox for the recovery email.</string>
<string name="empty_password_error">Password cannot be empty.</string>
</resources> </resources>