feat(login): replace Facebook with email-password auth screen

- Remove Facebook provider from Login flow
- Add EmailLoginScreen with gradient background, email/password fields
- Add EmailLoginScreenContract and EmailLoginViewModel with stub logic
- Add navigation: Sign in with email -> EmailLoginScreen
- Use back arrow icon instead of back text
- Move header above email field, add Register button
- Update run command to launch app after install
- Add ic_arrow_back drawable, update strings

Made-with: Cursor
This commit is contained in:
2026-03-02 20:38:09 +01:00
parent f4b6df10ac
commit 9a80ce5dff
14 changed files with 362 additions and 23 deletions

View File

@@ -1,9 +1,10 @@
# Run app on device
Build and install the app on the connected USB Android device.
Build, install, and launch the app on the connected USB Android device.
## What to do
1. Run `./gradlew installDebug` (use `gradlew.bat` on Windows).
2. Ensure the device is connected via USB with USB debugging enabled.
3. After a successful install, report the device name and that the app is ready to launch.
3. After a successful install, launch the app with `adb shell am start -n org.db3.airmq/.MainActivity`.
4. Report the device name and that the app was launched.

View File

@@ -413,11 +413,11 @@ private fun AirMQButtonsPreviewSocial() {
modifier = Modifier.fillMaxWidth()
)
AirMQSocialButton(
text = "Sign in with Facebook",
leadingIconRes = R.drawable.ic_facebook,
iconTint = Color.White,
containerColor = Color(0xFF3B5998),
contentColor = Color.White,
text = "Sign in with email",
leadingIconRes = R.drawable.ic_account,
iconTint = Color.Unspecified,
containerColor = Color.White,
contentColor = Color(0xFF202124),
onClick = {},
modifier = Modifier.fillMaxWidth()
)

View File

@@ -0,0 +1,237 @@
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.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.statusBarsPadding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectLatest
import org.db3.airmq.R
import androidx.compose.material3.TextButton
import org.db3.airmq.features.common.AirMQOutlinedLightButton
import org.db3.airmq.features.login.EmailLoginScreenContract.Action
import org.db3.airmq.features.login.EmailLoginScreenContract.Event
import org.db3.airmq.features.login.EmailLoginScreenContract.State
import org.db3.airmq.ui.theme.AirMQTheme
private val LegacyLoginGradientStart = Color(0xFF449CF5)
private val LegacyLoginGradientEnd = Color(0xFF5CE4BB)
@Composable
fun EmailLoginScreen(
onLogInToManage: () -> Unit,
onBack: () -> Unit,
viewModel: EmailLoginViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
LaunchedEffect(viewModel) {
viewModel.actions.collectLatest { action ->
when (action) {
Action.OpenManage -> onLogInToManage()
is Action.ShowMessage -> {
Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
}
}
}
}
EmailLoginScreenContent(
uiState = uiState,
onEvent = viewModel::onEvent,
onBack = onBack
)
}
@Composable
private fun EmailLoginScreenContent(
uiState: State,
onEvent: (Event) -> Unit,
onBack: () -> 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(16.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_back),
contentDescription = stringResource(id = R.string.content_back),
tint = Color.White
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(id = R.string.text_sign_in_email),
color = Color.White,
fontSize = 36.sp,
fontWeight = FontWeight.Light,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = uiState.email,
onValueChange = { onEvent(Event.EmailChanged(it)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
stringResource(id = R.string.hint_email),
color = Color.White.copy(alpha = 0.7f)
)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = Color.White,
focusedBorderColor = Color.White.copy(alpha = 0.8f),
unfocusedBorderColor = Color.White.copy(alpha = 0.55f),
focusedContainerColor = Color.White.copy(alpha = 0.1f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f)
)
)
OutlinedTextField(
value = uiState.password,
onValueChange = { onEvent(Event.PasswordChanged(it)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
stringResource(id = R.string.hint_password),
color = Color.White.copy(alpha = 0.7f)
)
},
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = Color.White,
focusedBorderColor = Color.White.copy(alpha = 0.8f),
unfocusedBorderColor = Color.White.copy(alpha = 0.55f),
focusedContainerColor = Color.White.copy(alpha = 0.1f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f)
)
)
if (uiState.isLoading) {
CircularProgressIndicator(color = Color.White)
} else {
AirMQOutlinedLightButton(
text = stringResource(id = R.string.button_sign_in),
modifier = Modifier.fillMaxWidth(),
onClick = { onEvent(Event.SignInClicked) }
)
}
TextButton(
onClick = { onEvent(Event.RegisterClicked) },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.button_register),
color = Color.White
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Preview(showBackground = true)
@Composable
private fun EmailLoginScreenPreview() {
AirMQTheme {
EmailLoginScreenContent(
uiState = State(),
onEvent = {},
onBack = {}
)
}
}

View File

@@ -0,0 +1,23 @@
package org.db3.airmq.features.login
object EmailLoginScreenContract {
data class State(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false
)
sealed interface Action {
data object OpenManage : Action
data class ShowMessage(val message: String) : Action
}
sealed interface Event {
data class EmailChanged(val value: String) : Event
data class PasswordChanged(val value: String) : Event
data object SignInClicked : Event
data object RegisterClicked : Event
data object BackClicked : 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.EmailLoginScreenContract.Action
import org.db3.airmq.features.login.EmailLoginScreenContract.Event
import org.db3.airmq.features.login.EmailLoginScreenContract.State
@HiltViewModel
class EmailLoginViewModel @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) {
is Event.EmailChanged -> {
_uiState.value = _uiState.value.copy(email = event.value)
}
is Event.PasswordChanged -> {
_uiState.value = _uiState.value.copy(password = event.value)
}
Event.SignInClicked -> {
_uiState.value = _uiState.value.copy(isLoading = true)
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.coming_soon))
)
_uiState.value = _uiState.value.copy(isLoading = false)
}
Event.RegisterClicked -> {
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.coming_soon))
)
}
Event.BackClicked -> Unit
}
}
}

View File

@@ -56,11 +56,11 @@ 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,
onOpenEmailLogin: () -> Unit,
viewModel: LoginViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@@ -78,6 +78,7 @@ fun LoginScreen(
is GoogleSignInResult.Error -> viewModel.onEvent(Event.GoogleSignInFailed(result.message))
}
}
Action.NavigateToEmailLogin -> onOpenEmailLogin()
Action.ShowContinueAnonymousDialog -> showContinueAnonymousDialog = true
Action.OpenPrivacyPolicy -> {
Toast.makeText(context, context.getString(R.string.coming_soon), Toast.LENGTH_SHORT).show()
@@ -216,13 +217,13 @@ private fun LoginScreenContent(
)
AirMQSocialButton(
text = stringResource(id = R.string.button_sign_in_facebook),
leadingIconRes = R.drawable.ic_facebook,
iconTint = Color.White,
containerColor = LegacyFacebookBlue,
contentColor = Color.White,
text = stringResource(id = R.string.button_sign_in_email),
leadingIconRes = R.drawable.ic_account,
iconTint = Color.Unspecified,
containerColor = Color.White,
contentColor = Color(0xFF202124),
modifier = Modifier.fillMaxWidth(),
onClick = { onEvent(Event.FacebookClicked) }
onClick = { onEvent(Event.EmailClicked) }
)
AirMQOutlinedLightButton(

View File

@@ -9,6 +9,7 @@ object LoginScreenContract {
sealed interface Action {
data object OpenManage : Action
data object LaunchGoogleSignIn : Action
data object NavigateToEmailLogin : Action
data object ShowContinueAnonymousDialog : Action
data object OpenPrivacyPolicy : Action
data object OpenTermsAndConditions : Action
@@ -20,7 +21,7 @@ object LoginScreenContract {
data class GoogleTokenReceived(val idToken: String) : Event
data object GoogleSignInCancelled : Event
data class GoogleSignInFailed(val message: String? = null) : Event
data object FacebookClicked : Event
data object EmailClicked : Event
data object ContinueAnonymousClicked : Event
data object ContinueAnonymousConfirmed : Event
data object ContinueAnonymousDismissed : Event

View File

@@ -52,8 +52,8 @@ class LoginViewModel @Inject constructor(
Event.GoogleSignInCancelled -> {
_uiState.value = _uiState.value.copy(isLoading = false)
}
Event.FacebookClicked -> {
_actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon)))
Event.EmailClicked -> {
_actions.tryEmit(Action.NavigateToEmailLogin)
}
Event.ContinueAnonymousClicked -> {
_actions.tryEmit(Action.ShowContinueAnonymousDialog)

View File

@@ -38,6 +38,7 @@ import org.db3.airmq.features.device.DeviceScreen
import org.db3.airmq.features.entry.SplashScreen
import org.db3.airmq.features.entry.WizardScreen
import org.db3.airmq.features.location.LocationScreen
import org.db3.airmq.features.login.EmailLoginScreen
import org.db3.airmq.features.login.LoginScreen
import org.db3.airmq.features.manage.ManageScreen
import org.db3.airmq.features.map.MapScreen
@@ -204,7 +205,18 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) {
}
composable(AirMqRoutes.LOGIN) {
LoginScreen(
onLogInToManage = { navController.navigate(AirMqRoutes.MANAGE) }
onLogInToManage = { navController.navigate(AirMqRoutes.MANAGE) },
onOpenEmailLogin = { navController.navigate(AirMqRoutes.EMAIL_LOGIN) }
)
}
composable(AirMqRoutes.EMAIL_LOGIN) {
EmailLoginScreen(
onLogInToManage = {
navController.navigate(AirMqRoutes.MANAGE) {
popUpTo(AirMqRoutes.LOGIN) { inclusive = true }
}
},
onBack = { navController.popBackStack() }
)
}
composable(AirMqRoutes.NEWS) {

View File

@@ -12,6 +12,7 @@ object AirMqRoutes {
const val LOCATION = "detail/location"
const val SETUP = "detail/setup"
const val LOGIN = "detail/login"
const val EMAIL_LOGIN = "detail/email-login"
const val NEWS = "detail/news"
const val NEWS_DETAIL = "detail/news/{newsId}"
const val DEVICE = "detail/device/{deviceId}"

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View File

@@ -23,7 +23,7 @@
<string name="button_cancel">Адмяніць</string>
<string name="text_privacy_policy"><b><u>Палітыка канфідэнцыйнасці</u></b></string>
<string name="button_sign_in_google">Увайсці з дапамогай Google</string>
<string name="button_sign_in_facebook">Увайсці з дапамогай Facebook</string>
<string name="button_sign_in_email">Увайсці па email</string>
<string name="button_look_for_device">Пошук прылады</string>
<string name="button_finish">Завяршыць</string>
<string name="button_continue_anonym">Працягнуць ананімна</string>

View File

@@ -23,7 +23,7 @@
<string name="button_cancel">Отменить</string>
<string name="text_privacy_policy"><b><u>Политикой конфиденциальности</u></b></string>
<string name="button_sign_in_google">Войти с помощью Google</string>
<string name="button_sign_in_facebook">Войти с помощью Facebook</string>
<string name="button_sign_in_email">Войти по email</string>
<string name="button_look_for_device">Поиск устройства</string>
<string name="button_finish">Завершить</string>
<string name="button_continue_anonym">Продолжить анонимно</string>

View File

@@ -81,7 +81,7 @@
<string name="button_cancel">Cancel</string>
<string name="button_register">Register</string>
<string name="button_sign_in_google">Sign in with Google</string>
<string name="button_sign_in_facebook">Sign in with Facebook</string>
<string name="button_sign_in_email">Sign in with email</string>
<string name="button_copy_log">Copy log</string>
<string name="button_look_for_device">Look for a device</string>
<string name="button_finish">Finish</string>
@@ -92,7 +92,10 @@
<string name="button_setup">Add new device</string>
<string name="button_connecting">Connecting…</string>
<string name="text_sign_in_email">Sign in with email</string>
<!-- Hints -->
<string name="hint_email">Email</string>
<string name="hint_password">Password</string>
<string name="hint_device_name">Device name</string>
<string name="hint_setup_name">Name</string>
@@ -142,8 +145,6 @@
<string name="toast_copied">Copied</string>
<string name="snackbar_not_registered">Device requires registration</string>
<string name="facebook_app_id" translatable="false">356744979027664</string>
<string name="fb_login_protocol_scheme" translatable="false">fb356744979027664</string>
<string name="text_air_quality">Air quality</string>
<string name="pref_fullscreen">Borderless layout</string>
<string name="text_error">Error</string>