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:
2026-03-01 19:34:29 +01:00
parent 7c00163304
commit 90792c601c
10 changed files with 462 additions and 7 deletions

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

View 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>