Implement login and register feature #1

Merged
shenlong merged 4 commits from feature/auth into main 2026-06-17 09:56:19 +00:00
25 changed files with 255 additions and 95 deletions
Showing only changes of commit dfd8414be6 - Show all commits

View File

@ -13,7 +13,7 @@ abstract class BaseRepository {
return if (response.isSuccessful) {
response.body()?.let {
ApiResult.Success(it)
} ?: ApiResult.Error(-999, "Empty response body")
} ?: ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "Empty response body")
} else {
response.errorBody().toApiError()
}
@ -28,7 +28,7 @@ abstract class BaseRepository {
return if (response.isSuccessful) {
response.body()?.let {
ApiResult.Success(transform(it))
} ?: ApiResult.Error(-999, "Empty response body")
} ?: ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "Empty response body")
} else {
response.errorBody().toApiError()
}

View File

@ -7,12 +7,12 @@ import okhttp3.ResponseBody
fun ResponseBody?.toApiError(): ApiResult.Error {
if (this == null) {
return ApiResult.Error(-999, "Unknown error")
return ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "Unknown error")
}
return try {
val error = json.decodeFromString<ErrorResponse>(string())
ApiResult.Error(error.error, error.message)
} catch (_: Exception) {
ApiResult.Error(-999, "Unknown error")
ApiResult.Error(ApiResult.Error.CODE_UNKNOWN, "Unknown error")
}
}

View File

@ -2,5 +2,10 @@ package com.longnh15.heimdall.domain.model
sealed class ApiResult<out T> {
data class Success<T>(val data: T): ApiResult<T>()
data class Error(val code: Int, val message: String): ApiResult<Nothing>()
data class Error(val code: Int, val message: String): ApiResult<Nothing>() {
companion object {
/** Sentinel code for client-side failures (no/garbled response). [message] is an internal default, not shown to users. */
const val CODE_UNKNOWN = -999
}
}
}

View File

@ -38,6 +38,7 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.longnh15.heimdall.ui.common.UiText
import com.longnh15.heimdall.ui.theme.HeimdallTheme
@Composable
@ -61,7 +62,7 @@ fun AuthFormTextField(
onValueChange: (String) -> Unit,
label: String,
placeholder: String,
error: String? = null,
error: UiText? = null,
isHideContent: Boolean = false,
isLastField: Boolean = false,
autoCorrectEnabled: Boolean = true,
@ -121,7 +122,7 @@ fun AuthFormTextField(
}
if (error != null) {
Text(
text = error,
text = error.asString(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.error,
fontSize = 12.sp

View File

@ -1,6 +1,8 @@
package com.longnh15.heimdall.ui.auth.login
import com.longnh15.heimdall.ui.common.UiText
sealed class LoginEvent {
data object LoginSuccess: LoginEvent()
data class LoginError(val message: String): LoginEvent()
data class LoginError(val message: UiText): LoginEvent()
}

View File

@ -38,12 +38,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.longnh15.heimdall.R
import com.longnh15.heimdall.ui.theme.HeimdallTheme
@Composable
@ -55,12 +58,13 @@ fun LoginScreen(
) {
val snackbarHostState = remember { SnackbarHostState() }
val state = viewModel.state
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.uiEvent.collect { event ->
when (event) {
is LoginEvent.LoginSuccess -> navigateToVault()
is LoginEvent.LoginError -> snackbarHostState.showSnackbar(event.message)
is LoginEvent.LoginError -> snackbarHostState.showSnackbar(event.message.asString(context))
}
}
}
@ -87,7 +91,7 @@ fun LoginScreen(
Spacer(Modifier.height(24.dp))
Text(
text = "Welcome back",
text = stringResource(R.string.login_title),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
@ -95,7 +99,7 @@ fun LoginScreen(
Spacer(Modifier.height(8.dp))
Text(
text = "Access your secure vault credentials",
text = stringResource(R.string.login_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@ -107,8 +111,8 @@ fun LoginScreen(
AuthFormTextField(
value = state.email,
onValueChange = viewModel::onEmailChanged,
label = "Email",
placeholder = "you@email.com",
label = stringResource(R.string.field_email_label),
placeholder = stringResource(R.string.login_email_placeholder),
error = state.emailError,
keyboardType = KeyboardType.Email
)
@ -116,8 +120,8 @@ fun LoginScreen(
AuthFormTextField(
value = state.password,
onValueChange = viewModel::onPasswordChanged,
label = "Password",
placeholder = "••••••••",
label = stringResource(R.string.field_password_label),
placeholder = stringResource(R.string.field_password_placeholder),
error = state.passwordError,
isHideContent = !state.isShowPassword,
autoCorrectEnabled = false,
@ -143,7 +147,7 @@ fun LoginScreen(
.align(Alignment.End)
.widthIn(max = 480.dp)
) {
Text("Forgot password?")
Text(stringResource(R.string.login_forgot_password))
}
Spacer(Modifier.height(8.dp))
@ -161,7 +165,7 @@ fun LoginScreen(
)
) {
Text(
text = "Login",
text = stringResource(R.string.login_button),
style = MaterialTheme.typography.titleMedium
)
}
@ -173,13 +177,13 @@ fun LoginScreen(
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Don't have an account?",
text = stringResource(R.string.login_no_account),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = navigateToRegister) {
Text(
text = "Create one",
text = stringResource(R.string.login_create_one),
fontWeight = FontWeight.SemiBold
)
}

View File

@ -1,11 +1,13 @@
package com.longnh15.heimdall.ui.auth.login
import com.longnh15.heimdall.ui.common.UiText
data class LoginState(
val email: String = "",
val password: String = "",
val emailError: String? = null,
val passwordError: String? = null,
val emailError: UiText? = null,
val passwordError: UiText? = null,
val isShowPassword: Boolean = false,
)

View File

@ -5,9 +5,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.longnh15.heimdall.R
import com.longnh15.heimdall.common.ValidationHelper
import com.longnh15.heimdall.domain.model.ApiResult
import com.longnh15.heimdall.domain.repository.AuthRepository
import com.longnh15.heimdall.ui.common.UiText
import com.longnh15.heimdall.ui.common.toUiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
@ -38,18 +41,18 @@ class LoginViewModel(
viewModelScope.launch {
when (val result = authRepository.login(state.email, state.password)) {
is ApiResult.Success -> _uiEvent.send(LoginEvent.LoginSuccess)
is ApiResult.Error -> _uiEvent.send(LoginEvent.LoginError(result.message))
is ApiResult.Error -> _uiEvent.send(LoginEvent.LoginError(result.toUiText()))
}
}
}
private fun validate(): Boolean {
val emailError = when {
state.email.isBlank() -> "Email is required"
!ValidationHelper.isEmail(state.email) -> "Enter a valid email address"
state.email.isBlank() -> UiText.Res(R.string.error_email_required)
!ValidationHelper.isEmail(state.email) -> UiText.Res(R.string.error_email_invalid)
else -> null
}
val passwordError = if (state.password.isBlank()) "Password is required" else null
val passwordError = if (state.password.isBlank()) UiText.Res(R.string.error_password_required) else null
state = state.copy(emailError = emailError, passwordError = passwordError)
return emailError == null && passwordError == null

View File

@ -1,6 +1,8 @@
package com.longnh15.heimdall.ui.auth.register
import com.longnh15.heimdall.ui.common.UiText
sealed class RegisterEvent {
data object RegisterSuccess: RegisterEvent()
data class RegisterError(val message: String): RegisterEvent()
data class RegisterError(val message: UiText): RegisterEvent()
}

View File

@ -27,19 +27,25 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.longnh15.heimdall.R
import com.longnh15.heimdall.ui.auth.login.AuthDivider
import com.longnh15.heimdall.ui.auth.login.AuthForm
import com.longnh15.heimdall.ui.auth.login.AuthFormTextField
@ -51,7 +57,18 @@ fun RegisterScreen(
viewModel: RegisterViewModel = viewModel(),
navigateToLogin: () -> Unit = {},
) {
val snackbarHostState = remember { SnackbarHostState() }
val state = viewModel.state
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.uiEvent.collect { event ->
when (event) {
is RegisterEvent.RegisterSuccess -> navigateToLogin()
is RegisterEvent.RegisterError -> snackbarHostState.showSnackbar(event.message.asString(context))
}
}
}
HeimdallTheme {
Scaffold(
@ -72,7 +89,7 @@ fun RegisterScreen(
Spacer(Modifier.height(24.dp))
Text(
text = "Create account",
text = stringResource(R.string.register_title),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
@ -80,7 +97,7 @@ fun RegisterScreen(
Spacer(Modifier.height(8.dp))
Text(
text = "Secure your digital life with enterprise-grade encryption.",
text = stringResource(R.string.register_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
@ -92,24 +109,26 @@ fun RegisterScreen(
AuthFormTextField(
value = state.name,
onValueChange = viewModel::onNameChanged,
label = "Full name",
placeholder = "John Doe",
label = stringResource(R.string.register_name_label),
placeholder = stringResource(R.string.register_name_placeholder),
keyboardType = KeyboardType.Text
)
AuthDivider()
AuthFormTextField(
value = state.email,
onValueChange = viewModel::onEmailChanged,
label = "Email",
placeholder = "name@vault.com",
label = stringResource(R.string.field_email_label),
placeholder = stringResource(R.string.register_email_placeholder),
error = state.emailError,
keyboardType = KeyboardType.Email,
)
AuthDivider()
AuthFormTextField(
value = state.password,
onValueChange = viewModel::onPasswordChanged,
label = "Password",
placeholder = "••••••••",
label = stringResource(R.string.field_password_label),
placeholder = stringResource(R.string.field_password_placeholder),
error = state.passwordError,
isHideContent = !state.isShowPassword,
autoCorrectEnabled = false,
isLastField = true,
@ -143,7 +162,7 @@ fun RegisterScreen(
)
) {
Text(
text = "Create account",
text = stringResource(R.string.register_button),
style = MaterialTheme.typography.titleMedium
)
}
@ -151,7 +170,7 @@ fun RegisterScreen(
Spacer(Modifier.height(16.dp))
Text(
text = "By creating an account you agree to our Terms of Service and Privacy Policy.",
text = stringResource(R.string.register_terms),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@ -165,13 +184,13 @@ fun RegisterScreen(
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Already have an account?",
text = stringResource(R.string.register_have_account),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = navigateToLogin) {
Text(
text = "Sign in",
text = stringResource(R.string.register_sign_in),
fontWeight = FontWeight.SemiBold
)
}

View File

@ -1,12 +1,14 @@
package com.longnh15.heimdall.ui.auth.register
import com.longnh15.heimdall.ui.common.UiText
data class RegisterState(
val name: String = "",
val email: String = "",
val password: String = "",
val emailError: String? = null,
val passwordError: String? = null,
val emailError: UiText? = null,
val passwordError: UiText? = null,
val isShowPassword: Boolean = false
)

View File

@ -5,9 +5,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.longnh15.heimdall.R
import com.longnh15.heimdall.common.ValidationHelper
import com.longnh15.heimdall.domain.model.ApiResult
import com.longnh15.heimdall.domain.repository.AuthRepository
import com.longnh15.heimdall.ui.common.UiText
import com.longnh15.heimdall.ui.common.toUiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
@ -42,20 +45,21 @@ class RegisterViewModel(
viewModelScope.launch {
when (val result = authRepository.register(state.email, state.password, state.name)) {
is ApiResult.Success -> _uiEvent.send(RegisterEvent.RegisterSuccess)
is ApiResult.Error -> _uiEvent.send(RegisterEvent.RegisterError(result.message))
is ApiResult.Error -> _uiEvent.send(RegisterEvent.RegisterError(result.toUiText()))
}
}
}
private fun validate(): Boolean {
val emailError = when {
state.email.isBlank() -> "Email is required"
!ValidationHelper.isEmail(state.email) -> "Enter a valid email address"
state.email.isBlank() -> UiText.Res(R.string.error_email_required)
!ValidationHelper.isEmail(state.email) -> UiText.Res(R.string.error_email_invalid)
else -> null
}
val passwordError = when {
state.password.isBlank() -> "Password is required"
state.password.length < 12 -> "Password must be at least 12 characters"
state.password.isBlank() -> UiText.Res(R.string.error_password_required)
state.password.length < MIN_PASSWORD_LENGTH ->
UiText.Res(R.string.error_password_too_short, MIN_PASSWORD_LENGTH)
else -> null
}
@ -63,4 +67,7 @@ class RegisterViewModel(
return emailError == null && passwordError == null
}
private companion object {
const val MIN_PASSWORD_LENGTH = 12
}
}

View File

@ -7,6 +7,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.longnh15.heimdall.R
@Composable
fun AutofillNotEnabledDialog(
@ -18,15 +20,15 @@ fun AutofillNotEnabledDialog(
icon = {
Icon(Icons.Outlined.Warning, contentDescription = null)
},
title = { Text("Autofill not enabled") },
title = { Text(stringResource(R.string.autofill_dialog_title)) },
text = {
Text("Heimdall wasn't selected as your autofill provider. Open settings and tap Heimdall to enable it.")
Text(stringResource(R.string.autofill_dialog_body))
},
confirmButton = {
TextButton(onClick = onRetry) { Text("Open settings") }
TextButton(onClick = onRetry) { Text(stringResource(R.string.open_settings)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Skip for now") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.autofill_dialog_skip)) }
}
)
}

View File

@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.longnh15.heimdall.R
@ -33,7 +34,7 @@ fun OnboardingFinalPage(
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
OnboardingBadge {
Text("You're all set")
Text(stringResource(R.string.onboarding_final_badge))
}
Box(
modifier = Modifier
@ -43,20 +44,20 @@ fun OnboardingFinalPage(
) {
Image(
painterResource(R.drawable.tick_icon),
"Page icon",
stringResource(R.string.onboarding_page_icon_desc),
)
}
Text("Heimdall is watching", style = MaterialTheme.typography.headlineMedium)
Text(stringResource(R.string.onboarding_final_title), style = MaterialTheme.typography.headlineMedium)
Text(
text = "Your vault is ready. Start adding passwords or import from your browser to get going.",
text = stringResource(R.string.onboarding_final_body),
textAlign = TextAlign.Center
)
Spacer(Modifier.weight(1f))
Button(onClick = navigateToVault) {
Text("Enter my vault ↗")
Text(stringResource(R.string.onboarding_final_enter_vault))
}
}
}

View File

@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.style.TextAlign
import androidx.compose.ui.unit.dp
@ -34,11 +35,11 @@ fun OnboardingIntroducePage(modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
OnboardingBadge {
Text("What you get")
Text(stringResource(R.string.onboarding_introduce_badge))
}
Text("Everything in one vault", style = MaterialTheme.typography.headlineMedium)
Text(stringResource(R.string.onboarding_introduce_title), style = MaterialTheme.typography.headlineMedium)
Text(
text = "Heimdall keeps your passwords safe and fills them in automatically",
text = stringResource(R.string.onboarding_introduce_body),
textAlign = TextAlign.Center
)
Column(
@ -47,18 +48,18 @@ fun OnboardingIntroducePage(modifier: Modifier = Modifier) {
) {
FeatureItem(
iconDrawableRes = R.drawable.lock_icon,
text = "End-to-end encrypted",
sub = "Only you can see your data"
text = stringResource(R.string.onboarding_feature_encrypted_title),
sub = stringResource(R.string.onboarding_feature_encrypted_sub)
)
FeatureItem(
iconDrawableRes = R.drawable.crosshair_icon,
text = "Autofill anywhere",
sub = "Works across all your apps"
text = stringResource(R.string.onboarding_feature_autofill_title),
sub = stringResource(R.string.onboarding_feature_autofill_sub)
)
FeatureItem(
iconDrawableRes = R.drawable.person_icon,
text = "Biometric unlock",
sub = "Fingerprint or face to open"
text = stringResource(R.string.onboarding_feature_biometric_title),
sub = stringResource(R.string.onboarding_feature_biometric_sub)
)
}
}
@ -89,7 +90,7 @@ fun FeatureItem(
.background(Color(0xFFEEEDFE), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Image(painterResource(iconDrawableRes), "Icon")
Image(painterResource(iconDrawableRes), stringResource(R.string.onboarding_feature_icon_desc))
}
Column {
Text(text = text, fontSize = 13.sp, fontWeight = FontWeight(500), color = MaterialTheme.colorScheme.onPrimary)

View File

@ -19,6 +19,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.longnh15.heimdall.R
@ -34,7 +35,7 @@ fun OnboardingRequestEnablePage(
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
OnboardingBadge {
Text("Setup · 1 of 2")
Text(stringResource(R.string.onboarding_enable_badge))
}
Box(
modifier = Modifier
@ -44,20 +45,20 @@ fun OnboardingRequestEnablePage(
) {
Image(
painterResource(R.drawable.page_icon),
"Page icon",
stringResource(R.string.onboarding_page_icon_desc),
)
}
Text("Enable autofill", style = MaterialTheme.typography.headlineMedium)
Text(stringResource(R.string.onboarding_enable_title), style = MaterialTheme.typography.headlineMedium)
Text(
text = "Let Heimdall fill your passwords automatically. You'll be taken to Android settings — tap Heimdall and you're done.",
text = stringResource(R.string.onboarding_enable_body),
textAlign = TextAlign.Center
)
Spacer(Modifier.weight(1f))
Button(onClick = onOpenAutofillSettings) {
Text("Open settings")
Text(stringResource(R.string.open_settings))
}
}
}

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -36,12 +37,12 @@ fun OnboardingWelcomePage(modifier: Modifier = Modifier) {
) {
Image(
painterResource(R.drawable.shield_icon),
"Heimdall Logo",
stringResource(R.string.onboarding_welcome_logo_desc),
)
}
Text("Welcome to Heimdall", style = MaterialTheme.typography.headlineMedium)
Text(stringResource(R.string.onboarding_welcome_title), style = MaterialTheme.typography.headlineMedium)
Text(
text = "Your all-seeing guardian for passwords. Secure, private, and always watching over your credentials",
text = stringResource(R.string.onboarding_welcome_body),
textAlign = TextAlign.Center
)
}

View File

@ -19,7 +19,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.longnh15.heimdall.R
@Composable
fun LockOverlay(
@ -43,12 +45,12 @@ fun LockOverlay(
modifier = Modifier.size(48.dp)
)
Text(
text = "Vault is locked",
text = stringResource(R.string.vault_locked_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "Authenticate to access your credentials",
text = stringResource(R.string.vault_locked_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -60,7 +62,7 @@ fun LockOverlay(
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Unlock with biometrics")
Text(stringResource(R.string.vault_unlock_biometrics))
}
}
}

View File

@ -17,14 +17,16 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.longnh15.heimdall.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VaultHeader() {
TopAppBar(
title = {
Text("My vault")
Text(stringResource(R.string.vault_title))
},
colors = TopAppBarDefaults.topAppBarColors().copy(
containerColor = MaterialTheme.colorScheme.background

View File

@ -13,8 +13,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.longnh15.heimdall.R
@Composable
fun VaultScreen(
@ -33,12 +35,14 @@ fun VaultScreen(
}
}
)
val requestBiometric = remember { {
val unlockTitle = stringResource(R.string.vault_unlock_title)
val unlockSubtitle = stringResource(R.string.vault_unlock_subtitle)
val requestBiometric = remember(unlockTitle, unlockSubtitle) { {
val request = biometricRequest(
title = "Unlock Heimdall",
title = unlockTitle,
AuthenticationRequest.Biometric.Fallback.DeviceCredential,
) {
setSubtitle("Confirm your identity to access your vault")
setSubtitle(unlockSubtitle)
}
launcher.launch(request)
} }

View File

@ -1,6 +1,8 @@
package com.longnh15.heimdall.ui.vault.edit
import com.longnh15.heimdall.ui.common.UiText
sealed class AddEditEvent {
data object SaveSuccess: AddEditEvent()
data class SaveError(val message: String): AddEditEvent()
data class SaveError(val message: UiText): AddEditEvent()
}

View File

@ -20,9 +20,11 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.longnh15.heimdall.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -34,7 +36,7 @@ fun AddEditHeader(
) {
TopAppBar(
title = {
Text("New item")
Text(stringResource(R.string.add_edit_title))
},
modifier = Modifier.padding(end = 16.dp),
colors = TopAppBarDefaults.topAppBarColors().copy(
@ -64,7 +66,7 @@ fun AddEditHeader(
Spacer(Modifier.width(8.dp))
}
Text(
text = if (isSaving) "Saving…" else "Save",
text = if (isSaving) stringResource(R.string.add_edit_saving) else stringResource(R.string.add_edit_save),
fontSize = 13.sp,
fontWeight = FontWeight(500),
color = MaterialTheme.colorScheme.onTertiaryContainer

View File

@ -24,10 +24,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.longnh15.heimdall.R
import com.longnh15.heimdall.ui.theme.HeimdallTheme
@Composable
@ -37,12 +40,13 @@ fun AddEditVaultItemScreen(
popScreen: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.uiEvent.collect { event ->
when (event) {
AddEditEvent.SaveSuccess -> popScreen()
is AddEditEvent.SaveError -> snackbarHostState.showSnackbar(event.message)
is AddEditEvent.SaveError -> snackbarHostState.showSnackbar(event.message.asString(context))
}
}
}
@ -66,20 +70,20 @@ fun AddEditVaultItemScreen(
modifier.padding(paddingValues).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
AddEditFormHeader("details")
AddEditFormHeader(stringResource(R.string.add_edit_section_details))
AddEditForm {
AddEditFormTextField(
value = viewModel.state.name,
onValueChange = viewModel::onNameChanged,
label = "Name",
placeholder = "e.g. Google"
label = stringResource(R.string.add_edit_name_label),
placeholder = stringResource(R.string.add_edit_name_placeholder)
)
AddEditDivider()
AddEditFormTextField(
value = viewModel.state.url,
onValueChange = viewModel::onUrlChanged,
label = "URL",
placeholder = "google.com",
label = stringResource(R.string.add_edit_url_label),
placeholder = stringResource(R.string.add_edit_url_placeholder),
actions = {
IconButton(
onClick = {},
@ -90,20 +94,20 @@ fun AddEditVaultItemScreen(
}
)
}
AddEditFormHeader("credentials")
AddEditFormHeader(stringResource(R.string.add_edit_section_credentials))
AddEditForm {
AddEditFormTextField(
viewModel.state.username,
onValueChange = viewModel::onUsernameChanged,
label = "Username",
placeholder = "you@email.com"
label = stringResource(R.string.add_edit_username_label),
placeholder = stringResource(R.string.add_edit_username_placeholder)
)
AddEditDivider()
AddEditFormTextField(
value = viewModel.state.password,
onValueChange = viewModel::onPasswordChanged,
label = "Password",
placeholder = "••••••••",
label = stringResource(R.string.field_password_label),
placeholder = stringResource(R.string.field_password_placeholder),
isHideContent = viewModel.state.isShowPassword.not(),
isLastField = true,
autoCorrectEnabled = false,

View File

@ -5,7 +5,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.longnh15.heimdall.R
import com.longnh15.heimdall.domain.repository.CredentialRepository
import com.longnh15.heimdall.ui.common.UiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
@ -52,7 +54,8 @@ class AddEditVaultViewModel(
_uiEvent.send(AddEditEvent.SaveSuccess)
} catch (e: Exception) {
state = state.copy(isSaving = false)
_uiEvent.send(AddEditEvent.SaveError(e.message ?: "Something went wrong."))
val message = e.message?.let { UiText.Dynamic(it) } ?: UiText.Res(R.string.error_generic)
_uiEvent.send(AddEditEvent.SaveError(message))
}
}
}

View File

@ -1,3 +1,91 @@
<resources>
<string name="app_name">Heimdall</string>
<!-- Errors / validation -->
<string name="error_email_required">Email is required</string>
<string name="error_email_invalid">Enter a valid email address</string>
<string name="error_password_required">Password is required</string>
<string name="error_password_too_short">Password must be at least %1$d characters</string>
<string name="error_generic">Something went wrong.</string>
<!-- Shared form fields -->
<string name="field_email_label">Email</string>
<string name="field_password_label">Password</string>
<string name="field_password_placeholder">••••••••</string>
<string name="open_settings">Open settings</string>
<!-- Login -->
<string name="login_title">Welcome back</string>
<string name="login_subtitle">Access your secure vault credentials</string>
<string name="login_email_placeholder">you@email.com</string>
<string name="login_forgot_password">Forgot password?</string>
<string name="login_button">Login</string>
<string name="login_no_account">Don\'t have an account?</string>
<string name="login_create_one">Create one</string>
<!-- Register -->
<string name="register_title">Create account</string>
<string name="register_subtitle">Secure your digital life with enterprise-grade encryption.</string>
<string name="register_name_label">Full name</string>
<string name="register_name_placeholder">John Doe</string>
<string name="register_email_placeholder">name@vault.com</string>
<string name="register_button">Create account</string>
<string name="register_terms">By creating an account you agree to our Terms of Service and Privacy Policy.</string>
<string name="register_have_account">Already have an account?</string>
<string name="register_sign_in">Sign in</string>
<!-- Onboarding: welcome -->
<string name="onboarding_welcome_logo_desc">Heimdall Logo</string>
<string name="onboarding_welcome_title">Welcome to Heimdall</string>
<string name="onboarding_welcome_body">Your all-seeing guardian for passwords. Secure, private, and always watching over your credentials</string>
<!-- Onboarding: introduce -->
<string name="onboarding_introduce_badge">What you get</string>
<string name="onboarding_introduce_title">Everything in one vault</string>
<string name="onboarding_introduce_body">Heimdall keeps your passwords safe and fills them in automatically</string>
<string name="onboarding_feature_icon_desc">Icon</string>
<string name="onboarding_feature_encrypted_title">End-to-end encrypted</string>
<string name="onboarding_feature_encrypted_sub">Only you can see your data</string>
<string name="onboarding_feature_autofill_title">Autofill anywhere</string>
<string name="onboarding_feature_autofill_sub">Works across all your apps</string>
<string name="onboarding_feature_biometric_title">Biometric unlock</string>
<string name="onboarding_feature_biometric_sub">Fingerprint or face to open</string>
<!-- Onboarding: request enable -->
<string name="onboarding_page_icon_desc">Page icon</string>
<string name="onboarding_enable_badge">Setup · 1 of 2</string>
<string name="onboarding_enable_title">Enable autofill</string>
<string name="onboarding_enable_body">Let Heimdall fill your passwords automatically. You\'ll be taken to Android settings — tap Heimdall and you\'re done.</string>
<!-- Onboarding: final -->
<string name="onboarding_final_badge">You\'re all set</string>
<string name="onboarding_final_title">Heimdall is watching</string>
<string name="onboarding_final_body">Your vault is ready. Start adding passwords or import from your browser to get going.</string>
<string name="onboarding_final_enter_vault">Enter my vault ↗</string>
<!-- Autofill not enabled dialog -->
<string name="autofill_dialog_title">Autofill not enabled</string>
<string name="autofill_dialog_body">Heimdall wasn\'t selected as your autofill provider. Open settings and tap Heimdall to enable it.</string>
<string name="autofill_dialog_skip">Skip for now</string>
<!-- Vault -->
<string name="vault_title">My vault</string>
<string name="vault_unlock_title">Unlock Heimdall</string>
<string name="vault_unlock_subtitle">Confirm your identity to access your vault</string>
<string name="vault_locked_title">Vault is locked</string>
<string name="vault_locked_subtitle">Authenticate to access your credentials</string>
<string name="vault_unlock_biometrics">Unlock with biometrics</string>
<!-- Add / edit vault item -->
<string name="add_edit_title">New item</string>
<string name="add_edit_saving">Saving…</string>
<string name="add_edit_save">Save</string>
<string name="add_edit_section_details">details</string>
<string name="add_edit_section_credentials">credentials</string>
<string name="add_edit_name_label">Name</string>
<string name="add_edit_name_placeholder">e.g. Google</string>
<string name="add_edit_url_label">URL</string>
<string name="add_edit_url_placeholder">google.com</string>
<string name="add_edit_username_label">Username</string>
<string name="add_edit_username_placeholder">you@email.com</string>
</resources>