Recreate legacy login screen in Compose.
Port the old sign-in UI and behavior (gradient, branded social buttons, policy footer, and continue-anonymous dialog) and add Login contract/ViewModel stubs while keeping unauthorized Manage-to-Login navigation intact. Made-with: Cursor
This commit is contained in:
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<State> = _uiState.asStateFlow()
|
||||
|
||||
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||
val actions: SharedFlow<Action> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 595 B |
BIN
app/src/main/res/drawable-mdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 411 B |
BIN
app/src/main/res/drawable-xhdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 703 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/src/main/res/drawable/ic_facebook.png
Normal file
BIN
app/src/main/res/drawable/ic_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 595 B |
18
app/src/main/res/drawable/ic_google.xml
Normal file
18
app/src/main/res/drawable/ic_google.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="#FFC107"
|
||||
android:pathData="M43.61,20.08H42V20H24v8h11.3C33.65,32.66 29.19,36 24,36c-6.63,0 -12,-5.37 -12,-12s5.37,-12 12,-12c3.06,0 5.84,1.15 7.96,3.04l5.66,-5.66C34.01,6.05 29.27,4 24,4 12.95,4 4,12.95 4,24s8.95,20 20,20 20,-8.95 20,-20c0,-1.34 -0.14,-2.65 -0.39,-3.92z" />
|
||||
<path
|
||||
android:fillColor="#FF3D00"
|
||||
android:pathData="M6.31,14.69l6.57,4.82C14.66,15.1 18.94,12 24,12c3.06,0 5.84,1.15 7.96,3.04l5.66,-5.66C34.01,6.05 29.27,4 24,4 16.32,4 9.65,8.34 6.31,14.69z" />
|
||||
<path
|
||||
android:fillColor="#4CAF50"
|
||||
android:pathData="M24,44c5.17,0 9.86,-1.98 13.41,-5.2l-6.19,-5.24C29.17,35.1 26.7,36 24,36c-5.17,0 -9.61,-3.32 -11.26,-7.93l-6.52,5.02C9.52,39.56 16.23,44 24,44z" />
|
||||
<path
|
||||
android:fillColor="#1976D2"
|
||||
android:pathData="M43.61,20.08H42V20H24v8h11.3c-0.79,2.25 -2.23,4.2 -4.08,5.56l0,0 6.19,5.24C37.01,39.16 44,34 44,24c0,-1.34 -0.14,-2.65 -0.39,-3.92z" />
|
||||
</vector>
|
||||
Reference in New Issue
Block a user