diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreen.kt index 50e3e13..ccbbc81 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreen.kt @@ -1,16 +1,374 @@ package org.db3.airmq.features.login +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +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.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +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.compose.foundation.Image +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Icon +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.flow.collectLatest import org.db3.airmq.R -import org.db3.airmq.features.common.MockScreenScaffold -import org.db3.airmq.features.common.ScreenAction +import org.db3.airmq.features.login.LoginScreenContract.Action +import org.db3.airmq.features.login.LoginScreenContract.Event +import org.db3.airmq.features.login.LoginScreenContract.State +import org.db3.airmq.ui.theme.AirMQTheme + +private val LegacyLoginGradientStart = Color(0xFF449CF5) +private val LegacyLoginGradientEnd = Color(0xFF5CE4BB) +private val LegacyFacebookBlue = Color(0xFF3B5998) @Composable -fun LoginScreen(onLogInToManage: () -> Unit) { - MockScreenScaffold( - title = stringResource(id = R.string.button_sign_in), - subtitle = stringResource(id = R.string.coming_soon), - actions = listOf(ScreenAction(stringResource(id = R.string.screen_log_in_to_manage), onLogInToManage)) +fun LoginScreen( + onLogInToManage: () -> Unit, + viewModel: LoginViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + var showContinueAnonymousDialog by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(viewModel) { + viewModel.actions.collectLatest { action -> + when (action) { + Action.OpenManage -> onLogInToManage() + Action.ShowContinueAnonymousDialog -> showContinueAnonymousDialog = true + Action.OpenPrivacyPolicy -> { + Toast.makeText(context, context.getString(R.string.coming_soon), Toast.LENGTH_SHORT).show() + } + Action.OpenTermsAndConditions -> { + Toast.makeText(context, context.getString(R.string.coming_soon), Toast.LENGTH_SHORT).show() + } + is Action.ShowMessage -> { + Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show() + } + } + } + } + + LoginScreenContent( + uiState = uiState, + onEvent = viewModel::onEvent + ) + + if (showContinueAnonymousDialog) { + AlertDialog( + onDismissRequest = { + showContinueAnonymousDialog = false + viewModel.onEvent(Event.ContinueAnonymousDismissed) + }, + title = { + Text( + text = stringResource(id = R.string.dialog_anonym_title), + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Text( + text = stringResource(id = R.string.dialog_anonym_mesage), + color = MaterialTheme.colorScheme.onSurface + ) + }, + dismissButton = { + TextButton( + onClick = { + showContinueAnonymousDialog = false + viewModel.onEvent(Event.ContinueAnonymousDismissed) + } + ) { + Text( + text = stringResource(id = R.string.button_sign_in), + color = MaterialTheme.colorScheme.primary + ) + } + }, + confirmButton = { + TextButton( + onClick = { + showContinueAnonymousDialog = false + viewModel.onEvent(Event.ContinueAnonymousConfirmed) + } + ) { + Text( + text = stringResource(id = R.string.button_continue), + color = MaterialTheme.colorScheme.primary + ) + } + } + ) + } +} + +@Composable +private fun LoginScreenContent( + uiState: State, + onEvent: (Event) -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colorStops = arrayOf( + 0.0f to LegacyLoginGradientStart, + 0.35f to LegacyLoginGradientStart, + 1.0f to LegacyLoginGradientEnd + ), + start = Offset(0f, 0f), + end = Offset(1200f, 1200f) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(horizontal = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(56.dp)) + Text( + text = stringResource(id = R.string.text_sign_in), + color = Color.White, + fontSize = 36.sp, + fontWeight = FontWeight.Light, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(40.dp)) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.airmq_logo), + contentDescription = stringResource(id = R.string.content_airmq_logo), + modifier = Modifier + .size(168.dp) + .alpha(0.54f) + ) + if (uiState.isLoading) { + CircularProgressIndicator(color = Color.White) + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SocialSignInButton( + text = stringResource(id = R.string.button_sign_in_google), + iconRes = R.drawable.ic_google, + iconTint = Color.Unspecified, + backgroundColor = Color.White, + textColor = Color(0xFF202124), + onClick = { onEvent(Event.GoogleClicked) } + ) + + SocialSignInButton( + text = stringResource(id = R.string.button_sign_in_facebook), + iconRes = R.drawable.ic_facebook, + iconTint = Color.White, + backgroundColor = LegacyFacebookBlue, + textColor = Color.White, + onClick = { onEvent(Event.FacebookClicked) } + ) + + OutlinedButton( + onClick = { onEvent(Event.ContinueAnonymousClicked) }, + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.55f)), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Text( + text = stringResource(id = R.string.button_continue_anonym).uppercase(), + style = MaterialTheme.typography.labelLarge + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + PrivacyAndTermsFooter(onEvent = onEvent) + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun SocialSignInButton( + text: String, + iconRes: Int, + iconTint: Color, + backgroundColor: Color, + textColor: Color, + onClick: () -> Unit +) { + Button( + onClick = onClick, + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors( + containerColor = backgroundColor, + contentColor = textColor + ), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxHeight() + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelLarge + ) + } + } +} + +@Composable +private fun PrivacyAndTermsFooter(onEvent: (Event) -> Unit) { + val privacyPolicy = "Privacy Policy" + val termsAndConditions = "Terms & conditions" + val fullText = stringResource( + id = R.string.text_policy_label, + privacyPolicy, + termsAndConditions + ) + + val annotatedText = remember(fullText, privacyPolicy, termsAndConditions) { + buildAnnotatedString { + append(fullText) + val privacyStart = fullText.indexOf(privacyPolicy) + val termsStart = fullText.indexOf(termsAndConditions) + + if (privacyStart >= 0) { + val privacyEnd = privacyStart + privacyPolicy.length + addStyle( + style = SpanStyle( + color = Color.White.copy(alpha = 0.85f), + textDecoration = TextDecoration.Underline + ), + start = privacyStart, + end = privacyEnd + ) + addStringAnnotation( + tag = "privacy", + annotation = "privacy", + start = privacyStart, + end = privacyEnd + ) + } + + if (termsStart >= 0) { + val termsEnd = termsStart + termsAndConditions.length + addStyle( + style = SpanStyle( + color = Color.White.copy(alpha = 0.85f), + textDecoration = TextDecoration.Underline + ), + start = termsStart, + end = termsEnd + ) + addStringAnnotation( + tag = "terms", + annotation = "terms", + start = termsStart, + end = termsEnd + ) + } + } + } + + ClickableText( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium.copy( + color = Color.White.copy(alpha = 0.63f), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth(), + onClick = { offset -> + annotatedText.getStringAnnotations(tag = "privacy", start = offset, end = offset) + .firstOrNull()?.let { + onEvent(Event.PrivacyPolicyClicked) + } + annotatedText.getStringAnnotations(tag = "terms", start = offset, end = offset) + .firstOrNull()?.let { + onEvent(Event.TermsAndConditionsClicked) + } + } ) } + +@Preview(showBackground = true) +@Composable +private fun LoginScreenPreview() { + AirMQTheme { + LoginScreenContent( + uiState = State(), + onEvent = {} + ) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreenContract.kt new file mode 100644 index 0000000..cb25111 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/login/LoginScreenContract.kt @@ -0,0 +1,26 @@ +package org.db3.airmq.features.login + +object LoginScreenContract { + + data class State( + val isLoading: Boolean = false + ) + + sealed interface Action { + data object OpenManage : Action + data object ShowContinueAnonymousDialog : Action + data object OpenPrivacyPolicy : Action + data object OpenTermsAndConditions : Action + data class ShowMessage(val message: String) : Action + } + + sealed interface Event { + data object GoogleClicked : Event + data object FacebookClicked : Event + data object ContinueAnonymousClicked : Event + data object ContinueAnonymousConfirmed : Event + data object ContinueAnonymousDismissed : Event + data object PrivacyPolicyClicked : Event + data object TermsAndConditionsClicked : Event + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt new file mode 100644 index 0000000..3703347 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/login/LoginViewModel.kt @@ -0,0 +1,53 @@ +package org.db3.airmq.features.login + +import android.content.Context +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import org.db3.airmq.R +import org.db3.airmq.features.login.LoginScreenContract.Action +import org.db3.airmq.features.login.LoginScreenContract.Event +import org.db3.airmq.features.login.LoginScreenContract.State + +@HiltViewModel +class LoginViewModel @Inject constructor( + @ApplicationContext private val appContext: Context +) : ViewModel() { + + private val _uiState = MutableStateFlow(State()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + val actions: SharedFlow = _actions.asSharedFlow() + + fun onEvent(event: Event) { + when (event) { + Event.GoogleClicked -> { + _actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon))) + } + Event.FacebookClicked -> { + _actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon))) + } + Event.ContinueAnonymousClicked -> { + _actions.tryEmit(Action.ShowContinueAnonymousDialog) + } + Event.ContinueAnonymousConfirmed -> { + _actions.tryEmit(Action.OpenManage) + } + Event.ContinueAnonymousDismissed -> Unit + Event.PrivacyPolicyClicked -> { + _actions.tryEmit(Action.OpenPrivacyPolicy) + } + Event.TermsAndConditionsClicked -> { + _actions.tryEmit(Action.OpenTermsAndConditions) + } + } + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_facebook.png b/app/src/main/res/drawable-hdpi/ic_facebook.png new file mode 100644 index 0000000..bf1b6c0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_facebook.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_facebook.png b/app/src/main/res/drawable-mdpi/ic_facebook.png new file mode 100644 index 0000000..4a671b4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_facebook.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_facebook.png b/app/src/main/res/drawable-xhdpi/ic_facebook.png new file mode 100644 index 0000000..5c80672 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_facebook.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_facebook.png b/app/src/main/res/drawable-xxhdpi/ic_facebook.png new file mode 100644 index 0000000..7cc8034 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_facebook.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_facebook.png b/app/src/main/res/drawable-xxxhdpi/ic_facebook.png new file mode 100644 index 0000000..41359ab Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_facebook.png differ diff --git a/app/src/main/res/drawable/ic_facebook.png b/app/src/main/res/drawable/ic_facebook.png new file mode 100644 index 0000000..bf1b6c0 Binary files /dev/null and b/app/src/main/res/drawable/ic_facebook.png differ diff --git a/app/src/main/res/drawable/ic_google.xml b/app/src/main/res/drawable/ic_google.xml new file mode 100644 index 0000000..2e6398b --- /dev/null +++ b/app/src/main/res/drawable/ic_google.xml @@ -0,0 +1,18 @@ + + + + + +