Implement login and register feature #1
5
.gitignore
vendored
5
.gitignore
vendored
@ -7,8 +7,13 @@
|
|||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/deploymentTargetSelector.xml
|
||||||
|
/.idea/deviceManager.xml
|
||||||
|
/.idea/appInsightsSettings.xml
|
||||||
|
/.idea/koin-grouping-settings.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
|
.claude/settings.local.json
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
|
|||||||
@ -4,6 +4,11 @@ plugins {
|
|||||||
alias(libs.plugins.devtool.ksp)
|
alias(libs.plugins.devtool.ksp)
|
||||||
alias(libs.plugins.serialization)
|
alias(libs.plugins.serialization)
|
||||||
alias(libs.plugins.koin.compiler)
|
alias(libs.plugins.koin.compiler)
|
||||||
|
alias(libs.plugins.room)
|
||||||
|
}
|
||||||
|
|
||||||
|
room {
|
||||||
|
schemaDirectory("$projectDir/schemas")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -51,6 +56,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
@ -76,4 +82,11 @@ dependencies {
|
|||||||
implementation(libs.androidx.room)
|
implementation(libs.androidx.room)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
|
|
||||||
|
// Retrofit
|
||||||
|
implementation(libs.squareup.retrofit2)
|
||||||
|
implementation(libs.squareup.retrofit2.kotlinx.serialization)
|
||||||
|
|
||||||
|
// Kotlinx
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "e09aa1c4dfa12dfb105b0ea16d4584ff",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "credential",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT, `username` TEXT NOT NULL, `password` TEXT NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "username",
|
||||||
|
"columnName": "username",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "password",
|
||||||
|
"columnName": "password",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e09aa1c4dfa12dfb105b0ea16d4584ff')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HeimdallApplication"
|
android:name=".HeimdallApplication"
|
||||||
@ -28,7 +29,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.Heimdall">
|
android:theme="@style/Theme.Heimdall.Starting">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,6 @@ import com.longnh15.heimdall.domain.vault.VaultSessionManager
|
|||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.android.ext.koin.androidLogger
|
import org.koin.android.ext.koin.androidLogger
|
||||||
import org.koin.core.annotation.ComponentScan
|
|
||||||
import org.koin.core.annotation.KoinApplication
|
|
||||||
import org.koin.plugin.module.dsl.startKoin
|
import org.koin.plugin.module.dsl.startKoin
|
||||||
|
|
||||||
//@KoinApplication(modules = [DatabaseModule::class])
|
//@KoinApplication(modules = [DatabaseModule::class])
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.longnh15.heimdall.navigation.HeimdallNavGraph
|
import com.longnh15.heimdall.navigation.HeimdallNavGraph
|
||||||
import com.longnh15.heimdall.navigation.Screen
|
import com.longnh15.heimdall.navigation.Screen
|
||||||
@ -18,20 +19,31 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val viewModel: MainViewModel by viewModel()
|
private val viewModel: MainViewModel by viewModel()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
val splashScreen = installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
// Keep the splash screen until the first-launch flag is resolved so the nav graph
|
||||||
|
// is built once with the correct start destination (no Onboarding -> Login flicker).
|
||||||
|
splashScreen.setKeepOnScreenCondition { viewModel.isFirstLaunch.value == null && viewModel.token.value == null }
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
HeimdallTheme {
|
HeimdallTheme {
|
||||||
val isFirstLaunch by viewModel.isFirstLaunch.collectAsStateWithLifecycle()
|
val isFirstLaunch by viewModel.isFirstLaunch.collectAsStateWithLifecycle()
|
||||||
|
val token by viewModel.token.collectAsStateWithLifecycle()
|
||||||
|
if (isFirstLaunch != null && token != null) {
|
||||||
HeimdallNavGraph(
|
HeimdallNavGraph(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
startDestination = if (isFirstLaunch != false) Screen.Onboarding else Screen.Vault,
|
startDestination = when {
|
||||||
|
isFirstLaunch!! -> Screen.Onboarding
|
||||||
|
token!!.isEmpty() -> Screen.Login
|
||||||
|
else -> Screen.Vault
|
||||||
|
},
|
||||||
isAutofillServiceEnabled = ::isAutofillServiceEnabled,
|
isAutofillServiceEnabled = ::isAutofillServiceEnabled,
|
||||||
onCompleteOnboarding = viewModel::onCompleteOnboarding,
|
onCompleteOnboarding = viewModel::onCompleteOnboarding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun isAutofillServiceEnabled(): Boolean {
|
fun isAutofillServiceEnabled(): Boolean {
|
||||||
val afm = getSystemService(AutofillManager::class.java)
|
val afm = getSystemService(AutofillManager::class.java)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.longnh15.heimdall
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.longnh15.heimdall.data.preferences.PreferenceDataSource
|
import com.longnh15.heimdall.data.preferences.PreferenceDataSource
|
||||||
|
import com.longnh15.heimdall.data.source.local.LocalDataSource
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@ -12,7 +13,8 @@ import org.koin.core.annotation.KoinViewModel
|
|||||||
|
|
||||||
@KoinViewModel
|
@KoinViewModel
|
||||||
class MainViewModel (
|
class MainViewModel (
|
||||||
private val preferenceDataSource: PreferenceDataSource
|
private val preferenceDataSource: PreferenceDataSource,
|
||||||
|
private val localDataSource: LocalDataSource
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val isFirstLaunch: StateFlow<Boolean?> = preferenceDataSource.preferences
|
val isFirstLaunch: StateFlow<Boolean?> = preferenceDataSource.preferences
|
||||||
.map { it.isFirstLaunch }
|
.map { it.isFirstLaunch }
|
||||||
@ -22,6 +24,14 @@ class MainViewModel (
|
|||||||
initialValue = null
|
initialValue = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val token: StateFlow<String?> = localDataSource.token
|
||||||
|
.map { it.orEmpty() }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = null
|
||||||
|
)
|
||||||
|
|
||||||
fun onCompleteOnboarding() {
|
fun onCompleteOnboarding() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferenceDataSource.setFirstLaunchDone()
|
preferenceDataSource.setFirstLaunchDone()
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.longnh15.heimdall.common
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object GlobalConfig {
|
||||||
|
val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.longnh15.heimdall.common
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
|
||||||
|
object TokenCrypto {
|
||||||
|
private const val PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val ALIAS = "auth_token_key"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val IV_SIZE = 12
|
||||||
|
private const val TAG_BITS = 128
|
||||||
|
|
||||||
|
private val keystore = KeyStore.getInstance(PROVIDER).apply { load(null) }
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
(keystore.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let {
|
||||||
|
return it.secretKey
|
||||||
|
}
|
||||||
|
val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER)
|
||||||
|
keyGen.init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
return keyGen.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encrypt(plainText: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
val iv = cipher.iv // 12-byte IV, generated by Keystore
|
||||||
|
val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
|
||||||
|
return Base64.encode(iv + cipherText) // prepend IV
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(encoded: String): String {
|
||||||
|
val combined = Base64.decode(encoded)
|
||||||
|
val iv = combined.copyOfRange(0, IV_SIZE)
|
||||||
|
val cipherText = combined.copyOfRange(IV_SIZE, combined.size)
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(TAG_BITS, iv))
|
||||||
|
return cipher.doFinal(cipherText).toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.common
|
||||||
|
|
||||||
|
import android.util.Patterns.EMAIL_ADDRESS
|
||||||
|
|
||||||
|
object ValidationHelper {
|
||||||
|
fun isEmail(input: String): Boolean {
|
||||||
|
return EMAIL_ADDRESS.matcher(input).matches()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package com.longnh15.heimdall.data.repository
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.data.source.local.LocalDataSource
|
||||||
|
import com.longnh15.heimdall.data.source.remote.ApiService
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.LoginRequest
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.RegisterRequest
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import com.longnh15.heimdall.domain.model.AuthInfo
|
||||||
|
import com.longnh15.heimdall.domain.repository.AuthRepository
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class AuthRepositoryImpl(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val localDataSource: LocalDataSource
|
||||||
|
) : BaseRepository(), AuthRepository {
|
||||||
|
override suspend fun register(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
name: String
|
||||||
|
): ApiResult<String> {
|
||||||
|
val registerInfo = RegisterRequest(email, password, name)
|
||||||
|
return safeApiCall(
|
||||||
|
block = { api.register(registerInfo) },
|
||||||
|
transform = { it.userId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String
|
||||||
|
): ApiResult<AuthInfo> {
|
||||||
|
val loginInfo = LoginRequest(email, password)
|
||||||
|
return safeApiCall(
|
||||||
|
block = { api.login(loginInfo) },
|
||||||
|
transform = {
|
||||||
|
localDataSource.saveToken(it.token)
|
||||||
|
AuthInfo(
|
||||||
|
it.userId,
|
||||||
|
it.token,
|
||||||
|
Instant.parse(it.expiresAt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.longnh15.heimdall.data.repository
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.data.source.remote.toApiError
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
abstract class BaseRepository {
|
||||||
|
suspend fun <RemoteResponse> safeApiCall(
|
||||||
|
block: suspend () -> Response<RemoteResponse>
|
||||||
|
): ApiResult<RemoteResponse> {
|
||||||
|
val response = block()
|
||||||
|
|
||||||
|
return if (response.isSuccessful) {
|
||||||
|
response.body()?.let {
|
||||||
|
ApiResult.Success(it)
|
||||||
|
} ?: ApiResult.Error(-999, "Empty response body")
|
||||||
|
} else {
|
||||||
|
response.errorBody().toApiError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <RemoteResponse, Expected> safeApiCall(
|
||||||
|
block: suspend () -> Response<RemoteResponse>,
|
||||||
|
transform: suspend (RemoteResponse) -> Expected
|
||||||
|
): ApiResult<Expected> {
|
||||||
|
val response = block()
|
||||||
|
|
||||||
|
return if (response.isSuccessful) {
|
||||||
|
response.body()?.let {
|
||||||
|
ApiResult.Success(transform(it))
|
||||||
|
} ?: ApiResult.Error(-999, "Empty response body")
|
||||||
|
} else {
|
||||||
|
response.errorBody().toApiError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,30 @@
|
|||||||
package com.longnh15.heimdall.data.source.local
|
package com.longnh15.heimdall.data.source.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.longnh15.heimdall.common.TokenCrypto
|
||||||
import com.longnh15.heimdall.data.source.local.entity.toDomain
|
import com.longnh15.heimdall.data.source.local.entity.toDomain
|
||||||
import com.longnh15.heimdall.data.source.local.entity.toEntity
|
import com.longnh15.heimdall.data.source.local.entity.toEntity
|
||||||
import com.longnh15.heimdall.domain.model.Credential
|
import com.longnh15.heimdall.domain.model.Credential
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.dataStore by preferencesDataStore("terces")
|
||||||
|
|
||||||
class LocalDataSource(
|
class LocalDataSource(
|
||||||
private val credentialDao: CredentialDao
|
private val credentialDao: CredentialDao,
|
||||||
|
private val appContext: Context
|
||||||
) {
|
) {
|
||||||
|
private object Keys {
|
||||||
|
val TOKEN = stringPreferencesKey("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedToken: String? = null
|
||||||
|
|
||||||
fun getAllCredentials(): Flow<List<Credential>> {
|
fun getAllCredentials(): Flow<List<Credential>> {
|
||||||
return credentialDao.getAll().map { entities -> entities.map { it.toDomain() } }
|
return credentialDao.getAll().map { entities -> entities.map { it.toDomain() } }
|
||||||
}
|
}
|
||||||
@ -16,4 +32,22 @@ class LocalDataSource(
|
|||||||
suspend fun save(credential: Credential) {
|
suspend fun save(credential: Credential) {
|
||||||
credentialDao.insert(credential.toEntity())
|
credentialDao.insert(credential.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun saveToken(token: String) {
|
||||||
|
cachedToken = token
|
||||||
|
appContext.dataStore.edit { it[Keys.TOKEN] = TokenCrypto.encrypt(token) } // encrypt with a Keystore key
|
||||||
|
}
|
||||||
|
|
||||||
|
val token: Flow<String?> = appContext.dataStore.data
|
||||||
|
.map { it[Keys.TOKEN]?.let(TokenCrypto::decrypt) }
|
||||||
|
|
||||||
|
suspend fun getToken(): String? {
|
||||||
|
cachedToken?.let { return it }
|
||||||
|
return token.first().also { cachedToken = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearToken() {
|
||||||
|
cachedToken = null
|
||||||
|
appContext.dataStore.edit { it.remove(Keys.TOKEN) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.LoginRequest
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.LoginResponse
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.RegisterRequest
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.RegisterResponse
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
interface ApiService {
|
||||||
|
@POST("/api/auth/register")
|
||||||
|
suspend fun register(@Body registerInfo: RegisterRequest): Response<RegisterResponse>
|
||||||
|
|
||||||
|
@POST("/api/app/login")
|
||||||
|
suspend fun login(@Body loginInfo: LoginRequest): Response<LoginResponse>
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.common.GlobalConfig.json
|
||||||
|
import com.longnh15.heimdall.data.source.remote.dto.ErrorResponse
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
|
fun ResponseBody?.toApiError(): ApiResult.Error {
|
||||||
|
if (this == null) {
|
||||||
|
return ApiResult.Error(-999, "Unknown error")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val error = json.decodeFromString<ErrorResponse>(string())
|
||||||
|
ApiResult.Error(error.error, error.message)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ApiResult.Error(-999, "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ErrorResponse(
|
||||||
|
val error: Int,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginResponse(
|
||||||
|
val token: String,
|
||||||
|
val userId: String,
|
||||||
|
val expiresAt: String,
|
||||||
|
)
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequest(
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.data.source.remote.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterResponse(
|
||||||
|
val ok: Boolean,
|
||||||
|
val userId: String
|
||||||
|
)
|
||||||
@ -2,16 +2,17 @@ package com.longnh15.heimdall.di
|
|||||||
|
|
||||||
import com.longnh15.heimdall.MainViewModel
|
import com.longnh15.heimdall.MainViewModel
|
||||||
import com.longnh15.heimdall.data.preferences.PreferenceDataSource
|
import com.longnh15.heimdall.data.preferences.PreferenceDataSource
|
||||||
|
import com.longnh15.heimdall.data.repository.AuthRepositoryImpl
|
||||||
import com.longnh15.heimdall.data.repository.CredentialRepositoryImpl
|
import com.longnh15.heimdall.data.repository.CredentialRepositoryImpl
|
||||||
import com.longnh15.heimdall.data.source.local.LocalDataSource
|
|
||||||
import com.longnh15.heimdall.data.vault.VaultSessionManagerImpl
|
import com.longnh15.heimdall.data.vault.VaultSessionManagerImpl
|
||||||
|
import com.longnh15.heimdall.domain.repository.AuthRepository
|
||||||
import com.longnh15.heimdall.domain.repository.CredentialRepository
|
import com.longnh15.heimdall.domain.repository.CredentialRepository
|
||||||
import com.longnh15.heimdall.domain.vault.VaultSessionManager
|
import com.longnh15.heimdall.domain.vault.VaultSessionManager
|
||||||
|
import com.longnh15.heimdall.ui.auth.login.LoginViewModel
|
||||||
import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel
|
import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel
|
||||||
|
import com.longnh15.heimdall.ui.auth.register.RegisterViewModel
|
||||||
import com.longnh15.heimdall.ui.vault.VaultViewModel
|
import com.longnh15.heimdall.ui.vault.VaultViewModel
|
||||||
import com.longnh15.heimdall.ui.vault.edit.AddEditVaultViewModel
|
import com.longnh15.heimdall.ui.vault.edit.AddEditVaultViewModel
|
||||||
import org.koin.core.annotation.Module
|
|
||||||
import org.koin.core.annotation.Single
|
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koin.plugin.module.dsl.single
|
import org.koin.plugin.module.dsl.single
|
||||||
@ -31,10 +32,13 @@ import org.koin.plugin.module.dsl.viewModel
|
|||||||
val appModule = module {
|
val appModule = module {
|
||||||
single<VaultSessionManagerImpl>() bind VaultSessionManager::class
|
single<VaultSessionManagerImpl>() bind VaultSessionManager::class
|
||||||
single<CredentialRepositoryImpl>() bind CredentialRepository::class
|
single<CredentialRepositoryImpl>() bind CredentialRepository::class
|
||||||
|
single<AuthRepositoryImpl>() bind AuthRepository::class
|
||||||
single<PreferenceDataSource>()
|
single<PreferenceDataSource>()
|
||||||
|
|
||||||
viewModel<MainViewModel>()
|
viewModel<MainViewModel>()
|
||||||
viewModel<OnboardingViewModel>()
|
viewModel<OnboardingViewModel>()
|
||||||
viewModel<VaultViewModel>()
|
viewModel<VaultViewModel>()
|
||||||
viewModel<AddEditVaultViewModel>()
|
viewModel<AddEditVaultViewModel>()
|
||||||
|
viewModel<LoginViewModel>()
|
||||||
|
viewModel<RegisterViewModel>()
|
||||||
}
|
}
|
||||||
@ -24,7 +24,7 @@ class DatabaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
fun provideLocalDataSource(credentialDao: CredentialDao): LocalDataSource {
|
fun provideLocalDataSource(credentialDao: CredentialDao, context: Context): LocalDataSource {
|
||||||
return LocalDataSource(credentialDao)
|
return LocalDataSource(credentialDao, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
27
app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt
Normal file
27
app/src/main/java/com/longnh15/heimdall/di/NetworkModule.kt
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package com.longnh15.heimdall.di
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.common.GlobalConfig.json
|
||||||
|
import com.longnh15.heimdall.data.source.remote.ApiService
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import org.koin.core.annotation.Module
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import retrofit2.create
|
||||||
|
|
||||||
|
@Module
|
||||||
|
class NetworkModule {
|
||||||
|
private companion object {
|
||||||
|
const val BASE_URL = "https://heimdall.lol"
|
||||||
|
}
|
||||||
|
@Single
|
||||||
|
fun provideRetrofit() = Retrofit.Builder()
|
||||||
|
.addConverterFactory(
|
||||||
|
json.asConverterFactory("application/json".toMediaType())
|
||||||
|
)
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Single
|
||||||
|
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create<ApiService>()
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
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>()
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.domain.model
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
data class AuthInfo(
|
||||||
|
val token: String,
|
||||||
|
val userId: String,
|
||||||
|
val expiresAt: Instant
|
||||||
|
)
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.longnh15.heimdall.domain.repository
|
||||||
|
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import com.longnh15.heimdall.domain.model.AuthInfo
|
||||||
|
|
||||||
|
interface AuthRepository {
|
||||||
|
suspend fun register(email: String, password: String, name: String): ApiResult<String>
|
||||||
|
suspend fun login(email: String, password: String): ApiResult<AuthInfo>
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
package com.longnh15.heimdall.domain.vault
|
package com.longnh15.heimdall.domain.vault
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.koin.core.annotation.Singleton
|
|
||||||
|
|
||||||
interface VaultSessionManager {
|
interface VaultSessionManager {
|
||||||
val sessionState: StateFlow<VaultSessionState>
|
val sessionState: StateFlow<VaultSessionState>
|
||||||
|
|||||||
@ -6,8 +6,12 @@ import androidx.navigation.NavHostController
|
|||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.longnh15.heimdall.ui.auth.login.LoginScreen
|
||||||
|
import com.longnh15.heimdall.ui.auth.login.LoginViewModel
|
||||||
import com.longnh15.heimdall.ui.onboarding.OnboardingPage
|
import com.longnh15.heimdall.ui.onboarding.OnboardingPage
|
||||||
import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel
|
import com.longnh15.heimdall.ui.onboarding.OnboardingViewModel
|
||||||
|
import com.longnh15.heimdall.ui.auth.register.RegisterScreen
|
||||||
|
import com.longnh15.heimdall.ui.auth.register.RegisterViewModel
|
||||||
import com.longnh15.heimdall.ui.vault.VaultScreen
|
import com.longnh15.heimdall.ui.vault.VaultScreen
|
||||||
import com.longnh15.heimdall.ui.vault.VaultViewModel
|
import com.longnh15.heimdall.ui.vault.VaultViewModel
|
||||||
import com.longnh15.heimdall.ui.vault.edit.AddEditVaultItemScreen
|
import com.longnh15.heimdall.ui.vault.edit.AddEditVaultItemScreen
|
||||||
@ -18,7 +22,7 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
fun HeimdallNavGraph(
|
fun HeimdallNavGraph(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navController: NavHostController = rememberNavController(),
|
navController: NavHostController = rememberNavController(),
|
||||||
startDestination: Screen = Screen.Onboarding,
|
startDestination: Screen = Screen.Login,
|
||||||
isAutofillServiceEnabled: () -> Boolean,
|
isAutofillServiceEnabled: () -> Boolean,
|
||||||
onCompleteOnboarding: () -> Unit,
|
onCompleteOnboarding: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -27,6 +31,27 @@ fun HeimdallNavGraph(
|
|||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) {
|
) {
|
||||||
|
composable<Screen.Login> {
|
||||||
|
LoginScreen(
|
||||||
|
viewModel = koinViewModel<LoginViewModel>(),
|
||||||
|
navigateToVault = {
|
||||||
|
navController.navigate(Screen.Vault) {
|
||||||
|
popUpTo(Screen.Login) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigateToRegister = {
|
||||||
|
navController.navigate(Screen.Register)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable<Screen.Register> {
|
||||||
|
RegisterScreen(
|
||||||
|
viewModel = koinViewModel<RegisterViewModel>(),
|
||||||
|
navigateToLogin = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
composable<Screen.Onboarding> {
|
composable<Screen.Onboarding> {
|
||||||
OnboardingPage(
|
OnboardingPage(
|
||||||
viewModel = koinViewModel<OnboardingViewModel>(),
|
viewModel = koinViewModel<OnboardingViewModel>(),
|
||||||
|
|||||||
@ -4,6 +4,10 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class Screen {
|
sealed class Screen {
|
||||||
|
@Serializable
|
||||||
|
data object Login: Screen()
|
||||||
|
@Serializable
|
||||||
|
data object Register: Screen()
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Onboarding: Screen()
|
data object Onboarding: Screen()
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@ -0,0 +1,210 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Visibility
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
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.text.TextStyle
|
||||||
|
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.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_NO
|
||||||
|
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.theme.HeimdallTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthForm(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.onPrimary)
|
||||||
|
.border(0.5.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp)),
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthFormTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
label: String,
|
||||||
|
placeholder: String,
|
||||||
|
error: String? = null,
|
||||||
|
isHideContent: Boolean = false,
|
||||||
|
isLastField: Boolean = false,
|
||||||
|
autoCorrectEnabled: Boolean = true,
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Unspecified,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label.uppercase(),
|
||||||
|
modifier = Modifier.width(100.dp).padding(vertical = 12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = TextStyle(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
keyboardOptions = if (isLastField)
|
||||||
|
KeyboardOptions.Default.copy(
|
||||||
|
autoCorrectEnabled = autoCorrectEnabled,
|
||||||
|
keyboardType = keyboardType,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
)
|
||||||
|
else
|
||||||
|
KeyboardOptions.Default.copy(
|
||||||
|
autoCorrectEnabled = autoCorrectEnabled,
|
||||||
|
keyboardType = keyboardType,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (isHideContent) PasswordVisualTransformation() else VisualTransformation.None,
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = placeholder,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
|
||||||
|
actions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthDivider() {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = UI_MODE_NIGHT_NO)
|
||||||
|
@Composable
|
||||||
|
fun AuthFormPreviewLight() {
|
||||||
|
HeimdallTheme {
|
||||||
|
AuthForm {
|
||||||
|
AuthFormTextField(
|
||||||
|
value = "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = "Name",
|
||||||
|
placeholder = "e.g. Google"
|
||||||
|
)
|
||||||
|
AuthDivider()
|
||||||
|
AuthFormTextField(
|
||||||
|
value = "abc",
|
||||||
|
onValueChange = {},
|
||||||
|
label = "Password",
|
||||||
|
placeholder = "••••••••",
|
||||||
|
isHideContent = true,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.focusProperties { canFocus = false }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Visibility,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun AuthFormPreviewDark() {
|
||||||
|
HeimdallTheme {
|
||||||
|
AuthForm {
|
||||||
|
AuthFormTextField(
|
||||||
|
value = "",
|
||||||
|
onValueChange = {},
|
||||||
|
label = "Name",
|
||||||
|
placeholder = "e.g. Google"
|
||||||
|
)
|
||||||
|
AuthDivider()
|
||||||
|
AuthFormTextField(
|
||||||
|
value = "abc",
|
||||||
|
onValueChange = {},
|
||||||
|
label = "Password",
|
||||||
|
placeholder = "••••••••",
|
||||||
|
isHideContent = true,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.focusProperties { canFocus = false }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.VisibilityOff,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.login
|
||||||
|
|
||||||
|
sealed class LoginEvent {
|
||||||
|
data object LoginSuccess: LoginEvent()
|
||||||
|
data class LoginError(val message: String): LoginEvent()
|
||||||
|
}
|
||||||
@ -0,0 +1,226 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.login
|
||||||
|
|
||||||
|
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
|
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.Row
|
||||||
|
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.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Lock
|
||||||
|
import androidx.compose.material.icons.outlined.Visibility
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
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.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.ui.theme.HeimdallTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: LoginViewModel = viewModel(),
|
||||||
|
navigateToVault: () -> Unit,
|
||||||
|
navigateToRegister: () -> Unit,
|
||||||
|
) {
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val state = viewModel.state
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.uiEvent.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is LoginEvent.LoginSuccess -> navigateToVault()
|
||||||
|
is LoginEvent.LoginError -> snackbarHostState.showSnackbar(event.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HeimdallTheme {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(snackbarHostState)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.imePadding()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 40.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
BrandBadge()
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Welcome back",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Access your secure vault credentials",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(36.dp))
|
||||||
|
|
||||||
|
AuthForm(modifier = Modifier.widthIn(max = 480.dp)) {
|
||||||
|
AuthFormTextField(
|
||||||
|
value = state.email,
|
||||||
|
onValueChange = viewModel::onEmailChanged,
|
||||||
|
label = "Email",
|
||||||
|
placeholder = "you@email.com",
|
||||||
|
error = state.emailError,
|
||||||
|
keyboardType = KeyboardType.Email
|
||||||
|
)
|
||||||
|
AuthDivider()
|
||||||
|
AuthFormTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = viewModel::onPasswordChanged,
|
||||||
|
label = "Password",
|
||||||
|
placeholder = "••••••••",
|
||||||
|
error = state.passwordError,
|
||||||
|
isHideContent = !state.isShowPassword,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
isLastField = true,
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = viewModel::toggleShowPassword,
|
||||||
|
modifier = Modifier.focusProperties { canFocus = false }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (state.isShowPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.widthIn(max = 480.dp)
|
||||||
|
) {
|
||||||
|
Text("Forgot password?")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::onLoginPressed,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.widthIn(max = 480.dp)
|
||||||
|
.height(52.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Login",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Don't have an account?",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
TextButton(onClick = navigateToRegister) {
|
||||||
|
Text(
|
||||||
|
text = "Create one",
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BrandBadge() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun LoginScreenPreviewLight() {
|
||||||
|
LoginScreen(
|
||||||
|
navigateToVault = {},
|
||||||
|
navigateToRegister = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun LoginScreenPreviewDark() {
|
||||||
|
LoginScreen(
|
||||||
|
navigateToVault = {},
|
||||||
|
navigateToRegister = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.login
|
||||||
|
|
||||||
|
data class LoginState(
|
||||||
|
val email: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
|
||||||
|
val emailError: String? = null,
|
||||||
|
val passwordError: String? = null,
|
||||||
|
|
||||||
|
val isShowPassword: Boolean = false,
|
||||||
|
)
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.login
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.longnh15.heimdall.common.ValidationHelper
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import com.longnh15.heimdall.domain.repository.AuthRepository
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class LoginViewModel(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(LoginState())
|
||||||
|
private set
|
||||||
|
private val _uiEvent = Channel<LoginEvent>()
|
||||||
|
val uiEvent = _uiEvent.receiveAsFlow()
|
||||||
|
|
||||||
|
fun onEmailChanged(value: String) {
|
||||||
|
state = state.copy(email = value, emailError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChanged(value: String) {
|
||||||
|
state = state.copy(password = value, passwordError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleShowPassword() {
|
||||||
|
state = state.copy(isShowPassword = !state.isShowPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLoginPressed() {
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validate(): Boolean {
|
||||||
|
val emailError = when {
|
||||||
|
state.email.isBlank() -> "Email is required"
|
||||||
|
!ValidationHelper.isEmail(state.email) -> "Enter a valid email address"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val passwordError = if (state.password.isBlank()) "Password is required" else null
|
||||||
|
|
||||||
|
state = state.copy(emailError = emailError, passwordError = passwordError)
|
||||||
|
return emailError == null && passwordError == null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.register
|
||||||
|
|
||||||
|
sealed class RegisterEvent {
|
||||||
|
data object RegisterSuccess: RegisterEvent()
|
||||||
|
data class RegisterError(val message: String): RegisterEvent()
|
||||||
|
}
|
||||||
@ -0,0 +1,212 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.register
|
||||||
|
|
||||||
|
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
|
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.Row
|
||||||
|
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.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.PersonAddAlt1
|
||||||
|
import androidx.compose.material.icons.outlined.Visibility
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.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.ui.auth.login.AuthDivider
|
||||||
|
import com.longnh15.heimdall.ui.auth.login.AuthForm
|
||||||
|
import com.longnh15.heimdall.ui.auth.login.AuthFormTextField
|
||||||
|
import com.longnh15.heimdall.ui.theme.HeimdallTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: RegisterViewModel = viewModel(),
|
||||||
|
navigateToLogin: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val state = viewModel.state
|
||||||
|
|
||||||
|
HeimdallTheme {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
containerColor = MaterialTheme.colorScheme.background
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.imePadding()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 40.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
BrandBadge()
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Create account",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Secure your digital life with enterprise-grade encryption.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(36.dp))
|
||||||
|
|
||||||
|
AuthForm(modifier = Modifier.widthIn(max = 480.dp)) {
|
||||||
|
AuthFormTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = viewModel::onNameChanged,
|
||||||
|
label = "Full name",
|
||||||
|
placeholder = "John Doe",
|
||||||
|
keyboardType = KeyboardType.Text
|
||||||
|
)
|
||||||
|
AuthDivider()
|
||||||
|
AuthFormTextField(
|
||||||
|
value = state.email,
|
||||||
|
onValueChange = viewModel::onEmailChanged,
|
||||||
|
label = "Email",
|
||||||
|
placeholder = "name@vault.com",
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
)
|
||||||
|
AuthDivider()
|
||||||
|
AuthFormTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = viewModel::onPasswordChanged,
|
||||||
|
label = "Password",
|
||||||
|
placeholder = "••••••••",
|
||||||
|
isHideContent = !state.isShowPassword,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
isLastField = true,
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = viewModel::toggleShowPassword,
|
||||||
|
modifier = Modifier.focusProperties { canFocus = false }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (state.isShowPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::onRegisterPressed,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.widthIn(max = 480.dp)
|
||||||
|
.height(52.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Create account",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "By creating an account you agree to our Terms of Service and Privacy Policy.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.widthIn(max = 480.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Already have an account?",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
TextButton(onClick = navigateToLogin) {
|
||||||
|
Text(
|
||||||
|
text = "Sign in",
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BrandBadge() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.PersonAddAlt1,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreenLightPreview() {
|
||||||
|
RegisterScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun RegisterScreenDarkPreview() {
|
||||||
|
RegisterScreen()
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.register
|
||||||
|
|
||||||
|
data class RegisterState(
|
||||||
|
val name: String = "",
|
||||||
|
val email: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
|
||||||
|
val emailError: String? = null,
|
||||||
|
val passwordError: String? = null,
|
||||||
|
|
||||||
|
val isShowPassword: Boolean = false
|
||||||
|
)
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
package com.longnh15.heimdall.ui.auth.register
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.longnh15.heimdall.common.ValidationHelper
|
||||||
|
import com.longnh15.heimdall.domain.model.ApiResult
|
||||||
|
import com.longnh15.heimdall.domain.repository.AuthRepository
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class RegisterViewModel(
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(RegisterState())
|
||||||
|
private set
|
||||||
|
private val _uiEvent = Channel<RegisterEvent>()
|
||||||
|
val uiEvent = _uiEvent.receiveAsFlow()
|
||||||
|
|
||||||
|
fun onNameChanged(value: String) {
|
||||||
|
state = state.copy(name = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEmailChanged(value: String) {
|
||||||
|
state = state.copy(email = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChanged(value: String) {
|
||||||
|
state = state.copy(password = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleShowPassword() {
|
||||||
|
state = state.copy(isShowPassword = !state.isShowPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRegisterPressed() {
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validate(): Boolean {
|
||||||
|
val emailError = when {
|
||||||
|
state.email.isBlank() -> "Email is required"
|
||||||
|
!ValidationHelper.isEmail(state.email) -> "Enter a valid email address"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val passwordError = when {
|
||||||
|
state.password.isBlank() -> "Password is required"
|
||||||
|
state.password.length < 12 -> "Password must be at least 12 characters"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copy(emailError = emailError, passwordError = passwordError)
|
||||||
|
return emailError == null && passwordError == null
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -2,4 +2,12 @@
|
|||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.Heimdall" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.Heimdall" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
|
||||||
|
<!-- Splash screen shown during cold start, kept on screen until the first-launch
|
||||||
|
flag is resolved so the nav graph builds with the correct start destination. -->
|
||||||
|
<style name="Theme.Heimdall.Starting" parent="Theme.SplashScreen">
|
||||||
|
<item name="windowSplashScreenBackground">@color/white</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
|
||||||
|
<item name="postSplashScreenTheme">@style/Theme.Heimdall</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@ -14,9 +14,12 @@ koinKsp = "4.2.1"
|
|||||||
koinPlugin = "1.0.0"
|
koinPlugin = "1.0.0"
|
||||||
navigation = "2.9.8"
|
navigation = "2.9.8"
|
||||||
serialization = "2.4.0"
|
serialization = "2.4.0"
|
||||||
|
serializationJson = "1.11.0"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
biometric = "1.4.0-alpha07"
|
biometric = "1.4.0-alpha07"
|
||||||
room = "2.8.4"
|
room = "2.8.4"
|
||||||
|
retrofit = "3.0.0"
|
||||||
|
splashscreen = "1.2.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@ -43,10 +46,14 @@ koin-compose-viewmodel-navigation = { group = "io.insert-koin", name = "koin-com
|
|||||||
koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin" }
|
koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel", version.ref = "koin" }
|
||||||
koin-annotations = { group = "io.insert-koin", name = "koin-annotations", version.ref = "koinKsp"}
|
koin-annotations = { group = "io.insert-koin", name = "koin-annotations", version.ref = "koinKsp"}
|
||||||
navigation-compose = { group = "androidx.navigation" , name = "navigation-compose", version.ref = "navigation" }
|
navigation-compose = { group = "androidx.navigation" , name = "navigation-compose", version.ref = "navigation" }
|
||||||
|
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
|
||||||
biometric-compose = { group = "androidx.biometric", name = "biometric-compose", version.ref = "biometric" }
|
biometric-compose = { group = "androidx.biometric", name = "biometric-compose", version.ref = "biometric" }
|
||||||
androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" }
|
||||||
|
squareup-retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
|
squareup-retrofit2-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
@ -54,4 +61,5 @@ devtool-ksp = { id = "com.google.devtools.ksp", version.ref = "kspVersion" }
|
|||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
|
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
|
||||||
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koinPlugin" }
|
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koinPlugin" }
|
||||||
|
room = { id = "androidx.room", version.ref = "room" }
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user